mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 18:13:56 +08:00
feat: add optional native mqtt and udp transport
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from config.logger import setup_logging
|
||||
from core.providers.tools.device_mcp import call_mcp_tool
|
||||
from core.utils.mqtt_auth import normalize_signature_key
|
||||
from core.utils.util import sanitize_tool_name
|
||||
|
||||
TAG = __name__
|
||||
|
||||
|
||||
class NativeMqttManagementHandler:
|
||||
def __init__(self, config: Dict[str, Any], management_owner: Any):
|
||||
self.config = config
|
||||
self.management_owner = management_owner
|
||||
self.logger = setup_logging()
|
||||
mqtt_config = config.get("mqtt_server", {})
|
||||
server_config = config.get("server", {})
|
||||
self.signature_key = normalize_signature_key(
|
||||
mqtt_config.get("manager_api_secret")
|
||||
or mqtt_config.get("signature_key")
|
||||
or server_config.get("mqtt_signature_key")
|
||||
)
|
||||
self.command_timeout = max(
|
||||
0.1, float(mqtt_config.get("manager_command_timeout", 5) or 5)
|
||||
)
|
||||
self.max_status_ids = max(
|
||||
1, int(mqtt_config.get("manager_max_status_ids", 1000) or 1000)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_daily_tokens(
|
||||
signature_key: str, now: Optional[datetime] = None
|
||||
) -> set[str]:
|
||||
normalized = normalize_signature_key(signature_key)
|
||||
if not normalized:
|
||||
return set()
|
||||
current = now or datetime.now(timezone.utc)
|
||||
utc_date = current.astimezone(timezone.utc).date()
|
||||
return {
|
||||
hashlib.sha256(
|
||||
f"{utc_date + timedelta(days=offset)}{normalized}".encode(
|
||||
"utf-8"
|
||||
)
|
||||
).hexdigest()
|
||||
for offset in (-1, 0, 1)
|
||||
}
|
||||
|
||||
def _is_authorized(self, authorization: str) -> bool:
|
||||
if not self.signature_key:
|
||||
return False
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return False
|
||||
provided = authorization[len("Bearer ") :].strip()
|
||||
return any(
|
||||
hmac.compare_digest(provided, expected)
|
||||
for expected in self.generate_daily_tokens(self.signature_key)
|
||||
)
|
||||
|
||||
def _authorize(self, request) -> Optional[web.Response]:
|
||||
if not self.signature_key:
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理API未配置签名密钥",
|
||||
"MANAGEMENT_AUTH_NOT_CONFIGURED",
|
||||
False,
|
||||
)
|
||||
if not self._is_authorized(request.headers.get("Authorization", "")):
|
||||
return self._error(
|
||||
401, "无效的授权令牌", "UNAUTHORIZED", False
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _error(
|
||||
status: int,
|
||||
message: str,
|
||||
code: str,
|
||||
dispatch_attempted: bool,
|
||||
) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": message,
|
||||
"code": code,
|
||||
"dispatchAttempted": dispatch_attempted,
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _read_json_object(self, request) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return None
|
||||
return body if isinstance(body, dict) else None
|
||||
|
||||
async def handle_device_status(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
client_ids = body.get("clientIds") if body else None
|
||||
if (
|
||||
not isinstance(client_ids, list)
|
||||
or not client_ids
|
||||
or len(client_ids) > self.max_status_ids
|
||||
or any(
|
||||
not isinstance(client_id, str) or not client_id
|
||||
for client_id in client_ids
|
||||
)
|
||||
):
|
||||
return self._error(
|
||||
400,
|
||||
"clientIds必须是非空字符串数组且未超过数量限制",
|
||||
"INVALID_CLIENT_IDS",
|
||||
False,
|
||||
)
|
||||
|
||||
get_status = getattr(
|
||||
self.management_owner, "get_native_mqtt_status", None
|
||||
)
|
||||
if not callable(get_status):
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理服务未就绪",
|
||||
"MANAGEMENT_NOT_READY",
|
||||
False,
|
||||
)
|
||||
return web.json_response(await get_status(client_ids))
|
||||
|
||||
async def handle_command(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
payload = (
|
||||
body.get("payload")
|
||||
if body and body.get("type") == "mcp"
|
||||
else None
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return self._error(
|
||||
400, "指令类型无效", "INVALID_COMMAND", False
|
||||
)
|
||||
|
||||
resolver = getattr(
|
||||
self.management_owner, "resolve_native_mqtt_connection", None
|
||||
)
|
||||
if not callable(resolver):
|
||||
return self._error(
|
||||
503,
|
||||
"Native MQTT管理服务未就绪",
|
||||
"MANAGEMENT_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
connection = await resolver(request.match_info.get("client_id", ""))
|
||||
if connection is None:
|
||||
return self._error(
|
||||
404, "设备未连接", "DEVICE_OFFLINE", False
|
||||
)
|
||||
|
||||
method = payload.get("method")
|
||||
params = payload.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
return self._error(
|
||||
400, "MCP参数格式无效", "INVALID_MCP_PARAMS", False
|
||||
)
|
||||
if method == "tools/list":
|
||||
return await self._list_tools(connection.context)
|
||||
if method == "tools/call":
|
||||
return await self._call_tool(connection.context, params)
|
||||
return self._error(
|
||||
422, "不支持的MCP方法", "UNSUPPORTED_MCP_METHOD", False
|
||||
)
|
||||
|
||||
async def handle_call_request(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
caller_mac = body.get("caller_mac") if body else None
|
||||
target_mac = body.get("target_mac") if body else None
|
||||
caller_nickname = body.get("caller_nickname", "") if body else ""
|
||||
if (
|
||||
not isinstance(caller_mac, str)
|
||||
or not caller_mac.strip()
|
||||
or not isinstance(target_mac, str)
|
||||
or not target_mac.strip()
|
||||
or not isinstance(caller_nickname, str)
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "缺少必要参数: caller_mac, target_mac",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
request_call = getattr(
|
||||
self.management_owner, "request_native_mqtt_call", None
|
||||
)
|
||||
if not callable(request_call):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Native MQTT呼叫服务未就绪"},
|
||||
status=503,
|
||||
)
|
||||
result = await request_call(
|
||||
caller_mac, target_mac, caller_nickname
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
async def handle_call_accept(self, request) -> web.Response:
|
||||
unauthorized = self._authorize(request)
|
||||
if unauthorized is not None:
|
||||
return unauthorized
|
||||
|
||||
body = await self._read_json_object(request)
|
||||
device_id = body.get("mac") if body else None
|
||||
if not isinstance(device_id, str) or not device_id.strip():
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "缺少必要参数: mac"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
accept_call = getattr(
|
||||
self.management_owner, "accept_native_mqtt_call", None
|
||||
)
|
||||
if not callable(accept_call):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Native MQTT呼叫服务未就绪"},
|
||||
status=503,
|
||||
)
|
||||
return web.json_response(await accept_call(device_id))
|
||||
|
||||
async def _list_tools(self, context) -> web.Response:
|
||||
mcp_client = getattr(context, "mcp_client", None)
|
||||
if mcp_client is None or not await mcp_client.is_ready():
|
||||
return self._error(
|
||||
503,
|
||||
"设备MCP尚未准备就绪",
|
||||
"MCP_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
async with mcp_client.lock:
|
||||
tools = [
|
||||
copy.deepcopy(tool)
|
||||
for tool in mcp_client.tools.values()
|
||||
]
|
||||
return web.json_response(
|
||||
{"success": True, "data": {"tools": tools}}
|
||||
)
|
||||
|
||||
async def _call_tool(self, context, params: Dict[str, Any]) -> web.Response:
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return self._error(
|
||||
422, "工具名称不能为空", "INVALID_TOOL_NAME", False
|
||||
)
|
||||
if not isinstance(arguments, dict):
|
||||
return self._error(
|
||||
422, "工具参数必须是对象", "INVALID_TOOL_ARGUMENTS", False
|
||||
)
|
||||
|
||||
mcp_client = getattr(context, "mcp_client", None)
|
||||
if mcp_client is None or not await mcp_client.is_ready():
|
||||
return self._error(
|
||||
503,
|
||||
"设备MCP尚未准备就绪",
|
||||
"MCP_NOT_READY",
|
||||
False,
|
||||
)
|
||||
|
||||
sanitized_name = sanitize_tool_name(tool_name)
|
||||
if not mcp_client.has_tool(sanitized_name):
|
||||
return self._error(
|
||||
422, "设备不存在该工具", "TOOL_NOT_FOUND", False
|
||||
)
|
||||
|
||||
try:
|
||||
result = await call_mcp_tool(
|
||||
context,
|
||||
mcp_client,
|
||||
sanitized_name,
|
||||
arguments,
|
||||
timeout=self.command_timeout,
|
||||
return_raw=True,
|
||||
)
|
||||
except TimeoutError:
|
||||
return self._error(
|
||||
504, "工具调用请求超时", "COMMAND_TIMEOUT", True
|
||||
)
|
||||
except ConnectionError:
|
||||
return self._error(
|
||||
503, "设备连接已关闭", "DEVICE_DISCONNECTED", True
|
||||
)
|
||||
except ValueError as error:
|
||||
return self._error(422, str(error), "INVALID_TOOL_CALL", False)
|
||||
except Exception as error:
|
||||
self.logger.bind(tag=TAG).warning(
|
||||
"Native MQTT设备工具调用失败: {}", error
|
||||
)
|
||||
return self._error(
|
||||
502, str(error), "TOOL_CALL_FAILED", True
|
||||
)
|
||||
|
||||
data = (
|
||||
result
|
||||
if isinstance(result, dict)
|
||||
else {"content": [{"type": "text", "text": str(result)}]}
|
||||
)
|
||||
return web.json_response({"success": True, "data": data})
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
@@ -11,6 +9,11 @@ from aiohttp import web
|
||||
|
||||
from core.auth import AuthManager
|
||||
from core.utils.util import get_local_ip, get_vision_url
|
||||
from core.utils.mqtt_auth import (
|
||||
generate_password_signature,
|
||||
normalize_signature_key,
|
||||
parse_mqtt_endpoint,
|
||||
)
|
||||
from core.api.base_handler import BaseHandler
|
||||
|
||||
TAG = __name__
|
||||
@@ -102,26 +105,6 @@ class OTAHandler(BaseHandler):
|
||||
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
|
||||
# keep previous cache if any
|
||||
|
||||
def generate_password_signature(self, content: str, secret_key: str) -> str:
|
||||
"""生成MQTT密码签名
|
||||
|
||||
Args:
|
||||
content: 签名内容 (clientId + '|' + username)
|
||||
secret_key: 密钥
|
||||
|
||||
Returns:
|
||||
str: Base64编码的HMAC-SHA256签名
|
||||
"""
|
||||
try:
|
||||
hmac_obj = hmac.new(
|
||||
secret_key.encode("utf-8"), content.encode("utf-8"), hashlib.sha256
|
||||
)
|
||||
signature = hmac_obj.digest()
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"生成MQTT密码签名失败: {e}")
|
||||
return ""
|
||||
|
||||
def _get_websocket_url(self, local_ip: str, port: int) -> str:
|
||||
"""获取websocket地址
|
||||
|
||||
@@ -231,11 +214,32 @@ class OTAHandler(BaseHandler):
|
||||
},
|
||||
}
|
||||
|
||||
# existing mqtt/websocket logic (unchanged)
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
# ========== 协议下发逻辑 ==========
|
||||
# 按照原版逻辑:总是下发 WebSocket,如果启用了 MQTT 则额外下发 MQTT 和 UDP
|
||||
# 这样设备有回退能力:如果 MQTT 连接失败,还可以使用 WebSocket
|
||||
|
||||
if mqtt_gateway_endpoint: # 如果配置了非空字符串
|
||||
# 尝试从请求数据中获取设备型号(已解析 above)
|
||||
mqtt_server_config = self.config.get("mqtt_server", {})
|
||||
enabled_protocols = self.config.get("enabled_protocols")
|
||||
if isinstance(enabled_protocols, list):
|
||||
mqtt_protocol_enabled = "mqtt" in enabled_protocols
|
||||
else:
|
||||
protocol_config = self.config.get("protocols", {})
|
||||
requested_protocols = protocol_config.get(
|
||||
"enabled_protocols", []
|
||||
)
|
||||
mqtt_protocol_enabled = (
|
||||
protocol_config.get("mqtt_enabled") is True
|
||||
or "mqtt" in requested_protocols
|
||||
)
|
||||
mqtt_server_enabled = bool(
|
||||
mqtt_server_config.get("enabled") and mqtt_protocol_enabled
|
||||
)
|
||||
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
|
||||
if not mqtt_gateway_endpoint or str(mqtt_gateway_endpoint).lower() == "null":
|
||||
mqtt_gateway_endpoint = None
|
||||
|
||||
# 生成通用的 MQTT 凭证信息
|
||||
def _build_mqtt_credentials():
|
||||
try:
|
||||
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
|
||||
except Exception as e:
|
||||
@@ -246,56 +250,116 @@ class OTAHandler(BaseHandler):
|
||||
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
|
||||
|
||||
# 构建用户数据
|
||||
user_data = {"ip": "unknown"}
|
||||
user_data = {"ip": local_ip}
|
||||
try:
|
||||
user_data_json = json.dumps(user_data)
|
||||
username = base64.b64encode(user_data_json.encode("utf-8")).decode(
|
||||
"utf-8"
|
||||
)
|
||||
username = base64.b64encode(user_data_json.encode("utf-8")).decode("utf-8")
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
|
||||
username = ""
|
||||
|
||||
return group_id, mac_address_safe, mqtt_client_id, username
|
||||
|
||||
# ========== 1. 总是下发 WebSocket 配置(作为基础/回退方案)==========
|
||||
ws_token = ""
|
||||
if self.auth_enable:
|
||||
if self.allowed_devices:
|
||||
if device_id not in self.allowed_devices:
|
||||
ws_token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
ws_token = self.auth.generate_token(client_id, device_id)
|
||||
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
"token": ws_token,
|
||||
}
|
||||
|
||||
# ========== 2. 如果启用了原生 MQTT 服务器,额外下发 MQTT 配置 ==========
|
||||
signature_key = normalize_signature_key(
|
||||
mqtt_server_config.get("signature_key")
|
||||
or server_config.get("mqtt_signature_key")
|
||||
)
|
||||
native_mqtt_ready = bool(mqtt_server_enabled and signature_key)
|
||||
if mqtt_server_enabled and not signature_key:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
"原生MQTT已启用但未配置签名密钥,跳过Native配置下发"
|
||||
)
|
||||
|
||||
if native_mqtt_ready:
|
||||
try:
|
||||
mqtt_host, mqtt_port = parse_mqtt_endpoint(
|
||||
mqtt_server_config.get("public_endpoint"),
|
||||
int(mqtt_server_config.get("port", 1883)),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
self.logger.bind(tag=TAG).error(f"MQTT endpoint配置无效: {exc}")
|
||||
mqtt_host, mqtt_port = "", None
|
||||
|
||||
placeholder_keywords = ("localhost", "0.0.0.0", "your", "example")
|
||||
if not mqtt_host or any(
|
||||
keyword in mqtt_host.lower() for keyword in placeholder_keywords
|
||||
):
|
||||
mqtt_host = local_ip
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"检测到 public_endpoint 为占位符,自动使用本地IP: {local_ip}"
|
||||
)
|
||||
|
||||
if mqtt_port is None:
|
||||
native_mqtt_ready = False
|
||||
|
||||
if native_mqtt_ready:
|
||||
|
||||
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||
|
||||
mqtt_password = generate_password_signature(
|
||||
mqtt_client_id + "|" + username, signature_key
|
||||
)
|
||||
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": f"{mqtt_host}:{mqtt_port}",
|
||||
"client_id": mqtt_client_id,
|
||||
"username": username,
|
||||
"password": mqtt_password,
|
||||
"publish_topic": "device-server",
|
||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||
}
|
||||
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"为设备 {device_id} 下发原生MQTT配置: {mqtt_host}:{mqtt_port}"
|
||||
)
|
||||
|
||||
# ========== 3. 如果配置了外部 MQTT 网关,额外下发 MQTT 配置 ==========
|
||||
elif mqtt_gateway_endpoint:
|
||||
group_id, mac_address_safe, mqtt_client_id, username = _build_mqtt_credentials()
|
||||
|
||||
# 生成密码
|
||||
password = ""
|
||||
mqtt_password = ""
|
||||
signature_key = server_config.get("mqtt_signature_key", "")
|
||||
if signature_key:
|
||||
password = self.generate_password_signature(
|
||||
mqtt_password = generate_password_signature(
|
||||
mqtt_client_id + "|" + username, signature_key
|
||||
)
|
||||
if not password:
|
||||
password = "" # 签名失败则留空,由设备决定是否允许无密码
|
||||
if not mqtt_password:
|
||||
mqtt_password = ""
|
||||
else:
|
||||
self.logger.bind(tag=TAG).warning("缺少MQTT签名密钥,密码留空")
|
||||
|
||||
# 构建MQTT配置(直接使用 mqtt_gateway 字符串)
|
||||
return_json["mqtt"] = {
|
||||
"endpoint": mqtt_gateway_endpoint,
|
||||
"client_id": mqtt_client_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"password": mqtt_password,
|
||||
"publish_topic": "device-server",
|
||||
"subscribe_topic": f"devices/p2p/{mac_address_safe}",
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置")
|
||||
|
||||
else: # 未配置 mqtt_gateway,下发 WebSocket
|
||||
# 如果开启了认证,则进行认证校验
|
||||
token = ""
|
||||
if self.auth_enable:
|
||||
if self.allowed_devices:
|
||||
if device_id not in self.allowed_devices:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
else:
|
||||
token = self.auth.generate_token(client_id, device_id)
|
||||
# NOTE: use websocket_port here
|
||||
return_json["websocket"] = {
|
||||
"url": self._get_websocket_url(local_ip, websocket_port),
|
||||
"token": token,
|
||||
}
|
||||
self.logger.bind(tag=TAG).info(
|
||||
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置"
|
||||
)
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发MQTT网关配置: {mqtt_gateway_endpoint}")
|
||||
|
||||
# 记录最终下发的协议
|
||||
protocols = ["websocket"]
|
||||
if "mqtt" in return_json:
|
||||
protocols.append("mqtt")
|
||||
self.logger.bind(tag=TAG).info(f"为设备 {device_id} 下发协议配置: {', '.join(protocols)}")
|
||||
|
||||
# Now check firmware files for updates
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user