feat: add optional native mqtt and udp transport

This commit is contained in:
caixypromise
2026-07-27 02:10:55 +08:00
parent 0c582ed3b6
commit eac573706d
35 changed files with 6325 additions and 200 deletions
@@ -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})
+118 -54
View File
@@ -1,8 +1,6 @@
import json
import time
import base64
import hashlib
import hmac
import os
import re
import glob
@@ -11,6 +9,11 @@ from aiohttp import web
from core.auth import AuthManager
from core.utils.util import get_local_ip, get_vision_url
from core.utils.mqtt_auth import (
generate_password_signature,
normalize_signature_key,
parse_mqtt_endpoint,
)
from core.api.base_handler import BaseHandler
TAG = __name__
@@ -102,26 +105,6 @@ class OTAHandler(BaseHandler):
self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
# keep previous cache if any
def generate_password_signature(self, content: str, secret_key: str) -> str:
"""生成MQTT密码签名
Args:
content: 签名内容 (clientId + '|' + username)
secret_key: 密钥
Returns:
str: Base64编码的HMAC-SHA256签名
"""
try:
hmac_obj = hmac.new(
secret_key.encode("utf-8"), content.encode("utf-8"), hashlib.sha256
)
signature = hmac_obj.digest()
return base64.b64encode(signature).decode("utf-8")
except Exception as e:
self.logger.bind(tag=TAG).error(f"生成MQTT密码签名失败: {e}")
return ""
def _get_websocket_url(self, local_ip: str, port: int) -> str:
"""获取websocket地址
@@ -231,11 +214,32 @@ class OTAHandler(BaseHandler):
},
}
# existing mqtt/websocket logic (unchanged)
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
# ========== 协议下发逻辑 ==========
# 按照原版逻辑:总是下发 WebSocket,如果启用了 MQTT 则额外下发 MQTT 和 UDP
# 这样设备有回退能力:如果 MQTT 连接失败,还可以使用 WebSocket
if mqtt_gateway_endpoint: # 如果配置了非空字符串
# 尝试从请求数据中获取设备型号(已解析 above)
mqtt_server_config = self.config.get("mqtt_server", {})
enabled_protocols = self.config.get("enabled_protocols")
if isinstance(enabled_protocols, list):
mqtt_protocol_enabled = "mqtt" in enabled_protocols
else:
protocol_config = self.config.get("protocols", {})
requested_protocols = protocol_config.get(
"enabled_protocols", []
)
mqtt_protocol_enabled = (
protocol_config.get("mqtt_enabled") is True
or "mqtt" in requested_protocols
)
mqtt_server_enabled = bool(
mqtt_server_config.get("enabled") and mqtt_protocol_enabled
)
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
if not mqtt_gateway_endpoint or str(mqtt_gateway_endpoint).lower() == "null":
mqtt_gateway_endpoint = None
# 生成通用的 MQTT 凭证信息
def _build_mqtt_credentials():
try:
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
except Exception as e:
@@ -246,56 +250,116 @@ class OTAHandler(BaseHandler):
mqtt_client_id = f"{group_id}@@@{mac_address_safe}@@@{mac_address_safe}"
# 构建用户数据
user_data = {"ip": "unknown"}
user_data = {"ip": local_ip}
try:
user_data_json = json.dumps(user_data)
username = base64.b64encode(user_data_json.encode("utf-8")).decode(
"utf-8"
)
username = base64.b64encode(user_data_json.encode("utf-8")).decode("utf-8")
except Exception as e:
self.logger.bind(tag=TAG).error(f"生成用户名失败: {e}")
username = ""
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: