mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
979 lines
42 KiB
Python
979 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import random
|
|
import re
|
|
import secrets
|
|
import uuid
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
from redis.asyncio import Redis
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.database import get_session_factory
|
|
from app.core.errors import AppError
|
|
from app.core.redis import JavaRedisCodec, get_redis
|
|
from app.core.security import AuthUser, shanghai_now_naive
|
|
from app.integrations.mqtt_gateway import post_json
|
|
from app.repositories.device import DeviceRepository
|
|
from app.schemas.device import DeviceManualAddRequest, DeviceReportRequest, DeviceUpdateRequest, OtaRecord
|
|
from app.services.system_params import SystemParamService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_TTL_SECONDS = 24 * 60 * 60
|
|
INVALID_FIRMWARE_URL = (
|
|
"http://xiaozhi.server.com:8002/xiaozhi/otaMag/download/NOT_ACTIVATED_FIRMWARE_THIS_IS_A_INVALID_URL"
|
|
)
|
|
MAC_PATTERN = re.compile(r"^([0-9A-Za-z]{2}[:-]){5}([0-9A-Za-z]{2})$")
|
|
OTA_ORDER_COLUMNS = {
|
|
"id": "id",
|
|
"firmwareName": "firmware_name",
|
|
"firmware_name": "firmware_name",
|
|
"type": "type",
|
|
"version": "version",
|
|
"size": "size",
|
|
"sort": "sort",
|
|
"updateDate": "update_date",
|
|
"update_date": "update_date",
|
|
"createDate": "create_date",
|
|
"create_date": "create_date",
|
|
}
|
|
|
|
|
|
def is_blank(value: str | None) -> bool:
|
|
return value is None or not value.strip()
|
|
|
|
|
|
def _java_semicolon_split(value: str) -> list[str]:
|
|
parts = value.split(";")
|
|
while parts and parts[-1] == "":
|
|
parts.pop()
|
|
return parts
|
|
|
|
|
|
def _mapping(value: Any) -> dict[str, Any] | None:
|
|
if isinstance(value, dict):
|
|
if "@class" in value:
|
|
return {str(key): item for key, item in value.items() if key != "@class"}
|
|
return {str(key): item for key, item in value.items()}
|
|
if isinstance(value, list) and len(value) == 2 and isinstance(value[1], dict):
|
|
return {str(key): item for key, item in value[1].items()}
|
|
return None
|
|
|
|
|
|
async def redis_get(key: str, client: Redis | None = None) -> Any:
|
|
selected = client or get_redis()
|
|
raw = await cast(Any, selected.get(key))
|
|
return JavaRedisCodec.decode(raw)
|
|
|
|
|
|
async def redis_set(key: str, value: Any, *, ttl: int = DEFAULT_TTL_SECONDS, client: Redis | None = None) -> None:
|
|
selected = client or get_redis()
|
|
await cast(Any, selected.set(key, JavaRedisCodec.encode(value), ex=ttl))
|
|
|
|
|
|
async def redis_delete(*keys: str, client: Redis | None = None) -> None:
|
|
if not keys:
|
|
return
|
|
selected = client or get_redis()
|
|
await cast(Any, selected.delete(*keys))
|
|
|
|
|
|
async def redis_increment(key: str, *, ttl: int = DEFAULT_TTL_SECONDS, client: Redis | None = None) -> int:
|
|
selected = client or get_redis()
|
|
value = int(await cast(Any, selected.incr(key)))
|
|
await cast(Any, selected.expire(key, ttl))
|
|
return value
|
|
|
|
|
|
class DeviceService:
|
|
def __init__(
|
|
self,
|
|
session: AsyncSession,
|
|
*,
|
|
redis_client: Redis | None = None,
|
|
http_client: httpx.AsyncClient | None = None,
|
|
):
|
|
self.session = session
|
|
self.repository = DeviceRepository(session)
|
|
self.params = SystemParamService(session)
|
|
self.redis = redis_client
|
|
self.http_client = http_client
|
|
|
|
async def register_device(self, mac_address: str) -> str:
|
|
while True:
|
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
|
key = f"sys:device:captcha:{code}"
|
|
if is_blank(cast(str | None, await redis_get(key, self.redis))):
|
|
await redis_set(key, mac_address, client=self.redis)
|
|
return code
|
|
|
|
async def activate_bound_device(self, *, agent_id: str, activation_code: str, user: AuthUser) -> None:
|
|
if is_blank(activation_code):
|
|
raise AppError(10061)
|
|
code_key = f"ota:activation:code:{activation_code}"
|
|
device_id_value = await redis_get(code_key, self.redis)
|
|
if device_id_value in (None, ""):
|
|
raise AppError(10062)
|
|
device_id = str(device_id_value)
|
|
safe_device_id = device_id.replace(":", "_").lower()
|
|
data_key = f"ota:activation:data:{safe_device_id}"
|
|
cached = _mapping(await redis_get(data_key, self.redis))
|
|
if cached is None or str(cached.get("activation_code") or "") != activation_code:
|
|
raise AppError(10062)
|
|
if await self.repository.get_device(device_id) is not None:
|
|
raise AppError(10063)
|
|
|
|
now = shanghai_now_naive()
|
|
values = {
|
|
"id": device_id,
|
|
"user_id": user.id,
|
|
"mac_address": cached.get("mac_address"),
|
|
"last_connected_at": now,
|
|
"auto_update": 1,
|
|
"board": cached.get("board"),
|
|
"alias": None,
|
|
"agent_id": agent_id,
|
|
"app_version": cached.get("app_version"),
|
|
"sort": None,
|
|
"updater": user.id,
|
|
"update_date": now,
|
|
"creator": user.id,
|
|
"create_date": now,
|
|
}
|
|
try:
|
|
await self.repository.insert_device(values)
|
|
await self.session.commit()
|
|
except Exception:
|
|
await self.session.rollback()
|
|
raise
|
|
await redis_delete(data_key, code_key, f"agent:device:count:{agent_id}", client=self.redis)
|
|
|
|
async def list_user_devices(self, user_id: int, agent_id: str) -> list[dict[str, Any]]:
|
|
devices = await self.repository.get_user_devices(user_id, agent_id)
|
|
return [self._user_device_view(row) for row in devices]
|
|
|
|
async def get_online_data(self, agent_id: str, user: AuthUser) -> str:
|
|
gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True)
|
|
if is_blank(gateway) or gateway == "null":
|
|
return ""
|
|
devices = await self.repository.get_user_devices(user.id, agent_id)
|
|
client_ids = {
|
|
self._mqtt_client_id(
|
|
str(device.get("board") or "GID_default"),
|
|
str(device.get("mac_address") or "unknown"),
|
|
)
|
|
for device in devices
|
|
}
|
|
if not client_ids:
|
|
return ""
|
|
signature_key = await self.params.get_value("server.mqtt_signature_key", from_cache=False)
|
|
return await post_json(
|
|
f"http://{gateway}/api/devices/status",
|
|
{"clientIds": sorted(client_ids)},
|
|
signature_key or "",
|
|
timeout_seconds=get_settings().external_request_timeout_seconds,
|
|
client=self.http_client,
|
|
)
|
|
|
|
async def unbind(self, *, user_id: int, device_id: str) -> None:
|
|
device = await self.repository.get_device(device_id)
|
|
if device is None:
|
|
return
|
|
mac_address = device.get("mac_address")
|
|
agent_id = device.get("agent_id")
|
|
if not is_blank(None if agent_id is None else str(agent_id)):
|
|
await redis_delete(f"agent:device:count:{agent_id}", client=self.redis)
|
|
try:
|
|
await self.repository.delete_device_for_user(device_id, user_id)
|
|
await self.session.commit()
|
|
except Exception:
|
|
await self.session.rollback()
|
|
raise
|
|
try:
|
|
if mac_address is not None:
|
|
await self.repository.delete_address_books_for_macs([str(mac_address)])
|
|
await self.session.commit()
|
|
except Exception:
|
|
await self.session.rollback()
|
|
raise
|
|
await self.refresh_address_book_cache()
|
|
|
|
async def update_device(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
request: DeviceUpdateRequest,
|
|
user: AuthUser,
|
|
) -> bool:
|
|
device = await self.repository.get_device(device_id)
|
|
if device is None or int(device.get("user_id") or -1) != user.id:
|
|
return False
|
|
await self.repository.update_device_info(
|
|
device_id,
|
|
auto_update=request.auto_update,
|
|
alias=request.alias,
|
|
updater=user.id,
|
|
now=shanghai_now_naive(),
|
|
)
|
|
await self.session.commit()
|
|
return True
|
|
|
|
async def manual_add(self, *, request: DeviceManualAddRequest, user: AuthUser) -> None:
|
|
mac_address = request.mac_address
|
|
if mac_address is not None and await self.repository.get_device_by_mac(mac_address) is not None:
|
|
raise AppError(10161)
|
|
now = shanghai_now_naive()
|
|
values = {
|
|
"id": uuid.uuid4().hex if mac_address in (None, "") else mac_address,
|
|
"user_id": user.id,
|
|
"mac_address": mac_address,
|
|
"last_connected_at": now,
|
|
"auto_update": 1,
|
|
"board": request.board,
|
|
"alias": None,
|
|
"agent_id": request.agent_id,
|
|
"app_version": request.app_version,
|
|
"sort": None,
|
|
"updater": user.id,
|
|
"update_date": now,
|
|
"creator": user.id,
|
|
"create_date": now,
|
|
}
|
|
try:
|
|
await self.repository.insert_device(values)
|
|
await self.session.commit()
|
|
except Exception:
|
|
await self.session.rollback()
|
|
raise
|
|
agent_cache_id = "null" if request.agent_id is None else request.agent_id
|
|
await redis_delete(f"agent:device:count:{agent_cache_id}", client=self.redis)
|
|
|
|
async def get_tools(self, *, device_id: str, user: AuthUser) -> dict[str, Any] | None:
|
|
gateway_and_device = await self._gateway_device(device_id, user)
|
|
if gateway_and_device is None:
|
|
return None
|
|
gateway, device = gateway_and_device
|
|
client_id = self._mqtt_client_id(
|
|
str(device.get("board") or "GID_default"),
|
|
str(device.get("mac_address") or "unknown"),
|
|
)
|
|
url = f"http://{gateway}/api/commands/{client_id}"
|
|
all_tools: list[Any] = []
|
|
cursor: str | None = None
|
|
while True:
|
|
params: dict[str, Any] = {"withUserTools": True}
|
|
if cursor is not None and cursor.strip():
|
|
params["cursor"] = cursor
|
|
body = {
|
|
"type": "mcp",
|
|
"payload": {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": params},
|
|
}
|
|
response_body = await self._post_gateway(url, body)
|
|
if is_blank(response_body):
|
|
break
|
|
payload = json.loads(response_body)
|
|
if not isinstance(payload, dict) or not bool(payload.get("success", False)):
|
|
break
|
|
data = payload.get("data")
|
|
if not isinstance(data, dict):
|
|
break
|
|
tools = data.get("tools")
|
|
if isinstance(tools, list):
|
|
all_tools.extend(tools)
|
|
next_cursor = data.get("nextCursor")
|
|
if not isinstance(next_cursor, str) or not next_cursor.strip():
|
|
break
|
|
cursor = next_cursor
|
|
return None if not all_tools else {"tools": all_tools}
|
|
|
|
async def call_tool(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
tool_name: str,
|
|
arguments: dict[str, Any] | None,
|
|
user: AuthUser,
|
|
) -> Any:
|
|
gateway_and_device = await self._gateway_device(device_id, user)
|
|
if gateway_and_device is None:
|
|
return None
|
|
gateway, device = gateway_and_device
|
|
client_id = self._mqtt_client_id(
|
|
str(device.get("board") or "GID_default"),
|
|
str(device.get("mac_address") or "unknown"),
|
|
)
|
|
response_body = await self._post_gateway(
|
|
f"http://{gateway}/api/commands/{client_id}",
|
|
{
|
|
"type": "mcp",
|
|
"payload": {
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {"name": tool_name, "arguments": arguments},
|
|
},
|
|
},
|
|
)
|
|
if is_blank(response_body):
|
|
return None
|
|
payload = json.loads(response_body)
|
|
if not isinstance(payload, dict) or not bool(payload.get("success", False)):
|
|
return None
|
|
data = payload.get("data")
|
|
content = data.get("content") if isinstance(data, dict) else None
|
|
if not isinstance(content, list) or not content or not isinstance(content[0], dict):
|
|
return None
|
|
first = content[0]
|
|
if first.get("type") != "text" or not isinstance(first.get("text"), str):
|
|
return None
|
|
text = str(first["text"])
|
|
if not text.strip():
|
|
return None
|
|
trimmed = text.strip()
|
|
if trimmed.startswith("{") or trimmed.startswith("["):
|
|
try:
|
|
parsed = json.loads(trimmed)
|
|
return parsed if isinstance(parsed, dict) else trimmed
|
|
except json.JSONDecodeError:
|
|
return trimmed
|
|
if trimmed == "true":
|
|
return True
|
|
if trimmed == "false":
|
|
return False
|
|
return trimmed
|
|
|
|
async def address_book(self, mac_address: str) -> list[dict[str, Any]]:
|
|
rows = await self.repository.get_address_book(mac_address)
|
|
for row in rows:
|
|
if row.get("has_permission") is not None:
|
|
row["has_permission"] = bool(row["has_permission"])
|
|
return rows
|
|
|
|
async def lookup_address_book(self, *, caller_mac: str, nickname: str) -> dict[str, str | None] | None:
|
|
books = await self.all_address_books()
|
|
caller_book = books.get(caller_mac.lower())
|
|
if caller_book is None:
|
|
return None
|
|
target_with_permission = caller_book.get(nickname)
|
|
if target_with_permission is None:
|
|
return None
|
|
parts = target_with_permission.split("|")
|
|
target_mac = parts[0]
|
|
has_permission = len(parts) > 1 and parts[1] == "1"
|
|
target_book = books.get(target_mac.lower())
|
|
if target_book is None:
|
|
return None
|
|
caller_nickname = target_book.get(caller_mac.lower())
|
|
return {
|
|
"targetMac": target_mac,
|
|
"callerNickname": caller_nickname,
|
|
"hasPermission": "true" if has_permission else "false",
|
|
}
|
|
|
|
async def call_by_nickname(self, *, caller_mac: str, nickname: str, answer: bool) -> dict[str, Any]:
|
|
books = await self.all_address_books()
|
|
if answer:
|
|
return await self._post_call("/api/call/accept", {"mac": caller_mac}, "接听")
|
|
caller_book = books.get(caller_mac.lower())
|
|
if caller_book is None or nickname not in caller_book:
|
|
return {"status": "error", "message": f"未找到备注为'{nickname}'的设备"}
|
|
parts = caller_book[nickname].split("|")
|
|
target_mac = parts[0]
|
|
allowed = len(parts) > 1 and parts[1] == "1"
|
|
if not allowed:
|
|
return {"status": "error", "message": "呼叫失败,您没有权限呼叫该设备"}
|
|
target_book = books.get(target_mac.lower())
|
|
caller_nickname = target_book.get(caller_mac.lower()) if target_book is not None else None
|
|
if is_blank(caller_nickname):
|
|
caller = await self.repository.get_device_by_mac(caller_mac)
|
|
if caller is None:
|
|
raise RuntimeError("caller device does not exist")
|
|
caller_nickname = None if caller.get("alias") is None else str(caller["alias"])
|
|
if is_blank(caller_nickname):
|
|
caller_nickname = self._mac_device_name(caller_mac)
|
|
return await self._post_call(
|
|
"/api/call/request",
|
|
{"caller_mac": caller_mac, "target_mac": target_mac, "caller_nickname": caller_nickname},
|
|
"呼叫",
|
|
)
|
|
|
|
async def save_address_book(
|
|
self,
|
|
*,
|
|
mac_address: str,
|
|
target_mac: str,
|
|
alias: str | None,
|
|
has_permission: bool | None,
|
|
actor: int,
|
|
) -> None:
|
|
record = await self.repository.get_address_book_record(mac_address, target_mac)
|
|
now = shanghai_now_naive()
|
|
if record is None:
|
|
final_alias = alias
|
|
if is_blank(final_alias):
|
|
target = await self.repository.get_device_by_mac(target_mac)
|
|
if target is None:
|
|
raise RuntimeError("target device does not exist")
|
|
final_alias = None if target.get("alias") is None else str(target["alias"])
|
|
final_alias = await self._unique_alias(mac_address, final_alias)
|
|
await self.repository.insert_address_book(
|
|
mac_address=mac_address,
|
|
target_mac=target_mac,
|
|
alias=final_alias,
|
|
has_permission=has_permission,
|
|
actor=actor,
|
|
now=now,
|
|
)
|
|
await self.session.commit()
|
|
else:
|
|
if alias is not None:
|
|
await self.repository.update_address_alias(
|
|
mac_address,
|
|
target_mac,
|
|
await self._unique_alias(mac_address, alias),
|
|
now=now,
|
|
)
|
|
await self.session.commit()
|
|
await self.refresh_address_book_cache()
|
|
if has_permission is not None:
|
|
await self.repository.update_address_permission(
|
|
mac_address,
|
|
target_mac,
|
|
has_permission,
|
|
now=now,
|
|
)
|
|
await self.session.commit()
|
|
await self.refresh_address_book_cache()
|
|
|
|
async def all_address_books(self) -> dict[str, dict[str, str]]:
|
|
cached = _mapping(await redis_get("device:address_book:all", self.redis))
|
|
if cached is not None:
|
|
result: dict[str, dict[str, str]] = {}
|
|
for key, value in cached.items():
|
|
nested = _mapping(value)
|
|
if nested is not None:
|
|
result[key] = {str(field): str(item) for field, item in nested.items()}
|
|
return result
|
|
return await self.refresh_address_book_cache()
|
|
|
|
async def refresh_address_book_cache(self) -> dict[str, dict[str, str]]:
|
|
records = await self.repository.get_all_address_book()
|
|
result: dict[str, dict[str, str]] = {}
|
|
reverse: dict[str, str] = {}
|
|
for record in records:
|
|
mac_a = str(record["mac_address"]).lower()
|
|
mac_b = str(record["target_mac"]).lower()
|
|
alias = record.get("alias")
|
|
if alias not in (None, ""):
|
|
alias_string = str(alias)
|
|
result.setdefault(mac_a, {})[alias_string] = (
|
|
f"{mac_b}|{'1' if bool(record.get('has_permission')) else '0'}"
|
|
)
|
|
reverse[f"{mac_b}:{mac_a}"] = alias_string
|
|
for record in records:
|
|
mac_a = str(record["mac_address"]).lower()
|
|
mac_b = str(record["target_mac"]).lower()
|
|
reverse_alias = reverse.get(f"{mac_a}:{mac_b}")
|
|
if isinstance(reverse_alias, str) and reverse_alias:
|
|
result.setdefault(mac_b, {})[mac_a] = reverse_alias
|
|
await redis_set("device:address_book:all", result, client=self.redis)
|
|
return result
|
|
|
|
async def check_ota(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
client_id: str,
|
|
report: DeviceReportRequest,
|
|
request_url: str,
|
|
client_ip: str,
|
|
defer_connection_update: Callable[[str, str | None, str | None], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
now = datetime.now(tz=ZoneInfo(get_settings().timezone))
|
|
utc_offset = now.utcoffset()
|
|
response: dict[str, Any] = {
|
|
"server_time": {
|
|
"timestamp": int(now.timestamp() * 1000),
|
|
"timeZone": get_settings().timezone,
|
|
"timezone_offset": int((utc_offset.total_seconds() if utc_offset is not None else 0) / 60),
|
|
},
|
|
"activation": None,
|
|
"error": None,
|
|
"firmware": None,
|
|
"websocket": None,
|
|
"mqtt": None,
|
|
}
|
|
device = await self.repository.get_device_by_mac(device_id)
|
|
if device is None:
|
|
if report.application is None:
|
|
raise RuntimeError("application is required")
|
|
response["firmware"] = {
|
|
"version": report.application.version,
|
|
"url": INVALID_FIRMWARE_URL,
|
|
}
|
|
elif device.get("auto_update") is None:
|
|
raise RuntimeError("auto_update is null")
|
|
elif int(device["auto_update"]) != 0:
|
|
ota_type = report.board.type if report.board is not None else None
|
|
current_version = report.application.version if report.application is not None else None
|
|
response["firmware"] = await self._firmware_info(ota_type, current_version, request_url)
|
|
|
|
websocket_url = await self.params.get_value("server.websocket", from_cache=True)
|
|
auth_enabled = await self.params.get_value("server.auth.enabled", from_cache=True)
|
|
websocket_token = ""
|
|
if (auth_enabled or "").lower() == "true":
|
|
try:
|
|
websocket_token = await self._websocket_token(client_id, device_id)
|
|
except Exception:
|
|
logger.exception("WebSocket token generation failed")
|
|
if is_blank(websocket_url) or websocket_url == "null":
|
|
selected_websocket = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"
|
|
else:
|
|
websocket_urls = _java_semicolon_split(websocket_url or "")
|
|
selected_websocket = (
|
|
random.choice(websocket_urls) # noqa: S311
|
|
if websocket_urls
|
|
else "ws://xiaozhi.server.com:8000/xiaozhi/v1/"
|
|
)
|
|
response["websocket"] = {"url": selected_websocket, "token": websocket_token}
|
|
|
|
mqtt_endpoint = await self.params.get_value("server.mqtt_gateway", from_cache=True)
|
|
if mqtt_endpoint not in (None, "", "null"):
|
|
try:
|
|
group_id = str(device.get("board") or "GID_default") if device is not None else "GID_default"
|
|
mqtt = await self._mqtt_config(device_id, group_id, client_ip)
|
|
if mqtt is not None:
|
|
mqtt["endpoint"] = mqtt_endpoint
|
|
response["mqtt"] = mqtt
|
|
except Exception:
|
|
logger.exception("MQTT credential generation failed")
|
|
|
|
if device is None:
|
|
response["activation"] = await self._activation(device_id, report)
|
|
else:
|
|
app_version = report.application.version if report.application is not None else None
|
|
agent_id = device.get("agent_id")
|
|
normalized_agent_id = None if agent_id is None else str(agent_id)
|
|
if defer_connection_update is not None:
|
|
defer_connection_update(str(device["id"]), normalized_agent_id, app_version)
|
|
else:
|
|
try:
|
|
await self._persist_connection_update(
|
|
str(device["id"]),
|
|
normalized_agent_id,
|
|
app_version,
|
|
)
|
|
except Exception:
|
|
logger.exception("Asynchronous device connection update failed")
|
|
return cast(dict[str, Any], self._drop_none(response))
|
|
|
|
async def _persist_connection_update(
|
|
self,
|
|
device_id: str,
|
|
agent_id: str | None,
|
|
app_version: str | None,
|
|
) -> None:
|
|
connection_time = shanghai_now_naive()
|
|
try:
|
|
await self.repository.update_connection(device_id, app_version=app_version, now=connection_time)
|
|
await self.session.commit()
|
|
except Exception:
|
|
await self.session.rollback()
|
|
raise
|
|
if not is_blank(agent_id):
|
|
await redis_set(f"agent:device:lastConnected:{agent_id}", connection_time, client=self.redis)
|
|
|
|
@staticmethod
|
|
async def persist_connection_update_background(
|
|
device_id: str,
|
|
agent_id: str | None,
|
|
app_version: str | None,
|
|
) -> None:
|
|
try:
|
|
async with get_session_factory()() as session:
|
|
await DeviceService(session)._persist_connection_update(device_id, agent_id, app_version)
|
|
except Exception:
|
|
logger.exception("Asynchronous device connection update failed")
|
|
|
|
async def ota_health_text(self) -> str:
|
|
mqtt_gateway = await self.params.get_value("server.mqtt_gateway", from_cache=False)
|
|
if is_blank(mqtt_gateway):
|
|
return "OTA接口不正常,缺少mqtt_gateway地址,请登录智控台,在参数管理找到【server.mqtt_gateway】配置"
|
|
websocket = await self.params.get_value("server.websocket", from_cache=True)
|
|
if is_blank(websocket) or websocket == "null":
|
|
return "OTA接口不正常,缺少websocket地址,请登录智控台,在参数管理找到【server.websocket】配置"
|
|
ota_url = await self.params.get_value("server.ota", from_cache=True)
|
|
if is_blank(ota_url) or ota_url == "null":
|
|
return "OTA接口不正常,缺少ota地址,请登录智控台,在参数管理找到【server.ota】配置"
|
|
return f"OTA接口运行正常,websocket集群数量:{len(_java_semicolon_split(websocket or ''))}"
|
|
|
|
async def ota_page(self, query: Mapping[str, Any]) -> dict[str, Any]:
|
|
page = self._positive_int(query.get("page"), 1)
|
|
limit = self._positive_int(query.get("limit"), 10)
|
|
requested = query.get("orderField")
|
|
requested_fields = [requested] if isinstance(requested, str) else list(requested or [])
|
|
fields = [OTA_ORDER_COLUMNS[field] for field in requested_fields if field in OTA_ORDER_COLUMNS]
|
|
if not fields:
|
|
fields = ["update_date"]
|
|
ascending = str(query.get("order") or "").lower() == "asc" if requested_fields else True
|
|
firmware_name = query.get("firmwareName")
|
|
name = str(firmware_name) if firmware_name is not None else None
|
|
rows = await self.repository.list_ota(
|
|
page=page,
|
|
limit=limit,
|
|
firmware_name=name,
|
|
order_fields=fields,
|
|
ascending=ascending,
|
|
)
|
|
rows = [self._ota_response_record(row) for row in rows]
|
|
return {"total": await self.repository.count_ota(name), "list": rows}
|
|
|
|
async def get_ota_record(self, ota_id: str) -> dict[str, Any] | None:
|
|
row = await self.repository.get_ota(ota_id)
|
|
return None if row is None else self._ota_response_record(row)
|
|
|
|
async def save_ota(self, record: OtaRecord, user: AuthUser) -> None:
|
|
values = record.model_dump(by_alias=False)
|
|
existing = await self.repository.get_first_ota_by_type(record.type or "")
|
|
now = shanghai_now_naive()
|
|
if existing is not None:
|
|
values["updater"] = record.updater if record.updater is not None else user.id
|
|
values["update_date"] = record.update_date if record.update_date is not None else now
|
|
await self.repository.update_ota(str(existing["id"]), values)
|
|
else:
|
|
values["id"] = record.id or uuid.uuid4().hex
|
|
values["creator"] = record.creator if record.creator is not None else user.id
|
|
values["updater"] = record.updater if record.updater is not None else user.id
|
|
values["create_date"] = record.create_date if record.create_date is not None else now
|
|
values["update_date"] = record.update_date if record.update_date is not None else now
|
|
await self.repository.insert_ota(values)
|
|
await self.session.commit()
|
|
|
|
async def update_ota(self, ota_id: str, record: OtaRecord, user: AuthUser) -> None:
|
|
if await self.repository.count_duplicate_ota(
|
|
ota_id=ota_id,
|
|
ota_type=record.type,
|
|
version=record.version,
|
|
):
|
|
raise RuntimeError("已存在相同类型和版本的固件,请修改后重试")
|
|
values = record.model_dump(by_alias=False)
|
|
values["updater"] = record.updater if record.updater is not None else user.id
|
|
values["update_date"] = shanghai_now_naive()
|
|
await self.repository.update_ota(ota_id, values)
|
|
await self.session.commit()
|
|
|
|
async def delete_ota(self, ids: Sequence[str]) -> None:
|
|
await self.repository.delete_ota(ids)
|
|
await self.session.commit()
|
|
|
|
async def create_ota_download_id(self, ota_id: str) -> str:
|
|
value = str(uuid.uuid4())
|
|
await redis_set(f"ota:id:{value}", ota_id, client=self.redis)
|
|
return value
|
|
|
|
async def resolve_ota_download(self, download_id: str) -> tuple[Path, str] | None:
|
|
id_key = f"ota:id:{download_id}"
|
|
ota_value = await redis_get(id_key, self.redis)
|
|
if is_blank(None if ota_value is None else str(ota_value)):
|
|
return None
|
|
count_key = f"ota:download:count:{download_id}"
|
|
count_value = await redis_get(count_key, self.redis)
|
|
count = int(count_value or 0)
|
|
if count >= 3:
|
|
await redis_delete(count_key, id_key, client=self.redis)
|
|
return None
|
|
await redis_set(count_key, count + 1, client=self.redis)
|
|
|
|
ota_id = str(ota_value)
|
|
if ota_id.startswith("file:"):
|
|
firmware_path = ota_id[5:]
|
|
ota_type = "assets"
|
|
version = "1.0.0"
|
|
else:
|
|
record = await self.repository.get_ota(ota_id)
|
|
firmware_value = None if record is None else record.get("firmware_path")
|
|
if record is None or is_blank(None if firmware_value is None else str(firmware_value)):
|
|
return None
|
|
firmware_path = str(record["firmware_path"])
|
|
ota_type = str(record.get("type"))
|
|
version = str(record.get("version"))
|
|
raw_path = Path(firmware_path)
|
|
candidates = [raw_path] if raw_path.is_absolute() else [Path.cwd() / raw_path]
|
|
if not raw_path.is_absolute() and raw_path.parts and raw_path.parts[0] == "uploadfile":
|
|
candidates.insert(0, get_settings().upload_dir.joinpath(*raw_path.parts[1:]))
|
|
candidates.append(Path.cwd() / "firmware" / raw_path.name)
|
|
resolved = next((candidate for candidate in candidates if candidate.is_file()), None)
|
|
if resolved is None:
|
|
return None
|
|
original_name = f"{ota_type}_{version}"
|
|
dot_index = firmware_path.rfind(".")
|
|
if dot_index >= 0:
|
|
original_name += firmware_path[dot_index:]
|
|
safe_name = re.sub(r"[^a-zA-Z0-9._-]", "_", original_name)
|
|
return resolved, safe_name
|
|
|
|
async def save_firmware_file(self, *, filename: str | None, content: bytes) -> str:
|
|
if not content:
|
|
raise ValueError("上传文件不能为空")
|
|
if filename is None:
|
|
raise ValueError("文件名不能为空")
|
|
dot_index = filename.rfind(".")
|
|
if dot_index < 0:
|
|
raise RuntimeError("文件名缺少扩展名")
|
|
extension = filename[dot_index:].lower()
|
|
if extension not in {".bin", ".apk"}:
|
|
raise ValueError("只允许上传.bin和.apk格式的文件")
|
|
digest = hashlib.md5(content, usedforsecurity=False).hexdigest()
|
|
directory = get_settings().upload_dir
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
filename_on_disk = f"{digest}{extension}"
|
|
physical_path = directory / filename_on_disk
|
|
if not physical_path.exists():
|
|
with physical_path.open("xb") as stream:
|
|
stream.write(content)
|
|
# Keep Java's database/API value stable even when the physical upload
|
|
# volume is mounted elsewhere (for example /data/uploads in Docker).
|
|
return str(Path("uploadfile") / filename_on_disk)
|
|
|
|
async def save_assets_file(self, *, filename: str | None, content: bytes, user: AuthUser) -> str:
|
|
ota_url = await self.params.get_value("server.ota", from_cache=True)
|
|
if is_blank(ota_url) or ota_url == "null":
|
|
raise AppError(10102)
|
|
if len(content) > 20 * 1024 * 1024:
|
|
raise AppError(10142)
|
|
if not user.is_super_admin:
|
|
count_key = f"ota:upload:count:{user.id}"
|
|
current = int(await redis_get(count_key, self.redis) or 0)
|
|
if current >= 50:
|
|
raise AppError(10195)
|
|
await redis_increment(count_key, client=self.redis)
|
|
path = await self.save_firmware_file(filename=filename, content=content)
|
|
download_id = await self.create_ota_download_id(f"file:{path}")
|
|
return (ota_url or "").replace("/ota/", "/otaMag/download/") + download_id
|
|
|
|
async def _gateway_device(self, device_id: str, user: AuthUser) -> tuple[str, dict[str, Any]] | None:
|
|
gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True)
|
|
if is_blank(gateway) or gateway == "null":
|
|
return None
|
|
device = await self.repository.get_device(device_id)
|
|
if device is None or int(device.get("user_id") or -1) != user.id:
|
|
return None
|
|
return gateway or "", device
|
|
|
|
async def _post_gateway(self, url: str, body: Any, *, timeout_seconds: float | None = None) -> str:
|
|
key = await self.params.get_value("server.mqtt_signature_key", from_cache=False)
|
|
return await post_json(
|
|
url,
|
|
body,
|
|
key or "",
|
|
timeout_seconds=timeout_seconds or get_settings().external_request_timeout_seconds,
|
|
client=self.http_client,
|
|
)
|
|
|
|
async def _post_call(self, path: str, body: dict[str, Any], action: str) -> dict[str, Any]:
|
|
gateway = await self.params.get_value("server.mqtt_manager_api", from_cache=True)
|
|
key = await self.params.get_value("server.mqtt_signature_key", from_cache=True)
|
|
if is_blank(gateway) or gateway == "null" or is_blank(key) or (key or "").strip().lower() == "null":
|
|
return {"status": "error", "message": f"{action}失败,网关配置缺失"}
|
|
result: dict[str, Any] = {"status": "error"}
|
|
try:
|
|
text = await post_json(
|
|
f"http://{gateway}{path}",
|
|
body,
|
|
key or "",
|
|
timeout_seconds=5.0,
|
|
client=self.http_client,
|
|
)
|
|
if text.strip():
|
|
payload = json.loads(text)
|
|
if isinstance(payload, dict):
|
|
result["status"] = payload.get("status")
|
|
result["message"] = payload.get("message")
|
|
return result
|
|
except Exception:
|
|
return {"status": "error", "message": f"{action}失败,请稍后再试"}
|
|
|
|
async def _firmware_info(
|
|
self,
|
|
ota_type: str | None,
|
|
current_version: str | None,
|
|
request_url: str,
|
|
) -> dict[str, Any] | None:
|
|
if is_blank(ota_type):
|
|
return None
|
|
selected_version = current_version if not is_blank(current_version) else "0.0.0"
|
|
ota = await self.repository.get_latest_ota(ota_type or "")
|
|
download_url: str | None = None
|
|
if ota is not None and self._compare_versions(ota.get("version"), selected_version) > 0:
|
|
ota_url = await self.params.get_value("server.ota", from_cache=True)
|
|
if is_blank(ota_url) or ota_url == "null":
|
|
ota_url = request_url
|
|
download_id = await self.create_ota_download_id(str(ota["id"]))
|
|
download_url = (ota_url or "").replace("/ota/", "/otaMag/download/") + download_id
|
|
return {
|
|
"version": selected_version if ota is None else ota.get("version"),
|
|
"url": download_url or INVALID_FIRMWARE_URL,
|
|
}
|
|
|
|
async def _activation(self, device_id: str, report: DeviceReportRequest) -> dict[str, Any]:
|
|
safe_device_id = device_id.replace(":", "_").lower()
|
|
data_key = f"ota:activation:data:{safe_device_id}"
|
|
cached = _mapping(await redis_get(data_key, self.redis))
|
|
code = str(cached.get("activation_code")) if cached and cached.get("activation_code") is not None else None
|
|
frontend = await self.params.get_value("server.fronted_url", from_cache=True)
|
|
if code is None or not code.strip():
|
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
|
board = (
|
|
report.board.type
|
|
if report.board is not None and report.board.type is not None
|
|
else (report.chip_model_name or "unknown")
|
|
)
|
|
app_version = report.application.version if report.application is not None else None
|
|
await redis_set(
|
|
data_key,
|
|
{
|
|
"id": device_id,
|
|
"mac_address": device_id,
|
|
"board": board,
|
|
"app_version": app_version,
|
|
"deviceId": device_id,
|
|
"activation_code": code,
|
|
},
|
|
client=self.redis,
|
|
)
|
|
await redis_set(f"ota:activation:code:{code}", device_id, client=self.redis)
|
|
return {
|
|
"code": code,
|
|
"message": f"{frontend if frontend is not None else 'null'}\n{code}",
|
|
"challenge": device_id,
|
|
}
|
|
|
|
async def _websocket_token(self, client_id: str, username: str) -> str:
|
|
secret = await self.params.get_value("server.secret", from_cache=False)
|
|
if is_blank(secret):
|
|
raise RuntimeError("WebSocket认证密钥未配置(server.secret)")
|
|
timestamp = int(datetime.now().timestamp())
|
|
message = f"{client_id}|{username}|{timestamp}".encode()
|
|
signature = hmac.new((secret or "").encode(), message, hashlib.sha256).digest()
|
|
encoded = base64.urlsafe_b64encode(signature).decode().rstrip("=")
|
|
return f"{encoded}.{timestamp}"
|
|
|
|
async def _mqtt_config(self, mac_address: str, group_id: str, client_ip: str) -> dict[str, Any] | None:
|
|
key = await self.params.get_value("server.mqtt_signature_key", from_cache=True)
|
|
if is_blank(key):
|
|
return None
|
|
client_id = self._mqtt_client_id(group_id, mac_address)
|
|
user_data = json.dumps({"ip": client_ip}, ensure_ascii=False, separators=(",", ":"))
|
|
username = base64.b64encode(user_data.encode()).decode()
|
|
password = base64.b64encode(
|
|
hmac.new((key or "").encode(), f"{client_id}|{username}".encode(), hashlib.sha256).digest()
|
|
).decode()
|
|
safe_mac = mac_address.replace(":", "_")
|
|
return {
|
|
"client_id": client_id,
|
|
"username": username,
|
|
"password": password,
|
|
"publish_topic": "device-server",
|
|
"subscribe_topic": f"devices/p2p/{safe_mac}",
|
|
}
|
|
|
|
@staticmethod
|
|
def _mqtt_client_id(group_id: str, mac_address: str) -> str:
|
|
safe_group = group_id.replace(":", "_")
|
|
safe_mac = mac_address.replace(":", "_")
|
|
return f"{safe_group}@@@{safe_mac}@@@{safe_mac}"
|
|
|
|
@staticmethod
|
|
def _compare_versions(first: Any, second: Any) -> int:
|
|
if first is None or second is None:
|
|
return 0
|
|
first = str(first)
|
|
second = str(second)
|
|
first_parts = first.split(".")
|
|
second_parts = second.split(".")
|
|
for index in range(max(len(first_parts), len(second_parts))):
|
|
first_value = int(first_parts[index]) if index < len(first_parts) else 0
|
|
second_value = int(second_parts[index]) if index < len(second_parts) else 0
|
|
if first_value != second_value:
|
|
return 1 if first_value > second_value else -1
|
|
return 0
|
|
|
|
async def _unique_alias(self, mac_address: str, alias: str | None) -> str | None:
|
|
existing = await self.repository.get_aliases(mac_address)
|
|
if alias not in existing:
|
|
return alias
|
|
suffix = 1
|
|
while f"{alias}{suffix}" in existing:
|
|
suffix += 1
|
|
return f"{alias}{suffix}"
|
|
|
|
@staticmethod
|
|
def _mac_device_name(mac: str) -> str:
|
|
return mac if len(mac) < 2 else f"尾号为{mac[-2:]}的设备"
|
|
|
|
@staticmethod
|
|
def _positive_int(value: Any, default: int) -> int:
|
|
if value is None:
|
|
return default
|
|
return int(str(value))
|
|
|
|
@staticmethod
|
|
def _drop_none(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
return {key: DeviceService._drop_none(item) for key, item in value.items() if item is not None}
|
|
if isinstance(value, list):
|
|
return [DeviceService._drop_none(item) for item in value]
|
|
return value
|
|
|
|
@staticmethod
|
|
def _user_device_view(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"app_version": row.get("app_version"),
|
|
"bind_user_name": None,
|
|
"device_type": row.get("board"),
|
|
"board": row.get("board"),
|
|
"id": row.get("id"),
|
|
"mac_address": row.get("mac_address"),
|
|
"alias": row.get("alias"),
|
|
"ota_upgrade": None,
|
|
"recent_chat_time": None,
|
|
"last_connected_at_timestamp": DeviceService._timestamp(row.get("last_connected_at")),
|
|
"create_date_timestamp": DeviceService._timestamp(row.get("create_date")),
|
|
# UserShowDeviceListVO pins only this field to UTC. The companion
|
|
# epoch value still uses the configured Asia/Shanghai instant.
|
|
"create_date": DeviceService._utc_datetime(row.get("create_date")),
|
|
}
|
|
|
|
@staticmethod
|
|
def _utc_datetime(value: Any) -> Any:
|
|
if not isinstance(value, datetime):
|
|
return value
|
|
localized = value.replace(tzinfo=ZoneInfo(get_settings().timezone)) if value.tzinfo is None else value
|
|
return localized.astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
@staticmethod
|
|
def _ota_response_record(row: Mapping[str, Any]) -> dict[str, Any]:
|
|
result = dict(row)
|
|
if result.get("size") is not None:
|
|
result["size"] = str(result["size"])
|
|
return result
|
|
|
|
@staticmethod
|
|
def _timestamp(value: Any) -> int | None:
|
|
if not isinstance(value, datetime):
|
|
return None
|
|
localized = value.replace(tzinfo=ZoneInfo(get_settings().timezone)) if value.tzinfo is None else value
|
|
return int(localized.timestamp() * 1000)
|