Add batch management APIs, API security, rate limiting, and optimizations

- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500),
  POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries
- Batch command: POST /api/commands/batch with model_validator mutual exclusion
- API key auth (X-API-Key header, secrets.compare_digest timing-safe)
- Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes)
- Real client IP extraction (X-Forwarded-For / CF-Connecting-IP)
- Global exception handler (no stack trace leaks, passes HTTPException through)
- CORS with auto-disable credentials on wildcard origins
- Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns
- Heartbeats router, per-ID endpoints for locations/attendance/bluetooth
- Input dedup in batch create, result ordering preserved
- Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion
- Device detail panel with feature toggles and command controls
- Side panel for location/beacon pages with auto-select active device

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-20 09:18:43 +00:00
parent 1bdbe4fa19
commit 7d6040af41
23 changed files with 1564 additions and 294 deletions

View File

@@ -1,14 +1,49 @@
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
# Project root directory (where config.py lives → parent = app/ → parent = project root)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"
class Settings(BaseSettings):
APP_NAME: str = "KKS Badge Management System"
DATABASE_URL: str = "sqlite+aiosqlite:///./badge_admin.db"
DATABASE_URL: str = Field(
default=f"sqlite+aiosqlite:///{_DEFAULT_DB_PATH}",
description="Database connection URL (absolute path for SQLite)",
)
TCP_HOST: str = "0.0.0.0"
TCP_PORT: int = 5000
TCP_PORT: int = Field(default=5000, ge=1, le=65535)
API_HOST: str = "0.0.0.0"
API_PORT: int = 8088
DEBUG: bool = True
API_PORT: int = Field(default=8088, ge=1, le=65535)
DEBUG: bool = Field(default=False, description="Enable debug mode (SQL echo, verbose errors)")
# API authentication
API_KEY: str | None = Field(default=None, description="API key for authentication (None=disabled)")
CORS_ORIGINS: str = Field(default="*", description="Comma-separated allowed CORS origins")
# Rate limiting
RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit")
RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations")
# Geocoding API keys
TIANDITU_API_KEY: str | None = Field(default=None, description="天地图 API key for reverse geocoding")
GOOGLE_API_KEY: str | None = Field(default=None, description="Google Geolocation API key")
UNWIRED_API_TOKEN: str | None = Field(default=None, description="Unwired Labs API token")
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
BAIDU_MAP_AK: str | None = Field(default=None, description="百度地图服务端 AK")
# Geocoding cache
GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries")
# Track query limit
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()

20
app/dependencies.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Shared FastAPI dependencies.
"""
import secrets
from fastapi import HTTPException, Security
from fastapi.security import APIKeyHeader
from app.config import settings
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def verify_api_key(api_key: str | None = Security(_api_key_header)):
"""Verify API key if authentication is enabled."""
if settings.API_KEY is None:
return # Auth disabled
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
raise HTTPException(status_code=401, detail="Invalid or missing API key")

22
app/extensions.py Normal file
View File

@@ -0,0 +1,22 @@
"""
Shared extension instances (rate limiter, etc.) to avoid circular imports.
"""
from starlette.requests import Request
from slowapi import Limiter
from app.config import settings
def _get_real_client_ip(request: Request) -> str:
"""Extract real client IP from X-Forwarded-For (behind Cloudflare/nginx) or fallback."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
cf_ip = request.headers.get("CF-Connecting-IP")
if cf_ip:
return cf_ip.strip()
return request.client.host if request.client else "127.0.0.1"
limiter = Limiter(key_func=_get_real_client_ip, default_limits=[settings.RATE_LIMIT_DEFAULT])

View File

@@ -10,7 +10,6 @@ Uses free APIs:
import json
import logging
import os
from collections import OrderedDict
from typing import Optional
from urllib.parse import quote
@@ -19,19 +18,16 @@ import aiohttp
logger = logging.getLogger(__name__)
# Google Geolocation API key (set to enable Google geocoding)
GOOGLE_API_KEY: Optional[str] = None
# Import keys from centralized config (no more hardcoded values here)
from app.config import settings as _settings
# Unwired Labs API token (free tier: 100 requests/day)
# Sign up at https://unwiredlabs.com/
UNWIRED_API_TOKEN: Optional[str] = None
GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY
UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN
TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY
BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK
# 天地图 API key (free tier: 10000 requests/day)
# Sign up at https://lbs.tianditu.gov.cn/
TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc")
# Maximum cache entries (LRU eviction)
_CACHE_MAX_SIZE = 10000
# Maximum cache entries (LRU eviction) — configurable via settings
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
class LRUCache(OrderedDict):
@@ -315,7 +311,8 @@ async def reverse_geocode(
"""
Convert lat/lon to a human-readable address.
Tries 天地图 (Tianditu) first, which uses WGS84 natively.
Priority: Baidu Map > Tianditu (fallback).
Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll).
Returns None if no reverse geocoding service is available.
"""
# Round to ~100m for cache key to reduce API calls
@@ -324,6 +321,14 @@ async def reverse_geocode(
if cached is not None:
return cached
# Try Baidu Map first (higher quality addresses for China)
if BAIDU_MAP_AK:
result = await _reverse_geocode_baidu(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
# Fallback to Tianditu
if TIANDITU_API_KEY:
result = await _reverse_geocode_tianditu(lat, lon)
if result:
@@ -333,6 +338,55 @@ async def reverse_geocode(
return None
async def _reverse_geocode_baidu(
lat: float, lon: float
) -> Optional[str]:
"""
Use Baidu Map reverse geocoding API.
API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding
Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed).
Free tier: 5,000 requests/day (personal developer).
"""
url = (
f"https://api.map.baidu.com/reverse_geocoding/v3/"
f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll"
f"&location={lat},{lon}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == 0:
result = data.get("result", {})
formatted = result.get("formatted_address", "")
if formatted:
# Add sematic_description for more context
sematic = result.get("sematic_description", "")
address = formatted
if sematic and sematic not in formatted:
address = f"{formatted} ({sematic})"
logger.info(
"Baidu reverse geocode: %.6f,%.6f -> %s",
lat, lon, address,
)
return address
else:
logger.warning(
"Baidu reverse geocode error: status=%s, msg=%s",
data.get("status"), data.get("message", ""),
)
else:
logger.warning("Baidu reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Baidu reverse geocode error: %s", e)
return None
async def _reverse_geocode_tianditu(
lat: float, lon: float
) -> Optional[str]:

View File

@@ -1,19 +1,31 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.database import init_db, async_session
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from app.database import init_db, async_session, engine
from app.tcp_server import tcp_manager
from app.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats
from app.dependencies import verify_api_key
import asyncio
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Rate limiter (shared instance via app.extensions for router access)
from app.extensions import limiter
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
@@ -40,7 +52,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="KKS Badge Management System / KKS工牌管理系统",
description="""
## KKS P240 & P241 蓝牙工牌管理后台
## KKS P240 & P241 蓝牙工牌管理后台 API
### 功能模块 / Features:
- **设备管理 / Device Management** - 设备注册、状态监控
@@ -50,6 +62,10 @@ app = FastAPI(
- **指令管理 / Commands** - 远程指令下发与留言
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
- **心跳数据 / Heartbeats** - 设备心跳记录查询
### 认证 / Authentication:
设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。
### 通讯协议 / Protocol:
- TCP端口: {tcp_port} (设备连接)
@@ -61,14 +77,48 @@ app = FastAPI(
lifespan=lifespan,
)
# Include routers
app.include_router(devices.router)
app.include_router(locations.router)
app.include_router(alarms.router)
app.include_router(attendance.router)
app.include_router(commands.router)
app.include_router(bluetooth.router)
app.include_router(beacons.router)
# Rate limiter — SlowAPIMiddleware applies default_limits to all routes
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials="*" not in _origins,
allow_methods=["*"],
allow_headers=["*"],
)
# Global exception handler — prevent stack trace leaks
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
# Let FastAPI handle its own exceptions
if isinstance(exc, (StarletteHTTPException, RequestValidationError)):
raise exc
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={"code": 500, "message": "Internal server error", "data": None},
)
# Include API routers (all under /api/ prefix, protected by API key if configured)
_api_deps = [verify_api_key] if settings.API_KEY else []
app.include_router(devices.router, dependencies=[*_api_deps])
app.include_router(locations.router, dependencies=[*_api_deps])
app.include_router(alarms.router, dependencies=[*_api_deps])
app.include_router(attendance.router, dependencies=[*_api_deps])
app.include_router(commands.router, dependencies=[*_api_deps])
app.include_router(bluetooth.router, dependencies=[*_api_deps])
app.include_router(beacons.router, dependencies=[*_api_deps])
app.include_router(heartbeats.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -90,11 +140,25 @@ async def root():
"redoc": "/redoc",
"admin": "/admin",
"tcp_port": settings.TCP_PORT,
"auth_enabled": settings.API_KEY is not None,
}
@app.get("/health", tags=["Root"])
async def health():
"""Health check with database connectivity test."""
db_ok = False
try:
from sqlalchemy import text
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
logger.warning("Health check: database unreachable")
status = "healthy" if db_ok else "degraded"
return {
"status": "healthy",
"status": status,
"database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections),
}

View File

@@ -136,6 +136,57 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec
})
# ---------------------------------------------------------------------------
# Device Defaults
# ---------------------------------------------------------------------------
DEFAULT_DEVICE_TYPE: str = "P240"
# ---------------------------------------------------------------------------
# Language Codes (used in 0x80 / 0x82 packets)
# ---------------------------------------------------------------------------
LANG_CHINESE: int = 0x0001
LANG_ENGLISH: int = 0x0002
DEFAULT_LANGUAGE: int = LANG_CHINESE
DEFAULT_LANGUAGE_BYTES: bytes = b"\x00\x01"
# Server flag placeholder (4 bytes, used in command/message packets)
SERVER_FLAG_BYTES: bytes = b"\x00\x00\x00\x00"
# ---------------------------------------------------------------------------
# Attendance Status (from terminal_info byte, bits[5:2])
# ---------------------------------------------------------------------------
ATTENDANCE_STATUS_SHIFT: int = 2
ATTENDANCE_STATUS_MASK: int = 0x0F
ATTENDANCE_TYPES: Dict[int, str] = {
0b0001: "clock_in",
0b0010: "clock_out",
}
# ---------------------------------------------------------------------------
# GPS Course/Status Bit Fields
# ---------------------------------------------------------------------------
COURSE_BIT_REALTIME: int = 0x2000 # bit 13
COURSE_BIT_POSITIONED: int = 0x1000 # bit 12
COURSE_BIT_EAST: int = 0x0800 # bit 11
COURSE_BIT_NORTH: int = 0x0400 # bit 10
COURSE_MASK: int = 0x03FF # bits 9-0
# MCC high-bit flag: if set, MNC is 2 bytes instead of 1
MCC_MNC2_FLAG: int = 0x8000
# ---------------------------------------------------------------------------
# Voltage Levels (0x00-0x06)
# ---------------------------------------------------------------------------
VOLTAGE_LEVELS: Dict[int, str] = {
0x00: "shutdown",
0x01: "very_low",
0x02: "low",
0x03: "medium",
0x04: "good",
0x05: "high",
0x06: "full",
}
# ---------------------------------------------------------------------------
# Protocol Number -> Human-Readable Name
# ---------------------------------------------------------------------------

View File

@@ -185,3 +185,20 @@ async def device_attendance(
total_pages=math.ceil(total / page_size) if total else 0,
)
)
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
@router.get(
"/{attendance_id}",
response_model=APIResponse[AttendanceRecordResponse],
summary="获取考勤记录详情 / Get attendance record",
)
async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
"""按ID获取考勤记录详情 / Get attendance record details by ID."""
result = await db.execute(
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
)
record = result.scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
return APIResponse(data=AttendanceRecordResponse.model_validate(record))

View File

@@ -70,7 +70,6 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
if existing:
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
beacon = await beacon_service.create_beacon(db, body)
await db.commit()
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
@@ -85,7 +84,6 @@ async def update_beacon(
beacon = await beacon_service.update_beacon(db, beacon_id, body)
if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
@@ -98,5 +96,4 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id)
if not success:
raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon deleted")

View File

@@ -88,15 +88,14 @@ async def device_bluetooth_records(
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""
获取指定设备的蓝牙数据记录。
Get Bluetooth records for a specific device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
@@ -133,3 +132,20 @@ async def device_bluetooth_records(
total_pages=math.ceil(total / page_size) if total else 0,
)
)
# NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts
@router.get(
"/{record_id}",
response_model=APIResponse[BluetoothRecordResponse],
summary="获取蓝牙记录详情 / Get bluetooth record",
)
async def get_bluetooth_record(record_id: int, db: AsyncSession = Depends(get_db)):
"""按ID获取蓝牙记录详情 / Get bluetooth record details by ID."""
result = await db.execute(
select(BluetoothRecord).where(BluetoothRecord.id == record_id)
)
record = result.scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail=f"Bluetooth record {record_id} not found")
return APIResponse(data=BluetoothRecordResponse.model_validate(record))

View File

@@ -3,19 +3,26 @@ Commands Router - 指令管理接口
API endpoints for sending commands / messages to devices and viewing command history.
"""
import logging
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.extensions import limiter
from app.schemas import (
APIResponse,
BatchCommandRequest,
BatchCommandResponse,
BatchCommandResult,
CommandResponse,
PaginatedList,
)
from app.services import command_service, device_service
from app.services import tcp_command_service
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
@@ -29,8 +36,8 @@ class SendCommandRequest(BaseModel):
"""Request body for sending a command to a device."""
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
command_type: str = Field(..., max_length=30, description="指令类型 / Command type")
command_content: str = Field(..., description="指令内容 / Command content")
command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
class SendMessageRequest(BaseModel):
@@ -48,7 +55,7 @@ class SendTTSRequest(BaseModel):
# ---------------------------------------------------------------------------
# Helper
# Helpers
# ---------------------------------------------------------------------------
@@ -78,6 +85,57 @@ async def _resolve_device(
return device
async def _send_to_device(
db: AsyncSession,
device,
command_type: str,
command_content: str,
executor,
success_msg: str,
fail_msg: str,
):
"""Common logic for sending command/message/tts to a device via TCP.
Parameters
----------
executor : async callable
The actual send function, e.g. tcp_command_service.send_command(...)
"""
if not tcp_command_service.is_device_online(device.imei):
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type=command_type,
command_content=command_content,
)
try:
await executor()
except Exception as e:
logging.getLogger(__name__).error("Command send failed: %s", e)
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=fail_msg,
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message=success_msg,
data=CommandResponse.model_validate(command_log),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -126,46 +184,15 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
Requires the device to be online.
"""
device = await _resolve_device(db, body.device_id, body.imei)
# Import tcp_manager lazily to avoid circular imports
from app.tcp_server import tcp_manager
# Check if device is connected
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
return await _send_to_device(
db, device,
command_type=body.command_type,
command_content=body.command_content,
)
# Send command via TCP
try:
await tcp_manager.send_command(
executor=lambda: tcp_command_service.send_command(
device.imei, body.command_type, body.command_content
)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send command / 指令发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Command sent successfully / 指令发送成功",
data=CommandResponse.model_validate(command_log),
),
success_msg="Command sent successfully / 指令发送成功",
fail_msg="Failed to send command / 指令发送失败",
)
@@ -181,41 +208,13 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
Send a text message to a device using protocol 0x82.
"""
device = await _resolve_device(db, body.device_id, body.imei)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log for the message
command_log = await command_service.create_command(
db,
device_id=device.id,
return await _send_to_device(
db, device,
command_type="message",
command_content=body.message,
)
try:
await tcp_manager.send_message(device.imei, body.message)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send message / 留言发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Message sent successfully / 留言发送成功",
data=CommandResponse.model_validate(command_log),
executor=lambda: tcp_command_service.send_message(device.imei, body.message),
success_msg="Message sent successfully / 留言发送成功",
fail_msg="Failed to send message / 留言发送失败",
)
@@ -232,43 +231,77 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
The device will use its built-in TTS engine to speak the text aloud.
"""
device = await _resolve_device(db, body.device_id, body.imei)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
tts_command = f"TTS,{body.text}"
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
return await _send_to_device(
db, device,
command_type="tts",
command_content=tts_command,
executor=lambda: tcp_command_service.send_command(
device.imei, "tts", tts_command
),
success_msg="TTS sent successfully / 语音下发成功",
fail_msg="Failed to send TTS / 语音下发失败",
)
try:
await tcp_manager.send_command(device.imei, "tts", tts_command)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
@router.post(
"/batch",
response_model=APIResponse[BatchCommandResponse],
status_code=201,
summary="批量发送指令 / Batch send command to multiple devices",
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
"""
向多台设备批量发送同一指令最多100台。
Send the same command to multiple devices (up to 100). Skips offline devices.
"""
# Resolve devices in single query (mutual exclusion validated by schema)
if body.device_ids:
devices = await device_service.get_devices_by_ids(db, body.device_ids)
else:
devices = await device_service.get_devices_by_imeis(db, body.imeis)
results = []
for device in devices:
if not tcp_command_service.is_device_online(device.imei):
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=False, error="Device offline",
))
continue
try:
cmd_log = await command_service.create_command(
db,
device_id=device.id,
command_type=body.command_type,
command_content=body.command_content,
)
await tcp_command_service.send_command(
device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
await db.flush()
await db.refresh(cmd_log)
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=True, command_id=cmd_log.id,
))
except Exception as e:
logging.getLogger(__name__).error("Batch cmd failed for %s: %s", device.imei, e)
results.append(BatchCommandResult(
device_id=device.id, imei=device.imei,
success=False, error="Send failed",
))
sent = sum(1 for r in results if r.success)
failed = len(results) - sent
return APIResponse(
message="TTS sent successfully / 语音下发成功",
data=CommandResponse.model_validate(command_log),
message=f"Batch command: {sent} sent, {failed} failed",
data=BatchCommandResponse(
total=len(results), sent=sent, failed=failed, results=results,
),
)

View File

@@ -5,17 +5,24 @@ API endpoints for device CRUD operations and statistics.
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
BatchDeviceCreateRequest,
BatchDeviceCreateResponse,
BatchDeviceCreateResult,
BatchDeviceDeleteRequest,
BatchDeviceUpdateRequest,
DeviceCreate,
DeviceResponse,
DeviceUpdate,
PaginatedList,
)
from app.config import settings
from app.extensions import limiter
from app.services import device_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@@ -81,6 +88,76 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
return APIResponse(data=DeviceResponse.model_validate(device))
@router.post(
"/batch",
response_model=APIResponse[BatchDeviceCreateResponse],
status_code=201,
summary="批量创建设备 / Batch create devices",
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
"""
批量注册设备最多500台跳过IMEI重复的设备。
Batch register devices (up to 500). Skips devices with duplicate IMEIs.
"""
results = await device_service.batch_create_devices(db, body.devices)
created = sum(1 for r in results if r["success"])
failed = len(results) - created
return APIResponse(
message=f"Batch create: {created} created, {failed} failed",
data=BatchDeviceCreateResponse(
total=len(results),
created=created,
failed=failed,
results=[BatchDeviceCreateResult(**r) for r in results],
),
)
@router.put(
"/batch",
response_model=APIResponse[dict],
summary="批量更新设备 / Batch update devices",
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
"""
批量更新设备信息名称、状态等最多500台。
Batch update device fields (name, status, etc.) for up to 500 devices.
"""
results = await device_service.batch_update_devices(db, body.device_ids, body.update)
updated = sum(1 for r in results if r["success"])
failed = len(results) - updated
return APIResponse(
message=f"Batch update: {updated} updated, {failed} failed",
data={"total": len(results), "updated": updated, "failed": failed, "results": results},
)
@router.post(
"/batch-delete",
response_model=APIResponse[dict],
summary="批量删除设备 / Batch delete devices",
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_delete_devices(
request: Request,
body: BatchDeviceDeleteRequest,
db: AsyncSession = Depends(get_db),
):
"""
批量删除设备最多100台。通过 POST body 传递 device_ids 列表。
Batch delete devices (up to 100). Pass device_ids in request body.
"""
results = await device_service.batch_delete_devices(db, body.device_ids)
deleted = sum(1 for r in results if r["success"])
failed = len(results) - deleted
return APIResponse(
message=f"Batch delete: {deleted} deleted, {failed} failed",
data={"total": len(results), "deleted": deleted, "failed": failed, "results": results},
)
@router.get(
"/{device_id}",
response_model=APIResponse[DeviceResponse],

92
app/routers/heartbeats.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Heartbeats Router - 心跳数据接口
API endpoints for querying device heartbeat records.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import HeartbeatRecord
from app.schemas import (
APIResponse,
HeartbeatRecordResponse,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/heartbeats", tags=["Heartbeats / 心跳数据"])
@router.get(
"",
response_model=APIResponse[PaginatedList[HeartbeatRecordResponse]],
summary="获取心跳记录列表 / List heartbeat records",
)
async def list_heartbeats(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取心跳记录列表,支持按设备和时间范围过滤。
List heartbeat records with optional device and time range filters.
"""
query = select(HeartbeatRecord)
count_query = select(func.count(HeartbeatRecord.id))
if device_id is not None:
query = query.where(HeartbeatRecord.device_id == device_id)
count_query = count_query.where(HeartbeatRecord.device_id == device_id)
if start_time:
query = query.where(HeartbeatRecord.created_at >= start_time)
count_query = count_query.where(HeartbeatRecord.created_at >= start_time)
if end_time:
query = query.where(HeartbeatRecord.created_at <= end_time)
count_query = count_query.where(HeartbeatRecord.created_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(HeartbeatRecord.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[HeartbeatRecordResponse.model_validate(r) for r in records],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/{heartbeat_id}",
response_model=APIResponse[HeartbeatRecordResponse],
summary="获取心跳详情 / Get heartbeat details",
)
async def get_heartbeat(heartbeat_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取心跳记录详情。
Get heartbeat record details by ID.
"""
result = await db.execute(
select(HeartbeatRecord).where(HeartbeatRecord.id == heartbeat_id)
)
record = result.scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail=f"Heartbeat {heartbeat_id} not found")
return APIResponse(data=HeartbeatRecordResponse.model_validate(record))

View File

@@ -7,9 +7,11 @@ import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import LocationRecord
from app.schemas import (
APIResponse,
LocationRecordResponse,
@@ -92,6 +94,7 @@ async def device_track(
device_id: int,
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
max_points: int = Query(default=10000, ge=1, le=50000, description="最大轨迹点数 / Max track points"),
db: AsyncSession = Depends(get_db),
):
"""
@@ -109,7 +112,23 @@ async def device_track(
detail="start_time must be before end_time / 开始时间必须早于结束时间",
)
records = await location_service.get_device_track(db, device_id, start_time, end_time)
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points)
return APIResponse(
data=[LocationRecordResponse.model_validate(r) for r in records]
)
@router.get(
"/{location_id}",
response_model=APIResponse[LocationRecordResponse],
summary="获取位置记录详情 / Get location record",
)
async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
"""按ID获取位置记录详情 / Get location record details by ID."""
result = await db.execute(
select(LocationRecord).where(LocationRecord.id == location_id)
)
record = result.scalar_one_or_none()
if record is None:
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
return APIResponse(data=LocationRecordResponse.model_validate(record))

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Any, Generic, TypeVar
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
T = TypeVar("T")
@@ -42,8 +42,8 @@ class PaginatedList(BaseModel, Generic[T]):
class DeviceBase(BaseModel):
imei: str = Field(..., min_length=15, max_length=20, description="IMEI number")
device_type: str = Field(..., max_length=10, description="Device type code")
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number (digits only)")
device_type: str = Field(..., max_length=10, description="Device type code (e.g. P240, P241)")
name: str | None = Field(None, max_length=100, description="Friendly name")
timezone: str = Field(default="+8", max_length=30)
language: str = Field(default="cn", max_length=10)
@@ -55,9 +55,7 @@ class DeviceCreate(DeviceBase):
class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
status: str | None = Field(None, max_length=20)
battery_level: int | None = None
gsm_signal: int | None = None
status: Literal["online", "offline"] | None = Field(None, description="Device status")
iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30)
@@ -95,8 +93,8 @@ class DeviceSingleResponse(APIResponse[DeviceResponse]):
class LocationRecordBase(BaseModel):
device_id: int
location_type: str = Field(..., max_length=10)
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
@@ -148,8 +146,8 @@ class AlarmRecordBase(BaseModel):
alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=10)
protocol_number: int
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
mcc: int | None = None
@@ -231,8 +229,8 @@ class AttendanceRecordBase(BaseModel):
attendance_type: str = Field(..., max_length=20)
protocol_number: int
gps_positioned: bool = False
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
@@ -288,8 +286,8 @@ class BluetoothRecordBase(BaseModel):
beacon_battery_unit: str | None = None
attendance_type: str | None = None
bluetooth_data: dict[str, Any] | None = None
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
recorded_at: datetime
@@ -321,17 +319,17 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, description="信标MAC地址")
beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, description="iBeacon Major")
beacon_minor: int | None = Field(None, description="iBeacon Minor")
beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)")
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID")
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称")
floor: str | None = Field(None, max_length=20, description="楼层")
area: str | None = Field(None, max_length=100, description="区域")
latitude: float | None = Field(None, description="纬度")
longitude: float | None = Field(None, description="经度")
latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
longitude: float | None = Field(None, ge=-180, le=180, description="经度")
address: str | None = Field(None, description="详细地址")
status: str = Field(default="active", max_length=20, description="状态")
status: Literal["active", "inactive"] = Field(default="active", description="状态")
class BeaconConfigCreate(BeaconConfigBase):
@@ -339,22 +337,34 @@ class BeaconConfigCreate(BeaconConfigBase):
class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = None
beacon_minor: int | None = None
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$")
beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100)
floor: str | None = Field(None, max_length=20)
area: str | None = Field(None, max_length=100)
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
address: str | None = None
status: str | None = Field(None, max_length=20)
status: Literal["active", "inactive"] | None = None
class BeaconConfigResponse(BeaconConfigBase):
class BeaconConfigResponse(BaseModel):
"""Response model — no pattern validation on output (existing data may not conform)."""
model_config = ConfigDict(from_attributes=True)
id: int
beacon_mac: str
beacon_uuid: str | None = None
beacon_major: int | None = None
beacon_minor: int | None = None
name: str
floor: str | None = None
area: str | None = None
latitude: float | None = None
longitude: float | None = None
address: str | None = None
status: str
created_at: datetime
updated_at: datetime | None = None
@@ -367,10 +377,87 @@ class BeaconConfigResponse(BeaconConfigBase):
class CommandCreate(BaseModel):
device_id: int
command_type: str = Field(..., max_length=30)
command_content: str
command_content: str = Field(..., max_length=500)
server_flag: str = Field(..., max_length=20)
# ---------------------------------------------------------------------------
# Batch operation schemas
# ---------------------------------------------------------------------------
class BatchDeviceCreateItem(BaseModel):
"""Single device in a batch create request."""
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number")
device_type: str = Field(default="P241", max_length=10, description="Device type (P240/P241)")
name: str | None = Field(None, max_length=100, description="Friendly name")
class BatchDeviceCreateRequest(BaseModel):
"""Batch create devices."""
devices: list[BatchDeviceCreateItem] = Field(..., min_length=1, max_length=500, description="List of devices to create")
class BatchDeviceCreateResult(BaseModel):
"""Result of a single device in batch create."""
imei: str
success: bool
device_id: int | None = None
error: str | None = None
class BatchDeviceCreateResponse(BaseModel):
"""Summary of batch create operation."""
total: int
created: int
failed: int
results: list[BatchDeviceCreateResult]
class BatchCommandRequest(BaseModel):
"""Send the same command to multiple devices."""
device_ids: list[int] | None = Field(default=None, min_length=1, max_length=100, description="Device IDs (provide device_ids or imeis)")
imeis: list[str] | None = Field(default=None, min_length=1, max_length=100, description="IMEI list (alternative to device_ids)")
command_type: str = Field(..., max_length=30, description="Command type (e.g. online_cmd)")
command_content: str = Field(..., max_length=500, description="Command content")
@model_validator(mode="after")
def check_device_ids_or_imeis(self):
if not self.device_ids and not self.imeis:
raise ValueError("Must provide device_ids or imeis / 必须提供 device_ids 或 imeis")
if self.device_ids and self.imeis:
raise ValueError("Provide device_ids or imeis, not both / 不能同时提供 device_ids 和 imeis")
return self
class BatchCommandResult(BaseModel):
"""Result of a single command in batch send."""
device_id: int
imei: str
success: bool
command_id: int | None = None
error: str | None = None
class BatchCommandResponse(BaseModel):
"""Summary of batch command operation."""
total: int
sent: int
failed: int
results: list[BatchCommandResult]
class BatchDeviceDeleteRequest(BaseModel):
"""Batch delete devices."""
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="Device IDs to delete")
class BatchDeviceUpdateRequest(BaseModel):
"""Batch update devices with the same settings."""
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="Device IDs to update")
update: DeviceUpdate = Field(..., description="Fields to update")
class CommandUpdate(BaseModel):
response_content: str | None = None
status: str | None = Field(None, max_length=20)

View File

@@ -9,7 +9,7 @@ from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Device
from app.schemas import DeviceCreate, DeviceUpdate
from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem
async def get_devices(
@@ -189,6 +189,103 @@ async def delete_device(db: AsyncSession, device_id: int) -> bool:
return True
async def get_devices_by_ids(db: AsyncSession, device_ids: list[int]) -> list[Device]:
"""Fetch multiple devices by IDs in a single query."""
if not device_ids:
return []
result = await db.execute(select(Device).where(Device.id.in_(device_ids)))
return list(result.scalars().all())
async def get_devices_by_imeis(db: AsyncSession, imeis: list[str]) -> list[Device]:
"""Fetch multiple devices by IMEIs in a single query."""
if not imeis:
return []
result = await db.execute(select(Device).where(Device.imei.in_(imeis)))
return list(result.scalars().all())
async def batch_create_devices(
db: AsyncSession, items: list[BatchDeviceCreateItem]
) -> list[dict]:
"""Batch create devices, skipping duplicates. Uses single query to check existing."""
# One query to find all existing IMEIs
imeis = [item.imei for item in items]
existing_devices = await get_devices_by_imeis(db, imeis)
existing_imeis = {d.imei for d in existing_devices}
results: list[dict] = [{} for _ in range(len(items))] # preserve input order
new_device_indices: list[tuple[int, Device]] = []
seen_imeis: set[str] = set()
for i, item in enumerate(items):
if item.imei in existing_imeis:
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": f"IMEI {item.imei} already exists"}
continue
if item.imei in seen_imeis:
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": "Duplicate IMEI in request"}
continue
seen_imeis.add(item.imei)
device = Device(imei=item.imei, device_type=item.device_type, name=item.name)
db.add(device)
new_device_indices.append((i, device))
if new_device_indices:
await db.flush()
for idx, device in new_device_indices:
await db.refresh(device)
results[idx] = {"imei": device.imei, "success": True, "device_id": device.id, "error": None}
return results
async def batch_update_devices(
db: AsyncSession, device_ids: list[int], update_data: DeviceUpdate
) -> list[dict]:
"""Batch update devices with the same settings. Uses single query to fetch all."""
devices = await get_devices_by_ids(db, device_ids)
found_map = {d.id: d for d in devices}
update_fields = update_data.model_dump(exclude_unset=True)
now = datetime.now(timezone.utc)
results = []
for device_id in device_ids:
device = found_map.get(device_id)
if device is None:
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
continue
for field, value in update_fields.items():
setattr(device, field, value)
device.updated_at = now
results.append({"device_id": device_id, "success": True, "error": None})
if any(r["success"] for r in results):
await db.flush()
return results
async def batch_delete_devices(
db: AsyncSession, device_ids: list[int]
) -> list[dict]:
"""Batch delete devices. Uses single query to fetch all."""
devices = await get_devices_by_ids(db, device_ids)
found_map = {d.id: d for d in devices}
results = []
for device_id in device_ids:
device = found_map.get(device_id)
if device is None:
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
continue
await db.delete(device)
results.append({"device_id": device_id, "success": True, "error": None})
if any(r["success"] for r in results):
await db.flush()
return results
async def get_device_stats(db: AsyncSession) -> dict:
"""
获取设备统计信息 / Get device statistics.

View File

@@ -106,6 +106,7 @@ async def get_device_track(
device_id: int,
start_time: datetime,
end_time: datetime,
max_points: int = 10000,
) -> list[LocationRecord]:
"""
获取设备轨迹 / Get device movement track within a time range.
@@ -134,5 +135,6 @@ async def get_device_track(
LocationRecord.recorded_at <= end_time,
)
.order_by(LocationRecord.recorded_at.asc())
.limit(max_points)
)
return list(result.scalars().all())

View File

@@ -0,0 +1,31 @@
"""
TCP Command Service — Abstraction layer for sending commands to devices via TCP.
Breaks the circular import between routers/commands.py and tcp_server.py
by lazily importing tcp_manager only when needed.
"""
import logging
logger = logging.getLogger(__name__)
def _get_tcp_manager():
"""Lazily import tcp_manager to avoid circular imports."""
from app.tcp_server import tcp_manager
return tcp_manager
def is_device_online(imei: str) -> bool:
"""Check if a device is currently connected via TCP."""
return imei in _get_tcp_manager().connections
async def send_command(imei: str, command_type: str, command_content: str) -> bool:
"""Send an online command (0x80) to a connected device."""
return await _get_tcp_manager().send_command(imei, command_type, command_content)
async def send_message(imei: str, message: str) -> bool:
"""Send a text message (0x82) to a connected device."""
return await _get_tcp_manager().send_message(imei, message)

View File

@@ -26,9 +26,9 @@
.page.active { display: block; }
.stat-card { background: #1f2937; border-radius: 12px; padding: 24px; border: 1px solid #374151; transition: transform 0.2s, box-shadow 0.2s; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; }
.toast.success { background: #059669; }
.toast.error { background: #dc2626; }
@@ -99,6 +99,42 @@
.guide-tips { margin-top: 10px; padding: 10px 14px; background: rgba(59,130,246,0.06); border-radius: 8px; border-left: 3px solid #3b82f6; }
.guide-tips p { font-size: 12px; color: #64748b; line-height: 1.6; }
.guide-tips p i { color: #3b82f6; margin-right: 4px; }
/* === Side Panel === */
.page-with-panel { display: flex; gap: 16px; height: calc(100vh - 140px); }
.side-panel { width: 280px; min-width: 280px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; transition: width 0.3s, min-width 0.3s, opacity 0.3s; }
.side-panel.collapsed { width: 0; min-width: 0; border: none; opacity: 0; }
.page-main-content { flex: 1; min-width: 0; overflow-y: auto; }
.panel-header { padding: 12px 14px; border-bottom: 1px solid #374151; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.panel-header .panel-title { font-size: 14px; font-weight: 600; color: #e5e7eb; flex: 1; }
.panel-toggle-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 4px; font-size: 14px; }
.panel-toggle-btn:hover { color: #e5e7eb; }
.panel-search { padding: 8px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; }
.panel-search-wrap { position: relative; }
.panel-search-wrap i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #6b7280; font-size: 12px; }
.panel-search-wrap input { padding-left: 30px; font-size: 13px; }
.panel-list { flex: 1; overflow-y: auto; padding: 8px; }
.panel-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; border: 1px solid transparent; margin-bottom: 6px; transition: all 0.15s; position: relative; }
.panel-item:hover { background: #374151; border-color: #4b5563; }
.panel-item.active { background: #1e3a5f; border-color: #2563eb; }
.panel-item-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.panel-item-name { font-size: 13px; font-weight: 600; color: #e5e7eb; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; }
.panel-item-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.panel-item-status.online { background: #34d399; }
.panel-item-status.offline { background: #f87171; }
.panel-item-status.active { background: #34d399; }
.panel-item-status.inactive { background: #f87171; }
.panel-item-sub { font-size: 11px; color: #9ca3af; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.panel-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; color: #6b7280; }
.battery-bar { width: 28px; height: 10px; background: #374151; border-radius: 2px; border: 1px solid #4b5563; overflow: hidden; display: inline-block; }
.battery-bar-fill { height: 100%; border-radius: 1px; }
.panel-item-actions { position: absolute; right: 8px; top: 8px; display: none; gap: 4px; }
.panel-item:hover .panel-item-actions { display: flex; }
.panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.panel-action-btn:hover { background: #2563eb; }
.panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; }
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
@media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } }
</style>
</head>
<body>
@@ -366,50 +402,70 @@
<div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<select id="locDeviceSelect" style="width:200px">
<option value="">选择设备...</option>
</select>
<select id="locTypeFilter" style="width:150px">
<option value="">全部类型</option>
<option value="gps">GPS</option>
<option value="gps_4g">GPS 4G</option>
<option value="wifi">WiFi</option>
<option value="wifi_4g">WiFi 4G</option>
<option value="lbs">LBS</option>
<option value="lbs_4g">LBS 4G</option>
</select>
<input type="date" id="locStartDate" style="width:160px">
<input type="date" id="locEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div id="locationMap" style="height: 100%; width: 100%;"></div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
<th>地址</th>
<th>速度</th>
<th>卫星数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="locationsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody>
</table>
<div class="page-with-panel">
<!-- Left: Device Panel -->
<div class="side-panel" id="locSidePanel">
<div class="panel-header">
<i class="fas fa-microchip text-blue-400" style="font-size:14px"></i>
<span class="panel-title">设备列表</span>
<span id="locPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('locSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="locPanelSearch" placeholder="搜索设备..." oninput="filterPanelItems('locations')"></div></div>
<div class="panel-list" id="locPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载设备...</p></div>
</div>
<div class="panel-footer" id="locPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<div class="page-main-content" style="position:relative">
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
<div class="flex flex-wrap items-center gap-3 mb-4">
<select id="locDeviceSelect" style="width:200px">
<option value="">选择设备...</option>
</select>
<select id="locTypeFilter" style="width:150px">
<option value="">全部类型</option>
<option value="gps">GPS</option>
<option value="gps_4g">GPS 4G</option>
<option value="wifi">WiFi</option>
<option value="wifi_4g">WiFi 4G</option>
<option value="lbs">LBS</option>
<option value="lbs_4g">LBS 4G</option>
</select>
<input type="date" id="locStartDate" style="width:160px">
<input type="date" id="locEndDate" style="width:160px">
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
<div id="locationMap" style="height: 100%; width: 100%;"></div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>设备ID</th>
<th>类型</th>
<th>纬度</th>
<th>经度</th>
<th>地址</th>
<th>速度</th>
<th>卫星数</th>
<th>时间</th>
</tr>
</thead>
<tbody id="locationsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
</tbody>
</table>
</div>
<div id="locationsPagination" class="pagination p-4"></div>
</div>
</div>
<div id="locationsPagination" class="pagination p-4"></div>
</div>
</div>
@@ -660,43 +716,60 @@
</div>
</div>
<!-- Filters & Controls -->
<div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div>
<!-- Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>MAC 地址</th>
<th>名称</th>
<th>UUID / Major / Minor</th>
<th>楼层 / 区域</th>
<th>坐标</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="beaconsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
<div class="page-with-panel">
<!-- Left: Beacon Panel -->
<div class="side-panel" id="beaconSidePanel">
<div class="panel-header">
<i class="fas fa-broadcast-tower text-green-400" style="font-size:14px"></i>
<span class="panel-title">信标列表</span>
<span id="beaconPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('beaconSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="beaconPanelSearch" placeholder="搜索信标..." oninput="filterPanelItems('beacons')"></div></div>
<div class="panel-list" id="beaconPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载信标...</p></div>
</div>
<div class="panel-footer" id="beaconPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<div class="page-main-content" style="position:relative">
<button class="panel-expand-btn" onclick="toggleSidePanel('beaconSidePanel')" title="展开信标面板"><i class="fas fa-chevron-right"></i></button>
<div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px">
<option value="">全部状态</option>
<option value="active">启用</option>
<option value="inactive">停用</option>
</select>
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
<div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>MAC 地址</th>
<th>名称</th>
<th>UUID / Major / Minor</th>
<th>楼层 / 区域</th>
<th>坐标</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="beaconsTableBody">
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
</tbody>
</table>
</div>
<div id="beaconsPagination" class="pagination p-4"></div>
</div>
</div>
<div id="beaconsPagination" class="pagination p-4"></div>
</div>
</div>
@@ -827,6 +900,12 @@
let dashAlarmChart = null;
let alarmTypeChart = null;
// Side panel state
let panelDevices = [];
let panelBeacons = [];
let selectedPanelDeviceId = null;
let selectedPanelBeaconId = null;
// Pagination state
const pageState = {
devices: { page: 1, pageSize: 20 },
@@ -1028,6 +1107,9 @@
document.getElementById('pageTitle').textContent = pageTitles[page] || page;
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
// Clean up panel state when leaving panel pages
if (page !== 'locations') { selectedPanelDeviceId = null; panelDevices = []; }
if (page !== 'beacons') { selectedPanelBeaconId = null; panelBeacons = []; }
switch (page) {
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
@@ -1041,6 +1123,166 @@
}
}
// ==================== SIDE PANEL ====================
// Panel element ID config (centralized, easy to extend for new panel pages)
const PANEL_IDS = {
locations: { panel: 'locSidePanel', list: 'locPanelList', count: 'locPanelCount', footer: 'locPanelFooter', search: 'locPanelSearch' },
beacons: { panel: 'beaconSidePanel', list: 'beaconPanelList', count: 'beaconPanelCount', footer: 'beaconPanelFooter', search: 'beaconPanelSearch' },
};
const PANEL_TRANSITION_MS = 300; // must match CSS transition duration
function toggleSidePanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.toggle('collapsed');
// Only invalidate map size when on locations page
if (currentPage === 'locations' && locationMap) {
setTimeout(() => locationMap.invalidateSize(), PANEL_TRANSITION_MS + 50);
}
}
function formatTimeAgo(t) {
if (!t) return '-';
try {
const diff = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
if (isNaN(diff) || diff < 0) return '-';
if (diff < 60) return '刚刚';
if (diff < 3600) return Math.floor(diff / 60) + '分钟前';
if (diff < 86400) return Math.floor(diff / 3600) + '小时前';
if (diff < 2592000) return Math.floor(diff / 86400) + '天前';
return formatTime(t);
} catch { return '-'; }
}
/** Generic panel render helper — sets count & footer text, returns container or null */
function _initPanelRender(ids, items, statusField, statusValue, emptyText, countFmt, footerFmt) {
const container = document.getElementById(ids.list);
const countEl = document.getElementById(ids.count);
const footerEl = document.getElementById(ids.footer);
if (!container) return null;
const activeCount = items.filter(it => it[statusField] === statusValue).length;
if (countEl) countEl.textContent = countFmt(activeCount, items.length);
if (footerEl) footerEl.textContent = footerFmt(activeCount, items.length);
if (items.length === 0) {
container.innerHTML = `<div style="text-align:center;padding:20px;color:#6b7280;font-size:13px">${emptyText}</div>`;
return null;
}
return container;
}
function renderDevicePanel(devices) {
panelDevices = devices;
const container = _initPanelRender(
PANEL_IDS.locations, devices, 'status', 'online', '暂无设备',
(a, t) => `${a}/${t}`, (a, t) => `${t} 台设备,${a} 台在线`
);
if (!container) return;
container.innerHTML = devices.map(d => {
const isActive = (d.id || d.device_id) == selectedPanelDeviceId;
const statusClass = d.status === 'online' ? 'online' : 'offline';
const deviceId = d.id || d.device_id || '';
const imeiShort = d.imei ? '...' + d.imei.slice(-8) : '-';
const bp = d.battery_level;
const bColor = bp != null ? (bp < 20 ? '#f87171' : bp < 50 ? '#fbbf24' : '#34d399') : '#4b5563';
const lastActive = d.last_heartbeat || d.last_login;
const timeAgo = lastActive ? formatTimeAgo(lastActive) : '无活动';
return `<div class="panel-item ${isActive ? 'active' : ''}" data-device-id="${deviceId}" data-search-text="${(d.name||'').toLowerCase()} ${(d.imei||'').toLowerCase()}" onclick="selectPanelDevice('${deviceId}')">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();selectPanelDevice('${deviceId}')" title="定位"><i class="fas fa-crosshairs"></i></button>
<button class="panel-action-btn" onclick="event.stopPropagation();showDeviceDetail('${deviceId}')" title="详情"><i class="fas fa-info-circle"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(d.name || d.imei || deviceId)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(imeiShort)}</div>
<div class="panel-item-meta">
${bp != null ? `<span style="display:flex;align-items:center;gap:3px"><span class="battery-bar"><span class="battery-bar-fill" style="width:${bp}%;background:${bColor}"></span></span><span>${bp}%</span></span>` : ''}
${d.gsm_signal != null ? `<span><i class="fas fa-signal" style="font-size:10px"></i> ${d.gsm_signal}</span>` : ''}
<span style="margin-left:auto"><i class="far fa-clock" style="font-size:10px"></i> ${timeAgo}</span>
</div>
</div>`;
}).join('');
}
function renderBeaconPanel(beacons) {
panelBeacons = beacons;
const container = _initPanelRender(
PANEL_IDS.beacons, beacons, 'status', 'active', '暂无信标',
(a, t) => `${a}/${t}`, (a, t) => `${t} 个信标,${a} 个启用`
);
if (!container) return;
container.innerHTML = beacons.map(b => {
const isActive = b.id == selectedPanelBeaconId;
const statusClass = b.status === 'active' ? 'active' : 'inactive';
const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '未设置';
const macShort = b.beacon_mac ? b.beacon_mac.slice(-8) : '-';
return `<div class="panel-item ${isActive ? 'active' : ''}" data-beacon-id="${b.id}" data-search-text="${(b.name||'').toLowerCase()} ${(b.beacon_mac||'').toLowerCase()} ${(b.area||'').toLowerCase()}" onclick="selectPanelBeacon(${b.id})">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();showEditBeaconModal(${b.id})" title="编辑"><i class="fas fa-edit"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(b.name || b.beacon_mac)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(macShort)}</div>
<div class="panel-item-meta">
<span><i class="fas fa-layer-group" style="font-size:10px"></i> ${escapeHtml(floorArea)}</span>
${b.latitude && b.longitude ? '<span style="color:#34d399"><i class="fas fa-map-pin" style="font-size:10px"></i> 已定位</span>' : '<span style="color:#6b7280">未定位</span>'}
</div>
</div>`;
}).join('');
}
function filterPanelItems(type) {
const ids = PANEL_IDS[type];
if (!ids) return;
const inputEl = document.getElementById(ids.search);
const listEl = document.getElementById(ids.list);
if (!inputEl || !listEl) return;
const keyword = (inputEl.value || '').toLowerCase().trim();
listEl.querySelectorAll('.panel-item').forEach(item => {
item.style.display = (!keyword || (item.dataset.searchText || '').includes(keyword)) ? '' : 'none';
});
}
function selectPanelDevice(deviceId, autoLocate = true) {
selectedPanelDeviceId = deviceId;
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
el.classList.toggle('active', el.dataset.deviceId == deviceId);
});
const select = document.getElementById('locDeviceSelect');
if (select) select.value = deviceId;
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (autoLocate && deviceId) loadLatestPosition();
}
function selectPanelBeacon(beaconId) {
selectedPanelBeaconId = beaconId;
document.querySelectorAll('#beaconPanelList .panel-item').forEach(el => {
el.classList.toggle('active', el.dataset.beaconId == beaconId);
});
}
function sortDevicesByActivity(devices) {
return [...devices].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
const tA = new Date(a.last_heartbeat || a.last_login || 0).getTime();
const tB = new Date(b.last_heartbeat || b.last_login || 0).getTime();
return tB - tA;
});
}
function autoSelectActiveDevice(devices) {
if (!devices || devices.length === 0) return;
const sorted = sortDevicesByActivity(devices);
const best = sorted[0];
const bestId = best.id || best.device_id;
if (bestId) selectPanelDevice(bestId, true);
}
// ==================== DEVICE SELECTOR HELPER ====================
let cachedDevices = null;
@@ -1064,6 +1306,12 @@
});
if (currentVal) sel.value = currentVal;
});
// Render device panel on locations page
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
const sorted = sortDevicesByActivity(devices);
renderDevicePanel(sorted);
if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted);
}
} catch (err) {
console.error('Failed to load device selectors:', err);
}
@@ -1264,31 +1512,159 @@
}
}
function _locTypeLabel(t) {
const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' };
return map[t] || t || '-';
}
function _locModeBadges(locType) {
const modes = [
{ label: 'GPS', match: ['gps','gps_4g'] },
{ label: 'LBS', match: ['lbs','lbs_4g'] },
{ label: 'WiFi', match: ['wifi','wifi_4g'] },
{ label: '蓝牙', match: ['bluetooth'] },
];
return modes.map(m => {
const active = m.match.includes(locType);
const color = active ? '#10b981' : '#4b5563';
const bg = active ? 'rgba(16,185,129,0.15)' : 'rgba(75,85,99,0.2)';
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:12px;border:1px solid ${color};background:${bg};color:${color};margin:2px">${active?'●':'○'} ${m.label}</span>`;
}).join('');
}
// --- Quick command sender for device detail panel ---
async function _quickCmd(deviceId, cmd, btnEl) {
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
try {
const res = await apiCall(`${API_BASE}/commands/send`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }),
});
showToast(`已发送: ${cmd}`);
// Poll for response (device replies via 0x81)
const cmdId = res && res.id;
if (cmdId) _pollCmdResponse(cmdId, cmd);
} catch (err) {
showToast(`发送失败: ${err.message}`, 'error');
} finally {
if (btnEl) { btnEl.disabled = false; btnEl.style.opacity = '1'; }
}
}
async function _pollCmdResponse(cmdId, cmdName) {
for (let i = 0; i < 6; i++) {
await new Promise(r => setTimeout(r, 1500));
try {
const cmd = await apiCall(`${API_BASE}/commands/${cmdId}`);
if (cmd.response_content) {
const el = document.getElementById('detailCmdResult');
if (el) el.innerHTML = `<span class="text-gray-400" style="font-size:11px">${escapeHtml(cmdName)}:</span> <span style="font-size:12px;color:#d1d5db">${escapeHtml(cmd.response_content)}</span>`;
return;
}
} catch (_) {}
}
}
async function _quickTts(deviceId) {
const input = document.getElementById('detailTtsInput');
if (!input || !input.value.trim()) { showToast('请输入语音内容', 'error'); return; }
try {
await apiCall(`${API_BASE}/commands/tts`, {
method: 'POST',
body: JSON.stringify({ device_id: parseInt(deviceId), text: input.value.trim() }),
});
showToast('TTS 已发送');
input.value = '';
} catch (err) {
showToast(`TTS失败: ${err.message}`, 'error');
}
}
async function showDeviceDetail(id) {
if (!id) return;
try {
const device = await apiCall(`${API_BASE}/devices/${id}`);
const [device, latestLoc] = await Promise.all([
apiCall(`${API_BASE}/devices/${id}`),
apiCall(`${API_BASE}/locations/latest/${id}`).catch(() => null),
]);
const d = device;
const did = d.id || d.device_id;
const loc = latestLoc;
const locType = loc ? loc.location_type : null;
const locTime = loc ? formatTime(loc.recorded_at) : '-';
const locAddr = loc && loc.address ? escapeHtml(loc.address) : '-';
const locCoord = loc && loc.latitude ? `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}` : '-';
const online = d.status === 'online';
const disabledAttr = online ? '' : 'disabled style="opacity:0.4;cursor:not-allowed"';
const _btn = (icon, label, cmd, color) =>
`<button class="btn" style="font-size:12px;padding:5px 10px;background:${color};border:none;color:#fff;border-radius:6px;cursor:pointer" ${disabledAttr} onclick="_quickCmd('${did}','${cmd}',this)"><i class="fas fa-${icon}"></i> ${label}</button>`;
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="text-gray-400">ID:</span><br><span class="font-mono">${escapeHtml(d.id || d.device_id)}</span></div>
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情${escapeHtml(d.name || d.imei)}</h3>
<!-- 基本信息 -->
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
<div><span class="text-gray-400">名称:</span><br>${escapeHtml(d.name || '-')}</div>
<div><span class="text-gray-400">类型:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">型号:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}</div>
<div><span class="text-gray-400">信号强度:</span><br>${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}</div>
<div><span class="text-gray-400">固件版本:</span><br>${escapeHtml(d.firmware_version || '-')}</div>
<div><span class="text-gray-400">时区:</span><br>${escapeHtml(d.timezone || '-')}</div>
<div><span class="text-gray-400">语言:</span><br>${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? '&nbsp;|&nbsp; 信号: ' + d.gsm_signal : ''}</div>
<div><span class="text-gray-400">ICCID:</span><br><span class="font-mono" style="font-size:11px">${escapeHtml(d.iccid || '-')}</span></div>
<div><span class="text-gray-400">时区/语言:</span><br>${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
<div class="col-span-2"><span class="text-gray-400">创建时间:</span><br>${formatTime(d.created_at)}</div>
</div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${d.id || d.device_id}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${d.id || d.device_id}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<!-- 定位信息 -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-map-marker-alt mr-1 text-green-400"></i>定位信息</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">当前模式:</span><br><span style="color:#10b981;font-weight:600">${_locTypeLabel(locType)}</span></div>
<div><span class="text-gray-400">定位时间:</span><br>${locTime}</div>
<div class="col-span-2"><span class="text-gray-400">地址:</span><br>${locAddr}</div>
</div>
<div style="margin-top:8px">${_locModeBadges(locType)}</div>
</div>
<!-- 功能开关 -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:10px"><i class="fas fa-sliders-h mr-1 text-yellow-400"></i>功能开关${online ? '' : ' <span style="color:#ef4444;font-weight:400;font-size:11px">(设备离线,无法操作)</span>'}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('satellite-dish', 'GPS 开启', 'GPSON#', '#0d9488')}
${_btn('bluetooth-b', '蓝牙开启', 'BTON#', '#2563eb')}
${_btn('broadcast-tower', 'BLE扫描', 'BTSCAN,1#', '#7c3aed')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:10px">工作模式:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">信息查询:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${_btn('info-circle', '设备状态', 'STATUS#', '#374151')}
${_btn('cog', '参数查询', 'PARAM#', '#374151')}
${_btn('code-branch', '固件版本', 'VERSION#', '#374151')}
${_btn('stopwatch', '定时器', 'TIMER#', '#374151')}
${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')}
</div>
<div id="detailCmdResult" style="margin-top:10px;padding:8px;background:#0d1117;border-radius:6px;min-height:20px;font-family:monospace;word-break:break-all;display:${online?'block':'none'}"></div>
</div>
<!-- TTS -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-volume-up mr-1 text-purple-400"></i>语音播报 (TTS)</div>
<div style="display:flex;gap:8px">
<input id="detailTtsInput" type="text" placeholder="输入语音内容 (最多200字)" maxlength="200" style="flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:6px 10px;color:#e5e7eb;font-size:13px" ${online?'':'disabled'}>
<button class="btn" style="font-size:12px;padding:6px 14px;background:#7c3aed;border:none;color:#fff;border-radius:6px;white-space:nowrap" ${disabledAttr} onclick="_quickTts('${did}')"><i class="fas fa-play"></i> 播报</button>
</div>
</div>
<!-- 危险操作 -->
<div style="margin-top:14px;padding:12px;background:rgba(127,29,29,0.15);border-radius:8px;border:1px solid #7f1d1d${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#fca5a5;margin-bottom:8px"><i class="fas fa-exclamation-triangle mr-1"></i>系统操作</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" style="font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" ${disabledAttr} onclick="if(confirm('确定重启设备?'))_quickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启设备</button>
</div>
</div>
<div class="flex gap-3 mt-5">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${did}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${did}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 关闭</button>
</div>
`);
@@ -1364,15 +1740,69 @@
}
}
// ==================== MAP PROVIDER ====================
// Switch tile provider: 'gaode' | 'tianditu'
// 高德: GCJ-02 tiles, need coordinate conversion; 天地图: WGS-84 native
const MAP_PROVIDER = 'gaode';
// WGS-84 → GCJ-02 coordinate conversion (for 高德 tiles)
const _gcj_a = 6378245.0, _gcj_ee = 0.00669342162296594;
function _outOfChina(lat, lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; }
function _transformLat(x, y) {
let r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*Math.sqrt(Math.abs(x));
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
r += (20.0*Math.sin(y*Math.PI) + 40.0*Math.sin(y/3.0*Math.PI)) * 2.0/3.0;
r += (160.0*Math.sin(y/12.0*Math.PI) + 320.0*Math.sin(y*Math.PI/30.0)) * 2.0/3.0;
return r;
}
function _transformLng(x, y) {
let r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*Math.sqrt(Math.abs(x));
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
r += (20.0*Math.sin(x*Math.PI) + 40.0*Math.sin(x/3.0*Math.PI)) * 2.0/3.0;
r += (150.0*Math.sin(x/12.0*Math.PI) + 300.0*Math.sin(x/30.0*Math.PI)) * 2.0/3.0;
return r;
}
function wgs84ToGcj02(lat, lng) {
if (_outOfChina(lat, lng)) return [lat, lng];
let dLat = _transformLat(lng - 105.0, lat - 35.0);
let dLng = _transformLng(lng - 105.0, lat - 35.0);
const radLat = lat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - _gcj_ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI);
return [lat + dLat, lng + dLng];
}
// Convert WGS-84 coords for current map provider
function toMapCoord(lat, lng) {
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
return [lat, lng]; // tianditu uses WGS-84 natively
}
// ==================== LOCATIONS ====================
function initLocationMap() {
if (locationMap) return;
setTimeout(() => {
locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(locationMap);
locationMap = L.map('locationMap').setView(toMapCoord(39.9042, 116.4074), 10);
if (MAP_PROVIDER === 'gaode') {
// 高德矢量底图 (GCJ-02, standard Mercator, no API key needed)
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
subdomains: '1234', maxZoom: 18,
attribution: '&copy; 高德地图',
}).addTo(locationMap);
} else {
// 天地图矢量底图 + 中文注记 (WGS-84)
const TDT_KEY = '1918548e81a5ae3ff0cb985537341146';
L.tileLayer('https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
}).addTo(locationMap);
L.tileLayer('https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
attribution: '&copy; 天地图',
}).addTo(locationMap);
}
locationMap.invalidateSize();
}, 100);
}
@@ -1420,10 +1850,11 @@
const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon;
if (lat && lng) {
latlngs.push([lat, lng]);
const [mLat, mLng] = toMapCoord(lat, lng);
latlngs.push([mLat, mLng]);
const isFirst = i === 0;
const isLast = i === locations.length - 1;
const marker = L.circleMarker([lat, lng], {
const marker = L.circleMarker([mLat, mLng], {
radius: isFirst || isLast ? 8 : 4,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
color: '#fff', weight: 1, fillOpacity: 0.9,
@@ -1466,7 +1897,8 @@
const lng = loc.longitude || loc.lng || loc.lon;
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; }
const marker = L.marker([lat, lng]).addTo(locationMap);
const [mLat, mLng] = toMapCoord(lat, lng);
const marker = L.marker([mLat, mLng]).addTo(locationMap);
marker.bindPopup(`
<b>最新位置</b><br>
类型: ${loc.location_type || '-'}<br>
@@ -1476,7 +1908,7 @@
时间: ${formatTime(loc.recorded_at || loc.created_at)}
`).openPopup();
mapMarkers.push(marker);
locationMap.setView([lat, lng], 15);
locationMap.setView([mLat, mLng], 15);
showToast('已显示最新位置');
} catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error');
@@ -1783,6 +2215,8 @@
}).join('');
}
renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons');
// Render beacon side panel
renderBeaconPanel(items);
} catch (err) {
showToast('加载信标列表失败: ' + err.message, 'error');
document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';

View File

@@ -60,6 +60,7 @@ from app.protocol.constants import (
PROTO_ONLINE_CMD_REPLY,
PROTO_TIME_SYNC,
PROTO_TIME_SYNC_2,
PROTO_ADDRESS_REPLY_EN,
PROTO_WIFI,
PROTO_WIFI_4G,
START_MARKER_LONG,
@@ -718,6 +719,11 @@ class TCPManager:
logger.warning("Heartbeat received before login")
return
# Ensure device is tracked in active connections (e.g. after server restart)
if imei not in self.connections:
self.connections[imei] = (reader, writer, conn_info)
logger.info("Device IMEI=%s re-registered via heartbeat", imei)
terminal_info: int = 0
battery_level: Optional[int] = None
gsm_signal: Optional[int] = None
@@ -752,11 +758,12 @@ class TCPManager:
logger.warning("Heartbeat for unknown IMEI=%s", imei)
return
# Update device record
# Update device record (also ensure status=online if heartbeat is coming in)
await session.execute(
update(Device)
.where(Device.id == device_id)
.values(
status="online",
battery_level=battery_level,
gsm_signal=gsm_signal,
last_heartbeat=now,