Compare commits

...
Author SHA1 Message Date
CGDandGitHub f2b4c7b932 Merge pull request #3308 from CaixyPromise/feature/native-mqtt-clean-pr
feature(mqtt): 新增原生MQTT与UDP传输支持
2026-07-27 16:28:41 +08:00
CGDandGitHub 5b288bb0d2 Merge pull request #3307 from CaixyPromise/feature/connection-runtime-pr
refactor: 传输协议无关的连接运行时
2026-07-27 16:26:18 +08:00
Sakura-RanChenandGitHub 5e0d853256 Merge pull request #3309 from xinnan-tech/py-fix-reportHandle
refactor(reportHandle): 重构上报处理逻辑,适配多格式音频转换
2026-07-27 15:24:15 +08:00
caixypromise eac573706d feat: add optional native mqtt and udp transport 2026-07-27 02:10:55 +08:00
caixypromise 0c582ed3b6 refactor: introduce transport-neutral connection runtime 2026-07-27 02:05:26 +08:00
CGDandGitHub f5ed1aaec8 Merge pull request #3305 from xinnan-tech/update_version
Bump to 0.9.6
2026-07-24 11:32:33 +08:00
hrz 9da58e254c Bump to 0.9.6 2026-07-24 09:53:03 +08:00
CGDandGitHub 27e57631a7 Merge pull request #3300 from xinnan-tech/test/manager-api-runtime-warnings
test(manager-api): 清理测试运行告警
2026-07-23 17:46:04 +08:00
CGDandGitHub b1e39da9b8 Merge pull request #3301 from chentyke/fix/issue-3299-auto-update-state
fix: 修复设备自动升级开关状态不生效
2026-07-22 17:38:13 +08:00
Tyke Chen e87fc766d2 fix: restore device auto-update switch behavior 2026-07-22 17:10:21 +08:00
Tyke Chen 942d55118b test(manager-api): clean test runtime warnings 2026-07-22 16:41:44 +08:00
CGDandGitHub f47f3b050b Merge pull request #3297 from xinnan-tech/fix/manager-api-compiler-warnings
fix(manager-api): 清理编译器告警
2026-07-22 16:22:46 +08:00
wengzhandGitHub e3453b1a87 Merge pull request #3298 from xinnan-tech/py-fix-intent
Py fix intent
2026-07-21 15:16:19 +08:00
Tyke Chen d760465408 fix(manager-api): make JSON collections type-safe 2026-07-21 14:43:25 +08:00
Tyke Chen bdb2538239 fix(manager-api): clean deprecated and generic warnings 2026-07-21 14:43:25 +08:00
CGDandGitHub ea6c144e43 Merge pull request #3294 from xinnan-tech/fix/manager-api-startup-warnings
fix(manager-api): 消除启动阶段告警
2026-07-21 14:37:22 +08:00
CGDandGitHub a79aa455d6 Merge pull request #3292 from xinnan-tech/fix/manager-api-test-i18n
test: 修复 manager-api 国际化测试配置
2026-07-21 14:29:30 +08:00
wengzh 6e92d169ec refactor(get_news_from_newsnow): 改用流式处理解析网页内容
将原有的直接转换响应内容改为流式读取字节流并传入参数,适配MarkItDown的convert_stream接口,优化大内容处理时的内存占用
2026-07-20 15:51:23 +08:00
Sakura-RanChen ed89c05245 fix: intent阻塞主线程 2026-07-20 14:48:26 +08:00
Tyke Chen b500d1c6bd fix(manager-api): avoid duplicate address book insert mapping 2026-07-20 14:46:14 +08:00
Tyke Chen 0598b09629 fix(manager-api): remove invalid logback startup directives 2026-07-20 14:46:14 +08:00
Tyke Chen e4907121f4 fix(manager-api): eliminate premature bean initialization warnings 2026-07-20 14:46:14 +08:00
Tyke Chen 2a618d2f8f test: 修复 manager-api 国际化测试配置 2026-07-20 10:34:33 +08:00
136 changed files with 17156 additions and 581 deletions
+161
View File
@@ -0,0 +1,161 @@
# Native MQTT/UDP integration
The server can accept device MQTT control messages and encrypted UDP audio
without running `xiaozhi-mqtt-gateway`. Native MQTT is optional. The existing
Gateway path and direct WebSocket path remain supported.
## Compatibility modes
| Mode | Device control | Device audio | Server business runtime |
| --- | --- | --- | --- |
| Direct WebSocket | WebSocket JSON | WebSocket binary | Shared runtime |
| MQTT Gateway | MQTT via Gateway WebSocket | UDP via Gateway WebSocket | Shared runtime |
| Native MQTT | MQTT 3.1.1 QoS 0 | AES-128-CTR UDP | Shared runtime |
Native MQTT keeps the physical MQTT connection alive between conversations.
Each device `hello` creates a new logical conversation and UDP encryption
session. `goodbye` ends only that logical conversation; MQTT is closed only by
MQTT disconnect, duplicate client takeover, keepAlive expiry, server shutdown,
or a protocol/authentication error.
## Enable Native MQTT
Native MQTT is disabled by default. Both the protocol and server switches must
be enabled:
```yaml
protocols:
enabled_protocols: [websocket, mqtt]
websocket_enabled: true
mqtt_enabled: true
mqtt_server:
enabled: true
host: 0.0.0.0
port: 1883
udp_port: 1883
udp_bind_host: ""
public_endpoint: mqtt.example.com
signature_key: replace-with-a-strong-secret
max_connections: 1000
max_pending_connections: 128
heartbeat_interval: 30
max_payload_size: 8192
message_queue_size: 128
business_ready_timeout: 30
goodbye_timeout: 1
close_timeout: 2
# Includes the common model and any Agent-specific local ASR variants.
shared_asr_max_models: 3
```
`host` is the local MQTT listen address. `public_endpoint` must be reachable by
the device and may be either `host` or `host:port`; manager-api normalizes it
and does not append the port twice. When `host` is a wildcard and
`public_endpoint` is a local IPv4 address, the UDP socket binds that address so
reply datagrams use the same source IP advertised to the device. Set
`udp_bind_host` only when the local UDP bind address must differ from the
advertised endpoint (for example, behind NAT); leaving it empty enables the
automatic behavior. Open the configured MQTT TCP and UDP ports in the firewall.
When configuration is read from manager-api, configure the equivalent
`protocols.*` and `mqtt_server.*` system parameters. The OTA response prefers a
valid Native endpoint when Native is enabled. If Native is disabled or invalid,
manager-api falls back to `server.mqtt_gateway`. If neither is configured, OTA
returns only WebSocket information.
The current firmware persists the OTA `mqtt` object. The UDP server, key and
nonce used by Native mode are negotiated in the MQTT `hello` response, so
manager-api does not send a separate top-level OTA `udp` object.
Long-lived Native connections refresh Agent-private components at logical
`hello` boundaries. Local ASR variants are shared by effective configuration
instead of loaded once per device. `shared_asr_max_models` bounds resident
models; when capacity is exhausted, the replacement runtime is rejected and
the previous healthy runtime remains active.
The default capacity reserves one transition slot so a single Agent can replace
an active private local model without dropping its healthy runtime first. Idle
variants are cached and evicted before another model is loaded; resident models
never exceed this limit.
MQTT application packets are dispatched in order outside the socket read loop,
so PINGREQ remains responsive while a logical Hello waits for private runtime
refresh. `business_ready_timeout` bounds that wait and closes an unusable
connection instead of leaving the device in a half-negotiated session.
`max_connections` limits authenticated client identities, while
`max_pending_connections` separately bounds sockets that have not completed
CONNECT. A connection using an existing client id may therefore replace its
previous owner even when active capacity is full. The server sends the success
CONNACK only after the previous owner has been reclaimed. `goodbye_timeout` and
`close_timeout` bound best-effort device reset and physical resource cleanup so
a stalled peer cannot block heartbeat or shutdown indefinitely.
## Authentication and topics
manager-api generates the device credentials and topics:
- client id: `<group>@@@<mac_with_underscores>@@@<mac_with_underscores>`
- publish topic: `device-server`
- subscribe topic: `devices/p2p/<mac_with_underscores>`
- username: Base64-encoded JSON metadata
- password: Base64(HMAC-SHA256(`client_id + "|" + username`, signature key))
Use the same secret for manager-api `mqtt_server.signature_key` and
xiaozhi-server `mqtt_server.signature_key`. The legacy
`server.mqtt_signature_key` remains a fallback for Gateway compatibility.
Native MQTT accepts MQTT 3.1.1 CONNECT, SUBSCRIBE, PUBLISH QoS 0, PING and
DISCONNECT. Unsupported protocol versions, QoS levels, malformed packets, or
invalid credentials close the physical connection.
## Session and UDP flow
1. Device connects and subscribes over MQTT.
2. Server validates CONNECT credentials and replaces any older connection with
the same client id.
3. Device publishes protocol version 3 `hello`.
4. Server waits until private configuration and business components are ready.
5. Server creates a logical session and returns the UDP server, AES key, nonce,
output audio parameters and session id.
6. The first valid UDP packet from the MQTT peer binds the UDP source tuple for
that logical session. Source changes are rejected until the next `hello`.
7. MQTT JSON and decrypted Opus frames enter the same message processors used
by WebSocket and Gateway connections.
8. Server `goodbye` returns the device to Idle and finalizes conversation tasks
while preserving the MQTT connection.
The 16-byte UDP header is also the AES-CTR nonce:
| Bytes | Field |
| --- | --- |
| 0 | packet type (`1`) |
| 1 | reserved |
| 2..3 | encrypted payload length, big endian |
| 4..7 | connection id, big endian |
| 8..11 | timestamp, big endian |
| 12..15 | sequence, big endian |
Datagrams with an invalid type, header, payload length, connection id, source
address or stale sequence are discarded. Sequence gaps are tolerated because
UDP can lose packets; late and duplicate packets are discarded. AES-CTR
provides confidentiality but not integrity, so Native MQTT and UDP should be
exposed only with a strong signature key and appropriate network controls.
## Verification
From `main/xiaozhi-server`:
```bash
python -m compileall -q .
```
From `main/manager-api` with JDK 21:
```bash
mvn clean package
```
Before deployment, verify Native and Gateway separately with real hardware:
multiple conversations, TTS interruption, exit to Idle, duplicate reconnect,
server restart, short network loss and a 30-60 minute keepAlive soak.
+12
View File
@@ -33,6 +33,7 @@
<aliyun-sms-version>4.1.0</aliyun-sms-version>
<okio-version>3.4.0</okio-version>
<skipTests>true</skipTests>
<argLine></argLine>
</properties>
<dependencies>
@@ -272,11 +273,22 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>full</proc>
<compilerArgs>
<arg>-Xlint:deprecation,unchecked</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>${skipTests}</skipTests>
<argLine>@{argLine} -Xshare:off -javaagent:"${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar"</argLine>
</configuration>
</plugin>
</plugins>
@@ -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地址
*/
@@ -324,7 +350,7 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.9.5";
public static final String VERSION = "0.9.6";
/**
* 无效固件URL
@@ -67,7 +67,7 @@ public class DataFilterInterceptor implements InnerInterceptor {
private String getSelect(String buildSql, DataScope scope) {
try {
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
PlainSelect plainSelect = select.getPlainSelect();
Expression expression = plainSelect.getWhere();
if (expression == null) {
@@ -81,8 +81,8 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
// 处理排序字段
if (orderField instanceof String) {
orderFields.add((String) orderField);
} else if (orderField instanceof List) {
orderFields.addAll((List<String>) orderField);
} else if (orderField instanceof List<?> fields) {
fields.forEach(field -> orderFields.add(String.class.cast(field)));
}
// 有排序字段则排序
@@ -142,11 +142,12 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
return SqlHelper.retBool(result);
}
protected Class<M> currentMapperClass() {
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
protected Class<?> currentMapperClass() {
return ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
}
@Override
@SuppressWarnings("unchecked")
public Class<T> currentModelClass() {
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
}
@@ -226,6 +227,6 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
@Override
public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
return SqlHelper.retBool(baseDao.deleteBatchIds(idList));
return SqlHelper.retBool(baseDao.deleteByIds(idList));
}
}
@@ -24,6 +24,7 @@ import xiaozhi.common.utils.ConvertUtils;
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
implements CrudService<T, D> {
@SuppressWarnings("unchecked")
protected Class<D> currentDtoClass() {
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
}
@@ -70,6 +71,6 @@ public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends Bas
@Override
public void delete(Serializable[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
}
@@ -1,7 +1,9 @@
package xiaozhi.common.utils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,6 +18,10 @@ import cn.hutool.core.util.StrUtil;
*/
public class JsonUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final TypeReference<Map<String, Object>> STRING_OBJECT_MAP = new TypeReference<>() {
};
private static final TypeReference<List<Map<String, Object>>> STRING_OBJECT_MAP_LIST = new TypeReference<>() {
};
public static String toJsonString(Object object) {
try {
@@ -67,4 +73,59 @@ public class JsonUtils {
}
}
public static Map<String, Object> parseMap(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP);
}
public static List<Map<String, Object>> parseMapList(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
return parseObject(text, STRING_OBJECT_MAP_LIST);
}
public static Map<String, Object> toStringObjectMap(Object value) {
if (value == null) {
return null;
}
if (!(value instanceof Map<?, ?> map)) {
throw new ClassCastException("Expected Map but got " + value.getClass().getName());
}
Map<String, Object> result = new LinkedHashMap<>(map.size());
for (Map.Entry<?, ?> entry : map.entrySet()) {
result.put(String.class.cast(entry.getKey()), entry.getValue());
}
return result;
}
public static List<Map<String, Object>> toStringObjectMapList(Object value) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<Map<String, Object>> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(toStringObjectMap(item));
}
return result;
}
public static <T> List<T> toList(Object value, Class<T> elementType) {
if (value == null) {
return null;
}
List<?> list = List.class.cast(value);
List<T> result = new ArrayList<>(list.size());
for (Object item : list) {
result.add(elementType.cast(item));
}
return result;
}
}
@@ -75,11 +75,11 @@ public class SensitiveDataUtils {
Object value = jsonObject.get(key);
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
result.put(key, maskMiddle((String) value));
result.set(key, maskMiddle((String) value));
} else if (value instanceof JSONObject) {
result.put(key, maskSensitiveFields((JSONObject) value));
result.set(key, maskSensitiveFields((JSONObject) value));
} else {
result.put(key, value);
result.set(key, value);
}
}
@@ -22,10 +22,10 @@ public class SqlFilter {
return null;
}
// 去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, "\"", "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\\", "");
str = str.replace("'", "");
str = str.replace("\"", "");
str = str.replace(";", "");
str = str.replace("\\", "");
// 转换成小写
str = str.toLowerCase();
@@ -34,6 +34,7 @@ import xiaozhi.common.page.PageData;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.common.utils.ResultUtils;
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
@@ -340,8 +341,8 @@ public class AgentController {
@RequiresPermissions("sys:role:normal")
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
requireAgentPermission(id);
List<String> tagIds = (List<String>) params.get("tagIds");
List<String> tagNames = (List<String>) params.get("tagNames");
List<String> tagIds = JsonUtils.toList(params.get("tagIds"), String.class);
List<String> tagNames = JsonUtils.toList(params.get("tagNames"), String.class);
AgentUpdateDTO dto = new AgentUpdateDTO();
dto.setTagIds(tagIds);
dto.setTagNames(tagNames);
@@ -39,7 +39,7 @@ public class AgentDTO {
private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
@@ -14,6 +14,6 @@ public class AgentMemoryDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
}
@@ -39,10 +39,10 @@ public class AgentUpdateDTO implements Serializable {
@Schema(description = "小模型标识", example = "slm_model_02", nullable = true)
private String slmModelId;
@Schema(description = "VLLM模型标识", example = "vllm_model_02", required = false)
@Schema(description = "VLLM模型标识", example = "vllm_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String vllmModelId;
@Schema(description = "语音合成模型标识", example = "tts_model_02", required = false)
@Schema(description = "语音合成模型标识", example = "tts_model_02", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String ttsModelId;
@Schema(description = "音色标识", example = "voice_02", nullable = true)
@@ -74,7 +74,7 @@ public class AgentEntity {
private String systemPrompt;
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String summaryMemory;
@Schema(description = "语言编码")
@@ -167,7 +167,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
// 尝试解析为 JSON
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(content);
if (jsonMap != null && jsonMap.containsKey("content")) {
Object contentObj = jsonMap.get("content");
return contentObj != null ? contentObj.toString() : content;
@@ -76,7 +76,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
List<String> initResponses = client.listenerWithoutClose(response -> {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
// 检查是否有result字段,表示初始化成功
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
@@ -92,7 +92,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
boolean initSucceeded = false;
for (String response : initResponses) {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
if (jsonMap.containsKey("result")) {
log.info("MCP初始化成功,智能体ID: {}", id);
@@ -123,7 +123,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 等待工具列表响应 (id=2)
List<String> toolsResponses = client.listener(response -> {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
} catch (Exception e) {
log.warn("解析工具列表响应失败: {}", response, e);
@@ -134,18 +134,18 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
// 处理工具列表响应
for (String response : toolsResponses) {
try {
Map<String, Object> jsonMap = JsonUtils.parseObject(response, Map.class);
Map<String, Object> jsonMap = JsonUtils.parseMap(response);
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
// 检查是否有result字段
Object resultObj = jsonMap.get("result");
if (resultObj instanceof Map) {
Map<String, Object> resultMap = (Map<String, Object>) resultObj;
if (resultObj instanceof Map<?, ?>) {
Map<String, Object> resultMap = JsonUtils.toStringObjectMap(resultObj);
Object toolsObj = resultMap.get("tools");
if (toolsObj instanceof List) {
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
if (toolsObj instanceof List<?>) {
List<Map<String, Object>> toolsList = JsonUtils.toStringObjectMapList(toolsObj);
// 提取工具名称列表
List<String> result = toolsList.stream()
.map(tool -> (String) tool.get("name"))
.map(tool -> String.class.cast(tool.get("name")))
.filter(name -> name != null)
.sorted()
.collect(Collectors.toList());
@@ -677,7 +677,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
mapping.setPluginId(pluginId);
Map<String, Object> paramInfo = new HashMap<>();
List<Map<String, Object>> fields = JsonUtils.parseObject(provider.getFields(), List.class);
List<Map<String, Object>> fields = JsonUtils.parseMapList(provider.getFields());
if (fields != null) {
for (Map<String, Object> field : fields) {
paramInfo.put((String) field.get("key"), field.get("default"));
@@ -133,7 +133,7 @@ public class AgentTagServiceImpl extends BaseServiceImpl<AgentTagDao, AgentTagEn
}
if (tagIds != null && !tagIds.isEmpty()) {
List<AgentTagEntity> tagIdEntities = baseDao.selectBatchIds(tagIds);
List<AgentTagEntity> tagIdEntities = baseDao.selectByIds(tagIds);
for (AgentTagEntity tag : tagIdEntities) {
if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
newTagNames.contains(tag.getTagName()))) {
@@ -10,7 +10,7 @@ public interface ConfigService {
* @param isCache 是否缓存
* @return 配置信息
*/
Object getConfig(Boolean isCache);
Map<String, Object> getConfig(Boolean isCache);
/**
* 获取智能体模型配置
@@ -65,12 +65,12 @@ public class ConfigServiceImpl implements ConfigService {
private final CorrectWordFileService correctWordFileService;
@Override
public Object getConfig(Boolean isCache) {
public Map<String, Object> getConfig(Boolean isCache) {
if (isCache) {
// 先从Redis获取配置
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
if (cachedConfig != null) {
return cachedConfig;
return JsonUtils.toStringObjectMap(cachedConfig);
}
}
@@ -123,7 +123,7 @@ public class ConfigServiceImpl implements ConfigService {
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
// 管理控制台请求,返回getConfig的结果
redisUtils.delete(redisKey); // 使用后清理
return (Map<String, Object>) getConfig(true);
return getConfig(true);
}
// 根据MAC地址查找设备
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
@@ -277,10 +277,10 @@ public class ConfigServiceImpl implements ConfigService {
// 遍历除最后一个key之外的所有key
for (int i = 0; i < keys.length - 1; i++) {
String key = keys[i];
if (!current.containsKey(key)) {
current.put(key, new HashMap<String, Object>());
}
current = (Map<String, Object>) current.get(key);
Object nestedConfig = current.computeIfAbsent(key, ignored -> new HashMap<String, Object>());
Map<String, Object> nestedMap = JsonUtils.toStringObjectMap(nestedConfig);
current.put(key, nestedMap);
current = nestedMap;
}
// 处理最后一个key
@@ -125,7 +125,9 @@ public class DeviceController {
return new Result<Void>().error("设备不存在");
}
BeanUtils.copyProperties(deviceUpdateDTO, entity);
deviceService.updateById(entity);
if (!deviceService.updateById(entity)) {
return new Result<Void>().error(ErrorCode.UPDATE_DATA_FAILED);
}
return new Result<Void>();
}
@@ -1,6 +1,7 @@
package xiaozhi.modules.device.controller;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
@@ -78,8 +79,8 @@ public class OTAController {
@Hidden
public ResponseEntity<String> getOTA() {
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
if (StringUtils.isBlank(mqttUdpConfig)) {
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置");
if (!isNativeMqttReady() && !isConfiguredValue(mqttUdpConfig)) {
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址或未启用原生MQTT,请登录智控台,在参数管理找到【server.mqtt_gateway】或【mqtt_server.enabled/protocols.mqtt_enabled】配置");
}
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
@@ -92,6 +93,49 @@ public class OTAController {
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
}
private boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, false);
boolean serverEnabled = isConfiguredValue(enabled)
? isTrue(enabled)
: isTrue(sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, false));
boolean protocolEnabled = isTrue(
sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, false));
if (!protocolEnabled) {
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, false);
if (StringUtils.isNotBlank(enabledProtocols)) {
String normalized = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
protocolEnabled = Arrays.stream(normalized.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
}
return serverEnabled && protocolEnabled;
}
private boolean isNativeMqttReady() {
if (!isNativeMqttEnabled()) {
return false;
}
String signatureKey = sysParamsService.getValue(
Constant.MQTT_SERVER_SIGNATURE_KEY, false);
if (!isConfiguredValue(signatureKey)) {
signatureKey = sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, false);
}
return isConfiguredValue(signatureKey);
}
private boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
private boolean isConfiguredValue(String value) {
return StringUtils.isNotBlank(value) && !"null".equalsIgnoreCase(value.trim());
}
@SneakyThrows
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
ObjectMapper objectMapper = new ObjectMapper();
@@ -12,6 +12,11 @@ import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
@Mapper
public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> {
/**
* 新增设备通讯录记录
*/
int insertAddressBook(DeviceAddressBookEntity entity);
/**
* 获取设备通讯录列表
*/
@@ -18,6 +18,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -59,7 +60,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
Map<String, Map<String, String>> allBooks = getAllAddressBooks();
if (isAnswer) {
return postToMqtt("/api/call/accept", Map.of("mac", callerMac), "接听");
DeviceEntity callerDevice =
deviceService.getDeviceByMacAddress(callerMac);
if (callerDevice == null) {
return errorResult("接听失败,设备信息不存在");
}
return postCallAccept(
buildMqttClientId(callerDevice),
Map.of("mac", callerMac));
}
// 主动呼叫模式
@@ -79,6 +87,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
return errorResult("呼叫失败,您没有权限呼叫该设备");
}
DeviceEntity callerDevice =
deviceService.getDeviceByMacAddress(callerMac);
DeviceEntity targetDevice =
deviceService.getDeviceByMacAddress(targetMac);
if (callerDevice == null || targetDevice == null) {
return errorResult("呼叫失败,设备信息不存在");
}
// 获取目标设备如何称呼主叫方
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
String callerNickname = null;
@@ -86,15 +102,19 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
callerNickname = targetBook.get(callerMac.toLowerCase());
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
callerNickname = callerDevice.getAlias();
if (StringUtils.isBlank(callerNickname)) {
callerNickname = formatMacAsDeviceName(callerMac);
}
}
return postToMqtt("/api/call/request",
Map.of("caller_mac", callerMac, "target_mac", targetMac, "caller_nickname", callerNickname),
"呼叫");
return postCallRequest(
buildMqttClientId(callerDevice),
buildMqttClientId(targetDevice),
Map.of(
"caller_mac", callerMac,
"target_mac", targetMac,
"caller_nickname", callerNickname));
}
@Override
@@ -167,7 +187,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
alias = generateUniqueAlias(macAddress, targetMac, alias);
entity.setAlias(alias);
entity.setHasPermission(hasPermission);
deviceAddressBookDao.insert(entity);
deviceAddressBookDao.insertAddressBook(entity);
} else {
if (alias != null) {
updateAlias(macAddress, targetMac, alias);
@@ -195,32 +215,50 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
return result;
}
private Map<String, Object> postToMqtt(String path, Map<String, Object> body, String action) {
private Map<String, Object> postCallRequest(
String callerClientId,
String targetClientId,
Map<String, Object> body) {
try {
MqttManagementHttpClient.Response response =
createMqttManagementRouter().sendCallRequest(
callerClientId, targetClientId, body);
return parseCallResponse(response, "呼叫");
} catch (Exception e) {
return errorResult("呼叫失败,请稍后再试");
}
}
private Map<String, Object> postCallAccept(
String clientId,
Map<String, Object> body) {
try {
MqttManagementHttpClient.Response response =
createMqttManagementRouter().sendCallAccept(
clientId, body);
return parseCallResponse(response, "接听");
} catch (Exception e) {
return errorResult("接听失败,请稍后再试");
}
}
private Map<String, Object> parseCallResponse(
MqttManagementHttpClient.Response response,
String action) {
Map<String, Object> result = new HashMap<>();
result.put("status", "error");
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
result.put("message", action + "失败,网关配置缺失");
if (response == null || StringUtils.isBlank(response.body())) {
result.put("message", action + "失败,MQTT管理配置缺失");
return result;
}
try {
String url = "http://" + mqttGatewayUrl + path;
String response = MqttGatewayAuthorization.postJson(
url,
JSONUtil.toJsonStr(body),
mqttSignatureKey,
Instant.now(),
5000);
if (StringUtils.isNotBlank(response)) {
Map<String, Object> gwResult = JSONUtil.parseObj(response);
result.put("status", gwResult.get("status"));
result.put("message", gwResult.get("message"));
Map<String, Object> backendResult =
JSONUtil.parseObj(response.body());
result.put("status", backendResult.get("status"));
result.put("message", backendResult.get("message"));
if (backendResult.containsKey("code")) {
result.put("code", backendResult.get("code"));
}
return result;
} catch (Exception e) {
@@ -229,6 +267,17 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
}
MqttManagementRouter createMqttManagementRouter() {
return new MqttManagementRouter(
new MqttManagementEndpointResolver(sysParamsService),
new MqttManagementHttpClient());
}
private String buildMqttClientId(DeviceEntity device) {
return MqttClientId.build(
device.getBoard(), device.getMacAddress());
}
private String formatMacAsDeviceName(String mac) {
if (StringUtils.isBlank(mac) || mac.length() < 2) {
return mac;
@@ -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;
@@ -50,6 +52,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.DateUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
@@ -109,7 +112,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
String deviceId = (String) cacheDeviceId;
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(cacheDeviceKey);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(cacheDeviceKey));
if (ToolUtil.isEmpty(cacheMap)) {
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
}
@@ -156,32 +159,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
*/
@Override
public String getDeviceOnlineData(String agentId) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return "";
}
// 构建完整的URL
String url = StrUtil.format("http://{}/api/devices/status", mqttGatewayUrl);
// 获取当前用户的设备列表
UserDetail user = SecurityUser.getUser();
List<DeviceEntity> devices = getUserDevices(user.getId(), agentId);
// 构建deviceIds数组
Set<String> deviceIds = devices.stream().map(o -> {
String macAddress = Optional.ofNullable(o.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(o.getBoard()).orElse("GID_default").replace(":", "_");
return StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
}).collect(Collectors.toSet());
Set<String> deviceIds = devices.stream()
.map(device -> MqttClientId.build(
device.getBoard(), device.getMacAddress()))
.collect(Collectors.toSet());
// 构建请求入参
Map<String, Set<String>> params = MapUtil
.builder(new HashMap<String, Set<String>>())
.put("clientIds", deviceIds).build();
if (ToolUtil.isNotEmpty(deviceIds)) {
return postToMqttGateway(url, params);
return createMqttManagementRouter()
.getMergedStatus(deviceIds);
}
// 返回响应
return "";
@@ -193,6 +184,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setServer_time(buildServerTime());
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
String reportedBoard = deviceReport.getBoard() == null
? null
: deviceReport.getBoard().getType();
if (deviceById != null
&& StringUtils.isBlank(deviceById.getBoard())
&& StringUtils.isNotBlank(reportedBoard)) {
deviceById.setBoard(reportedBoard);
baseDao.updateById(deviceById);
}
// 设备未绑定,则返回当前上传的固件信息(不更新)以此兼容旧固件版本
if (deviceById == null) {
@@ -201,10 +201,9 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
response.setFirmware(firmware);
} else {
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
if (deviceById.getAutoUpdate() != 0) {
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
// 只有在设备已绑定且明确开启自动升级时才返回固件升级信息
if (Integer.valueOf(1).equals(deviceById.getAutoUpdate())) {
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(reportedBoard,
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
response.setFirmware(firmware);
}
@@ -247,13 +246,35 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
response.setWebsocket(websocket);
// 添加MQTT UDP配置
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
// 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 {
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());
}
}
if (!mqttConfigured) {
// Native 未启用或配置无效时继续兼容 MQTT Gateway。
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
try {
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
: "GID_default";
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
if (mqtt != null) {
mqtt.setEndpoint(mqttUdpConfig);
@@ -263,6 +284,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
log.error("生成MQTT配置失败: {}", e.getMessage());
}
}
}
if (deviceById != null) {
// 如果设备存在,则异步更新上次连接时间和版本信息
@@ -298,6 +320,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
vo.setDeviceType(device.getBoard());
vo.setBoard(device.getBoard());
vo.setAutoUpdate(device.getAutoUpdate());
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
return vo;
@@ -411,7 +434,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
public String geCodeByDeviceId(String deviceId) {
String dataKey = getDeviceCacheKey(deviceId);
Map<String, Object> cacheMap = (Map<String, Object>) redisUtils.get(dataKey);
Map<String, Object> cacheMap = JsonUtils.toStringObjectMap(redisUtils.get(dataKey));
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
String cachedCode = (String) cacheMap.get("activation_code");
return cachedCode;
@@ -631,6 +654,210 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return String.format("%s.%d", signatureBase64, timestamp);
}
private boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(Constant.MQTT_SERVER_ENABLED, true);
String legacyEnabled = sysParamsService.getValue(Constant.SERVER_MQTT_ENABLED, true);
boolean serverEnabled = isConfiguredValue(enabled)
? isTrue(enabled)
: isTrue(legacyEnabled);
String protoEnabled = sysParamsService.getValue(Constant.PROTOCOLS_MQTT_ENABLED, true);
String enabledProtocols = sysParamsService.getValue(Constant.PROTOCOLS_ENABLED, true);
boolean protocolEnabled = isTrue(protoEnabled);
if (!protocolEnabled && StringUtils.isNotBlank(enabledProtocols)) {
String normalizedProtocols = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
protocolEnabled = Arrays.stream(normalizedProtocols.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
return serverEnabled && protocolEnabled;
}
private boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
private boolean isConfiguredValue(String value) {
return StringUtils.isNotBlank(value)
&& !"null".equalsIgnoreCase(value.trim())
&& !value.contains("");
}
private String buildNativeMqttEndpoint() {
String publicEndpoint = sysParamsService.getValue(Constant.MQTT_SERVER_PUBLIC_ENDPOINT, true);
String host = sysParamsService.getValue(Constant.MQTT_SERVER_HOST, true);
EndpointParts endpointParts = parseEndpoint(publicEndpoint);
EndpointParts hostParts = parseEndpoint(host);
if ((isConfiguredValue(publicEndpoint) && endpointParts == null)
|| (isConfiguredValue(host) && hostParts == null)) {
return null;
}
EndpointParts selectedEndpoint = null;
if (endpointParts != null && isClientReachableMqttHost(endpointParts.host)) {
selectedEndpoint = endpointParts;
} else if (hostParts != null && isClientReachableMqttHost(hostParts.host)) {
selectedEndpoint = hostParts;
}
if (selectedEndpoint == null) {
return null;
}
String mqttHost = selectedEndpoint.host;
Integer port = selectedEndpoint.port;
if (port == null) {
port = parseInt(sysParamsService.getValue(Constant.MQTT_SERVER_PORT, true), 1883);
}
if (StringUtils.isBlank(mqttHost) || port == null) {
return null;
}
return formatEndpoint(mqttHost, port);
}
private boolean isClientReachableMqttHost(String host) {
if (StringUtils.isBlank(host)) {
return false;
}
String normalized = host.trim().toLowerCase(Locale.ROOT);
return !"null".equals(normalized)
&& !"localhost".equals(normalized)
&& !"localhost.localdomain".equals(normalized)
&& !"0.0.0.0".equals(normalized)
&& !normalized.startsWith("127.");
}
private Integer parseInt(String value, Integer defaultValue) {
if (StringUtils.isBlank(value) || "null".equalsIgnoreCase(value)) {
return defaultValue;
}
try {
int port = Integer.parseInt(value);
if (port < 1 || port > 65535) {
return null;
}
return port;
} catch (NumberFormatException e) {
return null;
}
}
private String formatEndpoint(String host, int port) {
String normalizedHost = host.trim();
if (normalizedHost.indexOf(':') >= 0) {
return null;
}
return normalizedHost + ":" + port;
}
private static class EndpointParts {
private final String host;
private final Integer port;
private EndpointParts(String host, Integer port) {
this.host = host;
this.port = port;
}
}
private EndpointParts parseEndpoint(String raw) {
if (StringUtils.isBlank(raw) || "null".equalsIgnoreCase(raw)) {
return new EndpointParts(null, null);
}
String trimmed = raw.trim();
trimmed = trimmed.replaceFirst("(?i)^(mqtt|tcp|ssl|ws|wss|http|https)://", "");
int slashIdx = trimmed.indexOf('/');
if (slashIdx >= 0) {
trimmed = trimmed.substring(0, slashIdx);
}
if (StringUtils.isBlank(trimmed) || trimmed.startsWith("[")
|| trimmed.indexOf(':') != trimmed.lastIndexOf(':')) {
return null;
}
String host = trimmed;
Integer port = null;
int colon = trimmed.lastIndexOf(':');
if (colon >= 0) {
if (colon == 0 || colon == trimmed.length() - 1) {
return null;
}
port = parseInt(trimmed.substring(colon + 1), null);
if (port == null) {
return null;
}
host = trimmed.substring(0, colon);
}
if (!isValidEndpointHost(host)) {
return null;
}
return new EndpointParts(host, port);
}
private boolean isValidEndpointHost(String host) {
if (StringUtils.isBlank(host)
|| host.length() > 253
|| host.chars().anyMatch(Character::isWhitespace)
|| host.indexOf('@') >= 0
|| host.indexOf('?') >= 0
|| host.indexOf('#') >= 0
|| host.indexOf('\\') >= 0) {
return false;
}
return Arrays.stream(host.split("\\.", -1))
.allMatch(label -> !label.isEmpty()
&& label.length() <= 63
&& Character.isLetterOrDigit(label.charAt(0))
&& Character.isLetterOrDigit(label.charAt(label.length() - 1))
&& label.chars().allMatch(ch ->
Character.isLetterOrDigit(ch)
|| ch == '-'
|| ch == '_'));
}
private String buildMqttUsername() throws Exception {
Map<String, String> userData = new HashMap<>();
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String clientIp = request.getRemoteAddr();
userData.put("ip", clientIp);
}
} catch (Exception e) {
userData.put("ip", "unknown");
}
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
return Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
}
private DeviceReportRespDTO.MQTT buildNativeMqttConfig(String macAddress, String groupId, String clientId)
throws Exception {
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
String mqttClientId = MqttClientId.build(groupId, macAddress);
String username = buildMqttUsername();
String password = "";
String signatureKey = sysParamsService.getValue(Constant.MQTT_SERVER_SIGNATURE_KEY, true);
if (!isConfiguredValue(signatureKey)) {
signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
}
if (!isConfiguredValue(signatureKey)) {
log.error("原生MQTT已启用但未配置签名密钥,跳过原生MQTT配置下发");
return null;
}
password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
DeviceReportRespDTO.MQTT mqtt = new DeviceReportRespDTO.MQTT();
mqtt.setClient_id(mqttClientId);
mqtt.setUsername(username);
mqtt.setPassword(password);
mqtt.setPublish_topic("device-server");
mqtt.setSubscribe_topic("devices/p2p/" + deviceIdSafeStr);
return mqtt;
}
/**
* 构建MQTT配置信息
*
@@ -648,28 +875,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
String groupIdSafeStr = groupId.replace(":", "_");
String deviceIdSafeStr = macAddress.replace(":", "_");
String mqttClientId = String.format("%s@@@%s@@@%s", groupIdSafeStr, deviceIdSafeStr, deviceIdSafeStr);
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
String mqttClientId = MqttClientId.build(groupId, macAddress);
// 构建用户数据(包含IP等信息)
Map<String, String> userData = new HashMap<>();
// 尝试获取客户端IP
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String clientIp = request.getRemoteAddr();
userData.put("ip", clientIp);
}
} catch (Exception e) {
userData.put("ip", "unknown");
}
// 将用户数据编码为Base64 JSON
String userDataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(userData);
String username = Base64.getEncoder().encodeToString(userDataJson.getBytes(StandardCharsets.UTF_8));
String username = buildMqttUsername();
// 生成密码签名
String password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
@@ -685,23 +894,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return mqtt;
}
private String postToMqttGateway(String url, Object requestBody) {
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
return MqttGatewayAuthorization.postJson(
url,
JSONUtil.toJsonStr(requestBody),
signatureKey,
Instant.now());
MqttManagementRouter createMqttManagementRouter() {
return new MqttManagementRouter(
new MqttManagementEndpointResolver(sysParamsService),
new MqttManagementHttpClient());
}
@Override
public Object getDeviceTools(String deviceId) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return null;
}
// 获取设备信息
DeviceEntity device = baseDao.selectById(deviceId);
if (device == null) {
@@ -715,19 +915,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建clientId
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
// 构建完整的URL
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
String clientId = MqttClientId.build(
device.getBoard(), device.getMacAddress());
// 存储所有工具列表
List<Object> allTools = new ArrayList<>();
String cursor = null;
Set<String> seenCursors = new java.util.HashSet<>();
int pageCount = 0;
MqttManagementRouter managementRouter =
createMqttManagementRouter();
MqttManagementEndpointResolver.Backend selectedBackend = null;
// 循环获取分页数据
while (true) {
while (pageCount++ < 32) {
// 构建params
Map<String, Object> paramsMap = MapUtil.builder(new HashMap<String, Object>())
.put("withUserTools", true)
@@ -752,26 +953,44 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload)
.build();
String resultMessage = postToMqttGateway(url, requestBody);
MqttManagementHttpClient.Response response =
managementRouter.sendReadOnlyCommand(
clientId, requestBody, selectedBackend);
if (response == null || !response.isSuccessfulHttp()) {
return null;
}
String resultMessage =
response.body();
if (selectedBackend == null) {
selectedBackend = response.backend();
}
// 解析响应
if (StringUtils.isBlank(resultMessage)) {
break;
return null;
}
JSONObject jsonObject = JSONUtil.parseObj(resultMessage);
JSONObject jsonObject;
try {
jsonObject = JSONUtil.parseObj(resultMessage);
} catch (RuntimeException e) {
return null;
}
if (!jsonObject.getBool("success", false)) {
break;
return null;
}
JSONObject data = jsonObject.getJSONObject("data");
if (data == null) {
break;
return null;
}
// 获取当前页的工具列表
JSONArray tools = data.getJSONArray("tools");
if (tools != null && !tools.isEmpty()) {
if (tools == null) {
return null;
}
if (!tools.isEmpty()) {
allTools.addAll(tools);
}
@@ -779,29 +998,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
String nextCursor = data.getStr("nextCursor");
if (StringUtils.isBlank(nextCursor)) {
// 没有下一页了
break;
}
cursor = nextCursor;
}
// 构建返回结果
if (allTools.isEmpty()) {
return null;
}
Map<String, Object> resultData = new HashMap<>();
resultData.put("tools", allTools);
return resultData;
}
if (!seenCursors.add(nextCursor)) {
log.warn("MQTT设备工具列表返回重复cursor,终止分页: {}",
nextCursor);
return null;
}
cursor = nextCursor;
}
@Override
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
// 从系统参数中获取MQTT网关地址
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
return null;
}
@Override
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
// 获取设备信息
DeviceEntity device = baseDao.selectById(deviceId);
if (device == null) {
@@ -815,12 +1028,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
}
// 构建clientId
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
// 构建完整的URL
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
String clientId = MqttClientId.build(
device.getBoard(), device.getMacAddress());
// 构建请求体
Map<String, Object> params = MapUtil
@@ -843,7 +1052,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
.put("payload", payload)
.build();
String resultMessage = postToMqttGateway(url, requestBody);
MqttManagementHttpClient.Response response =
createMqttManagementRouter()
.sendMutatingCommand(clientId, requestBody);
String resultMessage =
response == null ? null : response.body();
// 解析响应
if (StringUtils.isNotBlank(resultMessage)) {
@@ -0,0 +1,28 @@
package xiaozhi.modules.device.service.impl;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
final class MqttClientId {
private MqttClientId() {
}
static String build(String board, String macAddress) {
String groupId = StringUtils.defaultIfBlank(
board, "GID_default").trim().replace(":", "_");
String deviceId = StringUtils.defaultIfBlank(macAddress, "unknown")
.trim()
.replace(":", "_");
return groupId + "@@@" + deviceId + "@@@" + deviceId;
}
static String normalizeDeviceId(String macAddress) {
return StringUtils.defaultIfBlank(macAddress, "unknown")
.trim()
.toLowerCase(Locale.ROOT)
.replace(":", "_")
.replace("-", "_");
}
}
@@ -27,10 +27,8 @@ final class MqttGatewayAuthorization {
}
static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) {
GatewayResponse response = executeWithDateFallback(
signatureKey,
now,
token -> executeRequest(url, jsonBody, token, timeoutMillis));
GatewayResponse response = postJsonResponse(
url, jsonBody, signatureKey, now, timeoutMillis);
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new GatewayRequestException(
@@ -40,6 +38,19 @@ final class MqttGatewayAuthorization {
return response.body();
}
static GatewayResponse postJsonResponse(
String url,
String jsonBody,
String signatureKey,
Instant now,
int timeoutMillis) {
return executeWithDateFallback(
signatureKey,
now,
token -> executeRequest(
url, jsonBody, token, timeoutMillis));
}
static List<String> generateDailyTokens(String signatureKey, Instant now) {
if (isMissingSignatureKey(signatureKey)) {
throw new GatewayRequestException("MQTT Gateway signature key is empty", null);
@@ -0,0 +1,183 @@
package xiaozhi.modules.device.service.impl;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.sys.service.SysParamsService;
class MqttManagementEndpointResolver {
enum Backend {
NATIVE,
GATEWAY
}
record Endpoint(Backend backend, String baseUrl, String signatureKey) {
}
private final SysParamsService sysParamsService;
MqttManagementEndpointResolver(SysParamsService sysParamsService) {
this.sysParamsService = sysParamsService;
}
List<Endpoint> resolve() {
List<Endpoint> endpoints = new ArrayList<>(2);
resolveNative().ifPresent(endpoints::add);
resolveGateway().ifPresent(endpoints::add);
return endpoints;
}
java.util.Optional<Endpoint> resolveNative() {
if (!isNativeMqttEnabled()) {
return java.util.Optional.empty();
}
String endpoint = normalizeHttpEndpoint(sysParamsService.getValue(
Constant.MQTT_SERVER_MANAGER_API, true));
String signatureKey = firstConfigured(
sysParamsService.getValue(
Constant.MQTT_SERVER_MANAGER_API_SECRET, true),
sysParamsService.getValue(
Constant.MQTT_SERVER_SIGNATURE_KEY, true),
sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, true));
if (endpoint == null || signatureKey == null) {
throw new ManagementConfigurationException(
"原生MQTT已启用但管理端点或密钥无效");
}
return java.util.Optional.of(
new Endpoint(Backend.NATIVE, endpoint, signatureKey));
}
java.util.Optional<Endpoint> resolveGateway() {
String rawGateway = sysParamsService.getValue(
Constant.SERVER_MQTT_GATEWAY, true);
String rawEndpoint = sysParamsService.getValue(
Constant.SERVER_MQTT_MANAGER_API, true);
if (configured(rawGateway) == null
&& configured(rawEndpoint) == null) {
return java.util.Optional.empty();
}
String endpoint = normalizeHttpEndpoint(rawEndpoint);
String signatureKey = configured(
sysParamsService.getValue(
Constant.SERVER_MQTT_SECRET, true));
if (endpoint == null || signatureKey == null) {
throw new ManagementConfigurationException(
"MQTT Gateway管理端点或密钥无效");
}
return java.util.Optional.of(
new Endpoint(Backend.GATEWAY, endpoint, signatureKey));
}
boolean isNativeMqttEnabled() {
String enabled = sysParamsService.getValue(
Constant.MQTT_SERVER_ENABLED, true);
String legacyEnabled = sysParamsService.getValue(
Constant.SERVER_MQTT_ENABLED, true);
boolean serverEnabled = configured(enabled) != null
? isTrue(enabled)
: isTrue(legacyEnabled);
String protocolEnabled = sysParamsService.getValue(
Constant.PROTOCOLS_MQTT_ENABLED, true);
String enabledProtocols = sysParamsService.getValue(
Constant.PROTOCOLS_ENABLED, true);
boolean enabledByProtocol = isTrue(protocolEnabled);
if (!enabledByProtocol && StringUtils.isNotBlank(enabledProtocols)) {
String normalized = enabledProtocols
.replace("[", "")
.replace("]", "")
.replace("\"", "");
enabledByProtocol = Arrays.stream(
normalized.split("[;,\\s]+"))
.anyMatch("mqtt"::equalsIgnoreCase);
}
return serverEnabled && enabledByProtocol;
}
static String normalizeHttpEndpoint(String raw) {
String configured = configured(raw);
if (configured == null) {
return null;
}
boolean hasScheme = configured.matches(
"^[A-Za-z][A-Za-z0-9+.-]*://.*");
if (hasScheme
&& !configured.matches("(?i)^https?://.*")) {
return null;
}
String candidate = hasScheme ? configured : "http://" + configured;
try {
URI uri = new URI(candidate);
String scheme = uri.getScheme();
if (scheme == null
|| (!"http".equalsIgnoreCase(scheme)
&& !"https".equalsIgnoreCase(scheme))
|| StringUtils.isBlank(uri.getHost())
|| uri.getUserInfo() != null
|| uri.getQuery() != null
|| uri.getFragment() != null
|| uri.getPort() == 0
|| uri.getPort() > 65535) {
return null;
}
String path = uri.getPath();
if (path == null || "/".equals(path)) {
path = "";
} else {
path = path.replaceAll("/+$", "");
}
return new URI(
scheme.toLowerCase(Locale.ROOT),
null,
uri.getHost(),
uri.getPort(),
path,
null,
null).toString();
} catch (URISyntaxException | IllegalArgumentException e) {
return null;
}
}
private static String firstConfigured(String... values) {
for (String value : values) {
String normalized = configured(value);
if (normalized != null) {
return normalized;
}
}
return null;
}
private static String configured(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
String normalized = value.trim();
if ("null".equalsIgnoreCase(normalized)
|| normalized.contains("")) {
return null;
}
return normalized;
}
private static boolean isTrue(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
static final class ManagementConfigurationException
extends RuntimeException {
ManagementConfigurationException(String message) {
super(message);
}
}
}
@@ -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;
}
}
}
@@ -0,0 +1,431 @@
package xiaozhi.modules.device.service.impl;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
final class MqttManagementRouter {
private final MqttManagementEndpointResolver endpointResolver;
private final MqttManagementHttpClient httpClient;
MqttManagementRouter(
MqttManagementEndpointResolver endpointResolver,
MqttManagementHttpClient httpClient) {
this.endpointResolver = endpointResolver;
this.httpClient = httpClient;
}
String getMergedStatus(Set<String> clientIds) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return "";
}
Map<String, MutableDeviceStatus> merged =
initializeStatuses(clientIds);
int successfulBackends = 0;
RuntimeException lastFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", clientIds));
if (!response.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ response.statusCode());
}
mergeStatusResponse(
merged, endpoint.backend(), response.body());
successfulBackends++;
} catch (RuntimeException e) {
lastFailure = e;
}
}
if (lastFailure != null
|| successfulBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认全部MQTT后端状态", lastFailure);
}
Map<String, Object> result = new LinkedHashMap<>();
merged.forEach((clientId, status) ->
result.put(clientId, status.toMap()));
return JSONUtil.toJsonStr(result);
}
MqttManagementHttpClient.Response sendReadOnlyCommand(
String clientId, Object requestBody) {
return sendReadOnlyCommand(clientId, requestBody, null);
}
MqttManagementHttpClient.Response sendReadOnlyCommand(
String clientId,
Object requestBody,
MqttManagementEndpointResolver.Backend preferredBackend) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
if (preferredBackend != null) {
MqttManagementEndpointResolver.Endpoint endpoint =
endpoints.stream()
.filter(candidate ->
candidate.backend()
== preferredBackend)
.findFirst()
.orElseThrow(() ->
new ManagementUnavailableException(
"分页MQTT后端配置已变化",
null));
return sendCommand(endpoint, clientId, requestBody);
}
MqttManagementHttpClient.Response lastResponse = null;
RuntimeException lastFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
sendCommand(endpoint, clientId, requestBody);
lastResponse = response;
if (isCommandSuccess(response)) {
return response;
}
} catch (RuntimeException e) {
lastFailure = e;
}
}
if (lastResponse != null) {
return lastResponse;
}
throw new ManagementUnavailableException(
"MQTT管理服务均不可用", lastFailure);
}
MqttManagementHttpClient.Response sendMutatingCommand(
String clientId, Object requestBody) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
List<MqttManagementEndpointResolver.Endpoint> online =
new ArrayList<>();
int successfulStatusBackends = 0;
RuntimeException lastStatusFailure = null;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response statusResponse =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", Set.of(clientId)));
if (!statusResponse.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ statusResponse.statusCode());
}
Map<String, BackendDeviceStatus> statuses =
parseStatusResponse(
statusResponse.body(),
Set.of(clientId));
successfulStatusBackends++;
if (statuses.get(clientId).exists()) {
online.add(endpoint);
}
} catch (RuntimeException e) {
lastStatusFailure = e;
}
}
if (lastStatusFailure != null
|| successfulStatusBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认设备所在的MQTT后端",
lastStatusFailure);
}
if (online.isEmpty()) {
return offlineResponse(endpoints.get(0).backend());
}
if (online.size() > 1) {
return commandErrorResponse(
endpoints.get(0).backend(),
409,
"设备同时存在于多个MQTT后端",
"DEVICE_BACKEND_AMBIGUOUS");
}
return sendCommand(online.get(0), clientId, requestBody);
}
MqttManagementHttpClient.Response sendCallRequest(
String callerClientId,
String targetClientId,
Object requestBody) {
Set<String> clientIds = new java.util.LinkedHashSet<>();
clientIds.add(callerClientId);
clientIds.add(targetClientId);
return sendCallMutation(
clientIds,
"/api/call/request",
requestBody);
}
MqttManagementHttpClient.Response sendCallAccept(
String clientId, Object requestBody) {
return sendCallMutation(
Set.of(clientId),
"/api/call/accept",
requestBody);
}
private MqttManagementHttpClient.Response sendCallMutation(
Set<String> clientIds,
String path,
Object requestBody) {
List<MqttManagementEndpointResolver.Endpoint> endpoints =
endpointResolver.resolve();
if (endpoints.isEmpty()) {
return null;
}
Map<String, List<MqttManagementEndpointResolver.Endpoint>> owners =
new LinkedHashMap<>();
clientIds.forEach(clientId ->
owners.put(clientId, new ArrayList<>()));
RuntimeException statusFailure = null;
int successfulStatusBackends = 0;
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
try {
MqttManagementHttpClient.Response response =
httpClient.post(
endpoint,
"/api/devices/status",
Map.of("clientIds", clientIds));
if (!response.isSuccessfulHttp()) {
throw new IllegalStateException(
"MQTT管理状态查询失败: "
+ response.statusCode());
}
Map<String, BackendDeviceStatus> statuses =
parseStatusResponse(response.body(), clientIds);
owners.forEach((clientId, matches) -> {
if (statuses.get(clientId).exists()) {
matches.add(endpoint);
}
});
successfulStatusBackends++;
} catch (RuntimeException e) {
statusFailure = e;
}
}
if (statusFailure != null
|| successfulStatusBackends != endpoints.size()) {
throw new ManagementUnavailableException(
"无法确认通话设备所在的MQTT后端",
statusFailure);
}
for (Map.Entry<String, List<MqttManagementEndpointResolver.Endpoint>>
owner : owners.entrySet()) {
if (owner.getValue().isEmpty()) {
return callErrorResponse(
endpoints.get(0).backend(),
404,
"offline",
"设备不在线",
"DEVICE_OFFLINE");
}
if (owner.getValue().size() > 1) {
return callErrorResponse(
endpoints.get(0).backend(),
409,
"error",
"设备同时存在于多个MQTT后端",
"DEVICE_BACKEND_AMBIGUOUS");
}
}
MqttManagementEndpointResolver.Endpoint selected =
owners.values().iterator().next().get(0);
boolean sameBackend = owners.values().stream()
.allMatch(matches -> matches.get(0).equals(selected));
if (!sameBackend) {
return callErrorResponse(
selected.backend(),
409,
"error",
"跨MQTT后端通话暂不支持",
"CROSS_BACKEND_CALL_UNSUPPORTED");
}
return httpClient.post(selected, path, requestBody);
}
private MqttManagementHttpClient.Response sendCommand(
MqttManagementEndpointResolver.Endpoint endpoint,
String clientId,
Object requestBody) {
String encodedClientId = URLEncoder.encode(
clientId, StandardCharsets.UTF_8)
.replace("+", "%20");
return httpClient.post(
endpoint,
"/api/commands/" + encodedClientId,
requestBody);
}
private static Map<String, MutableDeviceStatus> initializeStatuses(
Set<String> clientIds) {
Map<String, MutableDeviceStatus> result = new LinkedHashMap<>();
clientIds.forEach(clientId ->
result.put(clientId, new MutableDeviceStatus()));
return result;
}
private static void mergeStatusResponse(
Map<String, MutableDeviceStatus> merged,
MqttManagementEndpointResolver.Backend backend,
String body) {
Map<String, BackendDeviceStatus> response =
parseStatusResponse(body, merged.keySet());
merged.forEach((clientId, status) -> {
BackendDeviceStatus value = response.get(clientId);
status.exists |= value.exists();
status.alive |= value.alive();
if (value.exists()) {
status.backends.add(
backend.name().toLowerCase());
}
});
}
private static Map<String, BackendDeviceStatus> parseStatusResponse(
String body, Set<String> clientIds) {
JSONObject response;
try {
response = JSONUtil.parseObj(body);
} catch (RuntimeException e) {
throw new IllegalStateException(
"MQTT管理状态响应不是JSON对象", e);
}
Map<String, BackendDeviceStatus> statuses =
new LinkedHashMap<>();
for (String clientId : clientIds) {
Object rawStatus = response.get(clientId);
if (!(rawStatus instanceof JSONObject status)
|| !status.containsKey("exists")
|| !status.containsKey("isAlive")) {
throw new IllegalStateException(
"MQTT管理状态响应缺少设备状态: " + clientId);
}
Object rawExists = status.get("exists");
Object rawAlive = status.get("isAlive");
if (!(rawExists instanceof Boolean exists)
|| !(rawAlive instanceof Boolean alive)
|| (!exists && alive)) {
throw new IllegalStateException(
"MQTT管理状态响应字段无效: " + clientId);
}
statuses.put(
clientId,
new BackendDeviceStatus(exists, alive));
}
return statuses;
}
private static boolean isCommandSuccess(
MqttManagementHttpClient.Response response) {
if (response == null || !response.isSuccessfulHttp()) {
return false;
}
try {
return JSONUtil.parseObj(response.body())
.getBool("success", false);
} catch (Exception e) {
return false;
}
}
private static MqttManagementHttpClient.Response offlineResponse(
MqttManagementEndpointResolver.Backend backend) {
return new MqttManagementHttpClient.Response(
backend,
404,
JSONUtil.toJsonStr(Map.of(
"success", false,
"error", "设备未连接",
"code", "DEVICE_OFFLINE",
"dispatchAttempted", false)));
}
private static MqttManagementHttpClient.Response callErrorResponse(
MqttManagementEndpointResolver.Backend backend,
int statusCode,
String status,
String message,
String code) {
return new MqttManagementHttpClient.Response(
backend,
statusCode,
JSONUtil.toJsonStr(Map.of(
"status", status,
"message", message,
"code", code)));
}
private static MqttManagementHttpClient.Response commandErrorResponse(
MqttManagementEndpointResolver.Backend backend,
int statusCode,
String error,
String code) {
return new MqttManagementHttpClient.Response(
backend,
statusCode,
JSONUtil.toJsonStr(Map.of(
"success", false,
"error", error,
"code", code,
"dispatchAttempted", false)));
}
static final class ManagementUnavailableException
extends RuntimeException {
ManagementUnavailableException(
String message, Throwable cause) {
super(message, cause);
}
}
private static final class MutableDeviceStatus {
private boolean exists;
private boolean alive;
private final List<String> backends = new ArrayList<>();
Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("isAlive", alive);
result.put("exists", exists);
result.put("backends", backends);
return result;
}
}
private record BackendDeviceStatus(boolean exists, boolean alive) {
}
}
@@ -56,7 +56,7 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -32,8 +32,8 @@ public class UserShowDeviceListVO {
@Schema(description = "设备别名")
private String alias;
@Schema(description = "开启OTA")
private Integer otaUpgrade;
@Schema(description = "自动更新开关(0关闭/1开启)")
private Integer autoUpdate;
@Schema(description = "最近对话时间")
private String recentChatTime;
@@ -149,8 +149,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
// 使用 Jackson 将 DTO 转为 Map 作为查询参数
@SuppressWarnings("unchecked")
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
});
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
@@ -174,13 +174,12 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
.pageSize(1)
.build();
@SuppressWarnings("unchecked")
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
});
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
Object dataObj = response.get("data");
if (dataObj instanceof Map) {
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
if (dataObj instanceof Map<?, ?> dataMap) {
List<?> documents = (List<?>) dataMap.get("docs");
if (documents != null && !documents.isEmpty()) {
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
@@ -567,8 +566,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
return new PageData<>(new ArrayList<>(), 0);
}
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
List<Map<String, Object>> documents = (List<Map<String, Object>>) dataMap.get("docs");
Map<?, ?> dataMap = Map.class.cast(dataObj);
List<?> documents = List.class.cast(dataMap.get("docs"));
if (documents == null || documents.isEmpty()) {
// RAGFlow 明确返回了空文档列表,这是合法的"真空"
return new PageData<>(new ArrayList<>(), 0);
@@ -679,7 +678,9 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
}
if (info.getParserConfig() != null) {
dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(), Map.class));
dto.setParserConfig(objectMapper.convertValue(info.getParserConfig(),
new TypeReference<Map<String, Object>>() {
}));
}
return dto;
@@ -116,13 +116,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", temperature != null ? temperature : 0.7);
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
@@ -212,13 +210,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.2);
requestBody.put("max_tokens", 2000);
@@ -368,13 +364,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
Map<String, Object>[] messages = new Map[1];
Map<String, Object> message = new HashMap<>();
message.put("role", "user");
message.put("content", prompt);
messages[0] = message;
requestBody.put("messages", messages);
requestBody.put("messages", List.of(message));
requestBody.put("temperature", 0.3);
requestBody.put("max_tokens", 50);
@@ -362,14 +362,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
if (SensitiveDataUtils.isSensitiveField(key)) {
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
updatedJson.put(key, value);
updatedJson.set(key, value);
}
} else if (value instanceof JSONObject) {
// 递归处理嵌套JSON
mergeJson(updatedJson, key, (JSONObject) value);
} else {
// 非敏感字段直接更新
updatedJson.put(key, value);
updatedJson.set(key, value);
}
}
@@ -405,7 +405,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 如果 original 中不存在 key,创建一个新的 JSON 对象
if (!original.containsKey(key)) {
original.put(key, new JSONObject());
original.set(key, new JSONObject());
}
// 获取 original 中的子对象
@@ -420,7 +420,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
originalChild = new JSONObject();
original.put(key, originalChild);
original.set(key, originalChild);
}
for (String childKey : updated.keySet()) {
@@ -430,7 +430,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
} else {
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
originalChild.put(childKey, childValue);
originalChild.set(childKey, childValue);
}
}
}
@@ -164,7 +164,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
@Override
public void delete(List<String> ids) {
if (modelProviderDao.deleteBatchIds(ids) == 0) {
if (modelProviderDao.deleteByIds(ids) == 0) {
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
}
}
@@ -4,16 +4,19 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.config.ShiroFilterConfiguration;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import jakarta.servlet.Filter;
import xiaozhi.modules.security.oauth2.Oauth2Filter;
@@ -39,7 +42,7 @@ public class ShiroConfig {
}
@Bean("securityManager")
public SecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
public WebSecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm);
securityManager.setSessionManager(sessionManager);
@@ -48,7 +51,8 @@ public class ShiroConfig {
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
public static ShiroFilterFactoryBean shirFilter(@Lazy WebSecurityManager securityManager,
@Lazy SysParamsService sysParamsService) {
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
config.setFilterOncePerRequest(true);
@@ -101,12 +105,14 @@ public class ShiroConfig {
}
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public static AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
@Lazy WebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
@@ -85,6 +85,7 @@ public class SysParamsController {
public Result<Void> save(@RequestBody SysParamsDTO dto) {
// 效验数据
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
validateMqttSecretLength(dto.getParamCode(), dto.getParamValue());
sysParamsService.save(dto);
configService.getConfig(false);
@@ -277,7 +278,10 @@ public class SysParamsController {
// 校验mqtt密钥长度和复杂度
private void validateMqttSecretLength(String paramCode, String secret) {
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)) {
if (!paramCode.equals(Constant.SERVER_MQTT_SECRET)
&& !paramCode.equals(Constant.MQTT_SERVER_SIGNATURE_KEY)
&& !paramCode.equals(
Constant.MQTT_SERVER_MANAGER_API_SECRET)) {
return;
}
if (StringUtils.isBlank(secret) || secret.equals("null")) {
@@ -31,7 +31,7 @@ public class SysUserDTO implements Serializable {
@NotNull(message = "{id.require}", groups = UpdateGroup.class)
private Long id;
@Schema(description = "用户名", required = true)
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
private String username;
@@ -40,14 +40,14 @@ public class SysUserDTO implements Serializable {
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
private String password;
@Schema(description = "姓名", required = true)
@Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
private String realName;
@Schema(description = "头像")
private String headUrl;
@Schema(description = "性别 0:男 1:女 2:保密", required = true)
@Schema(description = "性别 0:男 1:女 2:保密", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 2, message = "{sysuser.gender.range}", groups = DefaultGroup.class)
private Integer gender;
@@ -58,11 +58,11 @@ public class SysUserDTO implements Serializable {
@Schema(description = "手机号")
private String mobile;
@Schema(description = "部门ID", required = true)
@Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
private Long deptId;
@Schema(description = "状态 0:停用 1:正常", required = true)
@Schema(description = "状态 0:停用 1:正常", requiredMode = Schema.RequiredMode.REQUIRED)
@Range(min = 0, max = 1, message = "{sysuser.status.range}", groups = DefaultGroup.class)
private Integer status;
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.service.impl.BaseServiceImpl;
import xiaozhi.common.utils.ConvertUtils;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.common.utils.ToolUtil;
import xiaozhi.modules.sys.dao.SysDictDataDao;
import xiaozhi.modules.sys.dao.SysUserDao;
@@ -138,7 +139,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -170,7 +171,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
// 先从Redis获取缓存
String key = RedisKeys.getDictDataByTypeKey(dictType);
List<SysDictDataItem> cachedData = (List<SysDictDataItem>) redisUtils.get(key);
List<SysDictDataItem> cachedData = JsonUtils.toList(redisUtils.get(key), SysDictDataItem.class);
if (cachedData != null) {
return cachedData;
}
@@ -128,7 +128,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
// 设置更新者和创建者名称
if (!userIds.isEmpty()) {
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
// 把List转成MapMap<Long, String>
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
SysUserEntity::getUsername, (existing, replacement) -> existing));
@@ -205,9 +205,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
public void initServerSecret() {
// 获取服务器密钥
String secretParam = getValue(Constant.SERVER_SECRET, false);
if (StringUtils.isBlank(secretParam) || "null".equals(secretParam)) {
if (StringUtils.isBlank(secretParam)
|| "null".equalsIgnoreCase(secretParam.trim())) {
String newSecret = UUID.randomUUID().toString();
updateValueByCode(Constant.SERVER_SECRET, newSecret);
} else {
// Startup must repair a stale Redis value because authentication
// reads this parameter from cache while initialization reads DB.
sysParamsRedis.set(Constant.SERVER_SECRET, secretParam);
}
// 初始化SM2密钥对
@@ -287,10 +292,10 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
try {
if (StringUtils.isNotBlank(currentConfig)) {
currentMap = JsonUtils.parseObject(currentConfig, Map.class);
currentMap = JsonUtils.parseMap(currentConfig);
}
if (StringUtils.isNotBlank(configJson)) {
newMap = JsonUtils.parseObject(configJson, Map.class);
newMap = JsonUtils.parseMap(configJson);
}
} catch (Exception e) {
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
@@ -298,8 +303,8 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
// 检查addressBook功能是否被关闭
if (currentMap != null && newMap != null) {
Map<String, Object> currentFeatures = (Map<String, Object>) currentMap.get("features");
Map<String, Object> newFeatures = (Map<String, Object>) newMap.get("features");
Map<?, ?> currentFeatures = Map.class.cast(currentMap.get("features"));
Map<?, ?> newFeatures = Map.class.cast(newMap.get("features"));
if (currentFeatures != null && newFeatures != null) {
Object currentAddressBookObj = currentFeatures.get("addressBook");
@@ -308,16 +313,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
Boolean currentEnabled = false;
Boolean newEnabled = false;
if (currentAddressBookObj instanceof Map) {
Map<String, Object> currentAddressBook = (Map<String, Object>) currentAddressBookObj;
currentEnabled = currentAddressBook.get("enabled") != null
? (Boolean) currentAddressBook.get("enabled") : false;
if (currentAddressBookObj instanceof Map<?, ?> currentAddressBook) {
Object enabled = currentAddressBook.get("enabled");
currentEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
}
if (newAddressBookObj instanceof Map) {
Map<String, Object> newAddressBook = (Map<String, Object>) newAddressBookObj;
newEnabled = newAddressBook.get("enabled") != null
? (Boolean) newAddressBook.get("enabled") : false;
if (newAddressBookObj instanceof Map<?, ?> newAddressBook) {
Object enabled = newAddressBook.get("enabled");
newEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
}
// 如果之前是启用状态,现在被禁用,删除所有call_device插件
@@ -117,7 +117,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -127,7 +127,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
@Override
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
baseDao.deleteByIds(Arrays.asList(ids));
}
@Override
@@ -0,0 +1,15 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(775, 'protocols.enabled_protocols', '["websocket"]', 'string', 1, '启用的协议列表'),
(776, 'protocols.websocket_enabled', 'true', 'boolean', 1, 'WebSocket协议开关'),
(777, 'protocols.mqtt_enabled', 'false', 'boolean', 1, 'MQTT协议开关'),
(778, 'mqtt_server.enabled', 'false', 'boolean', 1, '是否启用MQTT服务器'),
(779, 'mqtt_server.host', '0.0.0.0', 'string', 1, 'MQTT服务器监听地址'),
(780, 'mqtt_server.port', '1883', 'number', 1, 'MQTT服务器端口'),
(781, 'mqtt_server.udp_port', '1883', 'number', 1, 'UDP音频端口'),
(782, 'mqtt_server.public_endpoint', '127.0.0.1', 'string', 1, 'MQTT公网/局域网访问地址'),
(783, 'mqtt_server.max_connections', '1000', 'number', 1, '最大连接数'),
(784, 'mqtt_server.heartbeat_interval', '30', 'number', 1, '心跳间隔(秒)'),
(785, 'mqtt_server.max_payload_size', '8192', 'number', 1, '最大消息大小'),
(786, 'mqtt_server.signature_key', 'null', 'string', 1, 'MQTT签名密钥');
@@ -0,0 +1,9 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(787, 'mqtt_server.udp_bind_host', '', 'string', 1, 'UDP音频监听地址(留空时自动选择)'),
(788, 'mqtt_server.message_queue_size', '128', 'number', 1, 'MQTT应用消息队列上限'),
(789, 'mqtt_server.business_ready_timeout', '30', 'number', 1, 'Hello等待业务运行时就绪超时(秒)'),
(790, 'mqtt_server.max_pending_connections', '128', 'number', 1, 'MQTT待认证连接上限'),
(791, 'mqtt_server.goodbye_timeout', '1', 'number', 1, 'MQTT goodbye发送超时(秒)'),
(792, 'mqtt_server.close_timeout', '2', 'number', 1, 'MQTT连接清理超时(秒)');
@@ -0,0 +1,11 @@
insert ignore into `sys_params`
(id, param_code, param_value, value_type, param_type, remark)
values
(793, 'mqtt_server.manager_api', 'null', 'string', 1, '原生MQTT管理API地址'),
(794, 'mqtt_server.manager_api_secret', 'null', 'string', 1, '原生MQTT管理API签名密钥');
update `sys_params`
set param_value = '',
remark = 'MQTT公网/局域网访问地址(需配置为设备可达地址)'
where param_code = 'mqtt_server.public_endpoint'
and param_value = '127.0.0.1';
@@ -711,3 +711,26 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607101200.sql
- changeSet:
id: 202607190300
author: CAIXYPROMISE
validCheckSum:
- "8:5df1e1f3654e6271889d5579a3908f77"
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607190300.sql
- changeSet:
id: 202607260900
author: CAIXYPROMISE
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607260900.sql
- changeSet:
id: 202607262100
author: CAIXYPROMISE
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202607262100.sql
@@ -1,20 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 启用JansiConsoleAppender以确保控制台输出有颜色 -->
<conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
<!-- 确保日志目录存在 -->
<timestamp key="bySecond" datePattern="yyyyMMdd'T'HHmmss"/>
<!-- 定义日志文件存储位置 -->
<property name="LOG_HOME" value="./logs" />
<!-- 使用自定义的初始化监听器确保日志目录存在 -->
<define name="LOGBACK_DIR_CHECK" class="ch.qos.logback.core.property.FileExistsPropertyDefiner">
<path>${LOG_HOME}</path>
<createIfMissing>true</createIfMissing>
</define>
<!-- 引入Spring Boot默认配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
@@ -21,7 +21,7 @@
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
</update>
<insert id="insert">
<insert id="insertAddressBook">
INSERT INTO ai_device_address_book (mac_address, target_mac, alias, has_permission, creator, create_date, updater, update_date)
VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW())
</insert>
@@ -0,0 +1,57 @@
package xiaozhi.common.redis;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.serializer.RedisSerializer;
import xiaozhi.common.utils.JsonUtils;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class RedisSerializationTest {
private final RedisSerializer<Object> serializer = RedisSerializer.json();
@Test
void serverConfigRoundTripRestoresStringKeyedMaps() {
Map<String, Object> features = new HashMap<>();
features.put("addressBook", Map.of("enabled", true));
Map<String, Object> config = new HashMap<>();
config.put("features", features);
Object restored = roundTrip(config);
Map<String, Object> restoredConfig = JsonUtils.toStringObjectMap(restored);
Map<String, Object> restoredFeatures = JsonUtils.toStringObjectMap(restoredConfig.get("features"));
Map<String, Object> addressBook = JsonUtils.toStringObjectMap(restoredFeatures.get("addressBook"));
assertEquals(true, addressBook.get("enabled"));
}
@Test
void dictionaryListRoundTripRestoresDtoElements() {
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
Object restored = roundTrip(new ArrayList<>(List.of(item)));
List<SysDictDataItem> restoredItems = JsonUtils.toList(restored, SysDictDataItem.class);
assertInstanceOf(SysDictDataItem.class, restoredItems.get(0));
assertEquals("enabled", restoredItems.get(0).getName());
assertEquals("1", restoredItems.get(0).getKey());
}
private Object roundTrip(Object value) {
byte[] bytes = serializer.serialize(value);
assertNotNull(bytes);
Object restored = serializer.deserialize(bytes);
assertNotNull(restored);
return restored;
}
}
@@ -22,6 +22,7 @@ import java.util.function.BiFunction;
import org.apache.ibatis.binding.MapperMethod;
import org.apache.ibatis.executor.BatchResult;
import org.apache.ibatis.logging.nologging.NoLoggingImpl;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSession;
@@ -57,7 +58,8 @@ class BaseServiceImplTest {
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
when(sqlSession.flushStatements())
.thenReturn(List.of(batchResult(1, 1)), List.of(batchResult(1)));
.thenReturn(List.of(batchResult(1, 1)))
.thenReturn(List.of(batchResult(1)));
TestEntity first = new TestEntity(1L);
TestEntity second = new TestEntity(2L);
@@ -126,6 +128,19 @@ class BaseServiceImplTest {
verifyNoInteractions(sqlSessionFactory);
}
@Test
void deleteBatchIdsDelegatesToCompatibleApiAndPreservesAffectedRowResult() {
TestMapper mapper = mock(TestMapper.class);
List<Long> ids = List.of(1L, 2L);
service.baseDao = mapper;
when(mapper.deleteByIds(ids)).thenReturn(2).thenReturn(0);
assertTrue(service.deleteBatchIds(ids));
assertFalse(service.deleteBatchIds(ids));
verify(mapper, times(2)).deleteByIds(ids);
}
@Test
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
@@ -170,6 +185,10 @@ class BaseServiceImplTest {
}
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
private TestService() {
log = new NoLoggingImpl(TestService.class.getName());
}
@Override
protected String getSqlStatement(SqlMethod sqlMethod) {
return switch (sqlMethod) {
@@ -0,0 +1,77 @@
package xiaozhi.common.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonUtilsTest {
@Test
void parsesTypedMapsAndPreservesNestedCollectionShapes() {
Map<String, Object> map = JsonUtils.parseMap(
"{\"enabled\":true,\"nested\":{\"items\":[{\"name\":\"tool\"}]}}");
Map<?, ?> nested = assertInstanceOf(Map.class, map.get("nested"));
List<?> items = assertInstanceOf(List.class, nested.get("items"));
Map<?, ?> item = assertInstanceOf(Map.class, items.get(0));
assertEquals(true, map.get("enabled"));
assertEquals("tool", item.get("name"));
List<Map<String, Object>> maps = JsonUtils.parseMapList(
"[{\"name\":\"first\",\"values\":[1,2]},{\"name\":\"second\"}]");
assertEquals("first", maps.get(0).get("name"));
assertEquals(List.of(1, 2), maps.get(0).get("values"));
assertEquals("second", maps.get(1).get("name"));
}
@Test
void checkedConvertersAreShallowMutableCopies() {
List<Object> nested = new ArrayList<>(List.of(Map.of("name", "tool")));
Map<String, Object> source = new LinkedHashMap<>();
source.put("nested", nested);
Map<String, Object> map = JsonUtils.toStringObjectMap(source);
List<Map<String, Object>> maps = JsonUtils.toStringObjectMapList(List.of(source));
List<String> strings = JsonUtils.toList(Arrays.asList("a", null), String.class);
assertSame(nested, map.get("nested"));
assertSame(nested, maps.get(0).get("nested"));
map.put("enabled", true);
maps.add(Map.of("name", "second"));
strings.add("b");
assertEquals(true, map.get("enabled"));
assertEquals(2, maps.size());
assertEquals(Arrays.asList("a", null, "b"), strings);
}
@Test
void preservesNullInputs() {
assertNull(JsonUtils.parseMap(""));
assertNull(JsonUtils.parseMapList(null));
assertNull(JsonUtils.toStringObjectMap(null));
assertNull(JsonUtils.toStringObjectMapList(null));
assertNull(JsonUtils.toList(null, String.class));
}
@Test
void rejectsUnexpectedJsonShapesAndRuntimeTypes() {
assertThrows(RuntimeException.class, () -> JsonUtils.parseMap("[]"));
assertThrows(RuntimeException.class, () -> JsonUtils.parseMapList("{}"));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(List.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMap(Map.of(1, "value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(List.of("value")));
assertThrows(ClassCastException.class, () -> JsonUtils.toStringObjectMapList(Map.of()));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(List.of(1), String.class));
assertThrows(ClassCastException.class, () -> JsonUtils.toList(Map.of(), String.class));
}
}
@@ -0,0 +1,96 @@
package xiaozhi.modules.config.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.agent.dao.AgentVoicePrintDao;
import xiaozhi.modules.agent.service.AgentContextProviderService;
import xiaozhi.modules.agent.service.AgentMcpAccessPointService;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.correctword.service.CorrectWordFileService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.model.service.ModelConfigService;
import xiaozhi.modules.sys.dto.SysParamsDTO;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.timbre.service.TimbreService;
import xiaozhi.modules.voiceclone.service.VoiceCloneService;
class ConfigServiceImplTest {
@Test
void cachedServerConfigIsCheckedAsAStringKeyedMapWithoutChangingNestedValues() {
RedisUtils redisUtils = mock(RedisUtils.class);
Map<String, Object> nested = new HashMap<>();
nested.put("enabled", true);
Map<String, Object> cached = new HashMap<>();
cached.put("features", nested);
when(redisUtils.get(RedisKeys.getServerConfigKey())).thenReturn(cached);
ConfigServiceImpl service = newService(mock(SysParamsService.class), redisUtils);
Map<String, Object> result = service.getConfig(true);
assertNotSame(cached, result);
assertSame(nested, result.get("features"));
assertEquals(true, ((Map<?, ?>) result.get("features")).get("enabled"));
}
@Test
void nestedSystemParametersStillShareAndPopulateTheSameConfigBranch() {
SysParamsService sysParamsService = mock(SysParamsService.class);
SysParamsDTO enabled = parameter("server.features.enabled", "true", "boolean");
SysParamsDTO labels = parameter("server.features.labels", "first;second", "array");
when(sysParamsService.list(anyMap())).thenReturn(List.of(enabled, labels));
ConfigServiceImpl service = newService(sysParamsService, mock(RedisUtils.class));
Map<String, Object> config = new HashMap<>();
Object returned = ReflectionTestUtils.invokeMethod(service, "buildConfig", config);
assertSame(config, returned);
Map<?, ?> server = assertInstanceOf(Map.class, config.get("server"));
Map<?, ?> features = assertInstanceOf(Map.class, server.get("features"));
assertEquals(true, features.get("enabled"));
assertEquals(List.of("first", "second"), features.get("labels"));
}
private static SysParamsDTO parameter(String code, String value, String type) {
SysParamsDTO parameter = new SysParamsDTO();
parameter.setParamCode(code);
parameter.setParamValue(value);
parameter.setValueType(type);
return parameter;
}
private static ConfigServiceImpl newService(SysParamsService sysParamsService, RedisUtils redisUtils) {
return new ConfigServiceImpl(
sysParamsService,
mock(DeviceService.class),
mock(ModelConfigService.class),
mock(AgentService.class),
mock(AgentTemplateService.class),
redisUtils,
mock(TimbreService.class),
mock(AgentPluginMappingService.class),
mock(AgentMcpAccessPointService.class),
mock(AgentContextProviderService.class),
mock(VoiceCloneService.class),
mock(AgentVoicePrintDao.class),
mock(CorrectWordFileService.class));
}
}
@@ -1,7 +1,6 @@
package xiaozhi.modules.device;
import java.util.HashMap;
import java.util.UUID;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
@@ -11,6 +10,8 @@ import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dto.SysUserDTO;
import xiaozhi.modules.sys.service.SysUserService;
@@ -27,11 +28,13 @@ public class DeviceTest {
private SysUserService sysUserService;
@Test
public void testSaveUser() {
public void testRejectWeakPassword() {
SysUserDTO userDTO = new SysUserDTO();
userDTO.setUsername("test");
userDTO.setPassword(UUID.randomUUID().toString());
sysUserService.save(userDTO);
userDTO.setPassword("weak-password-123");
RenException exception = Assertions.assertThrows(RenException.class, () -> sysUserService.save(userDTO));
Assertions.assertEquals(ErrorCode.PASSWORD_WEAK_ERROR, exception.getCode());
}
@Test
@@ -0,0 +1,78 @@
package xiaozhi.modules.device.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
@DisplayName("设备更新接口回归测试")
class DeviceControllerTest {
private static final String DEVICE_ID = "device-id";
private static final long USER_ID = 1L;
@Test
@DisplayName("数据库未更新时不误报自动升级状态修改成功")
void updateFailureIsReturnedToCaller() {
DeviceService deviceService = mock(DeviceService.class);
DeviceEntity entity = ownedDevice();
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
when(deviceService.updateById(entity)).thenReturn(false);
DeviceController controller = controller(deviceService);
DeviceUpdateDTO update = new DeviceUpdateDTO();
update.setAutoUpdate(0);
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class);
MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(currentUser());
messageUtils.when(() -> MessageUtils.getMessage(ErrorCode.UPDATE_DATA_FAILED))
.thenReturn("Failed to update data");
Result<Void> result = controller.updateDeviceInfo(DEVICE_ID, update);
assertEquals(ErrorCode.UPDATE_DATA_FAILED, result.getCode());
assertEquals(0, entity.getAutoUpdate());
verify(deviceService).updateById(entity);
}
}
private DeviceController controller(DeviceService deviceService) {
return new DeviceController(
deviceService,
mock(DeviceAddressBookService.class),
mock(RedisUtils.class),
mock(SysParamsService.class));
}
private DeviceEntity ownedDevice() {
DeviceEntity entity = new DeviceEntity();
entity.setId(DEVICE_ID);
entity.setUserId(USER_ID);
entity.setAutoUpdate(1);
return entity;
}
private UserDetail currentUser() {
UserDetail user = new UserDetail();
user.setId(USER_ID);
return user;
}
}
@@ -0,0 +1,46 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
class DeviceAddressBookServiceImplTest {
@Test
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
assertEquals("living-room", entity.getAlias());
assertTrue(entity.getHasPermission());
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
}
}
@@ -0,0 +1,104 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.device.dto.DeviceReportReqDTO;
import xiaozhi.modules.device.dto.DeviceReportRespDTO;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.sys.service.SysParamsService;
@DisplayName("设备自动升级回归测试")
class DeviceAutoUpdateTest {
private static final String MAC_ADDRESS = "00:11:22:33:44:55";
private static final String BOARD_TYPE = "test-board";
private static final String CURRENT_VERSION = "1.0.0";
@Test
@DisplayName("#3299 自动升级关闭时 OTA 响应不包含固件")
void disabledAutoUpdateOmitsFirmware() {
OtaService otaService = mock(OtaService.class);
DeviceServiceImpl service = proxiedService(deviceWithAutoUpdate(0), otaService);
DeviceReportRespDTO response = service.checkDeviceActive(
MAC_ADDRESS, MAC_ADDRESS, deviceReport());
assertNull(response.getFirmware());
verifyNoInteractions(otaService);
}
@Test
@DisplayName("自动升级开启时继续执行固件查询")
void enabledAutoUpdateChecksFirmware() {
OtaService otaService = mock(OtaService.class);
when(otaService.getLatestOta(BOARD_TYPE)).thenReturn(null);
DeviceServiceImpl service = proxiedService(deviceWithAutoUpdate(1), otaService);
DeviceReportRespDTO response = service.checkDeviceActive(
MAC_ADDRESS, MAC_ADDRESS, deviceReport());
assertNotNull(response.getFirmware());
assertEquals(CURRENT_VERSION, response.getFirmware().getVersion());
assertEquals(Constant.INVALID_FIRMWARE_URL, response.getFirmware().getUrl());
verify(otaService).getLatestOta(BOARD_TYPE);
}
private DeviceServiceImpl proxiedService(DeviceEntity device, OtaService otaService) {
SysParamsService sysParamsService = mock(SysParamsService.class);
when(sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true))
.thenReturn("ws://127.0.0.1:8000/xiaozhi/v1/");
when(sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true)).thenReturn("false");
when(sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true)).thenReturn(null);
DeviceServiceImpl target = new DeviceServiceImpl(
null, null, sysParamsService, null, otaService, null) {
@Override
public DeviceEntity getDeviceByMacAddress(String macAddress) {
return device;
}
@Override
public void updateDeviceConnectionInfo(String agentId, String deviceId, String appVersion) {
// No-op: connection timestamps are outside this OTA decision test.
}
};
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.setProxyTargetClass(true);
proxyFactory.setExposeProxy(true);
return (DeviceServiceImpl) proxyFactory.getProxy();
}
private DeviceEntity deviceWithAutoUpdate(int autoUpdate) {
DeviceEntity device = new DeviceEntity();
device.setId(MAC_ADDRESS);
device.setMacAddress(MAC_ADDRESS);
device.setBoard(BOARD_TYPE);
device.setAutoUpdate(autoUpdate);
return device;
}
private DeviceReportReqDTO deviceReport() {
DeviceReportReqDTO.Application application = new DeviceReportReqDTO.Application();
application.setVersion(CURRENT_VERSION);
DeviceReportReqDTO.BoardInfo board = new DeviceReportReqDTO.BoardInfo();
board.setType(BOARD_TYPE);
DeviceReportReqDTO report = new DeviceReportReqDTO();
report.setApplication(application);
report.setBoard(board);
return report;
}
}
@@ -76,6 +76,25 @@ class DeviceTimeSerializationTest {
() -> assertTrue(payload.path("createDate").isNull()));
}
@ParameterizedTest(name = "自动升级状态 {0}")
@ValueSource(ints = { 0, 1 })
@DisplayName("#3299 设备列表按 autoUpdate 契约返回真实开关状态")
void serializedDeviceContainsAutoUpdateState(int autoUpdate) {
DeviceEntity entity = new DeviceEntity();
entity.setAutoUpdate(autoUpdate);
DeviceServiceImpl deviceService = serviceReturning(entity);
UserShowDeviceListVO device = deviceService.getUserDeviceList(1L, "agent-id").getFirst();
ObjectMapper objectMapper = new WebMvcConfig().jackson2HttpMessageConverter().getObjectMapper();
JsonNode payload = objectMapper.valueToTree(device);
assertAll(
() -> assertEquals(autoUpdate, device.getAutoUpdate()),
() -> assertEquals(autoUpdate, payload.path("autoUpdate").asInt()),
() -> assertTrue(payload.path("otaUpgrade").isMissingNode(),
"设备列表不应继续暴露未映射的旧字段 otaUpgrade"));
}
private DeviceServiceImpl serviceReturning(DeviceEntity entity) {
return new DeviceServiceImpl(null, null, null, null, null, null) {
@Override
@@ -0,0 +1,60 @@
package xiaozhi.modules.security.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Role;
import xiaozhi.modules.security.oauth2.Oauth2Realm;
import xiaozhi.modules.sys.service.SysParamsService;
class ShiroConfigTest {
@Test
void beanPostProcessorFactoriesDoNotInstantiateShiroConfigOrBusinessDependenciesEarly() throws Exception {
Method lifecycleFactory = ShiroConfig.class.getDeclaredMethod("lifecycleBeanPostProcessor");
Method filterFactory = ShiroConfig.class.getDeclaredMethod(
"shirFilter", WebSecurityManager.class, SysParamsService.class);
assertTrue(Modifier.isStatic(lifecycleFactory.getModifiers()));
assertTrue(BeanPostProcessor.class.isAssignableFrom(ShiroFilterFactoryBean.class));
assertTrue(Modifier.isStatic(filterFactory.getModifiers()));
Lazy securityManagerLazy = filterFactory.getParameters()[0].getAnnotation(Lazy.class);
Lazy sysParamsServiceLazy = filterFactory.getParameters()[1].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertNotNull(sysParamsServiceLazy);
assertTrue(securityManagerLazy.value());
assertTrue(sysParamsServiceLazy.value());
}
@Test
void authorizationAdvisorIsInfrastructureAndDefersItsSecurityManager() throws Exception {
Method advisorFactory = ShiroConfig.class.getDeclaredMethod(
"authorizationAttributeSourceAdvisor", WebSecurityManager.class);
assertTrue(Modifier.isStatic(advisorFactory.getModifiers()));
Lazy securityManagerLazy = advisorFactory.getParameters()[0].getAnnotation(Lazy.class);
assertNotNull(securityManagerLazy);
assertTrue(securityManagerLazy.value());
assertEquals(BeanDefinition.ROLE_INFRASTRUCTURE, advisorFactory.getAnnotation(Role.class).value());
}
@Test
void securityManagerRetainsTheWebSecurityContractRequiredByShiroFilter() throws Exception {
Method securityManagerFactory = ShiroConfig.class.getDeclaredMethod(
"securityManager", Oauth2Realm.class, SessionManager.class);
assertEquals(WebSecurityManager.class, securityManagerFactory.getReturnType());
}
}
@@ -1,15 +1,23 @@
package xiaozhi.modules.sys;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import lombok.extern.slf4j.Slf4j;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.modules.security.controller.LoginController;
import xiaozhi.modules.security.dto.LoginDTO;
import xiaozhi.modules.security.dto.SmsVerificationDTO;
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
import xiaozhi.modules.sys.service.SysUserService;
@Slf4j
@SpringBootTest
@@ -19,12 +27,19 @@ class loginControllerTest {
@Autowired
LoginController loginController;
@MockitoBean
SysUserService sysUserService;
@Test
public void testRegister() {
when(sysUserService.getAllowUserRegister()).thenReturn(false);
LoginDTO loginDTO = new LoginDTO();
loginDTO.setUsername("手机号码");
loginDTO.setPassword("密码");
loginController.register(loginDTO);
RenException exception = assertThrows(RenException.class, () -> loginController.register(loginDTO));
assertEquals(ErrorCode.USER_REGISTER_DISABLED, exception.getCode());
}
@Test
@@ -0,0 +1,35 @@
package xiaozhi.modules.sys.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.sys.dao.SysUserDao;
import xiaozhi.modules.sys.vo.SysDictDataItem;
class SysDictDataServiceImplTest {
@Test
void cachedDictionaryItemsAreCheckedAndReturnedAsDtos() {
RedisUtils redisUtils = mock(RedisUtils.class);
SysDictDataItem item = new SysDictDataItem();
item.setName("enabled");
item.setKey("1");
List<SysDictDataItem> cached = new ArrayList<>(List.of(item));
when(redisUtils.get(RedisKeys.getDictDataByTypeKey("status"))).thenReturn(cached);
SysDictDataServiceImpl service = new SysDictDataServiceImpl(mock(SysUserDao.class), redisUtils);
List<SysDictDataItem> result = service.getDictDataByType("status");
assertNotSame(cached, result);
assertEquals(List.of(item), result);
}
}
@@ -0,0 +1,36 @@
package xiaozhi.modules.sys.service.impl;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.constant.Constant;
import xiaozhi.modules.agent.service.AgentPluginMappingService;
import xiaozhi.modules.sys.dao.SysParamsDao;
import xiaozhi.modules.sys.redis.SysParamsRedis;
class SysParamsServiceImplTest {
@Test
void disablingAddressBookStillDeletesItsSystemPlugin() {
SysParamsRedis sysParamsRedis = mock(SysParamsRedis.class);
AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class);
SysParamsDao sysParamsDao = mock(SysParamsDao.class);
SysParamsServiceImpl service = new SysParamsServiceImpl(sysParamsRedis, pluginMappingService);
ReflectionTestUtils.setField(service, "baseDao", sysParamsDao);
String currentConfig = "{\"features\":{\"addressBook\":{\"enabled\":true}}}";
String newConfig = "{\"features\":{\"addressBook\":{\"enabled\":false}}}";
when(sysParamsDao.getValueByCode(Constant.SYSTEM_WEB_MENU)).thenReturn(currentConfig);
when(sysParamsDao.updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig)).thenReturn(1);
service.updateSystemWebMenu(newConfig);
verify(pluginMappingService).deleteByPluginId("SYSTEM_PLUGIN_CALL_DEVICE");
verify(sysParamsDao).updateValueByCode(Constant.SYSTEM_WEB_MENU, newConfig);
verify(sysParamsRedis).set(Constant.SYSTEM_WEB_MENU, newConfig);
}
}
@@ -1,4 +1,7 @@
spring:
messages:
encoding: UTF-8
basename: i18n/messages
data:
redis:
host: localhost
@@ -265,7 +265,7 @@ function showAbout() {
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE,
version: '0.9.5',
version: '0.9.6',
}),
showCancel: false,
confirmText: t('common.confirm'),
+134 -25
View File
@@ -3,13 +3,14 @@ import uuid
import signal
import asyncio
from aioconsole import ainput
from config.settings import load_config
from config.config_loader import load_config
from config.logger import setup_logging
from core.utils.util import get_local_ip, validate_mcp_endpoint
from core.http_server import SimpleHttpServer
from core.websocket_server import WebSocketServer
from core.xiaozhi_server_facade import XiaozhiServerFacade
from core.utils.util import check_ffmpeg_installed
from core.utils.gc_manager import get_gc_manager
from config.manage_api_client import manage_api_http_close
TAG = __name__
logger = setup_logging()
@@ -68,12 +69,49 @@ async def main():
gc_manager = get_gc_manager(interval_seconds=300)
await gc_manager.start()
# 启动 WebSocket 服务器
ws_server = WebSocketServer(config)
ws_task = asyncio.create_task(ws_server.start())
# 启动 Simple http 服务器
ota_server = SimpleHttpServer(config)
ota_task = asyncio.create_task(ota_server.start())
# 启动小智服务器门面(支持WebSocket和MQTT
xiaozhi_server = XiaozhiServerFacade(config)
ota_server = None
ota_task = None
try:
# Facade.start() returns after every listener is ready. Await it so bind
# or configuration failures stop startup instead of leaving a partial process.
await xiaozhi_server.start()
ota_server = SimpleHttpServer(
config, management_owner=xiaozhi_server
)
ota_task = asyncio.create_task(
ota_server.start(), name="xiaozhi-http-server"
)
await ota_server.wait_started(
ota_task,
timeout=float(config.get("server_startup_timeout", 10)),
)
except Exception:
if ota_server:
try:
await ota_server.stop()
except Exception as cleanup_error:
logger.bind(tag=TAG).error(f"停止HTTP服务器失败: {cleanup_error}")
if ota_task:
if not ota_task.done():
ota_task.cancel()
await asyncio.gather(ota_task, return_exceptions=True)
try:
await xiaozhi_server.stop()
except Exception as cleanup_error:
logger.bind(tag=TAG).error(f"停止协议服务器失败: {cleanup_error}")
try:
await gc_manager.stop()
except Exception as cleanup_error:
logger.bind(tag=TAG).error(f"停止GC管理器失败: {cleanup_error}")
try:
await manage_api_http_close()
except Exception as cleanup_error:
logger.bind(tag=TAG).error(f"关闭管理API客户端失败: {cleanup_error}")
stdin_task.cancel()
await asyncio.gather(stdin_task, return_exceptions=True)
raise
read_config_from_api = config.get("read_config_from_api", False)
port = int(config["server"].get("http_port", 8003))
@@ -100,23 +138,50 @@ async def main():
logger.bind(tag=TAG).error("mcp接入点不符合规范")
config["mcp_endpoint"] = "你的接入点 websocket地址"
# 获取WebSocket配置,使用安全的默认值
websocket_port = 8000
server_config = config.get("server", {})
if isinstance(server_config, dict):
websocket_port = int(server_config.get("port", 8000))
# 显示协议连接信息
connection_info = xiaozhi_server.get_connection_info()
# WebSocket信息
websocket_info = connection_info.get("websocket", {})
if websocket_info.get("enabled", False):
websocket_port = websocket_info.get("port", 8000)
logger.bind(tag=TAG).info(
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
"WebSocket地址是\tws://{}:{}/xiaozhi/v1/",
get_local_ip(),
websocket_port,
)
# MQTT信息
mqtt_info = connection_info.get("mqtt", {})
if mqtt_info.get("enabled", False):
mqtt_port = mqtt_info.get("port", 1883)
udp_port = mqtt_info.get("udp_port", 1883)
logger.bind(tag=TAG).info(
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
"MQTT地址是\t\tmqtt://{}:{}",
get_local_ip(),
mqtt_port,
)
logger.bind(tag=TAG).info(
"如想测试websocket请启动digital-human模块,打开浏览器交互测试"
"UDP音频端口是\t{}:{}",
get_local_ip(),
udp_port,
)
# 显示启用的协议
enabled_protocols = xiaozhi_server.config.get("enabled_protocols", [])
logger.bind(tag=TAG).info(f"启用的协议: {', '.join(enabled_protocols)}")
if "websocket" in enabled_protocols:
logger.bind(tag=TAG).info(
"=======上面的WebSocket地址请勿用浏览器访问======="
)
logger.bind(tag=TAG).info(
"如想测试WebSocket请启动digital-human模块,打开浏览器交互测试"
)
if "mqtt" in enabled_protocols:
logger.bind(tag=TAG).info(
"=======MQTT客户端ID格式: GID_test@@@mac_address@@@uuid======="
)
logger.bind(tag=TAG).info(
"=============================================================\n"
@@ -127,22 +192,66 @@ async def main():
except asyncio.CancelledError:
print("任务被取消,清理资源中...")
finally:
shutdown_errors = []
if ota_server:
try:
await ota_server.stop()
except Exception as e:
shutdown_errors.append(("HTTP服务器", e))
logger.bind(tag=TAG).error(f"停止HTTP服务器失败: {e}")
# 停止小智服务器
try:
await xiaozhi_server.stop()
except Exception as e:
shutdown_errors.append(("协议服务器", e))
logger.bind(tag=TAG).error(f"停止小智服务器失败: {e}")
# 停止全局GC管理器
try:
await gc_manager.stop()
except Exception as e:
shutdown_errors.append(("GC管理器", e))
logger.bind(tag=TAG).error(f"停止GC管理器失败: {e}")
# 取消所有任务(关键修复点)
try:
await manage_api_http_close()
except Exception as e:
shutdown_errors.append(("管理API客户端", e))
logger.bind(tag=TAG).error(f"关闭管理API客户端失败: {e}")
# stdin 可立即取消;HTTP task 先获得窗口完成 runner.cleanup()。
stdin_task.cancel()
ws_task.cancel()
if ota_task:
ota_task.cancel()
await asyncio.gather(stdin_task, return_exceptions=True)
# 等待任务终止(必须加超时)
await asyncio.wait(
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
if ota_task:
done, pending = await asyncio.wait({ota_task}, timeout=3.0)
if pending:
ota_task.cancel()
await asyncio.gather(ota_task, return_exceptions=True)
elif done and not ota_task.cancelled():
try:
ota_task.result()
except Exception as e:
shutdown_errors.append((ota_task.get_name(), e))
logger.bind(tag=TAG).error(
f"后台任务退出失败: {ota_task.get_name()}: {e}"
)
# If task cancellation or its first cleanup attempt retained the
# runner, retry after the task has fully relinquished ownership.
if ota_server:
try:
await ota_server.stop()
except Exception as e:
shutdown_errors.append(("HTTP服务器重试清理", e))
logger.bind(tag=TAG).error(f"重试停止HTTP服务器失败: {e}")
print("服务器已关闭,程序退出。")
if shutdown_errors:
details = ", ".join(
f"{owner}: {error}" for owner, error in shutdown_errors
)
raise RuntimeError(f"服务器清理失败: {details}")
if __name__ == "__main__":
+63
View File
@@ -41,6 +41,69 @@ server:
mqtt_signature_key: null
# UDP网关配置
udp_gateway: null
# #####################################################################################
# #############################协议配置(Protocol Configuration########################
# 支持WebSocket和MQTT两种协议,可以单独启用或同时启用
protocols:
# 启用的协议列表,可选值: ["websocket", "mqtt"]
enabled_protocols: ["websocket"] # 默认只启用WebSocket
# WebSocket协议开关
websocket_enabled: true
# MQTT协议开关
mqtt_enabled: false
# 同时驻留的本地共享ASR模型上限(包含公共模型)。Agent差异化ASR会按配置复用。
shared_asr_max_models: 3
# MQTT服务器配置(仅在mqtt_enabled为true时生效)
mqtt_server:
# 是否启用MQTT服务器
enabled: false
# MQTT服务器监听地址
host: 0.0.0.0
# MQTT服务器端口
port: 1883
# UDP音频传输端口(通常与MQTT端口相同)
udp_port: 1883
# UDP监听地址。留空时若public_endpoint是本机IPv4,会优先绑定该地址,
# 确保设备收到的UDP回包源地址与hello中声明的地址一致。
udp_bind_host: ""
# 公网/局域网可达地址(设备连接时使用)
public_endpoint: ""
# MQTT签名密钥(用于生成连接密码)
signature_key: ""
# 最大连接数
max_connections: 1000
# 尚未完成CONNECT认证的连接上限;重复clientId替换不占用新的活跃名额
max_pending_connections: 128
# 心跳检查间隔(秒)
heartbeat_interval: 30
# 最大消息载荷大小(字节)
max_payload_size: 8192
# MQTT应用控制消息队列上限
message_queue_size: 128
# Hello等待业务运行时就绪的最长时间(秒)
business_ready_timeout: 30
# 发送goodbye和释放物理连接的最长等待时间(秒)
goodbye_timeout: 1
close_timeout: 2
# MQTT协议使用说明:
# 1. 客户端ID格式:GID_test@@@mac_address@@@uuid 或 GID_test@@@mac_address
# 例如:GID_test@@@aa:bb:cc:dd:ee:ff@@@unique_uuid_123
# 2. 连接地址:mqtt://your.server.ip:1883
# 3. 音频传输:通过UDP加密传输,配置信息在hello消息中返回
# 4. 消息格式:JSON格式,支持hello、音频、文本等消息类型
#
# 启用MQTT的配置示例:
# protocols:
# enabled_protocols: ["websocket", "mqtt"] # 同时启用两种协议
# mqtt_enabled: true
# mqtt_server:
# enabled: true
# port: 1883
# public_endpoint: "your.server.ip" # 替换为实际IP
log:
# 设置控制台输出的日志格式,时间、日志级别、标签、消息
log_format: "<green>{time:YYMMDD HH:mm:ss}</green>[{version}_{selected_module}][<light-blue>{extra[tag]}</light-blue>]-<level>{level}</level>-<light-green>{message}</light-green>"
+1 -1
View File
@@ -6,7 +6,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from core.utils.cache.manager import cache_manager, CacheType
SERVER_VERSION = "0.9.5"
SERVER_VERSION = "0.9.6"
_logger_initialized = False
@@ -80,6 +80,28 @@ class ManageApiClient:
# 如果没有运行中的事件循环,创建一个临时的
raise Exception("必须在异步上下文中调用")
@classmethod
async def close_current_loop_client(cls):
"""Close the client owned by the running loop before that loop exits."""
import asyncio
loop_id = id(asyncio.get_running_loop())
client = cls._async_clients.pop(loop_id, None)
if client is not None:
await client.aclose()
@classmethod
async def close_all_clients(cls):
"""Close remaining clients after session/report workers have stopped."""
clients = list(cls._async_clients.values())
cls._async_clients.clear()
for client in clients:
try:
await client.aclose()
except Exception:
pass
cls._instance = None
@classmethod
async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
"""发送单次异步HTTP请求并处理响应"""
@@ -266,3 +288,7 @@ def init_service(config):
def manage_api_http_safe_close():
ManageApiClient.safe_close()
async def manage_api_http_close():
await ManageApiClient.close_all_clients()
@@ -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})
+121 -57
View File
@@ -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 = ""
# 生成密码
password = ""
signature_key = server_config.get("mqtt_signature_key", "")
if signature_key:
password = self.generate_password_signature(
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
)
if not password:
password = "" # 签名失败则留空,由设备决定是否允许无密码
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()
# 生成密码
mqtt_password = ""
signature_key = server_config.get("mqtt_signature_key", "")
if signature_key:
mqtt_password = generate_password_signature(
mqtt_client_id + "|" + username, signature_key
)
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:
+158
View File
@@ -10,6 +10,164 @@ class AuthenticationError(Exception):
pass
class AuthMiddleware:
"""
认证中间件
用于 WebSocket/MQTT 连接认证
集成 AuthManager token 验证逻辑支持多种认证方式
"""
def __init__(self, config: dict):
"""
初始化认证中间件
Args:
config: 配置字典包含认证相关配置
"""
self.config = config
server_config = config.get("server", {})
auth_config = server_config.get("auth", {})
self.enabled = auth_config.get("enabled", False)
self.tokens = auth_config.get("tokens", [])
self.allowed_devices = set(auth_config.get("allowed_devices", []))
# 获取 auth_key 用于 HMAC token 验证
self.auth_key = server_config.get("auth_key", "")
expire_seconds = auth_config.get("expire_seconds", None)
# 创建 AuthManager 实例用于 HMAC token 验证
if self.auth_key:
self._auth_manager = AuthManager(
secret_key=self.auth_key,
expire_seconds=expire_seconds
)
else:
self._auth_manager = None
def authenticate(self, device_id: str, token: str = None, client_id: str = None) -> bool:
"""
验证设备认证同步方法
Args:
device_id: 设备 ID
token: 认证令牌可以是静态 token HMAC token
client_id: 客户端 ID用于 HMAC token 验证
Returns:
bool: 认证是否通过
"""
from config.logger import setup_logging
logger = setup_logging()
if not self.enabled:
logger.debug("[AuthMiddleware.authenticate] 认证未启用")
return True
# 1. 检查白名单
if device_id and device_id in self.allowed_devices:
logger.debug(f"[AuthMiddleware.authenticate] 设备 {device_id} 在白名单中,放行")
return True
# 2. 检查静态 token
if token:
# 移除 Bearer 前缀(如果有)
if token.startswith("Bearer "):
token = token[7:]
logger.debug(f"[AuthMiddleware.authenticate] 检查静态token, 配置了 {len(self.tokens)} 个token")
for token_config in self.tokens:
configured_token = token_config.get("token")
if configured_token == token:
logger.debug(f"[AuthMiddleware.authenticate] 静态token匹配成功")
return True
logger.debug(f"[AuthMiddleware.authenticate] 静态token不匹配")
# 3. 检查 HMAC token(需要 AuthManager
if token and self._auth_manager and client_id and device_id:
logger.debug(f"[AuthMiddleware.authenticate] 尝试HMAC token验证")
if self._auth_manager.verify_token(token, client_id, device_id):
logger.debug(f"[AuthMiddleware.authenticate] HMAC token验证成功")
return True
logger.debug(f"[AuthMiddleware.authenticate] HMAC token验证失败")
logger.debug(f"[AuthMiddleware.authenticate] 所有认证方式均失败")
return False
async def authenticate_async(self, headers: dict) -> bool:
"""
headers 中提取信息并进行异步认证
Args:
headers: HTTP 请求头字典
Returns:
bool: 认证是否通过
Raises:
AuthenticationError: 认证失败时抛出
"""
from config.logger import setup_logging
logger = setup_logging()
logger.debug(f"[AuthMiddleware] 开始认证, enabled={self.enabled}")
if not self.enabled:
logger.debug("[AuthMiddleware] 认证未启用,跳过认证")
return True
device_id = headers.get("device-id")
client_id = headers.get("client-id")
authorization = headers.get("authorization", "")
logger.debug(f"[AuthMiddleware] device_id={device_id}, client_id={client_id}, has_auth={bool(authorization)}")
# 提取 token
token = None
if authorization:
if authorization.startswith("Bearer "):
token = authorization[7:]
else:
token = authorization
# 执行认证
auth_result = self.authenticate(device_id, token, client_id)
logger.debug(f"[AuthMiddleware] 认证结果: {auth_result}")
if auth_result:
return True
raise AuthenticationError(f"认证失败: device_id={device_id}")
def authenticate_websocket(self, websocket) -> bool:
"""
WebSocket 连接认证
Args:
websocket: WebSocket 连接对象
Returns:
bool: 认证是否通过
"""
if not self.enabled:
return True
headers = dict(websocket.request.headers)
device_id = headers.get("device-id")
client_id = headers.get("client-id")
authorization = headers.get("authorization", "")
# 提取 token
token = None
if authorization:
if authorization.startswith("Bearer "):
token = authorization[7:]
else:
token = authorization
return self.authenticate(device_id, token, client_id)
class AuthManager:
"""
统一授权认证管理器
@@ -0,0 +1,116 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import asr
from core.utils.modules_initialize import initialize_asr
from config.logger import setup_logging
logger = setup_logging()
TAG = __name__
class ASRAdapter(Component):
"""
ASR组件适配器将现有ASR组件包装为新的组件接口
支持两种模式
1. 共享实例模式使用 SharedASRManager 的全局共享实例
2. 独立实例模式每个连接创建独立的 ASR 实例原有逻辑
"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.ASR, config)
self._asr_instance = None
self._delete_audio = config.get("delete_audio", True)
self._using_shared = False # 是否使用共享实例
self._shared_owner = None
async def _do_initialize(self, context: Any) -> None:
"""初始化ASR组件"""
try:
# 获取ASR配置
selected_module = self.config.get("selected_module", {}).get("ASR")
if not selected_module:
raise ValueError("未配置ASR模块")
# 检查是否有全局共享 ASR 管理器
shared_manager = getattr(context, 'shared_asr_manager', None)
acquired_manager = None
if shared_manager and hasattr(shared_manager, "acquire_for_config"):
acquired_manager = await shared_manager.acquire_for_config(
self.config
)
elif (
shared_manager
and shared_manager.is_ready()
and shared_manager.matches_config(self.config)
):
acquired_manager = shared_manager
if acquired_manager is not None:
# 使用共享实例模式
logger.bind(tag=TAG).info(f"使用共享 ASR 实例: {selected_module}")
from core.providers.asr.shared_asr_proxy import SharedASRProxy
self._asr_instance = SharedASRProxy(acquired_manager)
self._using_shared = True
self._shared_owner = shared_manager
else:
# 使用独立实例模式(原有逻辑)
if shared_manager and shared_manager.is_ready():
logger.bind(tag=TAG).info(
f"连接ASR配置与共享实例不匹配,使用独立实例: {selected_module}"
)
logger.bind(tag=TAG).info(f"使用独立 ASR 实例: {selected_module}")
self._asr_instance = initialize_asr(self.config)
self._using_shared = False
# Legacy providers inspect conn.asr while handling stream state.
context.asr = self._asr_instance
# 打开音频通道(如果需要)
if hasattr(self._asr_instance, 'open_audio_channels'):
await self._asr_instance.open_audio_channels(context)
logger.bind(tag=TAG).info(
f"ASR组件初始化完成: {selected_module}, "
f"共享模式: {self._using_shared}"
)
except Exception as e:
logger.bind(tag=TAG).error(f"ASR组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理ASR组件"""
if self._asr_instance:
instance = self._asr_instance
if self._using_shared:
await self._close_resource(instance)
if self._shared_owner and hasattr(
self._shared_owner, "release_for_config"
):
await self._shared_owner.release_for_config(instance.manager)
logger.bind(tag=TAG).debug("共享 ASR 代理已释放")
else:
await self._close_resource(instance)
if hasattr(instance, 'cleanup_audio_files'):
instance.cleanup_audio_files()
logger.bind(tag=TAG).info("ASR组件清理完成")
self._asr_instance = None
self._using_shared = False
self._shared_owner = None
@property
def asr_instance(self):
"""获取ASR实例"""
return self._asr_instance
class ASRFactory(ComponentFactory):
"""ASR组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return ASRAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.ASR
@@ -0,0 +1,92 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import intent, llm
from config.logger import setup_logging
logger = setup_logging()
class IntentAdapter(Component):
"""Intent组件适配器:将现有Intent组件包装为新的组件接口"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.INTENT, config)
self._intent_instance = None
self._owned_llm_instance = None
async def _do_initialize(self, context: Any) -> None:
"""初始化Intent组件"""
try:
# 获取Intent配置
selected_module = self.config.get("selected_module", {}).get("Intent")
if not selected_module:
raise ValueError("未配置Intent模块")
# 获取Intent类型
intent_type = (
selected_module
if "type" not in self.config["Intent"][selected_module]
else self.config["Intent"][selected_module]["type"]
)
# 创建Intent实例
self._intent_instance = intent.create_instance(
intent_type,
self.config["Intent"][selected_module],
)
# intent_llm 可配置独立模型;未配置时回退主 LLM。
if intent_type == "intent_llm":
llm_component = None
if getattr(context, "component_manager", None):
llm_component = await context.component_manager.get_component(ComponentType.LLM, context)
main_llm = getattr(llm_component, "llm_instance", None)
intent_config = self.config["Intent"][selected_module]
dedicated_llm_name = intent_config.get("llm")
selected_llm = main_llm
if (
dedicated_llm_name
and dedicated_llm_name in self.config.get("LLM", {})
):
dedicated_config = self.config["LLM"][dedicated_llm_name]
dedicated_type = dedicated_config.get("type", dedicated_llm_name)
self._owned_llm_instance = llm.create_instance(
dedicated_type, dedicated_config
)
selected_llm = self._owned_llm_instance
logger.info(f"为意图识别创建专用LLM: {dedicated_llm_name}")
if selected_llm and hasattr(self._intent_instance, "set_llm"):
self._intent_instance.set_llm(selected_llm)
logger.info(f"Intent组件初始化完成: {intent_type}")
except Exception as e:
logger.error(f"Intent组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理Intent组件"""
if self._intent_instance:
instance = self._intent_instance
await self._close_resource(instance)
self._intent_instance = None
logger.info("Intent组件清理完成")
if self._owned_llm_instance:
instance = self._owned_llm_instance
await self._close_resource(instance)
self._owned_llm_instance = None
@property
def intent_instance(self):
"""获取Intent实例"""
return self._intent_instance
class IntentFactory(ComponentFactory):
"""Intent组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return IntentAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.INTENT
@@ -0,0 +1,64 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import llm
from config.logger import setup_logging
logger = setup_logging()
class LLMAdapter(Component):
"""LLM组件适配器:将现有LLM组件包装为新的组件接口"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.LLM, config)
self._llm_instance = None
async def _do_initialize(self, context: Any) -> None:
"""初始化LLM组件"""
try:
# 获取LLM配置
selected_module = self.config.get("selected_module", {}).get("LLM")
if not selected_module:
raise ValueError("未配置LLM模块")
# 获取LLM类型
llm_type = (
selected_module
if "type" not in self.config["LLM"][selected_module]
else self.config["LLM"][selected_module]["type"]
)
# 创建LLM实例
self._llm_instance = llm.create_instance(
llm_type,
self.config["LLM"][selected_module],
)
logger.info(f"LLM组件初始化完成: {llm_type}")
except Exception as e:
logger.error(f"LLM组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理LLM组件"""
if self._llm_instance:
instance = self._llm_instance
await self._close_resource(instance)
self._llm_instance = None
logger.info("LLM组件清理完成")
@property
def llm_instance(self):
"""获取LLM实例"""
return self._llm_instance
class LLMFactory(ComponentFactory):
"""LLM组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return LLMAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.LLM
@@ -0,0 +1,103 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import llm, memory
from config.logger import setup_logging
logger = setup_logging()
class MemoryAdapter(Component):
"""Memory组件适配器:将现有Memory组件包装为新的组件接口"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.MEMORY, config)
self._memory_instance = None
self._owned_llm_instance = None
async def _do_initialize(self, context: Any) -> None:
"""初始化Memory组件"""
try:
# 获取Memory配置
selected_module = self.config.get("selected_module", {}).get("Memory")
if not selected_module:
raise ValueError("未配置Memory模块")
# 获取Memory类型
memory_type = (
selected_module
if "type" not in self.config["Memory"][selected_module]
else self.config["Memory"][selected_module]["type"]
)
# 创建Memory实例
self._memory_instance = memory.create_instance(
memory_type,
self.config["Memory"][selected_module],
self.config.get("summaryMemory", None),
)
# 初始化记忆模块
main_llm = None
if hasattr(self._memory_instance, 'init_memory'):
# 需要LLM实例来初始化记忆
llm_component = None
if getattr(context, "component_manager", None):
llm_component = await context.component_manager.get_component(ComponentType.LLM, context)
if llm_component and hasattr(llm_component, 'llm_instance'):
main_llm = llm_component.llm_instance
self._memory_instance.init_memory(
role_id=context.device_id,
llm=main_llm,
summary_memory=self.config.get("summaryMemory", None),
save_to_file=not self.config.get("read_config_from_api", False),
)
memory_config = self.config["Memory"][selected_module]
dedicated_llm_name = memory_config.get("llm")
if (
memory_type == "mem_local_short"
and dedicated_llm_name
and dedicated_llm_name in self.config.get("LLM", {})
):
dedicated_config = self.config["LLM"][dedicated_llm_name]
dedicated_type = dedicated_config.get("type", dedicated_llm_name)
self._owned_llm_instance = llm.create_instance(
dedicated_type, dedicated_config
)
self._memory_instance.set_llm(self._owned_llm_instance)
logger.info(f"为记忆总结创建专用LLM: {dedicated_llm_name}")
elif main_llm and hasattr(self._memory_instance, "set_llm"):
self._memory_instance.set_llm(main_llm)
logger.info(f"Memory组件初始化完成: {memory_type}")
except Exception as e:
logger.error(f"Memory组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理Memory组件"""
if self._memory_instance:
instance = self._memory_instance
await self._close_resource(instance)
self._memory_instance = None
logger.info("Memory组件清理完成")
if self._owned_llm_instance:
instance = self._owned_llm_instance
await self._close_resource(instance)
self._owned_llm_instance = None
@property
def memory_instance(self):
"""获取Memory实例"""
return self._memory_instance
class MemoryFactory(ComponentFactory):
"""Memory组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return MemoryAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.MEMORY
@@ -0,0 +1,76 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import tts
from config.logger import setup_logging
logger = setup_logging()
class TTSAdapter(Component):
"""TTS组件适配器:将现有TTS组件包装为新的组件接口"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.TTS, config)
self._tts_instance = None
self._delete_audio = config.get("delete_audio", True)
async def _do_initialize(self, context: Any) -> None:
"""初始化TTS组件"""
try:
# 获取TTS配置
selected_module = self.config.get("selected_module", {}).get("TTS")
if not selected_module:
raise ValueError("未配置TTS模块")
# 获取TTS类型
tts_type = (
selected_module
if "type" not in self.config["TTS"][selected_module]
else self.config["TTS"][selected_module]["type"]
)
# 创建TTS实例
self._tts_instance = tts.create_instance(
tts_type,
self.config["TTS"][selected_module],
str(self._delete_audio).lower() in ("true", "1", "yes"),
)
# 打开音频通道
if hasattr(self._tts_instance, 'open_audio_channels'):
await self._tts_instance.open_audio_channels(context)
# 设置兼容属性(用于向后兼容)
if hasattr(context, 'tts'):
context.tts = self._tts_instance
logger.info(f"TTS组件初始化完成: {tts_type}")
except Exception as e:
logger.error(f"TTS组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理TTS组件"""
if self._tts_instance:
instance = self._tts_instance
await self._close_resource(instance)
if hasattr(instance, 'cleanup_audio_files'):
instance.cleanup_audio_files()
self._tts_instance = None
logger.info("TTS组件清理完成")
@property
def tts_instance(self):
"""获取TTS实例"""
return self._tts_instance
class TTSFactory(ComponentFactory):
"""TTS组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return TTSAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.TTS
@@ -0,0 +1,64 @@
from typing import Any, Dict
from core.components.component_manager import Component, ComponentType, ComponentFactory
from core.utils import vad
from config.logger import setup_logging
logger = setup_logging()
class VADAdapter(Component):
"""VAD组件适配器:将现有VAD组件包装为新的组件接口"""
def __init__(self, config: Dict[str, Any]):
super().__init__(ComponentType.VAD, config)
self._vad_instance = None
async def _do_initialize(self, context: Any) -> None:
"""初始化VAD组件"""
try:
# 获取VAD配置
selected_module = self.config.get("selected_module", {}).get("VAD")
if not selected_module:
raise ValueError("未配置VAD模块")
# 获取VAD类型
vad_type = (
selected_module
if "type" not in self.config["VAD"][selected_module]
else self.config["VAD"][selected_module]["type"]
)
# 创建VAD实例
self._vad_instance = vad.create_instance(
vad_type,
self.config["VAD"][selected_module],
)
logger.info(f"VAD组件初始化完成: {vad_type}")
except Exception as e:
logger.error(f"VAD组件初始化失败: {e}")
raise
async def _do_cleanup(self) -> None:
"""清理VAD组件"""
if self._vad_instance:
instance = self._vad_instance
await self._close_resource(instance)
self._vad_instance = None
logger.info("VAD组件清理完成")
@property
def vad_instance(self):
"""获取VAD实例"""
return self._vad_instance
class VADFactory(ComponentFactory):
"""VAD组件工厂"""
def create(self, config: Dict[str, Any]) -> Component:
return VADAdapter(config)
def get_component_type(self) -> ComponentType:
return ComponentType.VAD
@@ -0,0 +1,268 @@
import asyncio
import inspect
import weakref
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, Optional, Type, TypeVar, Generic
from enum import Enum
from config.logger import setup_logging
T = TypeVar('T')
logger = setup_logging()
class ComponentType(Enum):
"""组件类型枚举"""
TTS = "tts"
ASR = "asr"
VAD = "vad"
LLM = "llm"
MEMORY = "memory"
INTENT = "intent"
class ComponentState(Enum):
"""组件状态枚举"""
UNINITIALIZED = "uninitialized"
INITIALIZING = "initializing"
READY = "ready"
ERROR = "error"
CLEANING = "cleaning"
CLEANED = "cleaned"
class Component(ABC):
"""组件基类:定义统一的组件接口和生命周期管理"""
def __init__(self, component_type: ComponentType, config: Dict[str, Any]):
self.component_type = component_type
self.config = config
self.state = ComponentState.UNINITIALIZED
self._initialization_lock = asyncio.Lock()
self._cleanup_lock = asyncio.Lock()
self._dependencies: Dict[str, 'Component'] = {}
self._dependents: weakref.WeakSet['Component'] = weakref.WeakSet()
self._resources: list = [] # 存储需要清理的资源
@abstractmethod
async def _do_initialize(self, context: Any) -> None:
"""子类实现具体的初始化逻辑"""
pass
@abstractmethod
async def _do_cleanup(self) -> None:
"""子类实现具体的清理逻辑"""
pass
async def initialize(self, context: Any) -> None:
"""初始化组件(带锁保护)"""
async with self._initialization_lock:
if self.state != ComponentState.UNINITIALIZED:
return
try:
self.state = ComponentState.INITIALIZING
logger.info(f"正在初始化组件: {self.component_type.value}")
# 初始化依赖组件
await self._initialize_dependencies(context)
# 执行具体初始化
await self._do_initialize(context)
self.state = ComponentState.READY
logger.info(f"组件初始化完成: {self.component_type.value}")
except Exception as e:
self.state = ComponentState.ERROR
logger.error(f"组件初始化失败: {self.component_type.value}, 错误: {e}")
# 初始化可能已经分配线程、连接或文件,失败路径也必须释放。
try:
await self._do_cleanup()
await self._cleanup_resources()
except Exception as cleanup_error:
logger.error(
f"组件初始化回滚失败: {self.component_type.value}, "
f"错误: {cleanup_error}"
)
raise
async def cleanup(self) -> None:
"""清理组件(带锁保护)"""
async with self._cleanup_lock:
if self.state in [ComponentState.CLEANING, ComponentState.CLEANED]:
return
try:
self.state = ComponentState.CLEANING
logger.info(f"正在清理组件: {self.component_type.value}")
# 清理依赖此组件的其他组件
await self._cleanup_dependents()
# 执行具体清理
await self._do_cleanup()
# 清理资源
await self._cleanup_resources()
self.state = ComponentState.CLEANED
logger.info(f"组件清理完成: {self.component_type.value}")
except Exception as e:
logger.error(f"组件清理失败: {self.component_type.value}, 错误: {e}")
self.state = ComponentState.ERROR
raise
def add_dependency(self, name: str, component: 'Component') -> None:
"""添加依赖组件"""
self._dependencies[name] = component
component._dependents.add(self)
def add_resource(self, resource: Any) -> None:
"""添加需要清理的资源"""
self._resources.append(resource)
async def _initialize_dependencies(self, context: Any) -> None:
"""初始化依赖组件"""
for name, dep in self._dependencies.items():
if dep.state == ComponentState.UNINITIALIZED:
await dep.initialize(context)
async def _cleanup_dependents(self) -> None:
"""清理依赖此组件的其他组件"""
for dependent in list(self._dependents):
await dependent.cleanup()
async def _cleanup_resources(self) -> None:
"""清理所有注册的资源"""
for resource in self._resources:
try:
await self._close_resource(resource)
except Exception as e:
logger.warning(f"清理资源时出错: {e}")
self._resources.clear()
@staticmethod
async def _close_resource(resource: Any) -> None:
"""Close either sync or async providers without invoking them twice."""
cleanup = getattr(resource, "close", None) or getattr(resource, "cleanup", None)
if not cleanup:
return
result = cleanup()
if inspect.isawaitable(result):
await result
class ComponentFactory(ABC):
"""组件工厂基类"""
@abstractmethod
def create(self, config: Dict[str, Any]) -> Component:
"""创建组件实例"""
pass
@abstractmethod
def get_component_type(self) -> ComponentType:
"""获取组件类型"""
pass
class ComponentManager:
"""
组件管理器统一管理连接期内的组件实例生命周期
支持分类管理依赖注入按需懒加载与统一清理
"""
def __init__(self, config: Dict[str, Any]):
self._config = config
self._components: Dict[str, Component] = {}
self._factories: Dict[ComponentType, ComponentFactory] = {}
self._component_locks: Dict[str, asyncio.Lock] = {}
self._initialization_order: list[ComponentType] = []
self.lazy_enabled = True
def register_factory(self, factory: ComponentFactory) -> None:
"""注册组件工厂"""
component_type = factory.get_component_type()
self._factories[component_type] = factory
logger.debug(f"注册组件工厂: {component_type.value}")
def set_initialization_order(self, order: list[ComponentType]) -> None:
"""设置组件初始化顺序"""
self._initialization_order = order
async def get_component(self, component_type: ComponentType, context: Any) -> Optional[Component]:
"""获取组件实例(按需创建)"""
key = component_type.value
lock = self._component_locks.setdefault(key, asyncio.Lock())
async with lock:
if key in self._components:
return self._components[key]
if not self.lazy_enabled:
logger.warning(f"组件未初始化且已关闭懒加载: {component_type.value}")
return None
factory = self._factories.get(component_type)
if factory is None:
logger.warning(f"未找到组件工厂: {component_type.value}")
return None
try:
instance = factory.create(self._config)
# 先登记所有权,确保初始化中途失败时资源仍然可达。
self._components[key] = instance
await instance.initialize(context)
logger.info(f"组件创建并初始化完成: {component_type.value}")
except Exception as e:
logger.error(f"组件创建失败: {component_type.value}, 错误: {e}")
failed = self._components.pop(key, None)
if failed is not None:
await failed.cleanup()
return None
return self._components.get(key)
def get(self, component_name: str) -> Optional[Component]:
"""获取已初始化的组件实例(兼容接口)"""
return self._components.get(component_name)
async def initialize_all(self, context: Any) -> None:
"""按顺序初始化所有组件"""
for component_type in self._initialization_order:
await self.get_component(component_type, context)
async def cleanup_all(self) -> None:
"""清理所有组件(逆序清理)"""
errors = []
# 按逆序清理,确保依赖关系正确
for component_type in reversed(self._initialization_order):
key = component_type.value
if key in self._components:
component = self._components.pop(key)
try:
await component.cleanup()
except Exception as e:
errors.append((key, e))
# 清理可能遗漏的组件
remaining_components = list(self._components.items())
for key, component in remaining_components:
try:
await component.cleanup()
except Exception as e:
errors.append((key, e))
self._components.clear()
self._component_locks.clear()
logger.info("所有组件已清理完成")
if errors:
details = ", ".join(f"{name}: {error}" for name, error in errors)
raise RuntimeError(f"部分组件清理失败: {details}")
def get_component_status(self) -> Dict[str, str]:
"""获取所有组件状态"""
return {
name: component.state.value
for name, component in self._components.items()
}
@@ -0,0 +1,73 @@
from typing import Dict, Any
from core.components.component_manager import ComponentManager, ComponentType
from core.components.adapters.tts_adapter import TTSFactory
from core.components.adapters.asr_adapter import ASRFactory
from core.components.adapters.vad_adapter import VADFactory
from core.components.adapters.llm_adapter import LLMFactory
from core.components.adapters.memory_adapter import MemoryFactory
from core.components.adapters.intent_adapter import IntentFactory
from config.logger import setup_logging
logger = setup_logging()
class ComponentRegistry:
"""组件注册器:统一管理所有组件工厂的注册"""
_factories_registered = False
@classmethod
def create_component_manager(cls, config: Dict[str, Any]) -> ComponentManager:
"""创建并配置组件管理器"""
manager = ComponentManager(config)
# 只在第一次时记录注册日志
if not cls._factories_registered:
logger.info("注册组件工厂")
cls._factories_registered = True
# 注册所有组件工厂(每个manager都需要注册,但不重复记录日志)
manager.register_factory(TTSFactory())
manager.register_factory(ASRFactory())
manager.register_factory(VADFactory())
manager.register_factory(LLMFactory())
manager.register_factory(MemoryFactory())
manager.register_factory(IntentFactory())
# 设置组件初始化顺序(考虑依赖关系)
# VAD -> ASR -> LLM -> Memory/Intent -> TTS
initialization_order = [
ComponentType.VAD,
ComponentType.ASR,
ComponentType.LLM,
ComponentType.MEMORY,
ComponentType.INTENT,
ComponentType.TTS,
]
manager.set_initialization_order(initialization_order)
if not cls._factories_registered:
logger.info("组件管理器创建完成,已注册所有组件工厂")
return manager
@staticmethod
def get_required_components(config: Dict[str, Any]) -> list[ComponentType]:
"""根据配置获取需要的组件类型"""
required = []
selected_modules = config.get("selected_module", {})
if selected_modules.get("VAD"):
required.append(ComponentType.VAD)
if selected_modules.get("ASR"):
required.append(ComponentType.ASR)
if selected_modules.get("LLM"):
required.append(ComponentType.LLM)
if selected_modules.get("TTS"):
required.append(ComponentType.TTS)
if selected_modules.get("Memory"):
required.append(ComponentType.MEMORY)
if selected_modules.get("Intent"):
required.append(ComponentType.INTENT)
return required
@@ -0,0 +1,505 @@
import copy
import uuid
import time
import queue
import asyncio
import threading
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, List, Callable, Awaitable, Union
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from core.utils.dialogue import Dialogue
from core.auth import AuthMiddleware
from core.utils.prompt_manager import PromptManager
from core.utils.voiceprint_provider import VoiceprintProvider
from config.logger import setup_logging
@dataclass
class SessionContext:
"""
会话上下文完全替换ConnectionHandler的所有功能
承载单连接生命周期内的状态组件资源管理
与传输层解耦支持WebSocket/MQTT/UDP等多协议
"""
# === 基础标识 ===
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
device_id: Optional[str] = None
client_ip: Optional[str] = None
headers: Dict[str, str] = field(default_factory=dict)
# === 配置管理 ===
config: Dict[str, Any] = field(default_factory=dict)
common_config: Dict[str, Any] = field(default_factory=dict)
private_config: Dict[str, Any] = field(default_factory=dict)
selected_module_str: str = ""
# === 认证与绑定 ===
is_authenticated: bool = False
need_bind: bool = False
bind_code: Optional[str] = None
read_config_from_api: bool = False
max_output_size: int = 0
chat_history_conf: int = 0
# === 会话状态 ===
is_speaking: bool = False
listen_mode: str = "auto"
abort_requested: bool = False
close_after_chat: bool = False
conversation_active: bool = False
last_finalized_session_id: Optional[str] = None
just_woken_up: bool = False
calling: bool = False
incoming_call: Optional[Any] = None
load_function_plugin: bool = False
intent_type: str = "nointent"
# === 音频相关 ===
audio_format: str = "opus"
# sample_rate/channels/frame_duration remain the output-side compatibility
# fields consumed by existing TTS providers.
sample_rate: int = 24000
channels: int = 1
frame_duration: int = 60
input_sample_rate: int = 16000
input_channels: int = 1
input_frame_duration: int = 60
output_sample_rate: int = 24000
output_channels: int = 1
output_frame_duration: int = 60
client_aec: bool = False
# Native MQTT control and UDP audio use different channels. Keep UDP
# closed until the first listen/start of a logical session. It stays open
# through the bounded listen/stop grace period so queued tail frames are
# included in the same ASR turn.
accepting_input_audio: bool = False
wake_audio_suppression_task: Optional[asyncio.Task] = None
listen_stop_pending: bool = False
listen_stop_task: Optional[asyncio.Task] = None
listen_stop_deadline: float = 0.0
listen_start_task: Optional[asyncio.Task] = None
client_have_voice: bool = False
client_voice_stop: bool = False
client_audio_buffer: bytearray = field(default_factory=bytearray)
client_voice_window: deque = field(default_factory=lambda: deque(maxlen=5))
last_is_voice: bool = False
audio_flow_control: Dict[str, Any] = field(default_factory=dict)
aec_audio_cache: Dict[int, bytes] = field(default_factory=dict)
aec_audio_cache_time: Dict[int, float] = field(default_factory=dict)
# === ASR相关 ===
asr_audio: List[bytes] = field(default_factory=list)
asr_audio_queue: queue.Queue = field(default_factory=queue.Queue)
asr_priority_thread: Optional[threading.Thread] = None
asr_result_handler: Optional[Callable[[str, List[bytes]], Awaitable[None]]] = None
uses_pipeline_runtime: bool = True
# === LLM相关 ===
llm_finish_task: bool = True
dialogue: Optional[Dialogue] = None
current_speaker: Optional[str] = None
introduced_speakers: set = field(default_factory=set)
system_introduced_speakers: set = field(default_factory=set)
sentence_id: Optional[str] = None
# === TTS相关 ===
tts_MessageText: str = ""
# === IoT相关 ===
iot_descriptors: Dict[str, Any] = field(default_factory=dict)
func_handler: Optional[Any] = None
# === 时间管理 ===
last_activity_time_ms: float = field(default_factory=lambda: time.time() * 1000)
created_at: float = field(default_factory=lambda: time.time())
timeout_seconds: int = 180 # 默认超时时间
timeout_task: Optional[asyncio.Task] = None
# === 组件实例 ===
# components属性通过@property方法提供,指向component_manager
# === 其他状态 ===
welcome_msg: Optional[Dict[str, Any]] = None
prompt: Optional[str] = None
features: Optional[Dict[str, Any]] = None
mcp_client: Optional[Any] = None
cmd_exit: List[str] = field(default_factory=list)
# === 线程与并发 ===
loop: Optional[asyncio.AbstractEventLoop] = None
stop_event: Optional[threading.Event] = None
executor: Optional[ThreadPoolExecutor] = None
conversation_tasks: set = field(default_factory=set)
turn_tasks: set = field(default_factory=set)
background_tasks: set = field(default_factory=set)
# === 队列管理 ===
report_queue: queue.Queue = field(
default_factory=lambda: queue.Queue(maxsize=64)
)
report_thread: Optional[threading.Thread] = None
report_asr_enable: bool = False
report_tts_enable: bool = False
# === 组件管理器 ===
component_manager: Optional[Any] = None
# === 兼容属性(用于向后兼容TTS处理) ===
tts: Optional[Any] = None
websocket: Optional[Any] = None # 兼容旧TTS组件
transport: Optional[Any] = None # 新的transport接口
# === 工具类 ===
auth: Optional[AuthMiddleware] = None
prompt_manager: Optional[PromptManager] = None
voiceprint_provider: Optional[VoiceprintProvider] = None
server: Optional[Any] = None # WebSocket服务器引用
# === 会话级清理回调 ===
_cleanup_callbacks: List[Callable[[], Union[None, Awaitable[None]]]] = field(default_factory=list)
def __post_init__(self):
"""初始化后处理"""
# 深拷贝配置避免污染
if self.config:
self.common_config = self.config
self.config = copy.deepcopy(self.config)
# 从配置中读取相关设置
self.read_config_from_api = self.config.get("read_config_from_api", False)
self.max_output_size = self.config.get("max_output_size", 0)
self.chat_history_conf = self.config.get("chat_history_conf", 0)
self.cmd_exit = self.config.get("exit_commands", [])
self.timeout_seconds = int(self.config.get("close_connection_no_voice_time", 120)) + 60
# 初始化认证中间件
self.auth = AuthMiddleware(self.config)
# 初始化提示词管理器
self.prompt_manager = PromptManager(self.config, setup_logging())
# 初始化对话管理
if not self.dialogue:
self.dialogue = Dialogue()
# 初始化线程相关
if not self.loop:
try:
self.loop = asyncio.get_event_loop()
except RuntimeError:
self.loop = asyncio.new_event_loop()
if not self.stop_event:
self.stop_event = threading.Event()
if not self.executor:
self.executor = ThreadPoolExecutor(max_workers=5)
# 初始化上报设置
self.report_asr_enable = self.read_config_from_api
self.report_tts_enable = self.read_config_from_api
def update_activity(self) -> None:
"""刷新最后活跃时间"""
self.last_activity_time_ms = time.time() * 1000
def clearSpeakStatus(self) -> None:
"""清除服务端讲话状态(兼容方法)"""
self.is_speaking = False
logger = setup_logging()
logger.debug("清除服务端讲话状态")
def change_system_prompt(self, prompt: str) -> None:
"""Update the active role prompt for legacy function plugins."""
self.prompt = prompt
self.dialogue.update_system_message(prompt)
def reset_vad_states(self) -> None:
"""重置VAD状态(兼容方法)"""
self.client_audio_buffer = bytearray()
self.client_have_voice = False
self.client_voice_stop = False
self.last_is_voice = False
self.client_voice_window.clear()
logger = setup_logging()
logger.debug("VAD states reset.")
def reset_audio_states(self) -> None:
"""Reset all turn-scoped input state expected by legacy ASR providers."""
self.asr_audio.clear()
self.listen_stop_pending = False
self.reset_vad_states()
def is_timeout(self, timeout_seconds: int) -> bool:
"""检查是否超时"""
now_ms = time.time() * 1000
return (now_ms - self.last_activity_time_ms) > (timeout_seconds * 1000)
def register_cleanup(self, callback: Callable[[], Union[None, Awaitable[None]]]) -> None:
"""注册会话结束时需要执行的清理回调"""
self._cleanup_callbacks.append(callback)
def unregister_cleanup(
self, callback: Callable[[], Union[None, Awaitable[None]]]
) -> None:
"""Remove a callback when its connection-scoped owner is replaced."""
self._cleanup_callbacks = [
registered
for registered in self._cleanup_callbacks
if registered != callback
]
def create_background_task(
self,
coroutine,
*,
conversation_scoped: bool = True,
turn_scoped: bool = False,
) -> asyncio.Task:
"""Create a tracked task with an explicit turn/session/connection owner."""
task = asyncio.create_task(coroutine)
if turn_scoped:
owner = self.turn_tasks
elif conversation_scoped:
owner = self.conversation_tasks
else:
owner = self.background_tasks
owner.add(task)
task.add_done_callback(owner.discard)
return task
async def cancel_turn_tasks(self) -> None:
"""Cancel producers belonging only to the active speech/chat turn."""
current_task = asyncio.current_task()
pending_tasks = [
task
for task in list(self.turn_tasks)
if task is not current_task and not task.done()
]
for task in pending_tasks:
task.cancel()
if pending_tasks:
await asyncio.gather(*pending_tasks, return_exceptions=True)
self.turn_tasks = {
task for task in self.turn_tasks if task is current_task
}
async def cancel_conversation_tasks(self) -> None:
"""Cancel all producers owned by the current logical conversation."""
await self.cancel_turn_tasks()
current_task = asyncio.current_task()
while True:
pending_tasks = [
task
for task in list(self.conversation_tasks)
if task is not current_task and not task.done()
]
if not pending_tasks:
break
for task in pending_tasks:
task.cancel()
await asyncio.gather(*pending_tasks, return_exceptions=True)
self.conversation_tasks = {
task for task in self.conversation_tasks if task is current_task
}
async def run_cleanup(self) -> None:
"""执行所有注册的清理回调"""
logger = setup_logging()
logger.info(f"Session {self.session_id} 开始执行会话级清理 ({len(self._cleanup_callbacks)} 个回调)")
# 停止所有线程
if self.stop_event:
self.stop_event.set()
# 取消超时任务
if self.timeout_task and not self.timeout_task.done():
self.timeout_task.cancel()
await self.cancel_conversation_tasks()
current_task = asyncio.current_task()
pending_tasks = [
task
for task in list(self.background_tasks)
if task is not current_task and not task.done()
]
for task in pending_tasks:
task.cancel()
if pending_tasks:
await asyncio.gather(*pending_tasks, return_exceptions=True)
self.background_tasks.clear()
# 执行清理回调
for callback in reversed(self._cleanup_callbacks):
try:
result = callback()
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Session {self.session_id} 清理回调执行失败: {e}", exc_info=True)
self._cleanup_callbacks.clear()
# 所有异步生产者退出后再关闭线程池,避免任务继续提交工作。
if self.executor:
self.executor.shutdown(wait=False)
self.executor = None
logger.info(f"Session {self.session_id} 会话级清理完成")
# === 兼容旧代码的属性访问 ===
@property
def client_is_speaking(self) -> bool:
"""兼容旧代码的属性名"""
return self.is_speaking
@client_is_speaking.setter
def client_is_speaking(self, value: bool):
self.is_speaking = value
@property
def client_listen_mode(self) -> str:
"""兼容旧代码的属性名"""
return self.listen_mode
@client_listen_mode.setter
def client_listen_mode(self, value: str):
self.listen_mode = value
@property
def client_abort(self) -> bool:
"""兼容旧代码的属性名"""
return self.abort_requested
@client_abort.setter
def client_abort(self, value: bool):
self.abort_requested = value
@property
def components(self):
"""组件访问器(兼容属性)"""
return self.component_manager
@components.setter
def components(self, value):
"""组件设置器(兼容属性)- 实际设置到component_manager"""
# 如果尝试设置components,我们忽略它或者给出警告
# 因为components应该通过component_manager管理
logger = setup_logging()
logger.warning("尝试直接设置components属性,请使用component_manager")
@property
def last_activity_time(self) -> float:
"""兼容旧代码:返回毫秒级时间戳"""
return self.last_activity_time_ms
@last_activity_time.setter
def last_activity_time(self, value: float):
"""兼容旧代码:接受毫秒级时间戳"""
self.last_activity_time_ms = value
# === 日志相关 ===
@property
def logger(self):
"""获取日志记录器"""
return setup_logging()
# === 工具方法 ===
def get_component(self, component_name: str) -> Optional[Any]:
"""获取组件实例"""
return self.components.get(component_name)
def set_component(self, component_name: str, component_instance: Any) -> None:
"""设置组件实例"""
if self.component_manager:
self.component_manager._components[component_name] = component_instance
def has_component(self, component_name: str) -> bool:
"""检查是否有指定组件"""
return component_name in self.components
def clear_audio_buffer(self) -> None:
"""清空音频缓冲区"""
self.client_audio_buffer.clear()
self.asr_audio.clear()
# 清空队列
try:
while not self.asr_audio_queue.empty():
self.asr_audio_queue.get_nowait()
except queue.Empty:
pass
def reset_voice_state(self) -> None:
"""重置语音状态"""
self.client_have_voice = False
self.client_voice_stop = False
self.last_is_voice = False
self.client_voice_window.clear()
def initialize_private_config(self) -> None:
"""初始化差异化配置(从ConnectionHandler迁移)"""
from config.config_loader import get_private_config_from_api
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
if not self.read_config_from_api:
return
try:
# 获取设备私有配置
private_config = get_private_config_from_api(
self.config, self.device_id, self.headers.get("client-id")
)
if private_config:
self.private_config = private_config
# 合并私有配置到主配置
self.config.update(private_config)
except DeviceNotFoundException:
self.logger.error(f"设备 {self.device_id} 未找到")
self.need_bind = True
except DeviceBindException as e:
self.logger.error(f"设备绑定异常: {e}")
self.need_bind = True
self.bind_code = str(e)
except Exception as e:
self.logger.error(f"获取私有配置失败: {e}")
async def initialize_components(self) -> None:
"""异步初始化组件(从ConnectionHandler迁移)"""
if not self.component_manager:
return
try:
# 初始化各个组件
from core.components.component_registry import ComponentType
# 按依赖顺序初始化组件
component_types = [
ComponentType.VAD,
ComponentType.ASR,
ComponentType.LLM,
ComponentType.MEMORY,
ComponentType.INTENT,
ComponentType.TTS
]
for component_type in component_types:
try:
component = await self.component_manager.get_component(component_type, self)
if component:
self.logger.info(f"组件 {component_type} 初始化成功")
except Exception as e:
self.logger.error(f"组件 {component_type} 初始化失败: {e}")
except Exception as e:
self.logger.error(f"组件初始化失败: {e}")
def __str__(self) -> str:
return f"SessionContext(session_id={self.session_id}, device_id={self.device_id})"
def __repr__(self) -> str:
return self.__str__()
@@ -118,7 +118,16 @@ async def process_intent_result(
请根据以上信息回答用户的问题{original_text}"""
response = conn.intent.replyResult(context_prompt, original_text)
# 使用异步调用避免阻塞事件循环,影响其他设备的音频播放
try:
response = asyncio.run_coroutine_threadsafe(
conn.intent.replyResult(context_prompt, original_text),
conn.loop,
).result()
except Exception as e:
conn.logger.bind(tag=TAG).error(f"LLM生成回复失败: {e}")
response = None
if response:
speak_txt(conn, response)
conn.executor.submit(process_context_result)
@@ -184,7 +193,15 @@ async def process_intent_result(
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
text = result.result
conn.dialogue.put(Message(role="tool", content=text))
llm_result = conn.intent.replyResult(text, original_text)
# 使用异步调用避免阻塞事件循环,影响其他设备的音频播放
try:
llm_result = asyncio.run_coroutine_threadsafe(
conn.intent.replyResult(text, original_text),
conn.loop,
).result()
except Exception as e:
conn.logger.bind(tag=TAG).error(f"LLM生成回复失败: {e}")
llm_result = text
if llm_result is None:
llm_result = text
speak_txt(conn, llm_result)
@@ -350,4 +350,16 @@ async def send_display_message(conn: "ConnectionHandler", text):
"text": text,
"session_id": conn.session_id
}
await conn.websocket.send(json.dumps(message))
transport = getattr(conn, "transport", None)
if transport is not None:
send_json = getattr(transport, "send_json", None)
if callable(send_json):
await send_json(message)
else:
await transport.send(json.dumps(message))
return
websocket = getattr(conn, "websocket", None)
if websocket is None:
raise AttributeError("无法找到可用的传输层接口")
await websocket.send(json.dumps(message))
+107 -6
View File
@@ -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,11 +11,53 @@ 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())
try:
done, _ = await asyncio.wait(
{task, event_waiter},
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
if self._started_event.is_set():
if task.done():
await task
return
if task in done:
await task
raise RuntimeError("HTTP服务器在就绪前退出")
raise TimeoutError("等待HTTP服务器启动超时")
finally:
if not event_waiter.done():
event_waiter.cancel()
await asyncio.gather(event_waiter, return_exceptions=True)
def _get_websocket_url(self, local_ip: str, port: int) -> str:
"""获取websocket地址
@@ -33,14 +78,28 @@ class SimpleHttpServer:
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self):
runner = None
self._start_active = True
try:
self._started_event.clear()
self._stop_event.clear()
server_config = self.config["server"]
read_config_from_api = self.config.get("read_config_from_api", False)
host = server_config.get("ip", "0.0.0.0")
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接口
@@ -74,19 +133,61 @@ 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
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# 保持服务运行
while True:
await asyncio.sleep(3600) # 每隔 1 小时检查一次
self._started_event.set()
await self._stop_event.wait()
except Exception as e:
self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
import traceback
self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
raise
finally:
self._started_event.clear()
try:
if runner is not None:
await self._cleanup_runner()
finally:
self._start_active = False
async def _cleanup_runner(self):
"""Cleanup the owned runner and retain it when cleanup must be retried."""
async with self._cleanup_lock:
runner = self._runner
if runner is None:
return
await runner.cleanup()
self._runner = None
async def stop(self):
"""Stop the HTTP loop and retry cleanup left by a failed start task."""
self._stop_event.set()
if not self._start_active:
await self._cleanup_runner()
@@ -0,0 +1,26 @@
from abc import ABC, abstractmethod
from typing import Any, List
class MessageProcessor(ABC):
"""消息处理器接口。返回 True 表示已处理并中止后续处理。"""
@abstractmethod
async def process(self, context: Any, transport: Any, message: Any) -> bool:
raise NotImplementedError
class MessagePipeline:
"""责任链式消息处理管道。"""
def __init__(self) -> None:
self._processors: List[MessageProcessor] = []
def add_processor(self, processor: MessageProcessor) -> None:
self._processors.append(processor)
async def process_message(self, context: Any, transport: Any, message: Any) -> None:
for processor in self._processors:
handled = await processor.process(context, transport, message)
if handled:
return
@@ -0,0 +1,132 @@
import json
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.components.component_manager import ComponentType
from config.logger import setup_logging
logger = setup_logging()
class AbortProcessor(MessageProcessor):
"""中断消息处理器:完整迁移abortMessageHandler.py和abortHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理abort类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "abort":
await self.handle_abort_message(context, transport, msg_json)
return True
return False
async def handle_abort_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理中断消息 - 完整迁移自abortHandle.py的handleAbortMessage"""
msg_session_id = msg_json.get("session_id")
if msg_session_id and msg_session_id != context.session_id:
logger.warning(
f"忽略非当前会话的abort消息: "
f"msg={msg_session_id}, current={context.session_id}"
)
return
logger.info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务 - 完整迁移原逻辑
context.abort_requested = True
context.close_after_chat = False
# Stop the old LLM/tool producers before a subsequent listen/start can
# clear abort_requested and begin a new turn.
cancel_tasks = getattr(context, "cancel_turn_tasks", None)
if callable(cancel_tasks):
await cancel_tasks()
# 清理队列 - 完整迁移原逻辑
await self._clear_queues(context)
# 打断客户端说话状态 - 完整迁移原逻辑
await transport.send_json({
"type": "tts",
"state": "stop",
"session_id": context.session_id
})
# 清理说话状态 - 完整迁移原逻辑
self._clear_speak_status(context)
logger.info("Abort message received-end")
async def _clear_queues(self, context: SessionContext):
"""清理所有队列 - 完整迁移原clear_queues逻辑"""
try:
# 清理TTS音频队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
for queue_name in ('tts_text_queue', 'tts_audio_queue'):
pending_queue = getattr(tts_instance, queue_name, None)
if pending_queue is None:
continue
try:
while not pending_queue.empty():
pending_queue.get_nowait()
except:
pass
# Rotate the turn token so late text/audio from the aborted turn is stale.
context.sentence_id = uuid.uuid4().hex
# 清理ASR音频队列
context.clear_audio_buffer()
# 清理其他可能的队列
if hasattr(context, 'clear_queues'):
context.clear_queues()
except Exception as e:
logger.error(f"清理队列时出错: {e}")
def _clear_speak_status(self, context: SessionContext):
"""清理说话状态 - 完整迁移原clearSpeakStatus逻辑"""
try:
# 清理说话状态
context.is_speaking = False
# 如果有其他说话状态相关的属性,也一并清理
if hasattr(context, 'clearSpeakStatus'):
context.clearSpeakStatus()
# 重置相关状态
context.client_have_voice = False
context.client_voice_stop = True
except Exception as e:
logger.error(f"清理说话状态时出错: {e}")
async def _send_abort_confirmation(self, transport: TransportInterface, session_id: str):
"""发送中断确认响应(可选)"""
response = {
"type": "abort",
"status": "success",
"message": "中断操作已完成",
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送中断确认响应失败: {e}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
@@ -0,0 +1,439 @@
import time
import json
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.components.component_manager import ComponentType
from core.services.audio_ingress_service import AudioIngressService
from core.utils.util import audio_to_data
from core.utils.output_counter import check_device_output_limit
from config.logger import setup_logging
logger = setup_logging()
def arm_wake_audio_suppression(context: SessionContext) -> None:
"""Ignore reordered wake-word audio for a bounded interval."""
context.just_woken_up = True
previous = getattr(context, "wake_audio_suppression_task", None)
if previous and not previous.done():
previous.cancel()
delay_ms = max(
0,
int(context.config.get("wake_audio_suppression_ms", 2000)),
)
if delay_ms == 0:
context.just_woken_up = False
context.wake_audio_suppression_task = None
return
session_id = context.session_id
context.wake_audio_suppression_task = context.create_background_task(
_release_wake_audio_suppression(
context,
session_id,
delay_ms / 1000,
),
conversation_scoped=True,
)
async def _release_wake_audio_suppression(
context: SessionContext,
session_id: str,
delay_seconds: float,
) -> None:
current_task = asyncio.current_task()
try:
await asyncio.sleep(delay_seconds)
if context.session_id == session_id:
context.just_woken_up = False
finally:
if getattr(context, "wake_audio_suppression_task", None) is current_task:
context.wake_audio_suppression_task = None
class AudioReceiveProcessor(MessageProcessor):
"""音频接收处理器:完整迁移receiveAudioHandle.py的所有功能"""
def __init__(self, audio_ingress_service=None):
self.audio_ingress_service = audio_ingress_service or AudioIngressService()
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理音频消息"""
if isinstance(message, bytes):
await self.handle_audio_message(context, transport, message, 0)
return True
if isinstance(message, dict) and message.get("type") == "audio":
audio_data = message.get("data")
if isinstance(audio_data, (bytes, bytearray)):
await self.handle_audio_message(
context,
transport,
bytes(audio_data),
int(message.get("timestamp", 0) or 0),
)
return True
return True
return False
async def handle_audio_message(
self,
context: SessionContext,
transport: TransportInterface,
audio: bytes,
timestamp: int = 0,
):
"""处理音频消息 - 完整迁移自handleAudioMessage"""
if (
getattr(transport, "requires_audio_tail_grace", False)
and not getattr(context, "accepting_input_audio", False)
):
return
pcm_frame = self.audio_ingress_service.process(context, audio, timestamp)
if not pcm_frame:
return
# Do not feed encoded wake-word history into stateful VAD. Native MQTT
# gates the usual pre-start history above; this bounded suppression only
# covers frames that crossed the independent control/audio channels.
if context.just_woken_up:
if not getattr(context, "wake_audio_suppression_task", None):
arm_wake_audio_suppression(context)
context.asr_audio.clear()
context.reset_vad_states()
return
# 获取VAD组件
vad_component = await self._get_component(context, ComponentType.VAD)
if not vad_component or not hasattr(vad_component, 'vad_instance'):
now = time.time()
last_log = getattr(context, "_vad_missing_last_log", 0.0)
if now - last_log > 10:
logger.warning("VAD组件未初始化")
context._vad_missing_last_log = now
return
vad_instance = vad_component.vad_instance
# 当前片段是否有人说话
have_voice = vad_instance.is_vad(context, pcm_frame)
if have_voice:
if (
getattr(context, "client_aec", False)
and context.is_speaking
and context.listen_mode != "manual"
):
await self._handle_abort_message(context, transport)
# 设备长时间空闲检测,用于say goodbye
await self._no_voice_close_connect(context, transport, have_voice)
# 接收音频
asr_component = await self._get_component(context, ComponentType.ASR)
asr_instance = asr_component.asr_instance if asr_component and hasattr(asr_component, 'asr_instance') else None
# 自动模式兜底:没有收到 listen stop 时,基于VAD触发一次停止
if (
not have_voice
and context.client_have_voice
and not context.client_voice_stop
and not getattr(context, "listen_stop_pending", False)
and context.listen_mode != "manual"
and asr_instance is not None
):
context.client_voice_stop = True
try:
from core.providers.asr.dto.dto import InterfaceType
if hasattr(asr_instance, "interface_type") and asr_instance.interface_type == InterfaceType.STREAM:
if hasattr(asr_instance, "_send_stop_request"):
context.create_background_task(
asr_instance._send_stop_request(),
turn_scoped=True,
)
else:
if len(context.asr_audio) > 0:
asr_audio_task = context.asr_audio.copy()
context.asr_audio.clear()
context.reset_vad_states()
await asr_instance.handle_voice_stop(context, asr_audio_task)
except Exception as e:
logger.error(f"自动VAD停止处理失败: {e}")
if asr_instance and hasattr(asr_instance, 'receive_audio'):
await asr_instance.receive_audio(context, pcm_frame, have_voice)
async def start_to_chat(
self,
context: SessionContext,
transport: TransportInterface,
text: str,
*,
preserve_close_after_chat: bool = False,
):
"""开始聊天 - 完整迁移自startToChat"""
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
try:
# 尝试解析JSON格式的输入
if text.strip().startswith('{') and text.strip().endswith('}'):
data = json.loads(text)
if 'speaker' in data and 'content' in data:
speaker_name = data['speaker']
actual_content = data['content']
logger.info(f"解析到说话人信息: {speaker_name}")
if speaker_name not in context.introduced_speakers:
context.introduced_speakers.add(speaker_name)
actual_text = text
else:
actual_text = actual_content
except (json.JSONDecodeError, KeyError):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到上下文
if speaker_name:
context.current_speaker = speaker_name
else:
context.current_speaker = None
# 检查设备绑定
if context.need_bind:
await self.prompt_bind_device(context, transport)
return
# 如果当日的输出字数大于限定的字数
if context.max_output_size > 0:
if check_device_output_limit(
context.headers.get("device-id"), context.max_output_size
):
await self._max_out_size(context, transport)
return
if context.is_speaking and getattr(context, "listen_mode", "auto") != "manual":
await self._handle_abort_message(context, transport)
context.abort_requested = False
if not preserve_close_after_chat:
context.close_after_chat = False
# 首先进行意图分析,使用实际文本内容
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
intent_handled = await chat_processor.handle_user_intent(context, transport, actual_text)
if intent_handled:
# 如果意图已被处理,不再进行聊天
return
# 意图未被处理,继续常规聊天流程,使用实际文本内容
await self._send_stt_message(context, transport, actual_text)
# 与旧架构一致:将聊天处理放入线程池,避免阻塞事件循环
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
if hasattr(context, "create_background_task"):
context.create_background_task(
chat_processor.handle_chat(
context,
transport,
actual_text,
skip_intent=True,
),
turn_scoped=True,
)
else:
await chat_processor.handle_chat(
context, transport, actual_text, skip_intent=True
)
async def _no_voice_close_connect(self, context: SessionContext, transport: TransportInterface, have_voice: bool):
"""无声音时关闭连接检测 - 完整迁移自no_voice_close_connect"""
if have_voice:
context.update_activity()
return
# 只有在已经初始化过时间戳的情况下才进行超时检查
if context.last_activity_time_ms > 0.0:
no_voice_time = time.time() * 1000 - context.last_activity_time_ms
close_connection_no_voice_time = int(
context.config.get("close_connection_no_voice_time", 120)
)
if (
not context.close_after_chat
and no_voice_time > 1000 * close_connection_no_voice_time
):
context.close_after_chat = True
context.abort_requested = False
end_prompt = context.config.get("end_prompt", {})
if end_prompt and end_prompt.get("enable", True) is False:
logger.info("结束对话,无需发送结束提示语")
if transport.keeps_connection_between_sessions:
session_id = context.session_id
end_conversation = getattr(context, "end_conversation", None)
if callable(end_conversation):
await end_conversation(session_id)
await transport.end_session(session_id)
context.close_after_chat = False
context.reset_vad_states()
else:
await transport.close()
return
prompt = end_prompt.get("prompt")
if not prompt:
prompt = "请你以```时间过得真快```未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
await self.start_to_chat(
context,
transport,
prompt,
preserve_close_after_chat=True,
)
async def _max_out_size(self, context: SessionContext, transport: TransportInterface):
"""超出最大输出字数处理 - 完整迁移自max_out_size"""
# 播放超出最大输出字数的提示
context.abort_requested = False
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await self._send_stt_message(context, transport, text)
file_path = "config/assets/max_output_size.wav"
opus_packets = await audio_to_data(file_path)
# 获取TTS组件并添加到队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_queue'):
from core.providers.tts.dto.dto import SentenceType
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, opus_packets, text, context.sentence_id)
)
context.close_after_chat = True
async def prompt_bind_device(
self,
context: SessionContext,
transport: TransportInterface,
):
"""检查设备绑定 - 完整迁移自check_bind_device"""
bind_code = context.bind_code
if bind_code:
# 确保bind_code是6位数字
if len(bind_code) != 6:
logger.error(f"无效的绑定码格式: {bind_code}")
text = "绑定码格式错误,请检查配置。"
await self._send_stt_message(context, transport, text)
return
text = f"请登录控制面板,输入{bind_code},绑定设备。"
await self._send_stt_message(context, transport, text)
# 获取TTS组件
tts_component = await self._get_component(context, ComponentType.TTS)
if not tts_component or not hasattr(tts_component, 'tts_instance'):
return
tts_instance = tts_component.tts_instance
if not hasattr(tts_instance, 'tts_audio_queue'):
return
# 播放提示音
from core.providers.tts.dto.dto import SentenceType
music_path = "config/assets/bind_code.wav"
opus_packets = await audio_to_data(music_path)
tts_instance.tts_audio_queue.put(
(SentenceType.FIRST, opus_packets, text, context.sentence_id)
)
# 逐个播放数字
for i in range(6): # 确保只播放6位数字
try:
digit = bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav"
num_packets = await audio_to_data(num_path)
tts_instance.tts_audio_queue.put(
(SentenceType.MIDDLE, num_packets, None, context.sentence_id)
)
except Exception as e:
logger.error(f"播放数字音频失败: {e}")
continue
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, [], None, context.sentence_id)
)
else:
# 播放未绑定提示
context.abort_requested = False
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await self._send_stt_message(context, transport, text)
# 获取TTS组件
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_queue'):
from core.providers.tts.dto.dto import SentenceType
music_path = "config/assets/bind_not_found.wav"
opus_packets = await audio_to_data(music_path)
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, opus_packets, text, context.sentence_id)
)
async def _handle_abort_message(self, context: SessionContext, transport: TransportInterface):
"""处理中断消息"""
from core.processors.abort_processor import AbortProcessor
await AbortProcessor().handle_abort_message(
context,
transport,
{"type": "abort", "session_id": context.session_id},
)
async def _clear_queues(self, context: SessionContext):
"""清理所有队列"""
# 清理TTS音频队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
for queue_name in ('tts_text_queue', 'tts_audio_queue'):
pending_queue = getattr(tts_instance, queue_name, None)
if pending_queue is None:
continue
try:
while not pending_queue.empty():
pending_queue.get_nowait()
except Exception:
pass
# Invalidate late TTS text/audio generated by the interrupted turn.
context.sentence_id = uuid.uuid4().hex
# 清理ASR音频队列
context.clear_audio_buffer()
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
@@ -0,0 +1,578 @@
import asyncio
import json
import time
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tts.dto.dto import SentenceType
from core.utils import textUtils
from core.components.component_manager import ComponentType
from core.services.audio_ingress_service import AudioIngressService
from config.logger import setup_logging
logger = setup_logging()
class AudioSendProcessor(MessageProcessor):
"""音频发送处理器:完整迁移sendAudioHandle.py的所有功能"""
def __init__(self, audio_ingress_service=None):
self.audio_ingress_service = audio_ingress_service or AudioIngressService()
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""这个处理器不直接处理消息,而是被其他处理器调用"""
return False
async def send_audio_message(self, context: SessionContext, transport: TransportInterface,
sentence_type: SentenceType, audios: bytes, text: str,
sentence_id: str = None):
"""发送音频消息 - 完整迁移自sendAudioMessage"""
if not self._is_current_turn(context, sentence_id):
return
tts_component = await self._get_component(context, ComponentType.TTS)
if not tts_component or not hasattr(tts_component, 'tts_instance'):
return
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_first_sentence') and tts_instance.tts_audio_first_sentence:
logger.info(f"发送第一段语音: {text}")
tts_instance.tts_audio_first_sentence = False
await self.start_tts_stream(context, transport)
if sentence_type == SentenceType.FIRST:
await self.send_tts_message(
context, transport, "sentence_start", text, sentence_id=sentence_id
)
await self.send_audio(
context, transport, audios, sentence_id=sentence_id
)
if not self._is_current_turn(context, sentence_id):
return
# 发送句子开始消息
if sentence_type is not SentenceType.MIDDLE:
logger.info(f"发送音频消息: {sentence_type}, {text}")
# 发送结束消息(如果是最后一个文本)
if (
not getattr(context, "calling", False)
and context.llm_finish_task
and sentence_type == SentenceType.LAST
):
# Latch the terminal action before sending tts:stop. The device can
# immediately answer with listen:start, whose handler resets the
# mutable turn flags while this coroutine is still awaiting I/O.
close_after_chat = bool(context.close_after_chat)
closing_session_id = (
getattr(transport, "session_id", None) or context.session_id
)
if close_after_chat:
context.close_after_chat = False
await self.send_tts_message(
context, transport, "stop", None, sentence_id=sentence_id
)
if not self._is_current_turn(context, sentence_id):
return
context.is_speaking = False
if close_after_chat:
# MQTT/UDP:结束后回到Idle,不关闭连接
if transport.keeps_connection_between_sessions:
end_conversation = getattr(context, "end_conversation", None)
if callable(end_conversation):
await end_conversation(closing_session_id)
await transport.end_session(closing_session_id)
else:
await transport.close()
async def send_audio(
self,
context: SessionContext,
transport: TransportInterface,
audios: bytes,
frame_duration: int = 60,
sentence_id: str = None,
):
"""发送单个opus包,支持流控 - 完整迁移自sendAudio"""
if audios is None or len(audios) == 0:
return
if getattr(context, "audio_flow_control", {}).get("send_failed"):
return
# MQTT/UDP:等待UDP远端地址就绪,避免首包丢失
if transport.has_datagram_audio:
if not await transport.wait_audio_ready(timeout=2):
logger.warning("UDP远端地址未就绪,跳过音频发送")
return
audio_list = [audios] if isinstance(audios, bytes) else audios
if not isinstance(audio_list, list):
return
flow = context.audio_flow_control
pre_buffer_count = max(
0, int(context.config.get("tts_pre_buffer_count", 5))
)
for audio in audio_list:
if context.abort_requested or not self._is_current_turn(context, sentence_id):
break
# Match the legacy AudioRateController contract: send only the
# initial pre-buffer inline, then enqueue the remaining frames so
# the TTS consumer can prefetch later sentence chunks.
packet_count = int(flow.get("packet_count", 0))
if packet_count < pre_buffer_count and "_send_queue" not in flow:
sent = await self._send_audio_packet(
context,
transport,
flow,
audio,
frame_duration,
sentence_id,
paced=False,
)
if not sent:
break
continue
queue = self._ensure_audio_sender(
context, transport, flow, sentence_id
)
await queue.put(("audio", audio, frame_duration, sentence_id))
@staticmethod
async def _pace_audio_send(
context: SessionContext,
frame_duration: int,
flow: dict = None,
) -> None:
flow = flow if flow is not None else context.audio_flow_control
packet_count = int(flow.get("packet_count", 0))
pre_buffer_count = max(0, int(context.config.get("tts_pre_buffer_count", 5)))
if packet_count < pre_buffer_count:
return
configured_delay = int(context.config.get("tts_audio_send_delay", -1))
if configured_delay > 0:
await asyncio.sleep(configured_delay / 1000.0)
return
delay_ms = max(0, int(frame_duration))
if delay_ms <= 0:
return
now = time.monotonic()
# packet_count is the number already sent. The first packet after the
# pre-buffer must therefore wait one complete frame, not become an
# extra immediate packet.
paced_packet_index = packet_count - pre_buffer_count + 1
pacing_started_at = flow.get("pacing_started_at")
pacing_delay_ms = flow.get("pacing_delay_ms")
if pacing_started_at is None or pacing_delay_ms != delay_ms:
pacing_started_at = now
flow["pacing_started_at"] = pacing_started_at
flow["pacing_delay_ms"] = delay_ms
delay_seconds = delay_ms / 1000.0
target = pacing_started_at + paced_packet_index * delay_seconds
# TTS producers can pause between sentence chunks. Rebase after a
# full-frame gap instead of bursting stale deadlines to catch up.
if now - target >= delay_seconds:
pacing_started_at = now - paced_packet_index * delay_seconds
flow["pacing_started_at"] = pacing_started_at
target = now
wait_seconds = target - now
if wait_seconds > 0:
await asyncio.sleep(wait_seconds)
def _ensure_audio_sender(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
sentence_id: str = None,
) -> asyncio.Queue:
queue = flow.get("_send_queue")
task = flow.get("_send_task")
if queue is not None and task is not None and not task.done():
return queue
queue_size = max(
1, int(context.config.get("tts_audio_queue_size", 256))
)
queue = asyncio.Queue(maxsize=queue_size)
sender = self._audio_send_loop(
context, transport, flow, queue, sentence_id
)
create_task = getattr(context, "create_background_task", None)
if callable(create_task):
task = create_task(sender, turn_scoped=True)
else:
task = asyncio.create_task(sender)
flow["_send_queue"] = queue
flow["_send_task"] = task
return queue
async def _audio_send_loop(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
queue: asyncio.Queue,
sentence_id: str = None,
) -> None:
try:
while True:
item = await queue.get()
try:
item_type = item[0]
if not self._is_current_turn(context, sentence_id):
continue
if item_type == "audio":
_, audio, frame_duration, item_sentence_id = item
await self._send_audio_packet(
context,
transport,
flow,
audio,
frame_duration,
item_sentence_id,
paced=True,
)
elif item_type == "json":
_, message, item_sentence_id = item
if self._is_current_turn(context, item_sentence_id):
await transport.send_json(message)
finally:
queue.task_done()
except asyncio.CancelledError:
raise
except Exception as error:
flow["send_failed"] = True
logger.error("后台音频发送循环失败: {}", error)
finally:
while True:
try:
queue.get_nowait()
except asyncio.QueueEmpty:
break
else:
queue.task_done()
async def _send_audio_packet(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
audio: bytes,
frame_duration: int,
sentence_id: str = None,
*,
paced: bool,
) -> bool:
if paced:
await self._pace_audio_send(context, frame_duration, flow)
if context.abort_requested or not self._is_current_turn(context, sentence_id):
return False
context.update_activity()
timestamp = self._next_audio_timestamp(
context, frame_duration, flow
)
sent = await self._send_audio_with_retry(
context,
transport,
audio,
timestamp,
sentence_id,
flow,
)
if not sent or not self._is_current_turn(context, sentence_id):
return False
self.audio_ingress_service.cache_output_reference(
context, audio, timestamp
)
flow["packet_count"] = int(flow.get("packet_count", 0)) + 1
return True
async def _send_audio_with_retry(
self,
context: SessionContext,
transport: TransportInterface,
audio: bytes,
timestamp: int,
sentence_id: str = None,
flow: dict = None,
) -> bool:
flow = flow if flow is not None else context.audio_flow_control
retries = max(0, int(context.config.get("audio_send_retries", 2)))
retry_delay = max(
0, int(context.config.get("audio_send_retry_delay_ms", 20))
) / 1000.0
for attempt in range(retries + 1):
if context.abort_requested or not self._is_current_turn(context, sentence_id):
return False
try:
await transport.send_audio(audio, timestamp)
return self._is_current_turn(context, sentence_id)
except Exception as error:
if attempt >= retries:
flow["send_failed"] = True
logger.error(
"Audio send failed after {} attempts: {}",
attempt + 1,
error,
)
raise
await asyncio.sleep(retry_delay)
return False
@staticmethod
def _next_audio_timestamp(
context: SessionContext,
frame_duration: int,
flow: dict = None,
) -> int:
flow = flow if flow is not None else getattr(
context, "audio_flow_control", None
)
if flow is None:
flow = {}
context.audio_flow_control = flow
timestamp_step = int(frame_duration) or int(
getattr(context, "output_frame_duration", 60) or 60
)
timestamp = int(flow.get("output_timestamp", 0)) + timestamp_step
timestamp %= 2 ** 32
flow["output_timestamp"] = timestamp
return timestamp
async def send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息 - 完整迁移自send_stt_message"""
end_prompt = getattr(context, "config", {}).get("end_prompt", {}).get("prompt")
if end_prompt and text == end_prompt:
await self.start_tts_stream(context, transport)
return
display_text = text
parsed_data = None
if isinstance(text, dict):
parsed_data = text
elif isinstance(text, str):
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
try:
parsed_data = json.loads(stripped)
except json.JSONDecodeError:
pass
if isinstance(parsed_data, dict) and "content" in parsed_data:
display_text = parsed_data["content"]
if "speaker" in parsed_data:
context.current_speaker = parsed_data["speaker"]
stt_text = textUtils.get_string_no_punctuation_or_emoji(
str(display_text)
)
await self._send_json(transport, {
"type": "stt",
"text": stt_text,
"session_id": context.session_id
})
logger.info(f"发送STT消息: {stt_text}")
# Legacy WS sends TTS start together with STT, before synthesis begins.
# This lead time is required by MQTT/UDP clients because the device
# changes to Speaking asynchronously and drops UDP audio beforehand.
await self.start_tts_stream(context, transport)
async def start_tts_stream(
self,
context: SessionContext,
transport: TransportInterface,
) -> bool:
"""Open one device-side TTS stream without sending duplicate starts."""
if getattr(context, "is_speaking", False):
return False
context.is_speaking = True
flow_control = getattr(context, "audio_flow_control", None) or {}
await self._stop_audio_sender(flow_control)
context.audio_flow_control = {}
try:
await self._send_json(transport, {
"type": "tts",
"state": "start",
"session_id": context.session_id,
})
except Exception:
context.is_speaking = False
raise
return True
@staticmethod
async def _send_json(
transport: TransportInterface,
message: dict,
) -> None:
send_json = getattr(transport, "send_json", None)
if callable(send_json):
await send_json(message)
return
await transport.send(json.dumps(message))
async def send_tts_message(
self,
context: SessionContext,
transport: TransportInterface,
state: str,
text: str = None,
sentence_id: str = None,
):
"""发送TTS消息 - 完整迁移自send_tts_message"""
if not self._is_current_turn(context, sentence_id):
return False
if state == "sentence_start":
flow = getattr(context, "audio_flow_control", {})
queue = flow.get("_send_queue")
task = flow.get("_send_task")
if queue is not None and task is not None and not task.done():
message = {
"type": "tts",
"state": state,
"session_id": context.session_id,
}
if text:
message["text"] = text
await queue.put(("json", message, sentence_id))
return True
if state == "stop":
if context.config.get("enable_stop_tts_notify", False):
from core.utils.util import audio_to_data
notify_path = context.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
)
notify_audio = await audio_to_data(notify_path, is_opus=True)
if notify_audio:
await self.send_audio(
context,
transport,
notify_audio,
sentence_id=sentence_id,
)
if not await self._wait_for_audio_completion(context, sentence_id):
return False
if not self._is_current_turn(context, sentence_id):
return False
message = {
"type": "tts",
"state": state,
"session_id": context.session_id
}
if text:
message["text"] = text
await transport.send_json(message)
logger.debug(f"发送TTS消息: state={state}, text={text}")
return True
@staticmethod
async def _wait_for_audio_completion(
context: SessionContext, sentence_id: str = None
) -> bool:
flow = context.audio_flow_control
queue = flow.get("_send_queue")
sender_task = flow.get("_send_task")
if queue is not None and sender_task is not None:
join_task = asyncio.create_task(queue.join())
done, _ = await asyncio.wait(
{join_task, sender_task},
return_when=asyncio.FIRST_COMPLETED,
)
if sender_task in done and not join_task.done():
join_task.cancel()
await asyncio.gather(join_task, return_exceptions=True)
return False
await join_task
await AudioSendProcessor._stop_audio_sender(flow)
if flow.get("send_failed"):
return False
packet_count = int(flow.get("packet_count", 0))
if packet_count <= 0:
return AudioSendProcessor._is_current_turn(context, sentence_id)
frame_duration = max(1, int(getattr(context, "output_frame_duration", 60)))
pre_buffer_count = max(0, int(context.config.get("tts_pre_buffer_count", 5)))
tail_frames = max(
0,
int(context.config.get("tts_stop_buffer_frames", pre_buffer_count + 2)),
)
if tail_frames:
await asyncio.sleep(tail_frames * frame_duration / 1000.0)
return AudioSendProcessor._is_current_turn(context, sentence_id)
@staticmethod
async def _stop_audio_sender(flow: dict) -> None:
task = flow.pop("_send_task", None)
flow.pop("_send_queue", None)
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@staticmethod
def _is_current_turn(context: SessionContext, sentence_id: str = None) -> bool:
if sentence_id is None:
return bool(
getattr(context, "conversation_active", True)
and getattr(context, "sentence_id", None) is None
)
return sentence_id == context.sentence_id
async def send_music_message(self, context: SessionContext, transport: TransportInterface,
music_path: str, text: str):
"""发送音乐消息 - 完整迁移自send_music_message"""
from core.utils.util import audio_to_data
try:
# 获取音频数据
opus_packets = await audio_to_data(music_path)
if opus_packets:
# 发送音乐开始消息
await self.send_tts_message(context, transport, "start", text)
# 发送音频数据
await self.send_audio(context, transport, opus_packets)
# 发送音乐结束消息
await self.send_tts_message(context, transport, "stop", None)
logger.info(f"发送音乐: {music_path}")
else:
logger.warning(f"无法加载音乐文件: {music_path}")
except Exception as e:
logger.error(f"发送音乐失败: {e}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def send_welcome_audio(self, context: SessionContext, transport: TransportInterface):
"""发送欢迎音频"""
welcome_audio_path = context.config.get("welcome_audio_path")
if welcome_audio_path:
await self.send_music_message(context, transport, welcome_audio_path, "欢迎使用小智助手")
async def send_goodbye_audio(self, context: SessionContext, transport: TransportInterface):
"""发送告别音频"""
goodbye_audio_path = context.config.get("goodbye_audio_path")
if goodbye_audio_path:
await self.send_music_message(context, transport, goodbye_audio_path, "再见,期待下次相遇")
@@ -0,0 +1,55 @@
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.auth import AuthMiddleware, AuthenticationError
from config.logger import setup_logging
logger = setup_logging()
class AuthProcessor(MessageProcessor):
"""认证处理器:处理连接认证逻辑"""
def __init__(self):
self.auth_middleware = None
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理认证相关逻辑"""
# 服务器管理连接走server消息处理(基于secret校验),跳过认证
if isinstance(message, str):
try:
import json
msg_json = json.loads(message)
if isinstance(msg_json, dict) and msg_json.get("type") == "server":
return False
except json.JSONDecodeError:
pass
elif isinstance(message, dict) and message.get("type") == "server":
return False
# 如果已经认证,跳过
if context.is_authenticated:
return False
# 初始化认证中间件(延迟初始化)
if self.auth_middleware is None:
self.auth_middleware = AuthMiddleware(context.config)
# 检查是否为认证消息(通过headers进行认证)
if context.headers:
try:
await self.auth_middleware.authenticate_async(context.headers)
context.is_authenticated = True
logger.info(f"设备认证成功: {context.device_id}")
return False # 认证成功,继续处理其他消息
except AuthenticationError as e:
logger.error(f"设备认证失败: {e}")
# 发送认证失败消息
await transport.send("Authentication failed")
await transport.close()
return True # 认证失败,停止处理
# 如果没有认证信息,要求认证
await transport.send("Authentication required")
return True # 停止后续处理
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class GoodbyeProcessor(MessageProcessor):
"""Goodbye消息处理器:处理会话结束"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
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"))
else:
await transport.close()
return True
return False
@@ -0,0 +1,351 @@
import time
import json
import random
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.utils.dialogue import Message
from core.utils.util import audio_to_data, remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tts.dto.dto import SentenceType
from core.processors.audio_receive_processor import arm_wake_audio_suppression
from core.utils.wakeup_word import WakeupWordsConfig
from core.components.component_manager import ComponentType
from core.providers.tools.device_mcp import (
MCPClient,
send_mcp_initialize_message,
)
from config.logger import setup_logging
logger = setup_logging()
# 唤醒词配置
WAKEUP_CONFIG = {
"refresh_time": 5,
"words": ["你好", "你好啊", "嘿,你好", ""],
}
# 创建全局的唤醒词配置管理器
wakeup_words_config = WakeupWordsConfig()
# 用于防止并发调用wakeupWordsResponse的锁
_wakeup_response_lock = asyncio.Lock()
class HelloProcessor(MessageProcessor):
"""Hello消息处理器:完整迁移helloHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理hello类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "hello":
await self.handle_hello_message(context, transport, msg_json)
return True
return False
async def handle_hello_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理hello消息 - 完整迁移自handleHelloMessage"""
pending_tasks = []
for task_name in (
"listen_stop_task",
"listen_start_task",
):
pending_task = getattr(context, task_name, None)
if pending_task and not pending_task.done():
pending_task.cancel()
pending_tasks.append(pending_task)
setattr(context, task_name, None)
if pending_tasks:
await asyncio.gather(*pending_tasks, return_exceptions=True)
transport_session_id = getattr(transport, "session_id", None)
if transport_session_id:
if (
context.session_id != transport_session_id
and callable(getattr(context, "end_conversation", None))
):
# A previous finalizer may already have published
# conversation_active=False while persistence is still in
# progress. Always join it before exposing the new session.
await context.end_conversation(context.session_id)
context.session_id = transport_session_id
if context.welcome_msg:
context.welcome_msg["session_id"] = context.session_id
dialogue = getattr(context, "dialogue", None)
if getattr(context, "prompt", None) and dialogue and not dialogue.dialogue:
dialogue.update_system_message(context.prompt)
inject_fewshot = getattr(context, "inject_tool_call_fewshot", None)
if callable(inject_fewshot):
inject_fewshot()
context.conversation_active = True
context.listen_stop_pending = False
context.listen_stop_deadline = 0.0
if transport.requires_audio_tail_grace:
context.accepting_input_audio = False
# 处理音频参数
audio_params = msg_json.get("audio_params")
if audio_params:
format = audio_params.get("format")
logger.info(f"客户端音频格式: {format}")
context.audio_format = format
context.input_sample_rate = int(
audio_params.get("sample_rate", getattr(context, "input_sample_rate", 16000))
)
context.input_channels = int(
audio_params.get("channels", getattr(context, "input_channels", 1))
)
context.input_frame_duration = int(
audio_params.get(
"frame_duration", getattr(context, "input_frame_duration", 60)
)
)
# 处理客户端特性
features = msg_json.get("features") or {}
context.features = dict(features)
context.client_aec = bool(features.get("aec"))
mcp_enabled = bool(features.get("mcp"))
previous_mcp_client = getattr(context, "mcp_client", None)
if not mcp_enabled and previous_mcp_client:
initialize_task = getattr(
context, "mcp_initialize_task", None
)
if initialize_task and not initialize_task.done():
initialize_task.cancel()
await asyncio.gather(
initialize_task, return_exceptions=True
)
context.mcp_initialize_task = None
previous_mcp_cleanup = getattr(
context, "_mcp_cleanup_callback", None
)
if previous_mcp_cleanup:
context.unregister_cleanup(previous_mcp_cleanup)
context._mcp_cleanup_callback = None
if hasattr(previous_mcp_client, "close"):
await previous_mcp_client.close()
context.mcp_client = None
if features:
logger.info(f"客户端特性: {features}")
if mcp_enabled:
logger.info("客户端支持MCP")
if context.mcp_client is None:
context.mcp_client = MCPClient()
context._mcp_cleanup_callback = (
context.mcp_client.close
)
context.register_cleanup(
context._mcp_cleanup_callback
)
initialize_task = getattr(
context, "mcp_initialize_task", None
)
if (
not await context.mcp_client.is_ready()
and (
initialize_task is None
or initialize_task.done()
)
):
context.mcp_initialize_task = (
context.create_background_task(
send_mcp_initialize_message(
context, transport
),
conversation_scoped=False,
)
)
if features.get("aec"):
logger.info("客户端启用了服务端AEC")
# MQTT/UDP 场景已由MQTT连接层发送hello回复,避免重复发送websocket格式
if transport.has_datagram_audio:
return
# 发送欢迎消息
if context.welcome_msg:
await transport.send_json(context.welcome_msg)
else:
# 默认欢迎消息
welcome_msg = {
"type": "hello",
"session_id": context.session_id,
"version": 1,
"transport": "websocket"
}
await transport.send_json(welcome_msg)
async def check_wakeup_words(self, context: SessionContext, transport: TransportInterface, text: str) -> bool:
"""检查唤醒词 - 完整迁移自checkWakeupWords"""
enable_wakeup_words_response_cache = context.config.get("enable_wakeup_words_response_cache", False)
# 等待tts初始化,最多等待3秒
tts_component = await self._get_component(context, ComponentType.TTS)
start_time = time.time()
while time.time() - start_time < 3:
if tts_component and hasattr(tts_component, 'tts_instance'):
break
await asyncio.sleep(0.1)
else:
return False
if not enable_wakeup_words_response_cache:
return False
_, filtered_text = remove_punctuation_and_length(text)
if filtered_text not in context.config.get("wakeup_words", []):
return False
sentence_id = uuid.uuid4().hex
context.sentence_id = sentence_id
context.just_woken_up = True
await self._send_stt_message(context, transport, text)
# 获取当前音色
tts_instance = getattr(tts_component, 'tts_instance', None) if tts_component else None
voice = getattr(tts_instance, "voice", "default") if tts_instance else "default"
if not voice:
voice = "default"
# 获取唤醒词回复配置
response = wakeup_words_config.get_wakeup_response(voice)
if not response or not response.get("file_path"):
response = {
"voice": "default",
"file_path": "config/assets/wakeup_words.wav",
"time": 0,
"text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",
}
# 获取音频数据
opus_packets = await audio_to_data(response.get("file_path"))
# 播放唤醒词回复
context.abort_requested = False
context.llm_finish_task = True
if tts_instance is not None:
# A prior turn may have consumed the one-shot start marker. Cached
# playback is a new TTS stream and must always emit start first.
tts_instance.tts_audio_first_sentence = True
logger.info(f"播放唤醒词回复: {response.get('text')}")
try:
await self._send_audio_message(
context,
transport,
SentenceType.FIRST,
opus_packets,
response.get("text"),
sentence_id,
)
await self._send_audio_message(
context,
transport,
SentenceType.LAST,
[],
None,
sentence_id,
)
finally:
# Cached wake playback bypasses ListenProcessor, so it must arm
# the same bounded release used by the regular wake-word path.
arm_wake_audio_suppression(context)
# 补充对话
if context.dialogue:
context.dialogue.put(Message(role="assistant", content=response.get("text")))
# 检查是否需要更新唤醒词回复
if time.time() - response.get("time", 0) > WAKEUP_CONFIG["refresh_time"]:
if not _wakeup_response_lock.locked():
context.create_background_task(
self._wakeup_words_response(context, transport)
)
return True
async def _wakeup_words_response(self, context: SessionContext, transport: TransportInterface):
"""生成唤醒词回复 - 完整迁移自wakeupWordsResponse"""
tts_component = await self._get_component(context, ComponentType.TTS)
llm_component = await self._get_component(context, ComponentType.LLM)
tts_instance = getattr(tts_component, 'tts_instance', None) if tts_component else None
llm_instance = getattr(llm_component, 'llm_instance', None) if llm_component else None
if not tts_instance or not llm_instance or not hasattr(llm_instance, 'response_no_stream'):
return
try:
# 尝试获取锁,如果获取不到就返回
async with _wakeup_response_lock:
# 生成唤醒词回复
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
question = (
"此刻用户正在和你说```"
+ wakeup_word
+ "```。\n请你根据以上用户的内容进行20-30字回复。要符合系统设置的角色情感和态度,不要像机器人一样说话。\n"
+ "请勿对这条内容本身进行任何解释和回应,请勿返回表情符号,仅返回对用户的内容的回复。"
)
result = await asyncio.to_thread(
llm_instance.response_no_stream,
context.config.get("prompt", ""),
question,
)
if not result or len(result) == 0:
return
# 生成TTS音频
tts_result = await asyncio.to_thread(tts_instance.to_tts, result)
if not tts_result:
return
# 获取当前音色
voice = getattr(tts_instance, "voice", "default")
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
file_path = wakeup_words_config.generate_file_path(voice)
with open(file_path, "wb") as f:
f.write(wav_bytes)
# 更新配置
wakeup_words_config.update_wakeup_response(voice, file_path, result)
except Exception as e:
logger.error(f"生成唤醒词回复失败: {e}")
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
async def _send_audio_message(self, context: SessionContext, transport: TransportInterface,
sentence_type: SentenceType, audios: bytes, text: str,
sentence_id: str = None):
"""发送音频消息"""
# 这里应该调用AudioSendProcessor
from core.processors.audio_send_processor import AudioSendProcessor
audio_send_processor = AudioSendProcessor()
await audio_send_processor.send_audio_message(
context,
transport,
sentence_type,
audios,
text,
sentence_id=sentence_id,
)
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
@@ -0,0 +1,124 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tools.device_iot import handleIotStatus, handleIotDescriptors
from config.logger import setup_logging
logger = setup_logging()
class IotProcessor(MessageProcessor):
"""IoT消息处理器:完整迁移iotMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理iot类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "iot":
await self.handle_iot_message(context, transport, msg_json)
return True
return False
async def handle_iot_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理IoT消息 - 完整迁移自iotMessageHandler.py"""
tasks = []
# 处理设备描述符 - 完整迁移原逻辑
if "descriptors" in msg_json:
logger.debug("处理IoT设备描述符")
task = context.create_background_task(
self._handle_iot_descriptors(context, transport, msg_json["descriptors"])
)
tasks.append(task)
# 处理设备状态 - 完整迁移原逻辑
if "states" in msg_json:
logger.debug("处理IoT设备状态")
task = context.create_background_task(
self._handle_iot_status(context, transport, msg_json["states"])
)
tasks.append(task)
# 如果没有有效的IoT数据
if not tasks:
logger.warning("IoT消息缺少descriptors或states字段")
await self._send_error_response(
transport,
context.session_id,
"IoT消息格式错误:缺少descriptors或states字段"
)
return
# 任务由 SessionContext 持有,结束会话时统一取消。
async def _handle_iot_descriptors(self, context: SessionContext, transport: TransportInterface, descriptors: Any):
"""处理IoT设备描述符 - 包装原handleIotDescriptors函数"""
try:
# 调用原有的handleIotDescriptors函数
# 注意:这里需要传入context而不是conn,因为handleIotDescriptors可能需要适配
await handleIotDescriptors(context, descriptors)
logger.debug("IoT设备描述符处理完成")
except Exception as e:
logger.error(f"处理IoT设备描述符失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"IoT设备描述符处理失败: {str(e)}"
)
async def _handle_iot_status(self, context: SessionContext, transport: TransportInterface, states: Any):
"""处理IoT设备状态 - 包装原handleIotStatus函数"""
try:
# 调用原有的handleIotStatus函数
# 注意:这里需要传入context而不是conn,因为handleIotStatus可能需要适配
await handleIotStatus(context, states)
logger.debug("IoT设备状态处理完成")
except Exception as e:
logger.error(f"处理IoT设备状态失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"IoT设备状态处理失败: {str(e)}"
)
async def _send_error_response(self, transport: TransportInterface, session_id: str, message: str):
"""发送IoT错误响应"""
response = {
"type": "iot",
"status": "error",
"message": message,
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送IoT错误响应失败: {e}")
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, data: dict = None):
"""发送IoT成功响应"""
response = {
"type": "iot",
"status": "success",
"message": message,
"session_id": session_id
}
if data:
response["data"] = data
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送IoT成功响应失败: {e}")
@@ -0,0 +1,433 @@
import time
import json
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message
from core.components.component_manager import ComponentType
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from core.processors.audio_receive_processor import arm_wake_audio_suppression
from config.logger import setup_logging
logger = setup_logging()
class ListenProcessor(MessageProcessor):
"""Listen消息处理器:完整迁移listenMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理listen类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "listen":
await self.handle_listen_message(context, transport, msg_json)
return True
return False
async def handle_listen_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理listen消息 - 完整迁移自listenMessageHandler.py"""
msg_session_id = msg_json.get("session_id")
if msg_session_id and msg_session_id != context.session_id:
logger.warning(
f"忽略非当前会话的listen消息: "
f"msg={msg_session_id}, current={context.session_id}"
)
return
state = msg_json.get("state")
has_datagram_audio = getattr(
transport, "has_datagram_audio", False
)
if (
has_datagram_audio
and state in {"start", "detect"}
and not context.conversation_active
):
if transport.keeps_connection_between_sessions:
await transport.send_json(
{
"type": "goodbye",
"session_id": msg_session_id or context.session_id,
}
)
logger.info(
"忽略已结束会话的listen {}: session_id={}",
state,
context.session_id,
)
return
# 设置拾音模式
if "mode" in msg_json:
context.listen_mode = msg_json["mode"]
logger.debug(f"客户端拾音模式:{context.listen_mode}")
# 处理不同的状态
if state == "start":
pending_start = getattr(context, "listen_start_task", None)
if pending_start and not pending_start.done():
logger.debug("忽略尾包隔离期内的重复listen start")
return
tail_quarantine = await self._flush_pending_listen_stop(
context, transport
)
# 开始监听语音
logger.info(f"listen start: session_id={context.session_id}, mode={context.listen_mode}")
context.reset_audio_states()
if (
getattr(transport, "requires_audio_tail_grace", False)
and tail_quarantine > 0
):
context.accepting_input_audio = False
context.listen_start_task = context.create_background_task(
self._open_input_after_tail_quarantine(
context,
context.session_id,
tail_quarantine,
),
turn_scoped=True,
)
else:
context.accepting_input_audio = True
context.abort_requested = False
context.close_after_chat = False
logger.debug("开始语音监听")
elif state == "stop":
# 停止监听语音
logger.info(f"listen stop: session_id={context.session_id}")
context.client_have_voice = True
context.listen_stop_pending = True
pending_start = getattr(context, "listen_start_task", None)
if pending_start and not pending_start.done():
pending_start.cancel()
context.listen_start_task = None
if getattr(transport, "requires_audio_tail_grace", False):
pending = getattr(context, "listen_stop_task", None)
if pending and not pending.done():
logger.debug("忽略重复的listen stop")
return
delay_ms = max(
0,
min(
1000,
int(
context.config.get(
"mqtt_udp_tail_grace_ms", 180
)
),
),
)
context.listen_stop_deadline = (
time.monotonic() + delay_ms / 1000
)
context.listen_stop_task = context.create_background_task(
self._finalize_listen_stop(
context,
transport,
context.session_id,
delay_ms / 1000,
),
turn_scoped=True,
)
else:
context.listen_stop_deadline = 0.0
await self._finalize_listen_stop(
context,
transport,
context.session_id,
0,
)
logger.debug("停止语音监听")
elif state == "detect":
# 检测到文本输入
logger.info(f"listen detect: session_id={context.session_id}, text={msg_json.get('text')}")
context.client_have_voice = False
context.asr_audio.clear()
if "text" in msg_json:
context.update_activity()
original_text = msg_json["text"] # 保留原始文本
filtered_len, filtered_text = remove_punctuation_and_length(original_text)
if original_text.startswith("[device_call]"):
await self._handle_device_call(
context,
transport,
original_text[len("[device_call]"):].strip(),
)
return
# 识别是否是唤醒词
is_wakeup_words = filtered_text in context.config.get("wakeup_words", [])
# 是否开启唤醒词回复
enable_greeting = context.config.get("enable_greeting", True)
if is_wakeup_words and not enable_greeting:
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
# Native already drops wake history until listen/start.
# Keeping the greeting suppression window armed here would
# discard the beginning of the user's first real utterance.
await self._send_stt_message(context, transport, original_text)
await self._send_tts_message(context, transport, "stop", None)
context.is_speaking = False
elif is_wakeup_words:
# 处理唤醒词
self._arm_wake_audio_suppression(context)
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
await self._enqueue_asr_report(context, "嘿,你好呀", [])
await self._start_to_chat(context, transport, "嘿,你好呀", skip_intent=True)
else:
# 处理普通文本
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
await self._enqueue_asr_report(context, original_text, [])
# 否则需要LLM对文字内容进行答复
await self._start_to_chat(context, transport, original_text)
async def _flush_pending_listen_stop(
self,
context: SessionContext,
transport: TransportInterface,
) -> float:
"""Finalize buffered speech before a new listen/start resets the turn."""
pending = getattr(context, "listen_stop_task", None)
had_pending_stop = bool(getattr(context, "listen_stop_pending", False))
tail_quarantine = max(
0.0,
float(getattr(context, "listen_stop_deadline", 0.0) or 0.0)
- time.monotonic(),
)
if pending and not pending.done():
pending.cancel()
try:
await pending
except asyncio.CancelledError:
pass
context.listen_stop_task = None
if had_pending_stop:
await self._finalize_listen_stop(
context,
transport,
context.session_id,
0,
)
else:
context.listen_stop_pending = False
if tail_quarantine <= 0:
context.listen_stop_deadline = 0.0
return tail_quarantine
@staticmethod
async def _open_input_after_tail_quarantine(
context: SessionContext,
session_id: str,
delay_seconds: float,
) -> None:
current_task = asyncio.current_task()
try:
await asyncio.sleep(delay_seconds)
if (
context.session_id == session_id
and context.conversation_active
and not context.listen_stop_pending
):
context.accepting_input_audio = True
finally:
if getattr(context, "listen_start_task", None) is current_task:
context.listen_start_task = None
context.listen_stop_deadline = 0.0
async def _finalize_listen_stop(
self,
context: SessionContext,
transport: TransportInterface,
session_id: str,
delay_seconds: float,
) -> None:
"""Finalize ASR after datagram tail frames have crossed the router."""
current_task = asyncio.current_task()
try:
if delay_seconds > 0:
await asyncio.sleep(delay_seconds)
if context.session_id != session_id:
return
if (
getattr(transport, "has_datagram_audio", False)
and not context.conversation_active
):
return
# Close the tail-grace ingress gate before resolving ASR.
# Component lookup may fail; leaving it open would leak subsequent
# packets into a turn that has already stopped.
context.listen_stop_pending = False
context.client_voice_stop = True
if getattr(transport, "requires_audio_tail_grace", False):
context.accepting_input_audio = False
asr_component = await self._get_component(
context, ComponentType.ASR
)
if context.session_id != session_id:
return
asr_instance = (
getattr(asr_component, "asr_instance", None)
if asr_component
else None
)
if not asr_instance or not hasattr(
asr_instance, "interface_type"
):
return
from core.providers.asr.dto.dto import InterfaceType
if asr_instance.interface_type == InterfaceType.STREAM:
if hasattr(asr_instance, "_send_stop_request"):
await asr_instance._send_stop_request()
return
if context.asr_audio:
asr_audio_task = context.asr_audio.copy()
context.asr_audio.clear()
context.reset_vad_states()
await asr_instance.handle_voice_stop(
context, asr_audio_task
)
except asyncio.CancelledError:
raise
except Exception as error:
logger.error(
"listen stop收尾失败: session_id={}, error={}",
session_id,
error,
)
finally:
if getattr(context, "listen_stop_task", None) is current_task:
context.listen_stop_task = None
context.listen_stop_deadline = 0.0
if context.session_id == session_id:
context.listen_stop_pending = False
if getattr(transport, "requires_audio_tail_grace", False):
context.accepting_input_audio = False
def _arm_wake_audio_suppression(self, context: SessionContext) -> None:
"""Bound the UDP/TCP reorder window used by the legacy gateway flow."""
arm_wake_audio_suppression(context)
async def _handle_device_call(
self,
context: SessionContext,
transport: TransportInterface,
call_text: str,
) -> None:
"""Restore the legacy device-call announcement and call-state handoff."""
logger.info(f"收到设备呼叫指令: {call_text}")
context.incoming_call = True
context.sentence_id = uuid.uuid4().hex
await self._send_stt_message(context, transport, call_text)
tts_component = await self._get_component(context, ComponentType.TTS)
tts_instance = (
getattr(tts_component, "tts_instance", None)
if tts_component
else None
)
if tts_instance:
tts_instance.store_tts_text(context.sentence_id, call_text)
tts_instance.tts_text_queue.put(
TTSMessageDTO(
sentence_id=context.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
tts_instance.tts_one_sentence(
context,
ContentType.TEXT,
content_detail=call_text,
)
tts_instance.tts_text_queue.put(
TTSMessageDTO(
sentence_id=context.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
context.dialogue.put(Message(role="assistant", content=call_text))
async def _handle_audio_message(self, context: SessionContext, transport: TransportInterface, audio: bytes):
"""处理音频消息 - 调用AudioReceiveProcessor"""
# 这里应该调用AudioReceiveProcessor来处理音频
from core.processors.audio_receive_processor import AudioReceiveProcessor
audio_processor = AudioReceiveProcessor()
await audio_processor.handle_audio_message(context, transport, audio)
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
async def _send_tts_message(self, context: SessionContext, transport: TransportInterface, state: str, text: str = None):
"""发送TTS消息"""
message = {
"type": "tts",
"state": state,
"session_id": context.session_id
}
if text:
message["text"] = text
await transport.send(json.dumps(message))
logger.debug(f"发送TTS消息: state={state}, text={text}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def _enqueue_asr_report(self, context: SessionContext, text: str, audio_data: list):
"""ASR上报队列"""
if context.report_asr_enable:
from core.processors.report_processor import ReportProcessor
report_processor = ReportProcessor()
report_processor.enqueue_asr_report(context, text, audio_data)
async def _start_to_chat(self, context: SessionContext, transport: TransportInterface, text: str, skip_intent: bool = False):
"""开始聊天 - 调用ChatProcessor"""
# 与旧架构一致:先发送STT,再异步触发聊天,避免阻塞事件循环
await self._send_stt_message(context, transport, text)
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
if hasattr(context, "create_background_task"):
context.create_background_task(
chat_processor.handle_chat(
context, transport, text, skip_intent=skip_intent
),
turn_scoped=True,
)
logger.info(f"chat任务已提交: session_id={context.session_id}")
else:
await chat_processor.handle_chat(context, transport, text, skip_intent=skip_intent)
@@ -0,0 +1,96 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tools.device_mcp import handle_mcp_message
from config.logger import setup_logging
logger = setup_logging()
class McpProcessor(MessageProcessor):
"""MCP消息处理器:完整迁移mcpMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理mcp类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "mcp":
await self.handle_mcp_message(context, transport, msg_json)
return True
return False
async def handle_mcp_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理MCP消息 - 完整迁移自mcpMessageHandler.py"""
if "payload" in msg_json:
# 检查MCP客户端是否存在
if not context.mcp_client:
logger.warning("MCP客户端未初始化,无法处理MCP消息")
await self._send_error_response(transport, context.session_id, "MCP客户端未初始化")
return
# 创建异步任务处理MCP消息 - 完整迁移原逻辑
context.create_background_task(
self._handle_mcp_payload(
context, transport, msg_json["payload"]
),
conversation_scoped=False,
)
else:
logger.warning("MCP消息缺少payload字段")
await self._send_error_response(transport, context.session_id, "MCP消息格式错误:缺少payload")
async def _handle_mcp_payload(self, context: SessionContext, transport: TransportInterface, payload: dict):
"""处理MCP payload - 包装原handle_mcp_message函数"""
try:
# 调用原有的handle_mcp_message函数
# 注意:这里需要传入context而不是conn,因为handle_mcp_message可能需要适配
await handle_mcp_message(context, context.mcp_client, payload, transport)
logger.debug("MCP消息处理完成")
except Exception as e:
logger.error(f"处理MCP消息失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"MCP消息处理失败: {str(e)}"
)
async def _send_error_response(self, transport: TransportInterface, session_id: str, message: str):
"""发送MCP错误响应"""
response = {
"type": "mcp",
"status": "error",
"message": message,
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送MCP错误响应失败: {e}")
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, data: dict = None):
"""发送MCP成功响应"""
response = {
"type": "mcp",
"status": "success",
"message": message,
"session_id": session_id
}
if data:
response["data"] = data
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送MCP成功响应失败: {e}")
@@ -0,0 +1,127 @@
import json
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.processors.hello_processor import HelloProcessor
from core.processors.listen_processor import ListenProcessor
from core.processors.audio_receive_processor import AudioReceiveProcessor
from core.processors.auth_processor import AuthProcessor
from core.processors.timeout_processor import TimeoutProcessor
from core.processors.server_processor import ServerProcessor
from core.processors.mcp_processor import McpProcessor
from core.processors.iot_processor import IotProcessor
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()
class MessageRouter(MessageProcessor):
"""
消息路由器协调所有独立的processor
按功能职责分离避免耦合每个processor专注单一职责
"""
def __init__(self):
# 初始化所有独立的processor
self.auth_processor = AuthProcessor()
self.timeout_processor = TimeoutProcessor()
self.abort_processor = AbortProcessor()
self.goodbye_processor = GoodbyeProcessor()
self.hello_processor = HelloProcessor()
self.listen_processor = ListenProcessor()
self.server_processor = ServerProcessor()
self.mcp_processor = McpProcessor()
self.iot_processor = IotProcessor()
self.audio_receive_processor = AudioReceiveProcessor()
self.text_processor = TextProcessor()
self.ping_processor = PingProcessor()
self.udp_timeout_processor = UdpTimeoutProcessor()
# 按优先级排序的processor列表
self.processors: List[MessageProcessor] = [
self.timeout_processor, # 首先检查超时
self.auth_processor, # 然后检查认证
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消息
self.mcp_processor, # MCP消息
self.iot_processor, # IoT消息
self.audio_receive_processor, # 音频消息
self.text_processor, # 纯文本消息(放在最后,作为兜底处理)
]
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""
路由消息到合适的processor
每个processor专注处理自己的消息类型避免耦合
"""
# Audio activity is updated only after VAD confirms voice. Treating
# every silent frame as activity prevents logical-session timeouts.
is_audio = isinstance(message, bytes) or (
isinstance(message, dict) and message.get("type") == "audio"
)
if not is_audio:
context.update_activity()
# 按优先级顺序尝试每个processor
for processor in self.processors:
try:
if await processor.process(context, transport, message):
# 消息已被处理,记录日志并返回
logger.debug(f"消息被 {processor.__class__.__name__} 处理")
return True
except Exception as e:
logger.error(f"{processor.__class__.__name__} 处理消息时出错: {e}", exc_info=True)
continue
# 如果没有processor处理该消息,记录警告
if isinstance(message, str):
try:
msg_json = json.loads(message)
msg_type = msg_json.get("type", "unknown") if isinstance(msg_json, dict) else "non-dict"
logger.warning(f"未处理的消息类型: {msg_type}, 内容: {message[:100]}...")
except json.JSONDecodeError:
logger.warning(f"未处理的非JSON消息: {message[:100]}...")
elif isinstance(message, bytes):
logger.warning(f"未处理的二进制消息,大小: {len(message)} bytes")
else:
logger.warning(f"未处理的消息类型: {type(message)}, {message}")
return False
def add_processor(self, processor: MessageProcessor, priority: int = None):
"""
添加新的processor
priority: 优先级数字越小优先级越高None表示添加到末尾
"""
if priority is None:
self.processors.append(processor)
else:
self.processors.insert(priority, processor)
logger.info(f"添加processor: {processor.__class__.__name__}")
def remove_processor(self, processor_class):
"""移除指定类型的processor"""
self.processors = [p for p in self.processors if not isinstance(p, processor_class)]
logger.info(f"移除processor: {processor_class.__name__}")
def get_processor(self, processor_class):
"""获取指定类型的processor"""
for processor in self.processors:
if isinstance(processor, processor_class):
return processor
return None
def list_processors(self) -> List[str]:
"""列出所有processor的名称"""
return [processor.__class__.__name__ for processor in self.processors]
@@ -0,0 +1,40 @@
import json
import time
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 PingProcessor(MessageProcessor):
"""Handle the optional legacy JSON ping/pong control contract."""
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") != "ping":
return False
if not context.config.get("enable_websocket_ping", False):
logger.debug("WebSocket心跳功能未启用,忽略PING消息")
return True
await transport.send_json(
{
"type": "pong",
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
)
return True
@@ -0,0 +1,301 @@
import asyncio
import time
import queue
import threading
import json
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.manage_api_client import ManageApiClient, report as manage_report
from config.logger import setup_logging
logger = setup_logging()
class ReportProcessor(MessageProcessor):
"""上报处理器:完整迁移reportHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""这个处理器不直接处理消息,而是被其他处理器调用"""
return False
def enqueue_asr_report(self, context: SessionContext, text: str, audio_data: List[bytes]):
"""ASR上报队列 - 完整迁移自enqueue_asr_report"""
if not self._should_report(context, context.report_asr_enable):
return
report_time = int(time.time() * 1000)
if context.chat_history_conf != 2:
audio_data = None
# 将上报任务放入队列
self._enqueue_report(context, {
"type": 1, # 用户类型
"text": text,
"audio_data": audio_data,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
})
# 确保上报线程已启动
self._ensure_report_thread(context)
def enqueue_tts_report(self, context: SessionContext, text: str, opus_data: bytes):
"""TTS上报队列 - 完整迁移自enqueue_tts_report"""
if not self._should_report(context, context.report_tts_enable):
return
report_time = int(time.time() * 1000)
if context.chat_history_conf != 2:
opus_data = None
# 将上报任务放入队列
self._enqueue_report(context, {
"type": 2, # 智能体类型
"text": text,
"audio_data": opus_data,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
})
# 确保上报线程已启动
self._ensure_report_thread(context)
def enqueue_tool_report(
self,
context: SessionContext,
tool_name: str,
tool_input: dict,
tool_result: str = None,
report_tool_call: bool = True,
):
if not self._should_report(context, True):
return
timestamp = int(time.time() * 1000)
entries = []
if report_tool_call:
entries.append(
(
json.dumps(
[{"type": "tool", "text": f"{tool_name}({json.dumps(tool_input, ensure_ascii=False)})"}],
ensure_ascii=False,
),
timestamp,
)
)
if tool_result is not None:
entries.append(
(
json.dumps(
[{"type": "tool_result", "text": json.dumps({"result": str(tool_result)}, ensure_ascii=False)}],
ensure_ascii=False,
),
timestamp + 1,
)
)
for text, report_time in entries:
self._enqueue_report(
context,
{
"type": 3,
"text": text,
"audio_data": None,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
},
)
if entries:
self._ensure_report_thread(context)
def _ensure_report_thread(self, context: SessionContext):
"""确保上报线程已启动"""
if context.report_thread is None or not context.report_thread.is_alive():
if not getattr(context, "_report_cleanup_registered", False):
context.register_cleanup(
lambda: asyncio.to_thread(self.cleanup_session, context)
)
context._report_cleanup_registered = True
context.report_thread = threading.Thread(
target=self._report_worker,
args=(context,),
daemon=True
)
context.report_thread.start()
logger.info(f"上报线程已启动: {context.session_id}")
def _enqueue_report(self, context: SessionContext, report_task: dict) -> None:
try:
context.report_queue.put_nowait(report_task)
except queue.Full:
try:
context.report_queue.get_nowait()
context.report_queue.put_nowait(report_task)
logger.warning("上报队列已满,丢弃最旧任务: {}", context.session_id)
except (queue.Empty, queue.Full):
logger.warning("上报队列持续拥塞,丢弃当前任务: {}", context.session_id)
def _should_report(self, context: SessionContext, enabled: bool) -> bool:
return bool(
enabled
and context.read_config_from_api
and not context.need_bind
and context.chat_history_conf != 0
)
def _report_worker(self, context: SessionContext):
"""上报工作线程 - 完整迁移自ConnectionHandler中的上报逻辑"""
logger.info(f"上报工作线程启动: {context.session_id}")
event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(event_loop)
try:
while not context.stop_event.is_set():
try:
# 从队列获取上报任务
report_task = context.report_queue.get(timeout=1)
if report_task is None:
break
# 执行上报
self._execute_report(context, report_task, event_loop)
except queue.Empty:
continue
except Exception as e:
logger.error(f"上报工作线程异常: {e}")
finally:
try:
event_loop.run_until_complete(
ManageApiClient.close_current_loop_client()
)
except Exception as exc:
logger.debug("关闭上报HTTP客户端失败: {}", exc)
event_loop.close()
logger.info(f"上报工作线程退出: {context.session_id}")
def _execute_report(
self, context: SessionContext, report_task: dict,
event_loop=None
):
"""执行聊天记录上报操作 - 完整迁移自report函数"""
try:
report_type = report_task["type"]
text = report_task["text"]
audio_data = report_task["audio_data"]
report_time = report_task["report_time"]
# 处理音频数据
processed_audio = None
if audio_data:
if isinstance(audio_data, list):
# ASR音频数据(多个音频片段)
processed_audio = self._process_asr_audio(audio_data)
elif isinstance(audio_data, bytes):
# TTS音频数据(opus格式)
processed_audio = self._opus_to_wav(audio_data)
# 执行上报
coroutine = manage_report(
mac_address=report_task["device_id"],
session_id=report_task["session_id"],
chat_type=report_type,
content=text,
audio=processed_audio,
report_time=report_time,
)
if event_loop is None:
asyncio.run(coroutine)
else:
event_loop.run_until_complete(coroutine)
logger.debug(f"上报成功: type={report_type}, text={text[:50]}...")
except Exception as e:
logger.error(f"聊天记录上报失败: {e}")
def _process_asr_audio(self, audio_data_list: List[bytes]) -> bytes:
"""处理ASR音频数据"""
try:
# 将多个音频片段合并
combined_audio = b''.join(audio_data_list)
return self._pcm_to_wav(combined_audio)
except Exception as e:
logger.error(f"处理ASR音频数据失败: {e}")
return b''
def _pcm_to_wav(self, pcm_data: bytes) -> bytes:
import io
import wave
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
wav_file.writeframes(pcm_data)
return wav_buffer.getvalue()
def _opus_to_wav(self, opus_data: bytes) -> bytes:
"""将Opus数据转换为WAV格式的字节流 - 完整迁移自opus_to_wav"""
try:
import opuslib_next
import io
import wave
# Opus解码器配置
sample_rate = 16000
channels = 1
# 创建Opus解码器
decoder = opuslib_next.Decoder(sample_rate, channels)
# 解码Opus数据
pcm_data = decoder.decode(opus_data, frame_size=960)
# 创建WAV文件
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm_data)
return wav_buffer.getvalue()
except Exception as e:
logger.error(f"Opus转WAV失败: {e}")
return b''
def cleanup_session(self, context: SessionContext):
"""清理会话上报资源"""
# 停止上报线程
if context.report_thread and context.report_thread.is_alive():
context.stop_event.set()
try:
context.report_queue.put_nowait(None)
except queue.Full:
try:
context.report_queue.get_nowait()
context.report_queue.put_nowait(None)
except (queue.Empty, queue.Full):
pass
context.report_thread.join(timeout=5)
if context.report_thread.is_alive():
logger.warning(f"上报线程未能按时退出: {context.session_id}")
else:
context.report_thread = None
# 清理上报队列
try:
while not context.report_queue.empty():
context.report_queue.get_nowait()
except queue.Empty:
pass
logger.info(f"上报资源清理完成: {context.session_id}")
@@ -0,0 +1,177 @@
import json
import os
import sys
import time
import threading
import subprocess
from pathlib import Path
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
from core.utils.restart import restart_server
logger = setup_logging()
class ServerProcessor(MessageProcessor):
"""服务器消息处理器:完整迁移serverMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理server类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "server":
await self.handle_server_message(context, transport, msg_json)
return True
return False
async def handle_server_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理server消息 - 完整迁移自serverMessageHandler.py"""
# 如果配置是从API读取的,则需要验证secret
if not context.read_config_from_api:
return
# 获取post请求的secret
post_secret = msg_json.get("content", {}).get("secret", "")
secret = context.config.get("manager-api", {}).get("secret", "")
# 如果secret不匹配,则返回
if post_secret != secret:
await self._send_error_response(
transport,
context.session_id,
"服务器密钥验证失败"
)
await self._close_transport(transport)
return
# 处理不同的action
action = msg_json.get("action")
if action == "update_config":
await self._handle_update_config(context, transport, msg_json)
elif action == "restart":
await self._handle_restart(context, transport, msg_json)
else:
await self._send_error_response(
transport,
context.session_id,
f"未知的服务器操作: {action}"
)
await self._close_transport(transport)
async def _handle_update_config(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理配置更新 - 完整迁移自update_config逻辑"""
try:
# 检查是否有服务器实例
if not context.server:
await self._send_error_response(
transport,
context.session_id,
"无法获取服务器实例",
{"action": "update_config"}
)
await self._close_transport(transport)
return
# 更新WebSocketServer的配置
if not await context.server.update_config():
error_msg = ""
if hasattr(context.server, "get_last_update_error"):
error_msg = context.server.get_last_update_error()
await self._send_error_response(
transport,
context.session_id,
error_msg or "更新服务器配置失败",
{"action": "update_config"}
)
await self._close_transport(transport)
return
# 发送成功响应
await self._send_success_response(
transport,
context.session_id,
"配置更新成功",
{"action": "update_config"}
)
await self._close_transport(transport)
except Exception as e:
logger.error(f"更新配置失败: {str(e)}")
await self._send_error_response(
transport,
context.session_id,
f"更新配置失败: {str(e)}",
{"action": "update_config"}
)
await self._close_transport(transport)
async def _handle_restart(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理服务器重启 - 完整迁移自handle_restart逻辑"""
try:
# 发送确认响应
await self._send_success_response(
transport,
context.session_id,
"服务器重启中...",
{"action": "restart"}
)
await self._close_transport(transport)
# 异步执行重启操作
threading.Thread(target=lambda : restart_server(logger), daemon=True).start()
except Exception as e:
logger.error(f"处理重启请求失败: {str(e)}")
await self._send_error_response(
transport,
context.session_id,
f"重启失败: {str(e)}",
{"action": "restart"}
)
await self._close_transport(transport)
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, content: dict = None):
"""发送成功响应"""
response = {
"type": "server",
"status": "success",
"message": message,
"session_id": session_id
}
if content:
response["content"] = content
await transport.send(json.dumps(response))
logger.info(f"服务器操作成功: {message}")
async def _send_error_response(self, transport: TransportInterface, session_id: str,
message: str, content: dict = None):
"""发送错误响应"""
response = {
"type": "server",
"status": "fail",
"message": message,
"session_id": session_id
}
if content:
response["content"] = content
await transport.send(json.dumps(response))
logger.error(f"服务器操作失败: {message}")
async def _close_transport(self, transport: TransportInterface):
try:
await transport.close()
except Exception:
pass
@@ -0,0 +1,54 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class TextProcessor(MessageProcessor):
"""
纯文本消息处理器处理非JSON格式的文本消息
这是新架构中缺失的重要组件用于处理直接发送的文本聊天内容
"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理纯文本消息"""
if isinstance(message, str):
try:
# 尝试解析为JSON,如果成功则不是纯文本消息
json.loads(message)
return False # JSON消息由其他processor处理
except json.JSONDecodeError:
# 确实是纯文本消息,进行聊天处理
await self.handle_text_message(context, transport, message)
return True
return False
async def handle_text_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""处理纯文本消息 - 直接调用ChatProcessor进行聊天"""
try:
# 记录收到纯文本消息
logger.info(f"收到纯文本消息: {text[:100]}...")
# 使用ChatProcessor处理聊天
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
await chat_processor.handle_chat(context, transport, text)
except Exception as e:
logger.error(f"处理纯文本消息失败: {e}")
# 发送错误响应
await self._send_error_response(transport, "文本处理失败,请重试")
async def _send_error_response(self, transport: TransportInterface, error_message: str):
"""发送错误响应"""
try:
await transport.send(json.dumps({
"type": "error",
"message": error_message
}))
except Exception as e:
logger.error(f"发送错误响应失败: {e}")

Some files were not shown because too many files have changed in this diff Show More