mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 00:53:52 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b4c7b932 | ||
|
|
5b288bb0d2 | ||
|
|
5e0d853256 | ||
|
|
eac573706d | ||
|
|
0c582ed3b6 | ||
|
|
f5ed1aaec8 | ||
|
|
9da58e254c | ||
|
|
27e57631a7 | ||
|
|
b1e39da9b8 | ||
|
|
e87fc766d2 | ||
|
|
942d55118b | ||
|
|
f47f3b050b | ||
|
|
e3453b1a87 | ||
|
|
d760465408 | ||
|
|
bdb2538239 | ||
|
|
ea6c144e43 | ||
|
|
a79aa455d6 | ||
|
|
6e92d169ec | ||
|
|
ed89c05245 | ||
|
|
b500d1c6bd | ||
|
|
0598b09629 | ||
|
|
e4907121f4 | ||
|
|
2a618d2f8f |
@@ -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.
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
<aliyun-sms-version>4.1.0</aliyun-sms-version>
|
||||||
<okio-version>3.4.0</okio-version>
|
<okio-version>3.4.0</okio-version>
|
||||||
<skipTests>true</skipTests>
|
<skipTests>true</skipTests>
|
||||||
|
<argLine></argLine>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -272,11 +273,22 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</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>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<skipTests>${skipTests}</skipTests>
|
<skipTests>${skipTests}</skipTests>
|
||||||
|
<argLine>@{argLine} -Xshare:off -javaagent:"${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar"</argLine>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@@ -106,6 +106,32 @@ public interface Constant {
|
|||||||
*/
|
*/
|
||||||
String SERVER_MQTT_GATEWAY = "server.mqtt_gateway";
|
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地址
|
* 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
|
* 无效固件URL
|
||||||
@@ -350,4 +376,4 @@ public interface Constant {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -67,7 +67,7 @@ public class DataFilterInterceptor implements InnerInterceptor {
|
|||||||
private String getSelect(String buildSql, DataScope scope) {
|
private String getSelect(String buildSql, DataScope scope) {
|
||||||
try {
|
try {
|
||||||
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
|
Select select = (Select) CCJSqlParserUtil.parse(buildSql);
|
||||||
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
|
PlainSelect plainSelect = select.getPlainSelect();
|
||||||
|
|
||||||
Expression expression = plainSelect.getWhere();
|
Expression expression = plainSelect.getWhere();
|
||||||
if (expression == null) {
|
if (expression == null) {
|
||||||
@@ -82,4 +82,4 @@ public class DataFilterInterceptor implements InnerInterceptor {
|
|||||||
return buildSql;
|
return buildSql;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements Bas
|
|||||||
// 处理排序字段
|
// 处理排序字段
|
||||||
if (orderField instanceof String) {
|
if (orderField instanceof String) {
|
||||||
orderFields.add((String) orderField);
|
orderFields.add((String) orderField);
|
||||||
} else if (orderField instanceof List) {
|
} else if (orderField instanceof List<?> fields) {
|
||||||
orderFields.addAll((List<String>) orderField);
|
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);
|
return SqlHelper.retBool(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Class<M> currentMapperClass() {
|
protected Class<?> currentMapperClass() {
|
||||||
return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
|
return ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
public Class<T> currentModelClass() {
|
public Class<T> currentModelClass() {
|
||||||
return (Class<T>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1);
|
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
|
@Override
|
||||||
public boolean deleteBatchIds(Collection<? extends Serializable> idList) {
|
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>
|
public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T>
|
||||||
implements CrudService<T, D> {
|
implements CrudService<T, D> {
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
protected Class<D> currentDtoClass() {
|
protected Class<D> currentDtoClass() {
|
||||||
return (Class<D>) ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2);
|
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
|
@Override
|
||||||
public void delete(Serializable[] ids) {
|
public void delete(Serializable[] ids) {
|
||||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
baseDao.deleteByIds(Arrays.asList(ids));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package xiaozhi.common.utils;
|
package xiaozhi.common.utils;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -16,6 +18,10 @@ import cn.hutool.core.util.StrUtil;
|
|||||||
*/
|
*/
|
||||||
public class JsonUtils {
|
public class JsonUtils {
|
||||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
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) {
|
public static String toJsonString(Object object) {
|
||||||
try {
|
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);
|
Object value = jsonObject.get(key);
|
||||||
|
|
||||||
if (SENSITIVE_FIELDS.contains(key.toLowerCase()) && value instanceof String) {
|
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) {
|
} else if (value instanceof JSONObject) {
|
||||||
result.put(key, maskSensitiveFields((JSONObject) value));
|
result.set(key, maskSensitiveFields((JSONObject) value));
|
||||||
} else {
|
} else {
|
||||||
result.put(key, value);
|
result.set(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,4 +162,4 @@ public class SensitiveDataUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ public class SqlFilter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// 去掉'|"|;|\字符
|
// 去掉'|"|;|\字符
|
||||||
str = StringUtils.replace(str, "'", "");
|
str = str.replace("'", "");
|
||||||
str = StringUtils.replace(str, "\"", "");
|
str = str.replace("\"", "");
|
||||||
str = StringUtils.replace(str, ";", "");
|
str = str.replace(";", "");
|
||||||
str = StringUtils.replace(str, "\\", "");
|
str = str.replace("\\", "");
|
||||||
|
|
||||||
// 转换成小写
|
// 转换成小写
|
||||||
str = str.toLowerCase();
|
str = str.toLowerCase();
|
||||||
|
|||||||
+3
-2
@@ -34,6 +34,7 @@ import xiaozhi.common.page.PageData;
|
|||||||
import xiaozhi.common.redis.RedisKeys;
|
import xiaozhi.common.redis.RedisKeys;
|
||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.common.user.UserDetail;
|
import xiaozhi.common.user.UserDetail;
|
||||||
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.Result;
|
import xiaozhi.common.utils.Result;
|
||||||
import xiaozhi.common.utils.ResultUtils;
|
import xiaozhi.common.utils.ResultUtils;
|
||||||
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
import xiaozhi.modules.agent.dto.AgentChatHistoryDTO;
|
||||||
@@ -340,8 +341,8 @@ public class AgentController {
|
|||||||
@RequiresPermissions("sys:role:normal")
|
@RequiresPermissions("sys:role:normal")
|
||||||
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
|
public Result<Void> saveAgentTags(@PathVariable String id, @RequestBody Map<String, Object> params) {
|
||||||
requireAgentPermission(id);
|
requireAgentPermission(id);
|
||||||
List<String> tagIds = (List<String>) params.get("tagIds");
|
List<String> tagIds = JsonUtils.toList(params.get("tagIds"), String.class);
|
||||||
List<String> tagNames = (List<String>) params.get("tagNames");
|
List<String> tagNames = JsonUtils.toList(params.get("tagNames"), String.class);
|
||||||
AgentUpdateDTO dto = new AgentUpdateDTO();
|
AgentUpdateDTO dto = new AgentUpdateDTO();
|
||||||
dto.setTagIds(tagIds);
|
dto.setTagIds(tagIds);
|
||||||
dto.setTagNames(tagNames);
|
dto.setTagNames(tagNames);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class AgentDTO {
|
|||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||||
private String summaryMemory;
|
private String summaryMemory;
|
||||||
|
|
||||||
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
|
@Schema(description = "最后连接时间", example = "2024-03-20 10:00:00")
|
||||||
@@ -50,4 +50,4 @@ public class AgentDTO {
|
|||||||
|
|
||||||
@Schema(description = "标签列表")
|
@Schema(description = "标签列表")
|
||||||
private List<AgentTagDTO> tags;
|
private List<AgentTagDTO> tags;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ public class AgentMemoryDTO implements Serializable {
|
|||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||||
private String summaryMemory;
|
private String summaryMemory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ public class AgentUpdateDTO implements Serializable {
|
|||||||
@Schema(description = "小模型标识", example = "slm_model_02", nullable = true)
|
@Schema(description = "小模型标识", example = "slm_model_02", nullable = true)
|
||||||
private String slmModelId;
|
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;
|
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;
|
private String ttsModelId;
|
||||||
|
|
||||||
@Schema(description = "音色标识", example = "voice_02", nullable = true)
|
@Schema(description = "音色标识", example = "voice_02", nullable = true)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public class AgentEntity {
|
|||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|
||||||
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
@Schema(description = "总结记忆", example = "构建可生长的动态记忆网络,在有限空间内保留关键信息的同时,智能维护信息演变轨迹\n" +
|
||||||
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", required = false)
|
"根据对话记录,总结user的重要信息,以便在未来的对话中提供更个性化的服务", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||||
private String summaryMemory;
|
private String summaryMemory;
|
||||||
|
|
||||||
@Schema(description = "语言编码")
|
@Schema(description = "语言编码")
|
||||||
@@ -97,4 +97,4 @@ public class AgentEntity {
|
|||||||
|
|
||||||
@Schema(description = "更新时间")
|
@Schema(description = "更新时间")
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -167,7 +167,7 @@ public class AgentChatHistoryServiceImpl extends CrudRepository<AiAgentChatHisto
|
|||||||
|
|
||||||
// 尝试解析为 JSON
|
// 尝试解析为 JSON
|
||||||
try {
|
try {
|
||||||
Map<String, Object> jsonMap = JsonUtils.parseObject(content, Map.class);
|
Map<String, Object> jsonMap = JsonUtils.parseMap(content);
|
||||||
if (jsonMap != null && jsonMap.containsKey("content")) {
|
if (jsonMap != null && jsonMap.containsKey("content")) {
|
||||||
Object contentObj = jsonMap.get("content");
|
Object contentObj = jsonMap.get("content");
|
||||||
return contentObj != null ? contentObj.toString() : content;
|
return contentObj != null ? contentObj.toString() : content;
|
||||||
|
|||||||
+10
-10
@@ -76,7 +76,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
|
// 等待初始化响应 (id=1) - 移除固定延迟,改为响应驱动
|
||||||
List<String> initResponses = client.listenerWithoutClose(response -> {
|
List<String> initResponses = client.listenerWithoutClose(response -> {
|
||||||
try {
|
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 != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
|
||||||
// 检查是否有result字段,表示初始化成功
|
// 检查是否有result字段,表示初始化成功
|
||||||
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
|
return jsonMap.containsKey("result") && !jsonMap.containsKey("error");
|
||||||
@@ -92,7 +92,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
boolean initSucceeded = false;
|
boolean initSucceeded = false;
|
||||||
for (String response : initResponses) {
|
for (String response : initResponses) {
|
||||||
try {
|
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 != null && Integer.valueOf(1).equals(jsonMap.get("id"))) {
|
||||||
if (jsonMap.containsKey("result")) {
|
if (jsonMap.containsKey("result")) {
|
||||||
log.info("MCP初始化成功,智能体ID: {}", id);
|
log.info("MCP初始化成功,智能体ID: {}", id);
|
||||||
@@ -123,7 +123,7 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
// 等待工具列表响应 (id=2)
|
// 等待工具列表响应 (id=2)
|
||||||
List<String> toolsResponses = client.listener(response -> {
|
List<String> toolsResponses = client.listener(response -> {
|
||||||
try {
|
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"));
|
return jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("解析工具列表响应失败: {}", response, e);
|
log.warn("解析工具列表响应失败: {}", response, e);
|
||||||
@@ -134,18 +134,18 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
// 处理工具列表响应
|
// 处理工具列表响应
|
||||||
for (String response : toolsResponses) {
|
for (String response : toolsResponses) {
|
||||||
try {
|
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"))) {
|
if (jsonMap != null && Integer.valueOf(2).equals(jsonMap.get("id"))) {
|
||||||
// 检查是否有result字段
|
// 检查是否有result字段
|
||||||
Object resultObj = jsonMap.get("result");
|
Object resultObj = jsonMap.get("result");
|
||||||
if (resultObj instanceof Map) {
|
if (resultObj instanceof Map<?, ?>) {
|
||||||
Map<String, Object> resultMap = (Map<String, Object>) resultObj;
|
Map<String, Object> resultMap = JsonUtils.toStringObjectMap(resultObj);
|
||||||
Object toolsObj = resultMap.get("tools");
|
Object toolsObj = resultMap.get("tools");
|
||||||
if (toolsObj instanceof List) {
|
if (toolsObj instanceof List<?>) {
|
||||||
List<Map<String, Object>> toolsList = (List<Map<String, Object>>) toolsObj;
|
List<Map<String, Object>> toolsList = JsonUtils.toStringObjectMapList(toolsObj);
|
||||||
// 提取工具名称列表
|
// 提取工具名称列表
|
||||||
List<String> result = toolsList.stream()
|
List<String> result = toolsList.stream()
|
||||||
.map(tool -> (String) tool.get("name"))
|
.map(tool -> String.class.cast(tool.get("name")))
|
||||||
.filter(name -> name != null)
|
.filter(name -> name != null)
|
||||||
.sorted()
|
.sorted()
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
@@ -232,4 +232,4 @@ public class AgentMcpAccessPointServiceImpl implements AgentMcpAccessPointServic
|
|||||||
// 加密后成token值
|
// 加密后成token值
|
||||||
return AESUtils.encrypt(key, json);
|
return AESUtils.encrypt(key, json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -677,7 +677,7 @@ public class AgentServiceImpl extends BaseServiceImpl<AgentDao, AgentEntity> imp
|
|||||||
mapping.setPluginId(pluginId);
|
mapping.setPluginId(pluginId);
|
||||||
|
|
||||||
Map<String, Object> paramInfo = new HashMap<>();
|
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) {
|
if (fields != null) {
|
||||||
for (Map<String, Object> field : fields) {
|
for (Map<String, Object> field : fields) {
|
||||||
paramInfo.put((String) field.get("key"), field.get("default"));
|
paramInfo.put((String) field.get("key"), field.get("default"));
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ public class AgentTagServiceImpl extends BaseServiceImpl<AgentTagDao, AgentTagEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tagIds != null && !tagIds.isEmpty()) {
|
if (tagIds != null && !tagIds.isEmpty()) {
|
||||||
List<AgentTagEntity> tagIdEntities = baseDao.selectBatchIds(tagIds);
|
List<AgentTagEntity> tagIdEntities = baseDao.selectByIds(tagIds);
|
||||||
for (AgentTagEntity tag : tagIdEntities) {
|
for (AgentTagEntity tag : tagIdEntities) {
|
||||||
if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
|
if (tag != null && (currentTagNames.contains(tag.getTagName()) ||
|
||||||
newTagNames.contains(tag.getTagName()))) {
|
newTagNames.contains(tag.getTagName()))) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public interface ConfigService {
|
|||||||
* @param isCache 是否缓存
|
* @param isCache 是否缓存
|
||||||
* @return 配置信息
|
* @return 配置信息
|
||||||
*/
|
*/
|
||||||
Object getConfig(Boolean isCache);
|
Map<String, Object> getConfig(Boolean isCache);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取智能体模型配置
|
* 获取智能体模型配置
|
||||||
@@ -28,4 +28,4 @@ public interface ConfigService {
|
|||||||
* @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"]
|
* @return 替换词列表,格式如 ["模板1|模板01", "模板2|模板02"]
|
||||||
*/
|
*/
|
||||||
List<String> getCorrectWords(String macAddress);
|
List<String> getCorrectWords(String macAddress);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -65,12 +65,12 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
private final CorrectWordFileService correctWordFileService;
|
private final CorrectWordFileService correctWordFileService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object getConfig(Boolean isCache) {
|
public Map<String, Object> getConfig(Boolean isCache) {
|
||||||
if (isCache) {
|
if (isCache) {
|
||||||
// 先从Redis获取配置
|
// 先从Redis获取配置
|
||||||
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
|
Object cachedConfig = redisUtils.get(RedisKeys.getServerConfigKey());
|
||||||
if (cachedConfig != null) {
|
if (cachedConfig != null) {
|
||||||
return cachedConfig;
|
return JsonUtils.toStringObjectMap(cachedConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
|
if (isAdminRequest != null && "true".equals(isAdminRequest)) {
|
||||||
// 管理控制台请求,返回getConfig的结果
|
// 管理控制台请求,返回getConfig的结果
|
||||||
redisUtils.delete(redisKey); // 使用后清理
|
redisUtils.delete(redisKey); // 使用后清理
|
||||||
return (Map<String, Object>) getConfig(true);
|
return getConfig(true);
|
||||||
}
|
}
|
||||||
// 根据MAC地址查找设备
|
// 根据MAC地址查找设备
|
||||||
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
DeviceEntity device = deviceService.getDeviceByMacAddress(macAddress);
|
||||||
@@ -277,10 +277,10 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
// 遍历除最后一个key之外的所有key
|
// 遍历除最后一个key之外的所有key
|
||||||
for (int i = 0; i < keys.length - 1; i++) {
|
for (int i = 0; i < keys.length - 1; i++) {
|
||||||
String key = keys[i];
|
String key = keys[i];
|
||||||
if (!current.containsKey(key)) {
|
Object nestedConfig = current.computeIfAbsent(key, ignored -> new HashMap<String, Object>());
|
||||||
current.put(key, new HashMap<String, Object>());
|
Map<String, Object> nestedMap = JsonUtils.toStringObjectMap(nestedConfig);
|
||||||
}
|
current.put(key, nestedMap);
|
||||||
current = (Map<String, Object>) current.get(key);
|
current = nestedMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理最后一个key
|
// 处理最后一个key
|
||||||
|
|||||||
+4
-2
@@ -125,7 +125,9 @@ public class DeviceController {
|
|||||||
return new Result<Void>().error("设备不存在");
|
return new Result<Void>().error("设备不存在");
|
||||||
}
|
}
|
||||||
BeanUtils.copyProperties(deviceUpdateDTO, entity);
|
BeanUtils.copyProperties(deviceUpdateDTO, entity);
|
||||||
deviceService.updateById(entity);
|
if (!deviceService.updateById(entity)) {
|
||||||
|
return new Result<Void>().error(ErrorCode.UPDATE_DATA_FAILED);
|
||||||
|
}
|
||||||
return new Result<Void>();
|
return new Result<Void>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +213,4 @@ public class DeviceController {
|
|||||||
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
|
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
|
||||||
return new Result<Void>();
|
return new Result<Void>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-2
@@ -1,6 +1,7 @@
|
|||||||
package xiaozhi.modules.device.controller;
|
package xiaozhi.modules.device.controller;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -78,8 +79,8 @@ public class OTAController {
|
|||||||
@Hidden
|
@Hidden
|
||||||
public ResponseEntity<String> getOTA() {
|
public ResponseEntity<String> getOTA() {
|
||||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false);
|
||||||
if (StringUtils.isBlank(mqttUdpConfig)) {
|
if (!isNativeMqttReady() && !isConfiguredValue(mqttUdpConfig)) {
|
||||||
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置");
|
return ResponseEntity.ok("OTA接口不正常,缺少mqtt_gateway地址或未启用原生MQTT,请登录智控台,在参数管理找到【server.mqtt_gateway】或【mqtt_server.enabled/protocols.mqtt_enabled】配置");
|
||||||
}
|
}
|
||||||
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true);
|
||||||
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) {
|
||||||
@@ -92,6 +93,49 @@ public class OTAController {
|
|||||||
return ResponseEntity.ok("OTA接口运行正常,websocket集群数量:" + wsUrl.split(";").length);
|
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
|
@SneakyThrows
|
||||||
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
|
private ResponseEntity<String> createResponse(DeviceReportRespDTO deviceReportRespDTO) {
|
||||||
ObjectMapper objectMapper = new ObjectMapper();
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
|||||||
@Mapper
|
@Mapper
|
||||||
public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> {
|
public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增设备通讯录记录
|
||||||
|
*/
|
||||||
|
int insertAddressBook(DeviceAddressBookEntity entity);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取设备通讯录列表
|
* 获取设备通讯录列表
|
||||||
*/
|
*/
|
||||||
@@ -31,4 +36,4 @@ public interface DeviceAddressBookDao extends BaseMapper<DeviceAddressBookEntity
|
|||||||
* 批量删除设备相关的通讯录记录
|
* 批量删除设备相关的通讯录记录
|
||||||
*/
|
*/
|
||||||
void deleteByMacAddresses(@Param("macAddresses") List<String> macAddresses);
|
void deleteByMacAddresses(@Param("macAddresses") List<String> macAddresses);
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-26
@@ -18,6 +18,7 @@ import xiaozhi.common.redis.RedisKeys;
|
|||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
|
||||||
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
|
||||||
|
import xiaozhi.modules.device.entity.DeviceEntity;
|
||||||
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
import xiaozhi.modules.device.service.DeviceAddressBookService;
|
||||||
import xiaozhi.modules.device.service.DeviceService;
|
import xiaozhi.modules.device.service.DeviceService;
|
||||||
import xiaozhi.modules.sys.service.SysParamsService;
|
import xiaozhi.modules.sys.service.SysParamsService;
|
||||||
@@ -59,7 +60,14 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
|||||||
Map<String, Map<String, String>> allBooks = getAllAddressBooks();
|
Map<String, Map<String, String>> allBooks = getAllAddressBooks();
|
||||||
|
|
||||||
if (isAnswer) {
|
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("呼叫失败,您没有权限呼叫该设备");
|
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());
|
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
|
||||||
String callerNickname = null;
|
String callerNickname = null;
|
||||||
@@ -86,15 +102,19 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
|||||||
callerNickname = targetBook.get(callerMac.toLowerCase());
|
callerNickname = targetBook.get(callerMac.toLowerCase());
|
||||||
}
|
}
|
||||||
if (StringUtils.isBlank(callerNickname)) {
|
if (StringUtils.isBlank(callerNickname)) {
|
||||||
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
|
callerNickname = callerDevice.getAlias();
|
||||||
if (StringUtils.isBlank(callerNickname)) {
|
if (StringUtils.isBlank(callerNickname)) {
|
||||||
callerNickname = formatMacAsDeviceName(callerMac);
|
callerNickname = formatMacAsDeviceName(callerMac);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return postToMqtt("/api/call/request",
|
return postCallRequest(
|
||||||
Map.of("caller_mac", callerMac, "target_mac", targetMac, "caller_nickname", callerNickname),
|
buildMqttClientId(callerDevice),
|
||||||
"呼叫");
|
buildMqttClientId(targetDevice),
|
||||||
|
Map.of(
|
||||||
|
"caller_mac", callerMac,
|
||||||
|
"target_mac", targetMac,
|
||||||
|
"caller_nickname", callerNickname));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -167,7 +187,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
|||||||
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||||
entity.setAlias(alias);
|
entity.setAlias(alias);
|
||||||
entity.setHasPermission(hasPermission);
|
entity.setHasPermission(hasPermission);
|
||||||
deviceAddressBookDao.insert(entity);
|
deviceAddressBookDao.insertAddressBook(entity);
|
||||||
} else {
|
} else {
|
||||||
if (alias != null) {
|
if (alias != null) {
|
||||||
updateAlias(macAddress, targetMac, alias);
|
updateAlias(macAddress, targetMac, alias);
|
||||||
@@ -195,32 +215,50 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
|||||||
return result;
|
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<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("status", "error");
|
result.put("status", "error");
|
||||||
|
if (response == null || StringUtils.isBlank(response.body())) {
|
||||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
result.put("message", action + "失败,MQTT管理配置缺失");
|
||||||
String mqttSignatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true);
|
|
||||||
|
|
||||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)
|
|
||||||
|| MqttGatewayAuthorization.isMissingSignatureKey(mqttSignatureKey)) {
|
|
||||||
result.put("message", action + "失败,网关配置缺失");
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String url = "http://" + mqttGatewayUrl + path;
|
Map<String, Object> backendResult =
|
||||||
String response = MqttGatewayAuthorization.postJson(
|
JSONUtil.parseObj(response.body());
|
||||||
url,
|
result.put("status", backendResult.get("status"));
|
||||||
JSONUtil.toJsonStr(body),
|
result.put("message", backendResult.get("message"));
|
||||||
mqttSignatureKey,
|
if (backendResult.containsKey("code")) {
|
||||||
Instant.now(),
|
result.put("code", backendResult.get("code"));
|
||||||
5000);
|
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(response)) {
|
|
||||||
Map<String, Object> gwResult = JSONUtil.parseObj(response);
|
|
||||||
result.put("status", gwResult.get("status"));
|
|
||||||
result.put("message", gwResult.get("message"));
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (Exception e) {
|
} 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) {
|
private String formatMacAsDeviceName(String mac) {
|
||||||
if (StringUtils.isBlank(mac) || mac.length() < 2) {
|
if (StringUtils.isBlank(mac) || mac.length() < 2) {
|
||||||
return mac;
|
return mac;
|
||||||
|
|||||||
+316
-103
@@ -5,11 +5,13 @@ import java.security.InvalidKeyException;
|
|||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -50,6 +52,7 @@ import xiaozhi.common.service.impl.BaseServiceImpl;
|
|||||||
import xiaozhi.common.user.UserDetail;
|
import xiaozhi.common.user.UserDetail;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
import xiaozhi.common.utils.DateUtils;
|
import xiaozhi.common.utils.DateUtils;
|
||||||
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
import xiaozhi.common.utils.ToolUtil;
|
||||||
import xiaozhi.modules.device.dao.DeviceDao;
|
import xiaozhi.modules.device.dao.DeviceDao;
|
||||||
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
import xiaozhi.modules.device.dto.DeviceManualAddDTO;
|
||||||
@@ -109,7 +112,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
String deviceId = (String) cacheDeviceId;
|
String deviceId = (String) cacheDeviceId;
|
||||||
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
String safeDeviceId = deviceId.replace(":", "_").toLowerCase();
|
||||||
String cacheDeviceKey = RedisKeys.getOtaDeviceActivationInfo(safeDeviceId);
|
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)) {
|
if (ToolUtil.isEmpty(cacheMap)) {
|
||||||
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
throw new RenException(ErrorCode.ACTIVATION_CODE_ERROR);
|
||||||
}
|
}
|
||||||
@@ -156,32 +159,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String getDeviceOnlineData(String agentId) {
|
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();
|
UserDetail user = SecurityUser.getUser();
|
||||||
List<DeviceEntity> devices = getUserDevices(user.getId(), agentId);
|
List<DeviceEntity> devices = getUserDevices(user.getId(), agentId);
|
||||||
|
|
||||||
// 构建deviceIds数组
|
// 构建deviceIds数组
|
||||||
Set<String> deviceIds = devices.stream().map(o -> {
|
Set<String> deviceIds = devices.stream()
|
||||||
String macAddress = Optional.ofNullable(o.getMacAddress()).orElse("unknown").replace(":", "_");
|
.map(device -> MqttClientId.build(
|
||||||
String groupId = Optional.ofNullable(o.getBoard()).orElse("GID_default").replace(":", "_");
|
device.getBoard(), device.getMacAddress()))
|
||||||
return StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
.collect(Collectors.toSet());
|
||||||
}).collect(Collectors.toSet());
|
|
||||||
|
|
||||||
// 构建请求入参
|
// 构建请求入参
|
||||||
Map<String, Set<String>> params = MapUtil
|
|
||||||
.builder(new HashMap<String, Set<String>>())
|
|
||||||
.put("clientIds", deviceIds).build();
|
|
||||||
|
|
||||||
if (ToolUtil.isNotEmpty(deviceIds)) {
|
if (ToolUtil.isNotEmpty(deviceIds)) {
|
||||||
return postToMqttGateway(url, params);
|
return createMqttManagementRouter()
|
||||||
|
.getMergedStatus(deviceIds);
|
||||||
}
|
}
|
||||||
// 返回响应
|
// 返回响应
|
||||||
return "";
|
return "";
|
||||||
@@ -193,6 +184,15 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
response.setServer_time(buildServerTime());
|
response.setServer_time(buildServerTime());
|
||||||
|
|
||||||
DeviceEntity deviceById = getDeviceByMacAddress(macAddress);
|
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) {
|
if (deviceById == null) {
|
||||||
@@ -201,10 +201,9 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
|
firmware.setUrl(Constant.INVALID_FIRMWARE_URL);
|
||||||
response.setFirmware(firmware);
|
response.setFirmware(firmware);
|
||||||
} else {
|
} else {
|
||||||
// 只有在设备已绑定且autoUpdate不为0的情况下才返回固件升级信息
|
// 只有在设备已绑定且明确开启自动升级时才返回固件升级信息
|
||||||
if (deviceById.getAutoUpdate() != 0) {
|
if (Integer.valueOf(1).equals(deviceById.getAutoUpdate())) {
|
||||||
String type = deviceReport.getBoard() == null ? null : deviceReport.getBoard().getType();
|
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(reportedBoard,
|
||||||
DeviceReportRespDTO.Firmware firmware = buildFirmwareInfo(type,
|
|
||||||
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
deviceReport.getApplication() == null ? null : deviceReport.getApplication().getVersion());
|
||||||
response.setFirmware(firmware);
|
response.setFirmware(firmware);
|
||||||
}
|
}
|
||||||
@@ -247,20 +246,43 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
|
|
||||||
response.setWebsocket(websocket);
|
response.setWebsocket(websocket);
|
||||||
|
|
||||||
// 添加MQTT UDP配置
|
// UDP key/nonce/endpoint is negotiated by the MQTT hello response.
|
||||||
// 从系统参数获取MQTT Gateway地址,仅在配置有效时使用
|
// OTA only needs to provide the MQTT connection parameters.
|
||||||
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
String groupId = "GID_default";
|
||||||
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
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 {
|
try {
|
||||||
String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard()
|
DeviceReportRespDTO.MQTT mqtt = buildNativeMqttConfig(macAddress, groupId, clientId);
|
||||||
: "GID_default";
|
String endpoint = buildNativeMqttEndpoint();
|
||||||
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
if (mqtt != null && StringUtils.isNotBlank(endpoint)) {
|
||||||
if (mqtt != null) {
|
mqtt.setEndpoint(endpoint);
|
||||||
mqtt.setEndpoint(mqttUdpConfig);
|
|
||||||
response.setMqtt(mqtt);
|
response.setMqtt(mqtt);
|
||||||
|
mqttConfigured = true;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("生成MQTT配置失败: {}", e.getMessage());
|
log.error("生成原生MQTT配置失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mqttConfigured) {
|
||||||
|
// Native 未启用或配置无效时继续兼容 MQTT Gateway。
|
||||||
|
String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true);
|
||||||
|
if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) {
|
||||||
|
try {
|
||||||
|
DeviceReportRespDTO.MQTT mqtt = buildMqttConfig(macAddress, groupId);
|
||||||
|
if (mqtt != null) {
|
||||||
|
mqtt.setEndpoint(mqttUdpConfig);
|
||||||
|
response.setMqtt(mqtt);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("生成MQTT配置失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +320,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
UserShowDeviceListVO vo = ConvertUtils.sourceToTarget(device, UserShowDeviceListVO.class);
|
||||||
vo.setDeviceType(device.getBoard());
|
vo.setDeviceType(device.getBoard());
|
||||||
vo.setBoard(device.getBoard());
|
vo.setBoard(device.getBoard());
|
||||||
|
vo.setAutoUpdate(device.getAutoUpdate());
|
||||||
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
|
vo.setCreateDateTimestamp(toTimestamp(device.getCreateDate()));
|
||||||
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
|
vo.setLastConnectedAtTimestamp(toTimestamp(device.getLastConnectedAt()));
|
||||||
return vo;
|
return vo;
|
||||||
@@ -411,7 +434,7 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
public String geCodeByDeviceId(String deviceId) {
|
public String geCodeByDeviceId(String deviceId) {
|
||||||
String dataKey = getDeviceCacheKey(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")) {
|
if (cacheMap != null && cacheMap.containsKey("activation_code")) {
|
||||||
String cachedCode = (String) cacheMap.get("activation_code");
|
String cachedCode = (String) cacheMap.get("activation_code");
|
||||||
return cachedCode;
|
return cachedCode;
|
||||||
@@ -631,6 +654,210 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
return String.format("%s.%d", signatureBase64, timestamp);
|
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配置信息
|
* 构建MQTT配置信息
|
||||||
*
|
*
|
||||||
@@ -648,28 +875,10 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
|
// 构建客户端ID格式:groupId@@@macAddress@@@uuid
|
||||||
String groupIdSafeStr = groupId.replace(":", "_");
|
String deviceIdSafeStr = MqttClientId.normalizeDeviceId(macAddress);
|
||||||
String deviceIdSafeStr = macAddress.replace(":", "_");
|
String mqttClientId = MqttClientId.build(groupId, macAddress);
|
||||||
String mqttClientId = String.format("%s@@@%s@@@%s", groupIdSafeStr, deviceIdSafeStr, deviceIdSafeStr);
|
|
||||||
|
|
||||||
// 构建用户数据(包含IP等信息)
|
String username = buildMqttUsername();
|
||||||
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 password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
|
String password = generatePasswordSignature(mqttClientId + "|" + username, signatureKey);
|
||||||
@@ -685,23 +894,14 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
return mqtt;
|
return mqtt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String postToMqttGateway(String url, Object requestBody) {
|
MqttManagementRouter createMqttManagementRouter() {
|
||||||
String signatureKey = sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, false);
|
return new MqttManagementRouter(
|
||||||
return MqttGatewayAuthorization.postJson(
|
new MqttManagementEndpointResolver(sysParamsService),
|
||||||
url,
|
new MqttManagementHttpClient());
|
||||||
JSONUtil.toJsonStr(requestBody),
|
|
||||||
signatureKey,
|
|
||||||
Instant.now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object getDeviceTools(String deviceId) {
|
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);
|
DeviceEntity device = baseDao.selectById(deviceId);
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
@@ -715,19 +915,20 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建clientId
|
// 构建clientId
|
||||||
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
|
String clientId = MqttClientId.build(
|
||||||
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
|
device.getBoard(), device.getMacAddress());
|
||||||
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
|
||||||
|
|
||||||
// 构建完整的URL
|
|
||||||
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
|
|
||||||
|
|
||||||
// 存储所有工具列表
|
// 存储所有工具列表
|
||||||
List<Object> allTools = new ArrayList<>();
|
List<Object> allTools = new ArrayList<>();
|
||||||
String cursor = null;
|
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
|
// 构建params
|
||||||
Map<String, Object> paramsMap = MapUtil.builder(new HashMap<String, Object>())
|
Map<String, Object> paramsMap = MapUtil.builder(new HashMap<String, Object>())
|
||||||
.put("withUserTools", true)
|
.put("withUserTools", true)
|
||||||
@@ -752,26 +953,44 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
.put("payload", payload)
|
.put("payload", payload)
|
||||||
.build();
|
.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)) {
|
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)) {
|
if (!jsonObject.getBool("success", false)) {
|
||||||
break;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
JSONObject data = jsonObject.getJSONObject("data");
|
JSONObject data = jsonObject.getJSONObject("data");
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
break;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前页的工具列表
|
// 获取当前页的工具列表
|
||||||
JSONArray tools = data.getJSONArray("tools");
|
JSONArray tools = data.getJSONArray("tools");
|
||||||
if (tools != null && !tools.isEmpty()) {
|
if (tools == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!tools.isEmpty()) {
|
||||||
allTools.addAll(tools);
|
allTools.addAll(tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,29 +998,23 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
String nextCursor = data.getStr("nextCursor");
|
String nextCursor = data.getStr("nextCursor");
|
||||||
if (StringUtils.isBlank(nextCursor)) {
|
if (StringUtils.isBlank(nextCursor)) {
|
||||||
// 没有下一页了
|
// 没有下一页了
|
||||||
break;
|
Map<String, Object> resultData = new HashMap<>();
|
||||||
|
resultData.put("tools", allTools);
|
||||||
|
return resultData;
|
||||||
|
}
|
||||||
|
if (!seenCursors.add(nextCursor)) {
|
||||||
|
log.warn("MQTT设备工具列表返回重复cursor,终止分页: {}",
|
||||||
|
nextCursor);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
cursor = nextCursor;
|
cursor = nextCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建返回结果
|
return null;
|
||||||
if (allTools.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> resultData = new HashMap<>();
|
|
||||||
resultData.put("tools", allTools);
|
|
||||||
return resultData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
|
public Object callDeviceTool(String deviceId, String toolName, Map<String, Object> arguments) {
|
||||||
// 从系统参数中获取MQTT网关地址
|
|
||||||
String mqttGatewayUrl = sysParamsService.getValue("server.mqtt_manager_api", true);
|
|
||||||
if (StringUtils.isBlank(mqttGatewayUrl) || "null".equals(mqttGatewayUrl)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取设备信息
|
// 获取设备信息
|
||||||
DeviceEntity device = baseDao.selectById(deviceId);
|
DeviceEntity device = baseDao.selectById(deviceId);
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
@@ -815,12 +1028,8 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建clientId
|
// 构建clientId
|
||||||
String macAddress = Optional.ofNullable(device.getMacAddress()).orElse("unknown").replace(":", "_");
|
String clientId = MqttClientId.build(
|
||||||
String groupId = Optional.ofNullable(device.getBoard()).orElse("GID_default").replace(":", "_");
|
device.getBoard(), device.getMacAddress());
|
||||||
String clientId = StrUtil.format("{}@@@{}@@@{}", groupId, macAddress, macAddress);
|
|
||||||
|
|
||||||
// 构建完整的URL
|
|
||||||
String url = StrUtil.format("http://{}/api/commands/{}", mqttGatewayUrl, clientId);
|
|
||||||
|
|
||||||
// 构建请求体
|
// 构建请求体
|
||||||
Map<String, Object> params = MapUtil
|
Map<String, Object> params = MapUtil
|
||||||
@@ -843,7 +1052,11 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
|
|||||||
.put("payload", payload)
|
.put("payload", payload)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
String resultMessage = postToMqttGateway(url, requestBody);
|
MqttManagementHttpClient.Response response =
|
||||||
|
createMqttManagementRouter()
|
||||||
|
.sendMutatingCommand(clientId, requestBody);
|
||||||
|
String resultMessage =
|
||||||
|
response == null ? null : response.body();
|
||||||
|
|
||||||
// 解析响应
|
// 解析响应
|
||||||
if (StringUtils.isNotBlank(resultMessage)) {
|
if (StringUtils.isNotBlank(resultMessage)) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package xiaozhi.modules.device.service.impl;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
final class MqttClientId {
|
||||||
|
|
||||||
|
private MqttClientId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String build(String board, String macAddress) {
|
||||||
|
String groupId = StringUtils.defaultIfBlank(
|
||||||
|
board, "GID_default").trim().replace(":", "_");
|
||||||
|
String deviceId = StringUtils.defaultIfBlank(macAddress, "unknown")
|
||||||
|
.trim()
|
||||||
|
.replace(":", "_");
|
||||||
|
return groupId + "@@@" + deviceId + "@@@" + deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeDeviceId(String macAddress) {
|
||||||
|
return StringUtils.defaultIfBlank(macAddress, "unknown")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.replace(":", "_")
|
||||||
|
.replace("-", "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-4
@@ -27,10 +27,8 @@ final class MqttGatewayAuthorization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) {
|
static String postJson(String url, String jsonBody, String signatureKey, Instant now, int timeoutMillis) {
|
||||||
GatewayResponse response = executeWithDateFallback(
|
GatewayResponse response = postJsonResponse(
|
||||||
signatureKey,
|
url, jsonBody, signatureKey, now, timeoutMillis);
|
||||||
now,
|
|
||||||
token -> executeRequest(url, jsonBody, token, timeoutMillis));
|
|
||||||
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
throw new GatewayRequestException(
|
throw new GatewayRequestException(
|
||||||
@@ -40,6 +38,19 @@ final class MqttGatewayAuthorization {
|
|||||||
return response.body();
|
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) {
|
static List<String> generateDailyTokens(String signatureKey, Instant now) {
|
||||||
if (isMissingSignatureKey(signatureKey)) {
|
if (isMissingSignatureKey(signatureKey)) {
|
||||||
throw new GatewayRequestException("MQTT Gateway signature key is empty", null);
|
throw new GatewayRequestException("MQTT Gateway signature key is empty", null);
|
||||||
|
|||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package xiaozhi.modules.device.service.impl;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import xiaozhi.common.constant.Constant;
|
||||||
|
import xiaozhi.modules.sys.service.SysParamsService;
|
||||||
|
|
||||||
|
class MqttManagementEndpointResolver {
|
||||||
|
|
||||||
|
enum Backend {
|
||||||
|
NATIVE,
|
||||||
|
GATEWAY
|
||||||
|
}
|
||||||
|
|
||||||
|
record Endpoint(Backend backend, String baseUrl, String signatureKey) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final SysParamsService sysParamsService;
|
||||||
|
|
||||||
|
MqttManagementEndpointResolver(SysParamsService sysParamsService) {
|
||||||
|
this.sysParamsService = sysParamsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Endpoint> resolve() {
|
||||||
|
List<Endpoint> endpoints = new ArrayList<>(2);
|
||||||
|
resolveNative().ifPresent(endpoints::add);
|
||||||
|
resolveGateway().ifPresent(endpoints::add);
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.Optional<Endpoint> resolveNative() {
|
||||||
|
if (!isNativeMqttEnabled()) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
String endpoint = normalizeHttpEndpoint(sysParamsService.getValue(
|
||||||
|
Constant.MQTT_SERVER_MANAGER_API, true));
|
||||||
|
String signatureKey = firstConfigured(
|
||||||
|
sysParamsService.getValue(
|
||||||
|
Constant.MQTT_SERVER_MANAGER_API_SECRET, true),
|
||||||
|
sysParamsService.getValue(
|
||||||
|
Constant.MQTT_SERVER_SIGNATURE_KEY, true),
|
||||||
|
sysParamsService.getValue(
|
||||||
|
Constant.SERVER_MQTT_SECRET, true));
|
||||||
|
if (endpoint == null || signatureKey == null) {
|
||||||
|
throw new ManagementConfigurationException(
|
||||||
|
"原生MQTT已启用但管理端点或密钥无效");
|
||||||
|
}
|
||||||
|
return java.util.Optional.of(
|
||||||
|
new Endpoint(Backend.NATIVE, endpoint, signatureKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.Optional<Endpoint> resolveGateway() {
|
||||||
|
String rawGateway = sysParamsService.getValue(
|
||||||
|
Constant.SERVER_MQTT_GATEWAY, true);
|
||||||
|
String rawEndpoint = sysParamsService.getValue(
|
||||||
|
Constant.SERVER_MQTT_MANAGER_API, true);
|
||||||
|
if (configured(rawGateway) == null
|
||||||
|
&& configured(rawEndpoint) == null) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
String endpoint = normalizeHttpEndpoint(rawEndpoint);
|
||||||
|
String signatureKey = configured(
|
||||||
|
sysParamsService.getValue(
|
||||||
|
Constant.SERVER_MQTT_SECRET, true));
|
||||||
|
if (endpoint == null || signatureKey == null) {
|
||||||
|
throw new ManagementConfigurationException(
|
||||||
|
"MQTT Gateway管理端点或密钥无效");
|
||||||
|
}
|
||||||
|
return java.util.Optional.of(
|
||||||
|
new Endpoint(Backend.GATEWAY, endpoint, signatureKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isNativeMqttEnabled() {
|
||||||
|
String enabled = sysParamsService.getValue(
|
||||||
|
Constant.MQTT_SERVER_ENABLED, true);
|
||||||
|
String legacyEnabled = sysParamsService.getValue(
|
||||||
|
Constant.SERVER_MQTT_ENABLED, true);
|
||||||
|
boolean serverEnabled = configured(enabled) != null
|
||||||
|
? isTrue(enabled)
|
||||||
|
: isTrue(legacyEnabled);
|
||||||
|
|
||||||
|
String protocolEnabled = sysParamsService.getValue(
|
||||||
|
Constant.PROTOCOLS_MQTT_ENABLED, true);
|
||||||
|
String enabledProtocols = sysParamsService.getValue(
|
||||||
|
Constant.PROTOCOLS_ENABLED, true);
|
||||||
|
boolean enabledByProtocol = isTrue(protocolEnabled);
|
||||||
|
if (!enabledByProtocol && StringUtils.isNotBlank(enabledProtocols)) {
|
||||||
|
String normalized = enabledProtocols
|
||||||
|
.replace("[", "")
|
||||||
|
.replace("]", "")
|
||||||
|
.replace("\"", "");
|
||||||
|
enabledByProtocol = Arrays.stream(
|
||||||
|
normalized.split("[;,\\s]+"))
|
||||||
|
.anyMatch("mqtt"::equalsIgnoreCase);
|
||||||
|
}
|
||||||
|
return serverEnabled && enabledByProtocol;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String normalizeHttpEndpoint(String raw) {
|
||||||
|
String configured = configured(raw);
|
||||||
|
if (configured == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean hasScheme = configured.matches(
|
||||||
|
"^[A-Za-z][A-Za-z0-9+.-]*://.*");
|
||||||
|
if (hasScheme
|
||||||
|
&& !configured.matches("(?i)^https?://.*")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String candidate = hasScheme ? configured : "http://" + configured;
|
||||||
|
try {
|
||||||
|
URI uri = new URI(candidate);
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
if (scheme == null
|
||||||
|
|| (!"http".equalsIgnoreCase(scheme)
|
||||||
|
&& !"https".equalsIgnoreCase(scheme))
|
||||||
|
|| StringUtils.isBlank(uri.getHost())
|
||||||
|
|| uri.getUserInfo() != null
|
||||||
|
|| uri.getQuery() != null
|
||||||
|
|| uri.getFragment() != null
|
||||||
|
|| uri.getPort() == 0
|
||||||
|
|| uri.getPort() > 65535) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String path = uri.getPath();
|
||||||
|
if (path == null || "/".equals(path)) {
|
||||||
|
path = "";
|
||||||
|
} else {
|
||||||
|
path = path.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
return new URI(
|
||||||
|
scheme.toLowerCase(Locale.ROOT),
|
||||||
|
null,
|
||||||
|
uri.getHost(),
|
||||||
|
uri.getPort(),
|
||||||
|
path,
|
||||||
|
null,
|
||||||
|
null).toString();
|
||||||
|
} catch (URISyntaxException | IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstConfigured(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
String normalized = configured(value);
|
||||||
|
if (normalized != null) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String configured(String value) {
|
||||||
|
if (StringUtils.isBlank(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim();
|
||||||
|
if ("null".equalsIgnoreCase(normalized)
|
||||||
|
|| normalized.contains("你")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTrue(String value) {
|
||||||
|
return "true".equalsIgnoreCase(value) || "1".equals(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ManagementConfigurationException
|
||||||
|
extends RuntimeException {
|
||||||
|
ManagementConfigurationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package xiaozhi.modules.device.service.impl;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
|
||||||
|
class MqttManagementHttpClient {
|
||||||
|
|
||||||
|
private final int timeoutMillis;
|
||||||
|
|
||||||
|
MqttManagementHttpClient() {
|
||||||
|
this(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient(int timeoutMillis) {
|
||||||
|
this.timeoutMillis = timeoutMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response post(
|
||||||
|
MqttManagementEndpointResolver.Endpoint endpoint,
|
||||||
|
String path,
|
||||||
|
Object requestBody) {
|
||||||
|
String url = appendPath(endpoint.baseUrl(), path);
|
||||||
|
MqttGatewayAuthorization.GatewayResponse response =
|
||||||
|
MqttGatewayAuthorization.postJsonResponse(
|
||||||
|
url,
|
||||||
|
JSONUtil.toJsonStr(requestBody),
|
||||||
|
endpoint.signatureKey(),
|
||||||
|
Instant.now(),
|
||||||
|
timeoutMillis);
|
||||||
|
return new Response(
|
||||||
|
endpoint.backend(),
|
||||||
|
response.statusCode(),
|
||||||
|
response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
static String appendPath(String baseUrl, String path) {
|
||||||
|
String normalizedBase = baseUrl.endsWith("/")
|
||||||
|
? baseUrl.substring(0, baseUrl.length() - 1)
|
||||||
|
: baseUrl;
|
||||||
|
String normalizedPath = path.startsWith("/")
|
||||||
|
? path
|
||||||
|
: "/" + path;
|
||||||
|
return normalizedBase + normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
record Response(
|
||||||
|
MqttManagementEndpointResolver.Backend backend,
|
||||||
|
int statusCode,
|
||||||
|
String body) {
|
||||||
|
|
||||||
|
boolean isSuccessfulHttp() {
|
||||||
|
return statusCode >= 200 && statusCode < 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+431
@@ -0,0 +1,431 @@
|
|||||||
|
package xiaozhi.modules.device.service.impl;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import cn.hutool.json.JSONObject;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
|
||||||
|
final class MqttManagementRouter {
|
||||||
|
|
||||||
|
private final MqttManagementEndpointResolver endpointResolver;
|
||||||
|
private final MqttManagementHttpClient httpClient;
|
||||||
|
|
||||||
|
MqttManagementRouter(
|
||||||
|
MqttManagementEndpointResolver endpointResolver,
|
||||||
|
MqttManagementHttpClient httpClient) {
|
||||||
|
this.endpointResolver = endpointResolver;
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getMergedStatus(Set<String> clientIds) {
|
||||||
|
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||||
|
endpointResolver.resolve();
|
||||||
|
if (endpoints.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, MutableDeviceStatus> merged =
|
||||||
|
initializeStatuses(clientIds);
|
||||||
|
int successfulBackends = 0;
|
||||||
|
RuntimeException lastFailure = null;
|
||||||
|
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||||
|
try {
|
||||||
|
MqttManagementHttpClient.Response response =
|
||||||
|
httpClient.post(
|
||||||
|
endpoint,
|
||||||
|
"/api/devices/status",
|
||||||
|
Map.of("clientIds", clientIds));
|
||||||
|
if (!response.isSuccessfulHttp()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态查询失败: "
|
||||||
|
+ response.statusCode());
|
||||||
|
}
|
||||||
|
mergeStatusResponse(
|
||||||
|
merged, endpoint.backend(), response.body());
|
||||||
|
successfulBackends++;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
lastFailure = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastFailure != null
|
||||||
|
|| successfulBackends != endpoints.size()) {
|
||||||
|
throw new ManagementUnavailableException(
|
||||||
|
"无法确认全部MQTT后端状态", lastFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
merged.forEach((clientId, status) ->
|
||||||
|
result.put(clientId, status.toMap()));
|
||||||
|
return JSONUtil.toJsonStr(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response sendReadOnlyCommand(
|
||||||
|
String clientId, Object requestBody) {
|
||||||
|
return sendReadOnlyCommand(clientId, requestBody, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response sendReadOnlyCommand(
|
||||||
|
String clientId,
|
||||||
|
Object requestBody,
|
||||||
|
MqttManagementEndpointResolver.Backend preferredBackend) {
|
||||||
|
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||||
|
endpointResolver.resolve();
|
||||||
|
if (endpoints.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preferredBackend != null) {
|
||||||
|
MqttManagementEndpointResolver.Endpoint endpoint =
|
||||||
|
endpoints.stream()
|
||||||
|
.filter(candidate ->
|
||||||
|
candidate.backend()
|
||||||
|
== preferredBackend)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() ->
|
||||||
|
new ManagementUnavailableException(
|
||||||
|
"分页MQTT后端配置已变化",
|
||||||
|
null));
|
||||||
|
return sendCommand(endpoint, clientId, requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response lastResponse = null;
|
||||||
|
RuntimeException lastFailure = null;
|
||||||
|
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||||
|
try {
|
||||||
|
MqttManagementHttpClient.Response response =
|
||||||
|
sendCommand(endpoint, clientId, requestBody);
|
||||||
|
lastResponse = response;
|
||||||
|
if (isCommandSuccess(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
lastFailure = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastResponse != null) {
|
||||||
|
return lastResponse;
|
||||||
|
}
|
||||||
|
throw new ManagementUnavailableException(
|
||||||
|
"MQTT管理服务均不可用", lastFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response sendMutatingCommand(
|
||||||
|
String clientId, Object requestBody) {
|
||||||
|
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||||
|
endpointResolver.resolve();
|
||||||
|
if (endpoints.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MqttManagementEndpointResolver.Endpoint> online =
|
||||||
|
new ArrayList<>();
|
||||||
|
int successfulStatusBackends = 0;
|
||||||
|
RuntimeException lastStatusFailure = null;
|
||||||
|
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||||
|
try {
|
||||||
|
MqttManagementHttpClient.Response statusResponse =
|
||||||
|
httpClient.post(
|
||||||
|
endpoint,
|
||||||
|
"/api/devices/status",
|
||||||
|
Map.of("clientIds", Set.of(clientId)));
|
||||||
|
if (!statusResponse.isSuccessfulHttp()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态查询失败: "
|
||||||
|
+ statusResponse.statusCode());
|
||||||
|
}
|
||||||
|
Map<String, BackendDeviceStatus> statuses =
|
||||||
|
parseStatusResponse(
|
||||||
|
statusResponse.body(),
|
||||||
|
Set.of(clientId));
|
||||||
|
successfulStatusBackends++;
|
||||||
|
if (statuses.get(clientId).exists()) {
|
||||||
|
online.add(endpoint);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
lastStatusFailure = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastStatusFailure != null
|
||||||
|
|| successfulStatusBackends != endpoints.size()) {
|
||||||
|
throw new ManagementUnavailableException(
|
||||||
|
"无法确认设备所在的MQTT后端",
|
||||||
|
lastStatusFailure);
|
||||||
|
}
|
||||||
|
if (online.isEmpty()) {
|
||||||
|
return offlineResponse(endpoints.get(0).backend());
|
||||||
|
}
|
||||||
|
if (online.size() > 1) {
|
||||||
|
return commandErrorResponse(
|
||||||
|
endpoints.get(0).backend(),
|
||||||
|
409,
|
||||||
|
"设备同时存在于多个MQTT后端",
|
||||||
|
"DEVICE_BACKEND_AMBIGUOUS");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendCommand(online.get(0), clientId, requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response sendCallRequest(
|
||||||
|
String callerClientId,
|
||||||
|
String targetClientId,
|
||||||
|
Object requestBody) {
|
||||||
|
Set<String> clientIds = new java.util.LinkedHashSet<>();
|
||||||
|
clientIds.add(callerClientId);
|
||||||
|
clientIds.add(targetClientId);
|
||||||
|
return sendCallMutation(
|
||||||
|
clientIds,
|
||||||
|
"/api/call/request",
|
||||||
|
requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementHttpClient.Response sendCallAccept(
|
||||||
|
String clientId, Object requestBody) {
|
||||||
|
return sendCallMutation(
|
||||||
|
Set.of(clientId),
|
||||||
|
"/api/call/accept",
|
||||||
|
requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MqttManagementHttpClient.Response sendCallMutation(
|
||||||
|
Set<String> clientIds,
|
||||||
|
String path,
|
||||||
|
Object requestBody) {
|
||||||
|
List<MqttManagementEndpointResolver.Endpoint> endpoints =
|
||||||
|
endpointResolver.resolve();
|
||||||
|
if (endpoints.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<MqttManagementEndpointResolver.Endpoint>> owners =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
clientIds.forEach(clientId ->
|
||||||
|
owners.put(clientId, new ArrayList<>()));
|
||||||
|
|
||||||
|
RuntimeException statusFailure = null;
|
||||||
|
int successfulStatusBackends = 0;
|
||||||
|
for (MqttManagementEndpointResolver.Endpoint endpoint : endpoints) {
|
||||||
|
try {
|
||||||
|
MqttManagementHttpClient.Response response =
|
||||||
|
httpClient.post(
|
||||||
|
endpoint,
|
||||||
|
"/api/devices/status",
|
||||||
|
Map.of("clientIds", clientIds));
|
||||||
|
if (!response.isSuccessfulHttp()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态查询失败: "
|
||||||
|
+ response.statusCode());
|
||||||
|
}
|
||||||
|
Map<String, BackendDeviceStatus> statuses =
|
||||||
|
parseStatusResponse(response.body(), clientIds);
|
||||||
|
owners.forEach((clientId, matches) -> {
|
||||||
|
if (statuses.get(clientId).exists()) {
|
||||||
|
matches.add(endpoint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
successfulStatusBackends++;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
statusFailure = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFailure != null
|
||||||
|
|| successfulStatusBackends != endpoints.size()) {
|
||||||
|
throw new ManagementUnavailableException(
|
||||||
|
"无法确认通话设备所在的MQTT后端",
|
||||||
|
statusFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<String, List<MqttManagementEndpointResolver.Endpoint>>
|
||||||
|
owner : owners.entrySet()) {
|
||||||
|
if (owner.getValue().isEmpty()) {
|
||||||
|
return callErrorResponse(
|
||||||
|
endpoints.get(0).backend(),
|
||||||
|
404,
|
||||||
|
"offline",
|
||||||
|
"设备不在线",
|
||||||
|
"DEVICE_OFFLINE");
|
||||||
|
}
|
||||||
|
if (owner.getValue().size() > 1) {
|
||||||
|
return callErrorResponse(
|
||||||
|
endpoints.get(0).backend(),
|
||||||
|
409,
|
||||||
|
"error",
|
||||||
|
"设备同时存在于多个MQTT后端",
|
||||||
|
"DEVICE_BACKEND_AMBIGUOUS");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MqttManagementEndpointResolver.Endpoint selected =
|
||||||
|
owners.values().iterator().next().get(0);
|
||||||
|
boolean sameBackend = owners.values().stream()
|
||||||
|
.allMatch(matches -> matches.get(0).equals(selected));
|
||||||
|
if (!sameBackend) {
|
||||||
|
return callErrorResponse(
|
||||||
|
selected.backend(),
|
||||||
|
409,
|
||||||
|
"error",
|
||||||
|
"跨MQTT后端通话暂不支持",
|
||||||
|
"CROSS_BACKEND_CALL_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
return httpClient.post(selected, path, requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MqttManagementHttpClient.Response sendCommand(
|
||||||
|
MqttManagementEndpointResolver.Endpoint endpoint,
|
||||||
|
String clientId,
|
||||||
|
Object requestBody) {
|
||||||
|
String encodedClientId = URLEncoder.encode(
|
||||||
|
clientId, StandardCharsets.UTF_8)
|
||||||
|
.replace("+", "%20");
|
||||||
|
return httpClient.post(
|
||||||
|
endpoint,
|
||||||
|
"/api/commands/" + encodedClientId,
|
||||||
|
requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, MutableDeviceStatus> initializeStatuses(
|
||||||
|
Set<String> clientIds) {
|
||||||
|
Map<String, MutableDeviceStatus> result = new LinkedHashMap<>();
|
||||||
|
clientIds.forEach(clientId ->
|
||||||
|
result.put(clientId, new MutableDeviceStatus()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void mergeStatusResponse(
|
||||||
|
Map<String, MutableDeviceStatus> merged,
|
||||||
|
MqttManagementEndpointResolver.Backend backend,
|
||||||
|
String body) {
|
||||||
|
Map<String, BackendDeviceStatus> response =
|
||||||
|
parseStatusResponse(body, merged.keySet());
|
||||||
|
merged.forEach((clientId, status) -> {
|
||||||
|
BackendDeviceStatus value = response.get(clientId);
|
||||||
|
status.exists |= value.exists();
|
||||||
|
status.alive |= value.alive();
|
||||||
|
if (value.exists()) {
|
||||||
|
status.backends.add(
|
||||||
|
backend.name().toLowerCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, BackendDeviceStatus> parseStatusResponse(
|
||||||
|
String body, Set<String> clientIds) {
|
||||||
|
JSONObject response;
|
||||||
|
try {
|
||||||
|
response = JSONUtil.parseObj(body);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态响应不是JSON对象", e);
|
||||||
|
}
|
||||||
|
Map<String, BackendDeviceStatus> statuses =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
for (String clientId : clientIds) {
|
||||||
|
Object rawStatus = response.get(clientId);
|
||||||
|
if (!(rawStatus instanceof JSONObject status)
|
||||||
|
|| !status.containsKey("exists")
|
||||||
|
|| !status.containsKey("isAlive")) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态响应缺少设备状态: " + clientId);
|
||||||
|
}
|
||||||
|
Object rawExists = status.get("exists");
|
||||||
|
Object rawAlive = status.get("isAlive");
|
||||||
|
if (!(rawExists instanceof Boolean exists)
|
||||||
|
|| !(rawAlive instanceof Boolean alive)
|
||||||
|
|| (!exists && alive)) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"MQTT管理状态响应字段无效: " + clientId);
|
||||||
|
}
|
||||||
|
statuses.put(
|
||||||
|
clientId,
|
||||||
|
new BackendDeviceStatus(exists, alive));
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCommandSuccess(
|
||||||
|
MqttManagementHttpClient.Response response) {
|
||||||
|
if (response == null || !response.isSuccessfulHttp()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSONUtil.parseObj(response.body())
|
||||||
|
.getBool("success", false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MqttManagementHttpClient.Response offlineResponse(
|
||||||
|
MqttManagementEndpointResolver.Backend backend) {
|
||||||
|
return new MqttManagementHttpClient.Response(
|
||||||
|
backend,
|
||||||
|
404,
|
||||||
|
JSONUtil.toJsonStr(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", "设备未连接",
|
||||||
|
"code", "DEVICE_OFFLINE",
|
||||||
|
"dispatchAttempted", false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MqttManagementHttpClient.Response callErrorResponse(
|
||||||
|
MqttManagementEndpointResolver.Backend backend,
|
||||||
|
int statusCode,
|
||||||
|
String status,
|
||||||
|
String message,
|
||||||
|
String code) {
|
||||||
|
return new MqttManagementHttpClient.Response(
|
||||||
|
backend,
|
||||||
|
statusCode,
|
||||||
|
JSONUtil.toJsonStr(Map.of(
|
||||||
|
"status", status,
|
||||||
|
"message", message,
|
||||||
|
"code", code)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MqttManagementHttpClient.Response commandErrorResponse(
|
||||||
|
MqttManagementEndpointResolver.Backend backend,
|
||||||
|
int statusCode,
|
||||||
|
String error,
|
||||||
|
String code) {
|
||||||
|
return new MqttManagementHttpClient.Response(
|
||||||
|
backend,
|
||||||
|
statusCode,
|
||||||
|
JSONUtil.toJsonStr(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", error,
|
||||||
|
"code", code,
|
||||||
|
"dispatchAttempted", false)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ManagementUnavailableException
|
||||||
|
extends RuntimeException {
|
||||||
|
ManagementUnavailableException(
|
||||||
|
String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MutableDeviceStatus {
|
||||||
|
private boolean exists;
|
||||||
|
private boolean alive;
|
||||||
|
private final List<String> backends = new ArrayList<>();
|
||||||
|
|
||||||
|
Map<String, Object> toMap() {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("isAlive", alive);
|
||||||
|
result.put("exists", exists);
|
||||||
|
result.put("backends", backends);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record BackendDeviceStatus(boolean exists, boolean alive) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -56,7 +56,7 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String[] ids) {
|
public void delete(String[] ids) {
|
||||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
baseDao.deleteByIds(Arrays.asList(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,4 +82,4 @@ public class OtaServiceImpl extends BaseServiceImpl<OtaDao, OtaEntity> implement
|
|||||||
.last("LIMIT 1");
|
.last("LIMIT 1");
|
||||||
return baseDao.selectOne(wrapper);
|
return baseDao.selectOne(wrapper);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ public class UserShowDeviceListVO {
|
|||||||
@Schema(description = "设备别名")
|
@Schema(description = "设备别名")
|
||||||
private String alias;
|
private String alias;
|
||||||
|
|
||||||
@Schema(description = "开启OTA")
|
@Schema(description = "自动更新开关(0关闭/1开启)")
|
||||||
private Integer otaUpgrade;
|
private Integer autoUpdate;
|
||||||
|
|
||||||
@Schema(description = "最近对话时间")
|
@Schema(description = "最近对话时间")
|
||||||
private String recentChatTime;
|
private String recentChatTime;
|
||||||
|
|||||||
+11
-10
@@ -149,8 +149,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
|||||||
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
|
log.info("=== [RAGFlow] 获取文档列表: datasetId={} ===", datasetId);
|
||||||
|
|
||||||
// 使用 Jackson 将 DTO 转为 Map 作为查询参数
|
// 使用 Jackson 将 DTO 转为 Map 作为查询参数
|
||||||
@SuppressWarnings("unchecked")
|
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
|
||||||
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
|
});
|
||||||
|
|
||||||
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
||||||
|
|
||||||
@@ -174,13 +174,12 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
|||||||
.pageSize(1)
|
.pageSize(1)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
Map<String, Object> params = objectMapper.convertValue(req, new TypeReference<Map<String, Object>>() {
|
||||||
Map<String, Object> params = objectMapper.convertValue(req, Map.class);
|
});
|
||||||
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
Map<String, Object> response = getClient().get("/api/v1/datasets/" + datasetId + "/documents", params);
|
||||||
|
|
||||||
Object dataObj = response.get("data");
|
Object dataObj = response.get("data");
|
||||||
if (dataObj instanceof Map) {
|
if (dataObj instanceof Map<?, ?> dataMap) {
|
||||||
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
|
|
||||||
List<?> documents = (List<?>) dataMap.get("docs");
|
List<?> documents = (List<?>) dataMap.get("docs");
|
||||||
if (documents != null && !documents.isEmpty()) {
|
if (documents != null && !documents.isEmpty()) {
|
||||||
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
|
return objectMapper.convertValue(documents.get(0), DocumentDTO.InfoVO.class);
|
||||||
@@ -567,8 +566,8 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
|||||||
return new PageData<>(new ArrayList<>(), 0);
|
return new PageData<>(new ArrayList<>(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> dataMap = (Map<String, Object>) dataObj;
|
Map<?, ?> dataMap = Map.class.cast(dataObj);
|
||||||
List<Map<String, Object>> documents = (List<Map<String, Object>>) dataMap.get("docs");
|
List<?> documents = List.class.cast(dataMap.get("docs"));
|
||||||
if (documents == null || documents.isEmpty()) {
|
if (documents == null || documents.isEmpty()) {
|
||||||
// RAGFlow 明确返回了空文档列表,这是合法的"真空"
|
// RAGFlow 明确返回了空文档列表,这是合法的"真空"
|
||||||
return new PageData<>(new ArrayList<>(), 0);
|
return new PageData<>(new ArrayList<>(), 0);
|
||||||
@@ -679,7 +678,9 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
|||||||
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
|
dto.setChunkMethod(info.getChunkMethod().name().toLowerCase());
|
||||||
}
|
}
|
||||||
if (info.getParserConfig() != null) {
|
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;
|
return dto;
|
||||||
@@ -717,4 +718,4 @@ public class RAGFlowAdapter extends KnowledgeBaseAdapter {
|
|||||||
return !multipartFile.isEmpty();
|
return !multipartFile.isEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-10
@@ -116,13 +116,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
|||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||||
|
|
||||||
Map<String, Object>[] messages = new Map[1];
|
|
||||||
Map<String, Object> message = new HashMap<>();
|
Map<String, Object> message = new HashMap<>();
|
||||||
message.put("role", "user");
|
message.put("role", "user");
|
||||||
message.put("content", prompt);
|
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("temperature", temperature != null ? temperature : 0.7);
|
||||||
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
|
requestBody.put("max_tokens", maxTokens != null ? maxTokens : 2000);
|
||||||
|
|
||||||
@@ -212,13 +210,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
|||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||||
|
|
||||||
Map<String, Object>[] messages = new Map[1];
|
|
||||||
Map<String, Object> message = new HashMap<>();
|
Map<String, Object> message = new HashMap<>();
|
||||||
message.put("role", "user");
|
message.put("role", "user");
|
||||||
message.put("content", prompt);
|
message.put("content", prompt);
|
||||||
messages[0] = message;
|
|
||||||
|
|
||||||
requestBody.put("messages", messages);
|
requestBody.put("messages", List.of(message));
|
||||||
requestBody.put("temperature", 0.2);
|
requestBody.put("temperature", 0.2);
|
||||||
requestBody.put("max_tokens", 2000);
|
requestBody.put("max_tokens", 2000);
|
||||||
|
|
||||||
@@ -368,13 +364,11 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
|||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
requestBody.put("model", model != null ? model : "gpt-3.5-turbo");
|
||||||
|
|
||||||
Map<String, Object>[] messages = new Map[1];
|
|
||||||
Map<String, Object> message = new HashMap<>();
|
Map<String, Object> message = new HashMap<>();
|
||||||
message.put("role", "user");
|
message.put("role", "user");
|
||||||
message.put("content", prompt);
|
message.put("content", prompt);
|
||||||
messages[0] = message;
|
|
||||||
|
|
||||||
requestBody.put("messages", messages);
|
requestBody.put("messages", List.of(message));
|
||||||
requestBody.put("temperature", 0.3);
|
requestBody.put("temperature", 0.3);
|
||||||
requestBody.put("max_tokens", 50);
|
requestBody.put("max_tokens", 50);
|
||||||
|
|
||||||
@@ -422,4 +416,4 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -362,14 +362,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
if (SensitiveDataUtils.isSensitiveField(key)) {
|
if (SensitiveDataUtils.isSensitiveField(key)) {
|
||||||
|
|
||||||
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
|
if (value instanceof String && !SensitiveDataUtils.isMaskedValue((String) value)) {
|
||||||
updatedJson.put(key, value);
|
updatedJson.set(key, value);
|
||||||
}
|
}
|
||||||
} else if (value instanceof JSONObject) {
|
} else if (value instanceof JSONObject) {
|
||||||
// 递归处理嵌套JSON
|
// 递归处理嵌套JSON
|
||||||
mergeJson(updatedJson, key, (JSONObject) value);
|
mergeJson(updatedJson, key, (JSONObject) value);
|
||||||
} else {
|
} else {
|
||||||
// 非敏感字段直接更新
|
// 非敏感字段直接更新
|
||||||
updatedJson.put(key, value);
|
updatedJson.set(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
|
|
||||||
// 如果 original 中不存在 key,创建一个新的 JSON 对象
|
// 如果 original 中不存在 key,创建一个新的 JSON 对象
|
||||||
if (!original.containsKey(key)) {
|
if (!original.containsKey(key)) {
|
||||||
original.put(key, new JSONObject());
|
original.set(key, new JSONObject());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 original 中的子对象
|
// 获取 original 中的子对象
|
||||||
@@ -420,7 +420,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
|
log.warn("mergeJson: key '{}' 的值不是 JSONObject 类型 (实际类型:{}),将创建新对象",
|
||||||
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
|
key, originalValue != null ? originalValue.getClass().getSimpleName() : "null");
|
||||||
originalChild = new JSONObject();
|
originalChild = new JSONObject();
|
||||||
original.put(key, originalChild);
|
original.set(key, originalChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String childKey : updated.keySet()) {
|
for (String childKey : updated.keySet()) {
|
||||||
@@ -430,7 +430,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
|||||||
} else {
|
} else {
|
||||||
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
|
if (!SensitiveDataUtils.isSensitiveField(childKey) ||
|
||||||
(childValue instanceof String && !isMaskedValue((String) childValue))) {
|
(childValue instanceof String && !isMaskedValue((String) childValue))) {
|
||||||
originalChild.put(childKey, childValue);
|
originalChild.set(childKey, childValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -164,7 +164,7 @@ public class ModelProviderServiceImpl extends BaseServiceImpl<ModelProviderDao,
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(List<String> ids) {
|
public void delete(List<String> ids) {
|
||||||
if (modelProviderDao.deleteBatchIds(ids) == 0) {
|
if (modelProviderDao.deleteByIds(ids) == 0) {
|
||||||
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
|
throw new RenException(ErrorCode.DELETE_DATA_FAILED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import java.util.HashMap;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.apache.shiro.mgt.SecurityManager;
|
|
||||||
import org.apache.shiro.session.mgt.SessionManager;
|
import org.apache.shiro.session.mgt.SessionManager;
|
||||||
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
|
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
|
||||||
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
|
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
|
||||||
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
|
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
|
||||||
import org.apache.shiro.web.config.ShiroFilterConfiguration;
|
import org.apache.shiro.web.config.ShiroFilterConfiguration;
|
||||||
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
|
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
|
||||||
|
import org.apache.shiro.web.mgt.WebSecurityManager;
|
||||||
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
|
import org.springframework.context.annotation.Role;
|
||||||
|
|
||||||
import jakarta.servlet.Filter;
|
import jakarta.servlet.Filter;
|
||||||
import xiaozhi.modules.security.oauth2.Oauth2Filter;
|
import xiaozhi.modules.security.oauth2.Oauth2Filter;
|
||||||
@@ -39,7 +42,7 @@ public class ShiroConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean("securityManager")
|
@Bean("securityManager")
|
||||||
public SecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
|
public WebSecurityManager securityManager(Oauth2Realm oAuth2Realm, SessionManager sessionManager) {
|
||||||
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
|
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
|
||||||
securityManager.setRealm(oAuth2Realm);
|
securityManager.setRealm(oAuth2Realm);
|
||||||
securityManager.setSessionManager(sessionManager);
|
securityManager.setSessionManager(sessionManager);
|
||||||
@@ -48,7 +51,8 @@ public class ShiroConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean("shiroFilter")
|
@Bean("shiroFilter")
|
||||||
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager, SysParamsService sysParamsService) {
|
public static ShiroFilterFactoryBean shirFilter(@Lazy WebSecurityManager securityManager,
|
||||||
|
@Lazy SysParamsService sysParamsService) {
|
||||||
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
|
ShiroFilterConfiguration config = new ShiroFilterConfiguration();
|
||||||
config.setFilterOncePerRequest(true);
|
config.setFilterOncePerRequest(true);
|
||||||
|
|
||||||
@@ -101,12 +105,14 @@ public class ShiroConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean("lifecycleBeanPostProcessor")
|
@Bean("lifecycleBeanPostProcessor")
|
||||||
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
|
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
|
||||||
return new LifecycleBeanPostProcessor();
|
return new LifecycleBeanPostProcessor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
|
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
|
||||||
|
public static AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
|
||||||
|
@Lazy WebSecurityManager securityManager) {
|
||||||
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
|
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
|
||||||
advisor.setSecurityManager(securityManager);
|
advisor.setSecurityManager(securityManager);
|
||||||
return advisor;
|
return advisor;
|
||||||
|
|||||||
+5
-1
@@ -85,6 +85,7 @@ public class SysParamsController {
|
|||||||
public Result<Void> save(@RequestBody SysParamsDTO dto) {
|
public Result<Void> save(@RequestBody SysParamsDTO dto) {
|
||||||
// 效验数据
|
// 效验数据
|
||||||
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
|
ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class);
|
||||||
|
validateMqttSecretLength(dto.getParamCode(), dto.getParamValue());
|
||||||
|
|
||||||
sysParamsService.save(dto);
|
sysParamsService.save(dto);
|
||||||
configService.getConfig(false);
|
configService.getConfig(false);
|
||||||
@@ -277,7 +278,10 @@ public class SysParamsController {
|
|||||||
|
|
||||||
// 校验mqtt密钥长度和复杂度
|
// 校验mqtt密钥长度和复杂度
|
||||||
private void validateMqttSecretLength(String paramCode, String secret) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (StringUtils.isBlank(secret) || secret.equals("null")) {
|
if (StringUtils.isBlank(secret) || secret.equals("null")) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class SysUserDTO implements Serializable {
|
|||||||
@NotNull(message = "{id.require}", groups = UpdateGroup.class)
|
@NotNull(message = "{id.require}", groups = UpdateGroup.class)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Schema(description = "用户名", required = true)
|
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
|
@NotBlank(message = "{sysuser.username.require}", groups = DefaultGroup.class)
|
||||||
private String username;
|
private String username;
|
||||||
|
|
||||||
@@ -40,14 +40,14 @@ public class SysUserDTO implements Serializable {
|
|||||||
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
|
@NotBlank(message = "{sysuser.password.require}", groups = AddGroup.class)
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
@Schema(description = "姓名", required = true)
|
@Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
|
@NotBlank(message = "{sysuser.realname.require}", groups = DefaultGroup.class)
|
||||||
private String realName;
|
private String realName;
|
||||||
|
|
||||||
@Schema(description = "头像")
|
@Schema(description = "头像")
|
||||||
private String headUrl;
|
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)
|
@Range(min = 0, max = 2, message = "{sysuser.gender.range}", groups = DefaultGroup.class)
|
||||||
private Integer gender;
|
private Integer gender;
|
||||||
|
|
||||||
@@ -58,11 +58,11 @@ public class SysUserDTO implements Serializable {
|
|||||||
@Schema(description = "手机号")
|
@Schema(description = "手机号")
|
||||||
private String mobile;
|
private String mobile;
|
||||||
|
|
||||||
@Schema(description = "部门ID", required = true)
|
@Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
|
@NotNull(message = "{sysuser.deptId.require}", groups = DefaultGroup.class)
|
||||||
private Long deptId;
|
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)
|
@Range(min = 0, max = 1, message = "{sysuser.status.range}", groups = DefaultGroup.class)
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
@@ -84,4 +84,4 @@ public class SysUserDTO implements Serializable {
|
|||||||
@Schema(description = "部门名称")
|
@Schema(description = "部门名称")
|
||||||
private String deptName;
|
private String deptName;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -20,6 +20,7 @@ import xiaozhi.common.redis.RedisKeys;
|
|||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.common.service.impl.BaseServiceImpl;
|
import xiaozhi.common.service.impl.BaseServiceImpl;
|
||||||
import xiaozhi.common.utils.ConvertUtils;
|
import xiaozhi.common.utils.ConvertUtils;
|
||||||
|
import xiaozhi.common.utils.JsonUtils;
|
||||||
import xiaozhi.common.utils.ToolUtil;
|
import xiaozhi.common.utils.ToolUtil;
|
||||||
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
import xiaozhi.modules.sys.dao.SysDictDataDao;
|
||||||
import xiaozhi.modules.sys.dao.SysUserDao;
|
import xiaozhi.modules.sys.dao.SysUserDao;
|
||||||
@@ -138,7 +139,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
|||||||
|
|
||||||
// 设置更新者和创建者名称
|
// 设置更新者和创建者名称
|
||||||
if (!userIds.isEmpty()) {
|
if (!userIds.isEmpty()) {
|
||||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
|
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
|
||||||
// 把List转成Map,Map<Long, String>
|
// 把List转成Map,Map<Long, String>
|
||||||
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
||||||
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
||||||
@@ -170,7 +171,7 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
|||||||
|
|
||||||
// 先从Redis获取缓存
|
// 先从Redis获取缓存
|
||||||
String key = RedisKeys.getDictDataByTypeKey(dictType);
|
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) {
|
if (cachedData != null) {
|
||||||
return cachedData;
|
return cachedData;
|
||||||
}
|
}
|
||||||
@@ -185,4 +186,4 @@ public class SysDictDataServiceImpl extends BaseServiceImpl<SysDictDataDao, SysD
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -128,7 +128,7 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
|||||||
|
|
||||||
// 设置更新者和创建者名称
|
// 设置更新者和创建者名称
|
||||||
if (!userIds.isEmpty()) {
|
if (!userIds.isEmpty()) {
|
||||||
List<SysUserEntity> sysUserEntities = sysUserDao.selectBatchIds(userIds);
|
List<SysUserEntity> sysUserEntities = sysUserDao.selectByIds(userIds);
|
||||||
// 把List转成Map,Map<Long, String>
|
// 把List转成Map,Map<Long, String>
|
||||||
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
Map<Long, String> userNameMap = sysUserEntities.stream().collect(Collectors.toMap(SysUserEntity::getId,
|
||||||
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
SysUserEntity::getUsername, (existing, replacement) -> existing));
|
||||||
@@ -151,4 +151,4 @@ public class SysDictTypeServiceImpl extends BaseServiceImpl<SysDictTypeDao, SysD
|
|||||||
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
|
throw new RenException(ErrorCode.DICT_TYPE_DUPLICATE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -205,9 +205,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
|||||||
public void initServerSecret() {
|
public void initServerSecret() {
|
||||||
// 获取服务器密钥
|
// 获取服务器密钥
|
||||||
String secretParam = getValue(Constant.SERVER_SECRET, false);
|
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();
|
String newSecret = UUID.randomUUID().toString();
|
||||||
updateValueByCode(Constant.SERVER_SECRET, newSecret);
|
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密钥对
|
// 初始化SM2密钥对
|
||||||
@@ -287,10 +292,10 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (StringUtils.isNotBlank(currentConfig)) {
|
if (StringUtils.isNotBlank(currentConfig)) {
|
||||||
currentMap = JsonUtils.parseObject(currentConfig, Map.class);
|
currentMap = JsonUtils.parseMap(currentConfig);
|
||||||
}
|
}
|
||||||
if (StringUtils.isNotBlank(configJson)) {
|
if (StringUtils.isNotBlank(configJson)) {
|
||||||
newMap = JsonUtils.parseObject(configJson, Map.class);
|
newMap = JsonUtils.parseMap(configJson);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
throw new RenException(ErrorCode.PARAM_JSON_INVALID);
|
||||||
@@ -298,8 +303,8 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
|||||||
|
|
||||||
// 检查addressBook功能是否被关闭
|
// 检查addressBook功能是否被关闭
|
||||||
if (currentMap != null && newMap != null) {
|
if (currentMap != null && newMap != null) {
|
||||||
Map<String, Object> currentFeatures = (Map<String, Object>) currentMap.get("features");
|
Map<?, ?> currentFeatures = Map.class.cast(currentMap.get("features"));
|
||||||
Map<String, Object> newFeatures = (Map<String, Object>) newMap.get("features");
|
Map<?, ?> newFeatures = Map.class.cast(newMap.get("features"));
|
||||||
|
|
||||||
if (currentFeatures != null && newFeatures != null) {
|
if (currentFeatures != null && newFeatures != null) {
|
||||||
Object currentAddressBookObj = currentFeatures.get("addressBook");
|
Object currentAddressBookObj = currentFeatures.get("addressBook");
|
||||||
@@ -308,16 +313,14 @@ public class SysParamsServiceImpl extends BaseServiceImpl<SysParamsDao, SysParam
|
|||||||
Boolean currentEnabled = false;
|
Boolean currentEnabled = false;
|
||||||
Boolean newEnabled = false;
|
Boolean newEnabled = false;
|
||||||
|
|
||||||
if (currentAddressBookObj instanceof Map) {
|
if (currentAddressBookObj instanceof Map<?, ?> currentAddressBook) {
|
||||||
Map<String, Object> currentAddressBook = (Map<String, Object>) currentAddressBookObj;
|
Object enabled = currentAddressBook.get("enabled");
|
||||||
currentEnabled = currentAddressBook.get("enabled") != null
|
currentEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
|
||||||
? (Boolean) currentAddressBook.get("enabled") : false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newAddressBookObj instanceof Map) {
|
if (newAddressBookObj instanceof Map<?, ?> newAddressBook) {
|
||||||
Map<String, Object> newAddressBook = (Map<String, Object>) newAddressBookObj;
|
Object enabled = newAddressBook.get("enabled");
|
||||||
newEnabled = newAddressBook.get("enabled") != null
|
newEnabled = enabled != null ? Boolean.class.cast(enabled) : false;
|
||||||
? (Boolean) newAddressBook.get("enabled") : false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果之前是启用状态,现在被禁用,删除所有call_device插件
|
// 如果之前是启用状态,现在被禁用,删除所有call_device插件
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
|||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void delete(String[] ids) {
|
public void delete(String[] ids) {
|
||||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
baseDao.deleteByIds(Arrays.asList(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+1
-1
@@ -127,7 +127,7 @@ public class VoiceCloneServiceImpl extends BaseServiceImpl<VoiceCloneDao, VoiceC
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String[] ids) {
|
public void delete(String[] ids) {
|
||||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
baseDao.deleteByIds(Arrays.asList(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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:
|
- sqlFile:
|
||||||
encoding: utf8
|
encoding: utf8
|
||||||
path: classpath:db/changelog/202607101200.sql
|
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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<configuration>
|
<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" />
|
<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默认配置 -->
|
<!-- 引入Spring Boot默认配置 -->
|
||||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
|
WHERE mac_address = #{macAddress} AND target_mac = #{targetMac}
|
||||||
</update>
|
</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)
|
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())
|
VALUES (#{macAddress}, #{targetMac}, #{alias}, #{hasPermission}, #{creator}, NOW(), #{updater}, NOW())
|
||||||
</insert>
|
</insert>
|
||||||
@@ -36,4 +36,4 @@
|
|||||||
#{mac}
|
#{mac}
|
||||||
</foreach>
|
</foreach>
|
||||||
</delete>
|
</delete>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-1
@@ -22,6 +22,7 @@ import java.util.function.BiFunction;
|
|||||||
|
|
||||||
import org.apache.ibatis.binding.MapperMethod;
|
import org.apache.ibatis.binding.MapperMethod;
|
||||||
import org.apache.ibatis.executor.BatchResult;
|
import org.apache.ibatis.executor.BatchResult;
|
||||||
|
import org.apache.ibatis.logging.nologging.NoLoggingImpl;
|
||||||
import org.apache.ibatis.mapping.MappedStatement;
|
import org.apache.ibatis.mapping.MappedStatement;
|
||||||
import org.apache.ibatis.session.ExecutorType;
|
import org.apache.ibatis.session.ExecutorType;
|
||||||
import org.apache.ibatis.session.SqlSession;
|
import org.apache.ibatis.session.SqlSession;
|
||||||
@@ -57,7 +58,8 @@ class BaseServiceImplTest {
|
|||||||
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
when(sqlSessionFactory.openSession(ExecutorType.BATCH)).thenReturn(sqlSession);
|
||||||
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
|
when(sqlSession.insert(eq(INSERT_STATEMENT), any(TestEntity.class))).thenReturn(1);
|
||||||
when(sqlSession.flushStatements())
|
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 first = new TestEntity(1L);
|
||||||
TestEntity second = new TestEntity(2L);
|
TestEntity second = new TestEntity(2L);
|
||||||
@@ -126,6 +128,19 @@ class BaseServiceImplTest {
|
|||||||
verifyNoInteractions(sqlSessionFactory);
|
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
|
@Test
|
||||||
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
|
void activeTransactionSynchronizationUsesTransactionAwareCommitAndLifecycle() {
|
||||||
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
SqlSessionFactory sqlSessionFactory = mock(SqlSessionFactory.class);
|
||||||
@@ -170,6 +185,10 @@ class BaseServiceImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
|
private static class TestService extends BaseServiceImpl<TestMapper, TestEntity> {
|
||||||
|
private TestService() {
|
||||||
|
log = new NoLoggingImpl(TestService.class.getName());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected String getSqlStatement(SqlMethod sqlMethod) {
|
protected String getSqlStatement(SqlMethod sqlMethod) {
|
||||||
return switch (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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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;
|
package xiaozhi.modules.device;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
@@ -11,6 +10,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
|||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import xiaozhi.common.exception.ErrorCode;
|
||||||
|
import xiaozhi.common.exception.RenException;
|
||||||
import xiaozhi.common.redis.RedisUtils;
|
import xiaozhi.common.redis.RedisUtils;
|
||||||
import xiaozhi.modules.sys.dto.SysUserDTO;
|
import xiaozhi.modules.sys.dto.SysUserDTO;
|
||||||
import xiaozhi.modules.sys.service.SysUserService;
|
import xiaozhi.modules.sys.service.SysUserService;
|
||||||
@@ -27,11 +28,13 @@ public class DeviceTest {
|
|||||||
private SysUserService sysUserService;
|
private SysUserService sysUserService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSaveUser() {
|
public void testRejectWeakPassword() {
|
||||||
SysUserDTO userDTO = new SysUserDTO();
|
SysUserDTO userDTO = new SysUserDTO();
|
||||||
userDTO.setUsername("test");
|
userDTO.setUsername("test");
|
||||||
userDTO.setPassword(UUID.randomUUID().toString());
|
userDTO.setPassword("weak-password-123");
|
||||||
sysUserService.save(userDTO);
|
|
||||||
|
RenException exception = Assertions.assertThrows(RenException.class, () -> sysUserService.save(userDTO));
|
||||||
|
Assertions.assertEquals(ErrorCode.PASSWORD_WEAK_ERROR, exception.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,4 +73,4 @@ public class DeviceTest {
|
|||||||
|
|
||||||
log.info("测试完成");
|
log.info("测试完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+78
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -76,6 +76,25 @@ class DeviceTimeSerializationTest {
|
|||||||
() -> assertTrue(payload.path("createDate").isNull()));
|
() -> 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) {
|
private DeviceServiceImpl serviceReturning(DeviceEntity entity) {
|
||||||
return new DeviceServiceImpl(null, null, null, null, null, null) {
|
return new DeviceServiceImpl(null, null, null, null, null, null) {
|
||||||
@Override
|
@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;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.controller.LoginController;
|
||||||
import xiaozhi.modules.security.dto.LoginDTO;
|
import xiaozhi.modules.security.dto.LoginDTO;
|
||||||
import xiaozhi.modules.security.dto.SmsVerificationDTO;
|
import xiaozhi.modules.security.dto.SmsVerificationDTO;
|
||||||
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
import xiaozhi.modules.sys.dto.RetrievePasswordDTO;
|
||||||
|
import xiaozhi.modules.sys.service.SysUserService;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@@ -19,12 +27,19 @@ class loginControllerTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
LoginController loginController;
|
LoginController loginController;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
SysUserService sysUserService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testRegister() {
|
public void testRegister() {
|
||||||
|
when(sysUserService.getAllowUserRegister()).thenReturn(false);
|
||||||
|
|
||||||
LoginDTO loginDTO = new LoginDTO();
|
LoginDTO loginDTO = new LoginDTO();
|
||||||
loginDTO.setUsername("手机号码");
|
loginDTO.setUsername("手机号码");
|
||||||
loginDTO.setPassword("密码");
|
loginDTO.setPassword("密码");
|
||||||
loginController.register(loginDTO);
|
|
||||||
|
RenException exception = assertThrows(RenException.class, () -> loginController.register(loginDTO));
|
||||||
|
assertEquals(ErrorCode.USER_REGISTER_DISABLED, exception.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -54,4 +69,4 @@ class loginControllerTest {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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:
|
spring:
|
||||||
|
messages:
|
||||||
|
encoding: UTF-8
|
||||||
|
basename: i18n/messages
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: localhost
|
host: localhost
|
||||||
@@ -19,4 +22,4 @@ renren:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
xiaozhi.modules.device: DEBUG
|
xiaozhi.modules.device: DEBUG
|
||||||
org.springframework.data.redis: DEBUG
|
org.springframework.data.redis: DEBUG
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ function showAbout() {
|
|||||||
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
|
||||||
content: t('settings.aboutContent', {
|
content: t('settings.aboutContent', {
|
||||||
appName: import.meta.env.VITE_APP_TITLE,
|
appName: import.meta.env.VITE_APP_TITLE,
|
||||||
version: '0.9.5',
|
version: '0.9.6',
|
||||||
}),
|
}),
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: t('common.confirm'),
|
confirmText: t('common.confirm'),
|
||||||
|
|||||||
+146
-37
@@ -3,13 +3,14 @@ import uuid
|
|||||||
import signal
|
import signal
|
||||||
import asyncio
|
import asyncio
|
||||||
from aioconsole import ainput
|
from aioconsole import ainput
|
||||||
from config.settings import load_config
|
from config.config_loader import load_config
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from core.utils.util import get_local_ip, validate_mcp_endpoint
|
from core.utils.util import get_local_ip, validate_mcp_endpoint
|
||||||
from core.http_server import SimpleHttpServer
|
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.util import check_ffmpeg_installed
|
||||||
from core.utils.gc_manager import get_gc_manager
|
from core.utils.gc_manager import get_gc_manager
|
||||||
|
from config.manage_api_client import manage_api_http_close
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -51,14 +52,14 @@ async def main():
|
|||||||
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
# auth_key用于jwt认证,比如视觉分析接口的jwt认证、ota接口的token生成与websocket认证
|
||||||
# 获取配置文件中的auth_key
|
# 获取配置文件中的auth_key
|
||||||
auth_key = config["server"].get("auth_key", "")
|
auth_key = config["server"].get("auth_key", "")
|
||||||
|
|
||||||
# 验证auth_key,无效则尝试使用manager-api.secret
|
# 验证auth_key,无效则尝试使用manager-api.secret
|
||||||
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
||||||
auth_key = config.get("manager-api", {}).get("secret", "")
|
auth_key = config.get("manager-api", {}).get("secret", "")
|
||||||
# 验证secret,无效则生成随机密钥
|
# 验证secret,无效则生成随机密钥
|
||||||
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
if not auth_key or len(auth_key) == 0 or "你" in auth_key:
|
||||||
auth_key = str(uuid.uuid4().hex)
|
auth_key = str(uuid.uuid4().hex)
|
||||||
|
|
||||||
config["server"]["auth_key"] = auth_key
|
config["server"]["auth_key"] = auth_key
|
||||||
|
|
||||||
# 添加 stdin 监控任务
|
# 添加 stdin 监控任务
|
||||||
@@ -68,12 +69,49 @@ async def main():
|
|||||||
gc_manager = get_gc_manager(interval_seconds=300)
|
gc_manager = get_gc_manager(interval_seconds=300)
|
||||||
await gc_manager.start()
|
await gc_manager.start()
|
||||||
|
|
||||||
# 启动 WebSocket 服务器
|
# 启动小智服务器门面(支持WebSocket和MQTT)
|
||||||
ws_server = WebSocketServer(config)
|
xiaozhi_server = XiaozhiServerFacade(config)
|
||||||
ws_task = asyncio.create_task(ws_server.start())
|
ota_server = None
|
||||||
# 启动 Simple http 服务器
|
ota_task = None
|
||||||
ota_server = SimpleHttpServer(config)
|
try:
|
||||||
ota_task = asyncio.create_task(ota_server.start())
|
# 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)
|
read_config_from_api = config.get("read_config_from_api", False)
|
||||||
port = int(config["server"].get("http_port", 8003))
|
port = int(config["server"].get("http_port", 8003))
|
||||||
@@ -100,24 +138,51 @@ async def main():
|
|||||||
logger.bind(tag=TAG).error("mcp接入点不符合规范")
|
logger.bind(tag=TAG).error("mcp接入点不符合规范")
|
||||||
config["mcp_endpoint"] = "你的接入点 websocket地址"
|
config["mcp_endpoint"] = "你的接入点 websocket地址"
|
||||||
|
|
||||||
# 获取WebSocket配置,使用安全的默认值
|
# 显示协议连接信息
|
||||||
websocket_port = 8000
|
connection_info = xiaozhi_server.get_connection_info()
|
||||||
server_config = config.get("server", {})
|
|
||||||
if isinstance(server_config, dict):
|
|
||||||
websocket_port = int(server_config.get("port", 8000))
|
|
||||||
|
|
||||||
logger.bind(tag=TAG).info(
|
# WebSocket信息
|
||||||
"Websocket地址是\tws://{}:{}/xiaozhi/v1/",
|
websocket_info = connection_info.get("websocket", {})
|
||||||
get_local_ip(),
|
if websocket_info.get("enabled", False):
|
||||||
websocket_port,
|
websocket_port = websocket_info.get("port", 8000)
|
||||||
)
|
logger.bind(tag=TAG).info(
|
||||||
|
"WebSocket地址是\tws://{}:{}/xiaozhi/v1/",
|
||||||
|
get_local_ip(),
|
||||||
|
websocket_port,
|
||||||
|
)
|
||||||
|
|
||||||
logger.bind(tag=TAG).info(
|
# MQTT信息
|
||||||
"=======上面的地址是websocket协议地址,请勿用浏览器访问======="
|
mqtt_info = connection_info.get("mqtt", {})
|
||||||
)
|
if mqtt_info.get("enabled", False):
|
||||||
logger.bind(tag=TAG).info(
|
mqtt_port = mqtt_info.get("port", 1883)
|
||||||
"如想测试websocket请启动digital-human模块,打开浏览器交互测试"
|
udp_port = mqtt_info.get("udp_port", 1883)
|
||||||
)
|
logger.bind(tag=TAG).info(
|
||||||
|
"MQTT地址是\t\tmqtt://{}:{}",
|
||||||
|
get_local_ip(),
|
||||||
|
mqtt_port,
|
||||||
|
)
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
"UDP音频端口是\t{}:{}",
|
||||||
|
get_local_ip(),
|
||||||
|
udp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 显示启用的协议
|
||||||
|
enabled_protocols = xiaozhi_server.config.get("enabled_protocols", [])
|
||||||
|
logger.bind(tag=TAG).info(f"启用的协议: {', '.join(enabled_protocols)}")
|
||||||
|
|
||||||
|
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(
|
logger.bind(tag=TAG).info(
|
||||||
"=============================================================\n"
|
"=============================================================\n"
|
||||||
)
|
)
|
||||||
@@ -127,22 +192,66 @@ async def main():
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("任务被取消,清理资源中...")
|
print("任务被取消,清理资源中...")
|
||||||
finally:
|
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管理器
|
# 停止全局GC管理器
|
||||||
await gc_manager.stop()
|
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()
|
stdin_task.cancel()
|
||||||
ws_task.cancel()
|
await asyncio.gather(stdin_task, return_exceptions=True)
|
||||||
if ota_task:
|
|
||||||
ota_task.cancel()
|
|
||||||
|
|
||||||
# 等待任务终止(必须加超时)
|
if ota_task:
|
||||||
await asyncio.wait(
|
done, pending = await asyncio.wait({ota_task}, timeout=3.0)
|
||||||
[stdin_task, ws_task, ota_task] if ota_task else [stdin_task, ws_task],
|
if pending:
|
||||||
timeout=3.0,
|
ota_task.cancel()
|
||||||
return_when=asyncio.ALL_COMPLETED,
|
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("服务器已关闭,程序退出。")
|
print("服务器已关闭,程序退出。")
|
||||||
|
if shutdown_errors:
|
||||||
|
details = ", ".join(
|
||||||
|
f"{owner}: {error}" for owner, error in shutdown_errors
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"服务器清理失败: {details}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -35,12 +35,75 @@ server:
|
|||||||
# 如果属于白名单内的设备,不校验token,直接放行
|
# 如果属于白名单内的设备,不校验token,直接放行
|
||||||
allowed_devices:
|
allowed_devices:
|
||||||
- "11:22:33:44:55:66"
|
- "11:22:33:44:55:66"
|
||||||
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
# MQTT网关配置,用于通过OTA下发到设备,根据mqtt_gateway的.env文件配置,格式为host:port
|
||||||
mqtt_gateway: null
|
mqtt_gateway: null
|
||||||
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
|
# MQTT签名密钥,用于生成MQTT连接密码,根据mqtt_gateway的.env文件配置
|
||||||
mqtt_signature_key: null
|
mqtt_signature_key: null
|
||||||
# UDP网关配置
|
# UDP网关配置
|
||||||
udp_gateway: null
|
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:
|
||||||
# 设置控制台输出的日志格式,时间、日志级别、标签、消息
|
# 设置控制台输出的日志格式,时间、日志级别、标签、消息
|
||||||
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>"
|
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>"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from config.config_loader import load_config
|
|||||||
from config.settings import check_config_file
|
from config.settings import check_config_file
|
||||||
from core.utils.cache.manager import cache_manager, CacheType
|
from core.utils.cache.manager import cache_manager, CacheType
|
||||||
|
|
||||||
SERVER_VERSION = "0.9.5"
|
SERVER_VERSION = "0.9.6"
|
||||||
_logger_initialized = False
|
_logger_initialized = False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,28 @@ class ManageApiClient:
|
|||||||
# 如果没有运行中的事件循环,创建一个临时的
|
# 如果没有运行中的事件循环,创建一个临时的
|
||||||
raise Exception("必须在异步上下文中调用")
|
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
|
@classmethod
|
||||||
async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
|
async def _async_request(cls, method: str, endpoint: str, **kwargs) -> Dict:
|
||||||
"""发送单次异步HTTP请求并处理响应"""
|
"""发送单次异步HTTP请求并处理响应"""
|
||||||
@@ -266,3 +288,7 @@ def init_service(config):
|
|||||||
|
|
||||||
def manage_api_http_safe_close():
|
def manage_api_http_safe_close():
|
||||||
ManageApiClient.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})
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import glob
|
import glob
|
||||||
@@ -11,6 +9,11 @@ from aiohttp import web
|
|||||||
|
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
from core.utils.util import get_local_ip, get_vision_url
|
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
|
from core.api.base_handler import BaseHandler
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
@@ -102,26 +105,6 @@ class OTAHandler(BaseHandler):
|
|||||||
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
|
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
|
||||||
# keep previous cache if any
|
# 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:
|
def _get_websocket_url(self, local_ip: str, port: int) -> str:
|
||||||
"""获取websocket地址
|
"""获取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: # 如果配置了非空字符串
|
mqtt_server_config = self.config.get("mqtt_server", {})
|
||||||
# 尝试从请求数据中获取设备型号(已解析 above)
|
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:
|
try:
|
||||||
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -246,56 +250,116 @@ class OTAHandler(BaseHandler):
|
|||||||
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
|
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
|
||||||
|
|
||||||
# 构建用户数据
|
# 构建用户数据
|
||||||
user_data = {"ip": "unknown"}
|
user_data = {"ip": local_ip}
|
||||||
try:
|
try:
|
||||||
user_data_json = json.dumps(user_data)
|
user_data_json = json.dumps(user_data)
|
||||||
username = base64.b64encode(user_data_json.encode("utf-8")).decode(
|
username = base64.b64encode(user_data_json.encode("utf-8")).decode("utf-8")
|
||||||
"utf-8"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
|
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
|
||||||
username = ""
|
username = ""
|
||||||
|
|
||||||
|
return group_id, mac_address_safe, mqtt_client_id, username
|
||||||
|
|
||||||
|
# ========== 1. 总是下发 WebSocket 配置(作为基础/回退方案)==========
|
||||||
|
ws_token = ""
|
||||||
|
if self.auth_enable:
|
||||||
|
if self.allowed_devices:
|
||||||
|
if device_id not in self.allowed_devices:
|
||||||
|
ws_token = self.auth.generate_token(client_id, device_id)
|
||||||
|
else:
|
||||||
|
ws_token = self.auth.generate_token(client_id, device_id)
|
||||||
|
|
||||||
|
return_json["websocket"] = {
|
||||||
|
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||||
|
"token": ws_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 2. 如果启用了原生 MQTT 服务器,额外下发 MQTT 配置 ==========
|
||||||
|
signature_key = normalize_signature_key(
|
||||||
|
mqtt_server_config.get("signature_key")
|
||||||
|
or server_config.get("mqtt_signature_key")
|
||||||
|
)
|
||||||
|
native_mqtt_ready = bool(mqtt_server_enabled and signature_key)
|
||||||
|
if mqtt_server_enabled and not signature_key:
|
||||||
|
self.logger.bind(tag=TAG).error(
|
||||||
|
"原生MQTT已启用但未配置签名密钥,跳过Native配置下发"
|
||||||
|
)
|
||||||
|
|
||||||
|
if native_mqtt_ready:
|
||||||
|
try:
|
||||||
|
mqtt_host, mqtt_port = parse_mqtt_endpoint(
|
||||||
|
mqtt_server_config.get("public_endpoint"),
|
||||||
|
int(mqtt_server_config.get("port", 1883)),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
self.logger.bind(tag=TAG).error(f"MQTT endpoint配置无效: {exc}")
|
||||||
|
mqtt_host, mqtt_port = "", None
|
||||||
|
|
||||||
|
placeholder_keywords = ("localhost", "0.0.0.0", "your", "example")
|
||||||
|
if not mqtt_host or any(
|
||||||
|
keyword in mqtt_host.lower() for keyword in placeholder_keywords
|
||||||
|
):
|
||||||
|
mqtt_host = local_ip
|
||||||
|
self.logger.bind(tag=TAG).info(
|
||||||
|
f"检测到 public_endpoint 为占位符,自动使用本地IP: {local_ip}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mqtt_port is None:
|
||||||
|
native_mqtt_ready = False
|
||||||
|
|
||||||
|
if native_mqtt_ready:
|
||||||
|
|
||||||
|
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||||
|
|
||||||
|
mqtt_password = generate_password_signature(
|
||||||
|
mqtt_client_id + "|" + username, signature_key
|
||||||
|
)
|
||||||
|
|
||||||
|
return_json["mqtt"] = {
|
||||||
|
"endpoint": f"{mqtt_host}:{mqtt_port}",
|
||||||
|
"client_id": mqtt_client_id,
|
||||||
|
"username": username,
|
||||||
|
"password": mqtt_password,
|
||||||
|
"publish_topic": "device-server",
|
||||||
|
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.bind(tag=TAG).info(
|
||||||
|
f"为设备 {device_id} 下发原生MQTT配置: {mqtt_host}:{mqtt_port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== 3. 如果配置了外部 MQTT 网关,额外下发 MQTT 配置 ==========
|
||||||
|
elif mqtt_gateway_endpoint:
|
||||||
|
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||||
|
|
||||||
# 生成密码
|
# 生成密码
|
||||||
password = ""
|
mqtt_password = ""
|
||||||
signature_key = server_config.get("mqtt_signature_key", "")
|
signature_key = server_config.get("mqtt_signature_key", "")
|
||||||
if signature_key:
|
if signature_key:
|
||||||
password = self.generate_password_signature(
|
mqtt_password = generate_password_signature(
|
||||||
mqtt_client_id + "|" + username, signature_key
|
mqtt_client_id + "|" + username, signature_key
|
||||||
)
|
)
|
||||||
if not password:
|
if not mqtt_password:
|
||||||
password = "" # 签名失败则留空,由设备决定是否允许无密码
|
mqtt_password = ""
|
||||||
else:
|
else:
|
||||||
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
||||||
|
|
||||||
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
|
|
||||||
return_json["mqtt"] = {
|
return_json["mqtt"] = {
|
||||||
"endpoint": mqtt_gateway_endpoint,
|
"endpoint": mqtt_gateway_endpoint,
|
||||||
"client_id": mqtt_client_id,
|
"client_id": mqtt_client_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": mqtt_password,
|
||||||
"publish_topic": "device-server",
|
"publish_topic": "device-server",
|
||||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||||
}
|
}
|
||||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置")
|
|
||||||
|
|
||||||
else: # 未配置 mqtt_gateway,下发 WebSocket
|
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置: {mqtt_gateway_endpoint}")
|
||||||
# 如果开启了认证,则进行认证校验
|
|
||||||
token = ""
|
# 记录最终下发的协议
|
||||||
if self.auth_enable:
|
protocols = ["websocket"]
|
||||||
if self.allowed_devices:
|
if "mqtt" in return_json:
|
||||||
if device_id not in self.allowed_devices:
|
protocols.append("mqtt")
|
||||||
token = self.auth.generate_token(client_id, device_id)
|
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发协议配置: {', '.join(protocols)}")
|
||||||
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配置"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Now check firmware files for updates
|
# Now check firmware files for updates
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,6 +10,164 @@ class AuthenticationError(Exception):
|
|||||||
pass
|
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:
|
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,8 +118,17 @@ async def process_intent_result(
|
|||||||
|
|
||||||
请根据以上信息回答用户的问题:{original_text}"""
|
请根据以上信息回答用户的问题:{original_text}"""
|
||||||
|
|
||||||
response = conn.intent.replyResult(context_prompt, original_text)
|
# 使用异步调用避免阻塞事件循环,影响其他设备的音频播放
|
||||||
speak_txt(conn, response)
|
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)
|
conn.executor.submit(process_context_result)
|
||||||
return True
|
return True
|
||||||
@@ -184,7 +193,15 @@ async def process_intent_result(
|
|||||||
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
|
elif result.action == Action.REQLLM: # 调用函数后再请求llm生成回复
|
||||||
text = result.result
|
text = result.result
|
||||||
conn.dialogue.put(Message(role="tool", content=text))
|
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:
|
if llm_result is None:
|
||||||
llm_result = text
|
llm_result = text
|
||||||
speak_txt(conn, llm_result)
|
speak_txt(conn, llm_result)
|
||||||
|
|||||||
@@ -350,4 +350,16 @@ async def send_display_message(conn: "ConnectionHandler", text):
|
|||||||
"text": text,
|
"text": text,
|
||||||
"session_id": conn.session_id
|
"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))
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from config.logger import setup_logging
|
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.ota_handler import OTAHandler
|
||||||
from core.api.vision_handler import VisionHandler
|
from core.api.vision_handler import VisionHandler
|
||||||
|
|
||||||
@@ -8,11 +11,53 @@ TAG = __name__
|
|||||||
|
|
||||||
|
|
||||||
class SimpleHttpServer:
|
class SimpleHttpServer:
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict, management_owner=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.management_owner = management_owner
|
||||||
self.logger = setup_logging()
|
self.logger = setup_logging()
|
||||||
self.ota_handler = OTAHandler(config)
|
self.ota_handler = OTAHandler(config)
|
||||||
self.vision_handler = VisionHandler(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:
|
def _get_websocket_url(self, local_ip: str, port: int) -> str:
|
||||||
"""获取websocket地址
|
"""获取websocket地址
|
||||||
@@ -33,14 +78,28 @@ class SimpleHttpServer:
|
|||||||
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
|
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
|
runner = None
|
||||||
|
self._start_active = True
|
||||||
try:
|
try:
|
||||||
|
self._started_event.clear()
|
||||||
|
self._stop_event.clear()
|
||||||
server_config = self.config["server"]
|
server_config = self.config["server"]
|
||||||
read_config_from_api = self.config.get("read_config_from_api", False)
|
read_config_from_api = self.config.get("read_config_from_api", False)
|
||||||
host = server_config.get("ip", "0.0.0.0")
|
host = server_config.get("ip", "0.0.0.0")
|
||||||
port = int(server_config.get("http_port", 8003))
|
port = int(server_config.get("http_port", 8003))
|
||||||
|
|
||||||
if port:
|
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:
|
if not read_config_from_api:
|
||||||
# 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
|
# 如果没有开启智控台,只是单模块运行,就需要再添加简单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)
|
runner = web.AppRunner(app)
|
||||||
|
self._runner = runner
|
||||||
await runner.setup()
|
await runner.setup()
|
||||||
site = web.TCPSite(runner, host, port)
|
site = web.TCPSite(runner, host, port)
|
||||||
await site.start()
|
await site.start()
|
||||||
|
self._started_event.set()
|
||||||
# 保持服务运行
|
await self._stop_event.wait()
|
||||||
while True:
|
|
||||||
await asyncio.sleep(3600) # 每隔 1 小时检查一次
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
|
self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
|
self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
|
||||||
raise
|
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
Reference in New Issue
Block a user