Initial commit: migrate badge-admin from /tmp to /home/gpsystem
via HAPI (https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/database.cpython-312.pyc
Normal file
BIN
app/__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/geocoding.cpython-312.pyc
Normal file
BIN
app/__pycache__/geocoding.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/models.cpython-312.pyc
Normal file
BIN
app/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/schemas.cpython-312.pyc
Normal file
BIN
app/__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/tcp_server.cpython-312.pyc
Normal file
BIN
app/__pycache__/tcp_server.cpython-312.pyc
Normal file
Binary file not shown.
14
app/config.py
Normal file
14
app/config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "KKS Badge Management System"
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./badge_admin.db"
|
||||
TCP_HOST: str = "0.0.0.0"
|
||||
TCP_PORT: int = 5000
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8088
|
||||
DEBUG: bool = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
49
app/database.py
Normal file
49
app/database.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency injection for async database sessions."""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all database tables."""
|
||||
async with engine.begin() as conn:
|
||||
from app.models import ( # noqa: F401
|
||||
AlarmRecord,
|
||||
AttendanceRecord,
|
||||
BluetoothRecord,
|
||||
CommandLog,
|
||||
Device,
|
||||
HeartbeatRecord,
|
||||
LocationRecord,
|
||||
)
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
384
app/geocoding.py
Normal file
384
app/geocoding.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
|
||||
and reverse geocode coordinates to addresses.
|
||||
|
||||
Uses free APIs:
|
||||
- Cell tower: Google Geolocation API (if key available) or unwiredlabs.com
|
||||
- WiFi: Same APIs support WiFi AP lookup
|
||||
- Reverse geocoding: 天地图 (Tianditu) - free, WGS84 native
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Google Geolocation API key (set to enable Google geocoding)
|
||||
GOOGLE_API_KEY: Optional[str] = None
|
||||
|
||||
# Unwired Labs API token (free tier: 100 requests/day)
|
||||
# Sign up at https://unwiredlabs.com/
|
||||
UNWIRED_API_TOKEN: Optional[str] = None
|
||||
|
||||
# 天地图 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
|
||||
|
||||
|
||||
class LRUCache(OrderedDict):
|
||||
"""Simple LRU cache based on OrderedDict."""
|
||||
|
||||
def __init__(self, maxsize: int = _CACHE_MAX_SIZE):
|
||||
super().__init__()
|
||||
self._maxsize = maxsize
|
||||
|
||||
def get_cached(self, key):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
return self[key]
|
||||
return None
|
||||
|
||||
def put(self, key, value):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
self[key] = value
|
||||
while len(self) > self._maxsize:
|
||||
self.popitem(last=False)
|
||||
|
||||
|
||||
# Cache cell tower lookups to avoid redundant API calls
|
||||
_cell_cache: LRUCache = LRUCache()
|
||||
_wifi_cache: LRUCache = LRUCache()
|
||||
# Cache reverse geocoding results (coord rounded to ~100m -> address)
|
||||
_address_cache: LRUCache = LRUCache()
|
||||
|
||||
|
||||
async def geocode_location(
|
||||
mcc: Optional[int] = None,
|
||||
mnc: Optional[int] = None,
|
||||
lac: Optional[int] = None,
|
||||
cell_id: Optional[int] = None,
|
||||
wifi_list: Optional[list[dict]] = None,
|
||||
neighbor_cells: Optional[list[dict]] = None,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Convert cell tower and/or WiFi AP data to lat/lon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mcc : int - Mobile Country Code
|
||||
mnc : int - Mobile Network Code
|
||||
lac : int - Location Area Code
|
||||
cell_id : int - Cell Tower ID
|
||||
wifi_list : list[dict] - WiFi APs [{"mac": "AA:BB:CC:DD:EE:FF", "signal": -70}, ...]
|
||||
neighbor_cells : list[dict] - Neighbor cells [{"lac": ..., "cell_id": ..., "rssi": ...}, ...]
|
||||
|
||||
Returns
|
||||
-------
|
||||
(latitude, longitude) or (None, None)
|
||||
"""
|
||||
# Check cache first (cell tower)
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
cache_key = (mcc, mnc or 0, lac, cell_id)
|
||||
cached = _cell_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Try Google Geolocation API first
|
||||
if GOOGLE_API_KEY:
|
||||
result = await _geocode_google(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
|
||||
if result[0] is not None:
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
|
||||
return result
|
||||
|
||||
# Try Unwired Labs API
|
||||
if UNWIRED_API_TOKEN:
|
||||
result = await _geocode_unwired(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
|
||||
if result[0] is not None:
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
|
||||
return result
|
||||
|
||||
# Fallback: Mylnikov.org (free, no API key required)
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
result = await _geocode_mylnikov_cell(mcc, mnc or 0, lac, cell_id)
|
||||
if result[0] is not None:
|
||||
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
|
||||
return result
|
||||
|
||||
# Try WiFi via Mylnikov
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
mac = ap.get("mac", "")
|
||||
if mac:
|
||||
result = await _geocode_mylnikov_wifi(mac)
|
||||
if result[0] is not None:
|
||||
return result
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_google(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Use Google Geolocation API."""
|
||||
url = f"https://www.googleapis.com/geolocation/v1/geolocate?key={GOOGLE_API_KEY}"
|
||||
body: dict = {}
|
||||
|
||||
if mcc is not None:
|
||||
body["homeMobileCountryCode"] = mcc
|
||||
if mnc is not None:
|
||||
body["homeMobileNetworkCode"] = mnc
|
||||
|
||||
# Cell towers
|
||||
towers = []
|
||||
if lac is not None and cell_id is not None:
|
||||
towers.append({
|
||||
"cellId": cell_id,
|
||||
"locationAreaCode": lac,
|
||||
"mobileCountryCode": mcc or 0,
|
||||
"mobileNetworkCode": mnc or 0,
|
||||
})
|
||||
if neighbor_cells:
|
||||
for nc in neighbor_cells:
|
||||
towers.append({
|
||||
"cellId": nc.get("cell_id", 0),
|
||||
"locationAreaCode": nc.get("lac", 0),
|
||||
"mobileCountryCode": mcc or 0,
|
||||
"mobileNetworkCode": mnc or 0,
|
||||
"signalStrength": -(nc.get("rssi", 0)),
|
||||
})
|
||||
if towers:
|
||||
body["cellTowers"] = towers
|
||||
|
||||
# WiFi APs
|
||||
if wifi_list:
|
||||
aps = []
|
||||
for ap in wifi_list:
|
||||
aps.append({
|
||||
"macAddress": ap.get("mac", ""),
|
||||
"signalStrength": -(ap.get("signal", 0)),
|
||||
})
|
||||
body["wifiAccessPoints"] = aps
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
loc = data.get("location", {})
|
||||
lat = loc.get("lat")
|
||||
lng = loc.get("lng")
|
||||
if lat is not None and lng is not None:
|
||||
logger.info("Google geocode: lat=%.6f, lon=%.6f", lat, lng)
|
||||
return (lat, lng)
|
||||
else:
|
||||
text = await resp.text()
|
||||
logger.warning("Google geocode failed: %d %s", resp.status, text[:200])
|
||||
except Exception as e:
|
||||
logger.warning("Google geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_unwired(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Use Unwired Labs LocationAPI."""
|
||||
url = "https://us1.unwiredlabs.com/v2/process.php"
|
||||
body: dict = {"token": UNWIRED_API_TOKEN}
|
||||
|
||||
# Cell towers
|
||||
cells = []
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
cells.append({
|
||||
"lac": lac,
|
||||
"cid": cell_id,
|
||||
"mcc": mcc,
|
||||
"mnc": mnc or 0,
|
||||
})
|
||||
if neighbor_cells:
|
||||
for nc in neighbor_cells:
|
||||
cells.append({
|
||||
"lac": nc.get("lac", 0),
|
||||
"cid": nc.get("cell_id", 0),
|
||||
"mcc": mcc or 0,
|
||||
"mnc": mnc or 0,
|
||||
"signal": -(nc.get("rssi", 0)),
|
||||
})
|
||||
if cells:
|
||||
body["cells"] = cells
|
||||
|
||||
# WiFi APs
|
||||
if wifi_list:
|
||||
aps = []
|
||||
for ap in wifi_list:
|
||||
aps.append({
|
||||
"bssid": ap.get("mac", ""),
|
||||
"signal": -(ap.get("signal", 0)),
|
||||
})
|
||||
body["wifi"] = aps
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
if data.get("status") == "ok":
|
||||
lat = data.get("lat")
|
||||
lon = data.get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
logger.info("Unwired geocode: lat=%.6f, lon=%.6f", lat, lon)
|
||||
return (lat, lon)
|
||||
else:
|
||||
logger.warning("Unwired geocode: %s", data.get("message", "unknown error"))
|
||||
except Exception as e:
|
||||
logger.warning("Unwired geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_mylnikov_cell(
|
||||
mcc: int, mnc: int, lac: int, cell_id: int
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Use Mylnikov.org free cell tower geocoding API (no API key required)."""
|
||||
url = (
|
||||
f"https://api.mylnikov.org/geolocation/cell"
|
||||
f"?v=1.1&data=open"
|
||||
f"&mcc={mcc}&mnc={mnc}&lac={lac}&cellid={cell_id}"
|
||||
)
|
||||
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("result") == 200:
|
||||
lat = data.get("data", {}).get("lat")
|
||||
lon = data.get("data", {}).get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
logger.info("Mylnikov cell geocode: lat=%.6f, lon=%.6f", lat, lon)
|
||||
return (lat, lon)
|
||||
else:
|
||||
logger.debug("Mylnikov cell: no result for MCC=%d MNC=%d LAC=%d CellID=%d",
|
||||
mcc, mnc, lac, cell_id)
|
||||
else:
|
||||
logger.warning("Mylnikov cell API HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Mylnikov cell geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_mylnikov_wifi(mac: str) -> tuple[Optional[float], Optional[float]]:
|
||||
"""Use Mylnikov.org free WiFi AP geocoding API."""
|
||||
# Normalize MAC format (needs colons)
|
||||
mac = mac.upper().replace("-", ":")
|
||||
url = f"https://api.mylnikov.org/geolocation/wifi?v=1.1&data=open&bssid={mac}"
|
||||
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("result") == 200:
|
||||
lat = data.get("data", {}).get("lat")
|
||||
lon = data.get("data", {}).get("lon")
|
||||
if lat is not None and lon is not None:
|
||||
logger.info("Mylnikov WiFi geocode: lat=%.6f, lon=%.6f (MAC=%s)", lat, lon, mac)
|
||||
_wifi_cache.put(mac, (lat, lon))
|
||||
return (lat, lon)
|
||||
else:
|
||||
logger.debug("Mylnikov WiFi API HTTP %d for MAC=%s", resp.status, mac)
|
||||
except Exception as e:
|
||||
logger.warning("Mylnikov WiFi geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse Geocoding: coordinates -> address
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def reverse_geocode(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Convert lat/lon to a human-readable address.
|
||||
|
||||
Tries 天地图 (Tianditu) first, which uses WGS84 natively.
|
||||
Returns None if no reverse geocoding service is available.
|
||||
"""
|
||||
# Round to ~100m for cache key to reduce API calls
|
||||
cache_key = (round(lat, 3), round(lon, 3))
|
||||
cached = _address_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if TIANDITU_API_KEY:
|
||||
result = await _reverse_geocode_tianditu(lat, lon)
|
||||
if result:
|
||||
_address_cache.put(cache_key, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _reverse_geocode_tianditu(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Use 天地图 (Tianditu) reverse geocoding API.
|
||||
|
||||
API docs: http://lbs.tianditu.gov.cn/server/geocoding.html
|
||||
Coordinate system: WGS84 (same as our GPS data, no conversion needed).
|
||||
Free tier: 10,000 requests/day.
|
||||
"""
|
||||
post_str = json.dumps({"lon": lon, "lat": lat, "ver": 1}, separators=(",", ":"))
|
||||
url = (
|
||||
f"http://api.tianditu.gov.cn/geocoder"
|
||||
f"?postStr={quote(post_str)}&type=geocode&tk={TIANDITU_API_KEY}"
|
||||
)
|
||||
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", {})
|
||||
# Build address from components
|
||||
addr_comp = result.get("addressComponent", {})
|
||||
formatted = result.get("formatted_address", "")
|
||||
if formatted:
|
||||
# Add nearby POI if available
|
||||
poi = addr_comp.get("poi", "")
|
||||
address = formatted
|
||||
if poi and poi not in formatted:
|
||||
address = f"{formatted} ({poi})"
|
||||
logger.info(
|
||||
"Tianditu reverse geocode: %.6f,%.6f -> %s",
|
||||
lat, lon, address,
|
||||
)
|
||||
return address
|
||||
else:
|
||||
logger.warning(
|
||||
"Tianditu reverse geocode error: %s",
|
||||
data.get("msg", data),
|
||||
)
|
||||
else:
|
||||
logger.warning("Tianditu reverse geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Tianditu reverse geocode error: %s", e)
|
||||
|
||||
return None
|
||||
100
app/main.py
Normal file
100
app/main.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from app.database import init_db, async_session
|
||||
from app.tcp_server import tcp_manager
|
||||
from app.config import settings
|
||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logger.info("Initializing database...")
|
||||
await init_db()
|
||||
# Reset all devices to offline on startup (stale state from previous run)
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
from app.models import Device
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
await session.execute(update(Device).values(status="offline"))
|
||||
logger.info("All devices reset to offline on startup")
|
||||
except Exception:
|
||||
logger.exception("Failed to reset device statuses on startup")
|
||||
logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT)
|
||||
tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT))
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down TCP server...")
|
||||
await tcp_manager.stop()
|
||||
tcp_task.cancel()
|
||||
|
||||
app = FastAPI(
|
||||
title="KKS Badge Management System / KKS工牌管理系统",
|
||||
description="""
|
||||
## KKS P240 & P241 蓝牙工牌管理后台
|
||||
|
||||
### 功能模块 / Features:
|
||||
- **设备管理 / Device Management** - 设备注册、状态监控
|
||||
- **位置数据 / Location Data** - GPS/LBS/WIFI定位数据查询与轨迹回放
|
||||
- **报警管理 / Alarm Management** - SOS、围栏、低电等报警处理
|
||||
- **考勤管理 / Attendance** - 打卡记录查询与统计
|
||||
- **指令管理 / Commands** - 远程指令下发与留言
|
||||
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
|
||||
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
|
||||
|
||||
### 通讯协议 / Protocol:
|
||||
- TCP端口: {tcp_port} (设备连接)
|
||||
- 支持协议: KKS P240/P241 通讯协议
|
||||
""".format(tcp_port=settings.TCP_PORT),
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
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)
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse, tags=["Admin"])
|
||||
async def admin_page():
|
||||
"""管理后台页面 / Admin Dashboard"""
|
||||
html_path = _STATIC_DIR / "admin.html"
|
||||
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
"admin": "/admin",
|
||||
"tcp_port": settings.TCP_PORT,
|
||||
}
|
||||
|
||||
@app.get("/health", tags=["Root"])
|
||||
async def health():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"connected_devices": len(tcp_manager.connections),
|
||||
}
|
||||
323
app/models.py
Normal file
323
app/models.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""Registered Bluetooth badge devices."""
|
||||
|
||||
__tablename__ = "devices"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
imei: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||
device_type: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="offline", nullable=False)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
iccid: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
locations: Mapped[list["LocationRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
alarms: Mapped[list["AlarmRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
heartbeats: Mapped[list["HeartbeatRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
attendance_records: Mapped[list["AttendanceRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
bluetooth_records: Mapped[list["BluetoothRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
command_logs: Mapped[list["CommandLog"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Device(id={self.id}, imei={self.imei}, status={self.status})>"
|
||||
|
||||
|
||||
class LocationRecord(Base):
|
||||
"""GPS / LBS / WIFI location records."""
|
||||
|
||||
__tablename__ = "location_records"
|
||||
__table_args__ = (
|
||||
Index("ix_location_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
location_type: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False
|
||||
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
neighbor_cells: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
report_mode: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
is_realtime: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
mileage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="locations")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<LocationRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.location_type})>"
|
||||
)
|
||||
|
||||
|
||||
class AlarmRecord(Base):
|
||||
"""Alarm events raised by devices."""
|
||||
|
||||
__tablename__ = "alarm_records"
|
||||
__table_args__ = (
|
||||
Index("ix_alarm_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
alarm_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False
|
||||
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
||||
alarm_source: Mapped[str | None] = mapped_column(
|
||||
String(10), nullable=True
|
||||
) # single_fence, multi_fence, lbs, wifi
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fence_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="alarms")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AlarmRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.alarm_type})>"
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatRecord(Base):
|
||||
"""Heartbeat history from devices."""
|
||||
|
||||
__tablename__ = "heartbeat_records"
|
||||
__table_args__ = (
|
||||
Index("ix_heartbeat_device_time", "device_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
|
||||
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
gsm_signal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
extension_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="heartbeats")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<HeartbeatRecord(id={self.id}, device_id={self.device_id})>"
|
||||
|
||||
|
||||
class AttendanceRecord(Base):
|
||||
"""Attendance punch records from badges."""
|
||||
|
||||
__tablename__ = "attendance_records"
|
||||
__table_args__ = (
|
||||
Index("ix_attendance_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
attendance_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # clock_in, clock_out
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
lbs_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="attendance_records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AttendanceRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.attendance_type})>"
|
||||
)
|
||||
|
||||
|
||||
class BluetoothRecord(Base):
|
||||
"""Bluetooth punch card and location records."""
|
||||
|
||||
__tablename__ = "bluetooth_records"
|
||||
__table_args__ = (
|
||||
Index("ix_bluetooth_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
record_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # punch, location
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
beacon_mac: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_battery: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
beacon_battery_unit: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
attendance_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
bluetooth_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<BluetoothRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.record_type})>"
|
||||
)
|
||||
|
||||
|
||||
class BeaconConfig(Base):
|
||||
"""Registered Bluetooth beacon configuration for indoor positioning."""
|
||||
|
||||
__tablename__ = "beacon_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
beacon_mac: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
floor: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
area: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
||||
|
||||
|
||||
class CommandLog(Base):
|
||||
"""Log of commands sent to devices."""
|
||||
|
||||
__tablename__ = "command_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_command_device_status", "device_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
command_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
command_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
server_flag: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
response_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), default="pending", nullable=False
|
||||
) # pending, sent, success, failed
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
response_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="command_logs")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<CommandLog(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.command_type}, status={self.status})>"
|
||||
)
|
||||
20
app/protocol/__init__.py
Normal file
20
app/protocol/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol
|
||||
|
||||
Provides packet parsing, building, and CRC computation for the
|
||||
KKS Bluetooth badge communication protocol over TCP.
|
||||
"""
|
||||
|
||||
from .constants import * # noqa: F401,F403
|
||||
from .crc import crc_itu, verify_crc
|
||||
from .parser import PacketParser
|
||||
from .builder import PacketBuilder
|
||||
|
||||
__all__ = [
|
||||
# CRC
|
||||
"crc_itu",
|
||||
"verify_crc",
|
||||
# Classes
|
||||
"PacketParser",
|
||||
"PacketBuilder",
|
||||
]
|
||||
BIN
app/protocol/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/protocol/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/protocol/__pycache__/builder.cpython-312.pyc
Normal file
BIN
app/protocol/__pycache__/builder.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/protocol/__pycache__/constants.cpython-312.pyc
Normal file
BIN
app/protocol/__pycache__/constants.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/protocol/__pycache__/crc.cpython-312.pyc
Normal file
BIN
app/protocol/__pycache__/crc.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/protocol/__pycache__/parser.cpython-312.pyc
Normal file
BIN
app/protocol/__pycache__/parser.cpython-312.pyc
Normal file
Binary file not shown.
331
app/protocol/builder.py
Normal file
331
app/protocol/builder.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol Packet Builder
|
||||
|
||||
Constructs server response packets for the KKS badge protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
PROTO_HEARTBEAT,
|
||||
PROTO_HEARTBEAT_EXT,
|
||||
PROTO_LBS_ADDRESS_REQ,
|
||||
PROTO_LBS_MULTI_REPLY,
|
||||
PROTO_LOGIN,
|
||||
PROTO_MESSAGE,
|
||||
PROTO_ONLINE_CMD,
|
||||
PROTO_TIME_SYNC,
|
||||
PROTO_TIME_SYNC_2,
|
||||
PROTO_ADDRESS_REPLY_EN,
|
||||
START_MARKER_LONG,
|
||||
START_MARKER_SHORT,
|
||||
STOP_MARKER,
|
||||
)
|
||||
from .crc import crc_itu
|
||||
|
||||
|
||||
class PacketBuilder:
|
||||
"""Builds server response packets for the KKS badge protocol."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core builder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_response(
|
||||
protocol_number: int,
|
||||
serial_number: int,
|
||||
info_content: bytes = b"",
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a complete response packet.
|
||||
|
||||
Packet layout (short form, 0x7878):
|
||||
START(2) + LENGTH(1) + PROTO(1) + INFO(N) + SERIAL(2) + CRC(2) + STOP(2)
|
||||
LENGTH = 1(proto) + N(info) + 2(serial) + 2(crc)
|
||||
|
||||
If the payload exceeds 255 bytes the long form (0x7979, 2-byte
|
||||
length) is used automatically.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
protocol_number : int
|
||||
Protocol number byte.
|
||||
serial_number : int
|
||||
Packet serial number (16-bit).
|
||||
info_content : bytes
|
||||
Information content (may be empty).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The fully assembled packet.
|
||||
"""
|
||||
proto_byte = struct.pack("B", protocol_number)
|
||||
serial_bytes = struct.pack("!H", serial_number)
|
||||
|
||||
# Payload for length calculation: proto + info + serial + crc
|
||||
payload_len = 1 + len(info_content) + 2 + 2 # proto + info + serial + crc
|
||||
|
||||
if payload_len > 0xFF:
|
||||
# Long packet
|
||||
length_bytes = struct.pack("!H", payload_len)
|
||||
start_marker = START_MARKER_LONG
|
||||
else:
|
||||
length_bytes = struct.pack("B", payload_len)
|
||||
start_marker = START_MARKER_SHORT
|
||||
|
||||
# CRC is computed over: length_bytes + proto + info + serial
|
||||
crc_input = length_bytes + proto_byte + info_content + serial_bytes
|
||||
crc_value = crc_itu(crc_input)
|
||||
crc_bytes = struct.pack("!H", crc_value)
|
||||
|
||||
return (
|
||||
start_marker
|
||||
+ length_bytes
|
||||
+ proto_byte
|
||||
+ info_content
|
||||
+ serial_bytes
|
||||
+ crc_bytes
|
||||
+ STOP_MARKER
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Specific response builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_login_response(self, serial_number: int) -> bytes:
|
||||
"""
|
||||
Build a login response (0x01).
|
||||
|
||||
The server responds with an empty info content to acknowledge login.
|
||||
"""
|
||||
return self.build_response(PROTO_LOGIN, serial_number)
|
||||
|
||||
def build_heartbeat_response(
|
||||
self,
|
||||
serial_number: int,
|
||||
protocol: int = PROTO_HEARTBEAT,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a heartbeat response.
|
||||
|
||||
Works for both standard heartbeat (0x13) and extended heartbeat (0x36).
|
||||
"""
|
||||
return self.build_response(protocol, serial_number)
|
||||
|
||||
def build_time_sync_response(
|
||||
self,
|
||||
serial_number: int,
|
||||
protocol: int = PROTO_TIME_SYNC,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a time sync response (0x1F).
|
||||
|
||||
Returns the current UTC time as a 4-byte Unix timestamp.
|
||||
"""
|
||||
utc_now = int(time.time())
|
||||
info = struct.pack("!I", utc_now)
|
||||
return self.build_response(protocol, serial_number, info)
|
||||
|
||||
def build_time_sync_8a_response(self, serial_number: int) -> bytes:
|
||||
"""
|
||||
Build a Time Sync 2 response (0x8A).
|
||||
|
||||
Returns the current UTC time as YY MM DD HH MM SS (6 bytes).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
info = struct.pack(
|
||||
"BBBBBB",
|
||||
now.year - 2000,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
)
|
||||
return self.build_response(PROTO_TIME_SYNC_2, serial_number, info)
|
||||
|
||||
def build_lbs_multi_response(self, serial_number: int) -> bytes:
|
||||
"""
|
||||
Build an LBS Multi Reply response (0x2E).
|
||||
|
||||
The server acknowledges with an empty info content.
|
||||
"""
|
||||
return self.build_response(PROTO_LBS_MULTI_REPLY, serial_number)
|
||||
|
||||
def build_online_command(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
command: str,
|
||||
language: int = 0x0001,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build an online command packet (0x80).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
command : str
|
||||
The command string to send (ASCII).
|
||||
language : int
|
||||
Language code (default 0x0001 = Chinese).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
cmd_bytes = command.encode("ascii")
|
||||
# inner_len = server_flag(4) + cmd_content(N)
|
||||
inner_len = 4 + len(cmd_bytes)
|
||||
|
||||
info = struct.pack("B", inner_len) # 1 byte inner length
|
||||
info += struct.pack("!I", server_flag) # 4 bytes server flag
|
||||
info += cmd_bytes # N bytes command
|
||||
info += struct.pack("!H", language) # 2 bytes language
|
||||
|
||||
return self.build_response(PROTO_ONLINE_CMD, serial_number, info)
|
||||
|
||||
def build_message(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
message_text: str,
|
||||
language: int = 0x0001,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a message packet (0x82).
|
||||
|
||||
The message is encoded in UTF-16 Big-Endian.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
message_text : str
|
||||
The message string to send.
|
||||
language : int
|
||||
Language code (default 0x0001 = Chinese).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
msg_bytes = message_text.encode("utf-16-be")
|
||||
# inner_len = server_flag(4) + msg_content(N)
|
||||
inner_len = 4 + len(msg_bytes)
|
||||
|
||||
info = struct.pack("B", inner_len) # 1 byte inner length
|
||||
info += struct.pack("!I", server_flag) # 4 bytes server flag
|
||||
info += msg_bytes # N bytes message (UTF16BE)
|
||||
info += struct.pack("!H", language) # 2 bytes language
|
||||
|
||||
return self.build_response(PROTO_MESSAGE, serial_number, info)
|
||||
|
||||
def build_address_reply_cn(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
address: str,
|
||||
phone: str = "",
|
||||
protocol: int = PROTO_LBS_ADDRESS_REQ,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a Chinese address reply packet.
|
||||
|
||||
Used as a response to protocol 0x17 (LBS Address Request)
|
||||
or similar address query protocols.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
address : str
|
||||
Address string (encoded as UTF-16 Big-Endian).
|
||||
phone : str
|
||||
Phone number string (BCD encoded, even length, padded with 'F').
|
||||
protocol : int
|
||||
Protocol number to respond with (default 0x17).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
addr_bytes = address.encode("utf-16-be")
|
||||
addr_len = len(addr_bytes)
|
||||
|
||||
info = struct.pack("!I", server_flag) # 4 bytes server flag
|
||||
info += struct.pack("!H", addr_len) # 2 bytes address length
|
||||
info += addr_bytes # N bytes address
|
||||
|
||||
if phone:
|
||||
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
|
||||
phone_bcd = bytes.fromhex(phone_padded)
|
||||
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
|
||||
info += phone_bcd # N bytes phone BCD
|
||||
else:
|
||||
info += struct.pack("B", 0) # 0 phone length
|
||||
|
||||
return self.build_response(protocol, serial_number, info)
|
||||
|
||||
def build_address_reply_en(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
address: str,
|
||||
phone: str = "",
|
||||
protocol: int = PROTO_ADDRESS_REPLY_EN,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build an English address reply packet (0x97).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
address : str
|
||||
Address string (ASCII/UTF-8 encoded).
|
||||
phone : str
|
||||
Phone number string (BCD encoded, even length, padded with 'F').
|
||||
protocol : int
|
||||
Protocol number to respond with (default 0x97).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
addr_bytes = address.encode("utf-8")
|
||||
addr_len = len(addr_bytes)
|
||||
|
||||
info = struct.pack("!I", server_flag) # 4 bytes server flag
|
||||
info += struct.pack("!H", addr_len) # 2 bytes address length
|
||||
info += addr_bytes # N bytes address
|
||||
|
||||
if phone:
|
||||
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
|
||||
phone_bcd = bytes.fromhex(phone_padded)
|
||||
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
|
||||
info += phone_bcd # N bytes phone BCD
|
||||
else:
|
||||
info += struct.pack("B", 0) # 0 phone length
|
||||
|
||||
return self.build_response(protocol, serial_number, info)
|
||||
172
app/protocol/constants.py
Normal file
172
app/protocol/constants.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol Constants
|
||||
|
||||
Defines all protocol markers, protocol numbers, alarm types,
|
||||
signal strength levels, data report modes, and related mappings.
|
||||
"""
|
||||
|
||||
from typing import Dict, FrozenSet
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start / Stop Markers
|
||||
# ---------------------------------------------------------------------------
|
||||
START_MARKER_SHORT: bytes = b'\x78\x78' # 1-byte packet length field
|
||||
START_MARKER_LONG: bytes = b'\x79\x79' # 2-byte packet length field
|
||||
STOP_MARKER: bytes = b'\x0D\x0A'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Numbers
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTO_LOGIN: int = 0x01
|
||||
PROTO_HEARTBEAT: int = 0x13
|
||||
PROTO_LBS_ADDRESS_REQ: int = 0x17
|
||||
PROTO_ADDRESS_QUERY: int = 0x1A
|
||||
PROTO_TIME_SYNC: int = 0x1F
|
||||
PROTO_GPS: int = 0x22
|
||||
PROTO_LBS_MULTI: int = 0x28
|
||||
PROTO_LBS_MULTI_REPLY: int = 0x2E
|
||||
PROTO_WIFI: int = 0x2C
|
||||
PROTO_HEARTBEAT_EXT: int = 0x36
|
||||
PROTO_ONLINE_CMD: int = 0x80
|
||||
PROTO_ONLINE_CMD_REPLY: int = 0x81
|
||||
PROTO_MESSAGE: int = 0x82
|
||||
PROTO_TIME_SYNC_2: int = 0x8A
|
||||
PROTO_GENERAL_INFO: int = 0x94
|
||||
PROTO_ADDRESS_REPLY_EN: int = 0x97
|
||||
PROTO_GPS_4G: int = 0xA0
|
||||
PROTO_LBS_4G: int = 0xA1
|
||||
PROTO_WIFI_4G: int = 0xA2
|
||||
PROTO_ALARM_SINGLE_FENCE: int = 0xA3
|
||||
PROTO_ALARM_MULTI_FENCE: int = 0xA4
|
||||
PROTO_ALARM_LBS_4G: int = 0xA5
|
||||
PROTO_LBS_4G_ADDRESS_REQ: int = 0xA7
|
||||
PROTO_ALARM_ACK: int = 0x26
|
||||
PROTO_ALARM_WIFI: int = 0xA9
|
||||
PROTO_ATTENDANCE: int = 0xB0
|
||||
PROTO_ATTENDANCE_4G: int = 0xB1
|
||||
PROTO_BT_PUNCH: int = 0xB2
|
||||
PROTO_BT_LOCATION: int = 0xB3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alarm Types (bit-pattern -> name)
|
||||
# ---------------------------------------------------------------------------
|
||||
ALARM_TYPES: Dict[int, str] = {
|
||||
0x00: "normal",
|
||||
0x01: "sos",
|
||||
0x02: "power_cut",
|
||||
0x03: "vibration",
|
||||
0x04: "enter_fence",
|
||||
0x05: "exit_fence",
|
||||
0x06: "over_speed",
|
||||
0x09: "displacement",
|
||||
0x0A: "enter_gps_dead_zone",
|
||||
0x0B: "exit_gps_dead_zone",
|
||||
0x0C: "power_on",
|
||||
0x0D: "gps_first_fix",
|
||||
0x0E: "low_battery",
|
||||
0x0F: "low_battery_protection",
|
||||
0x10: "sim_change",
|
||||
0x11: "power_off",
|
||||
0x12: "airplane_mode",
|
||||
0x13: "remove",
|
||||
0x14: "door",
|
||||
0x15: "shutdown",
|
||||
0x16: "voice_alarm",
|
||||
0x17: "fake_base_station",
|
||||
0x18: "cover_open",
|
||||
0x19: "internal_low_battery",
|
||||
0xFE: "acc_on",
|
||||
0xFF: "acc_off",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GSM Signal Strength Levels
|
||||
# ---------------------------------------------------------------------------
|
||||
GSM_SIGNAL_LEVELS: Dict[int, str] = {
|
||||
0x00: "No Signal",
|
||||
0x01: "Very Weak",
|
||||
0x02: "Weak",
|
||||
0x03: "Good",
|
||||
0x04: "Strong",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Report Mode (0x00 - 0x0F)
|
||||
# ---------------------------------------------------------------------------
|
||||
DATA_REPORT_MODES: Dict[int, str] = {
|
||||
0x00: "Timing Upload", # 定时上报
|
||||
0x01: "Distance Upload", # 定距上报
|
||||
0x02: "Turn Point Upload", # 拐点上传
|
||||
0x03: "ACC Status Changed", # ACC状态改变上传
|
||||
0x04: "Last Point After Stop", # 运动→静止补传最后定位点
|
||||
0x05: "Reconnect Upload", # 断网重连上报最后有效点
|
||||
0x06: "Ephemeris Force Upload", # 星历更新强制上传GPS点
|
||||
0x07: "Button Upload", # 按键上传定位点
|
||||
0x08: "Power On Upload", # 开机上报位置信息
|
||||
0x09: "Unused", # 未使用
|
||||
0x0A: "Static Update", # 设备静止后上报(时间更新)
|
||||
0x0B: "WiFi Parsed Upload", # WIFI解析经纬度上传
|
||||
0x0C: "LJDW Upload", # 立即定位指令上报
|
||||
0x0D: "Static Last Point", # 设备静止后上报最后经纬度
|
||||
0x0E: "GPSDUP Upload", # 静止状态定时上传
|
||||
0x0F: "Exit Tracking Mode", # 退出追踪模式
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Numbers That Require a Server Response
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
|
||||
PROTO_LOGIN,
|
||||
PROTO_HEARTBEAT,
|
||||
PROTO_LBS_ADDRESS_REQ,
|
||||
PROTO_ADDRESS_QUERY,
|
||||
PROTO_TIME_SYNC,
|
||||
PROTO_LBS_MULTI,
|
||||
PROTO_HEARTBEAT_EXT,
|
||||
PROTO_TIME_SYNC_2,
|
||||
# PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc
|
||||
PROTO_ALARM_SINGLE_FENCE,
|
||||
PROTO_ALARM_MULTI_FENCE,
|
||||
PROTO_ALARM_LBS_4G,
|
||||
PROTO_LBS_4G_ADDRESS_REQ,
|
||||
PROTO_ALARM_WIFI,
|
||||
PROTO_ATTENDANCE,
|
||||
PROTO_ATTENDANCE_4G,
|
||||
PROTO_BT_PUNCH,
|
||||
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec
|
||||
})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Number -> Human-Readable Name
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTOCOL_NAMES: Dict[int, str] = {
|
||||
PROTO_LOGIN: "Login",
|
||||
PROTO_HEARTBEAT: "Heartbeat",
|
||||
PROTO_LBS_ADDRESS_REQ: "LBS Address Request",
|
||||
PROTO_ADDRESS_QUERY: "Address Query",
|
||||
PROTO_TIME_SYNC: "Time Sync",
|
||||
PROTO_ALARM_ACK: "Alarm ACK",
|
||||
PROTO_GPS: "GPS",
|
||||
PROTO_LBS_MULTI: "LBS Multi",
|
||||
PROTO_LBS_MULTI_REPLY: "LBS Multi Reply",
|
||||
PROTO_WIFI: "WIFI",
|
||||
PROTO_HEARTBEAT_EXT: "Heartbeat Extended",
|
||||
PROTO_ONLINE_CMD: "Online Command",
|
||||
PROTO_ONLINE_CMD_REPLY: "Online Command Reply",
|
||||
PROTO_MESSAGE: "Message",
|
||||
PROTO_TIME_SYNC_2: "Time Sync 2",
|
||||
PROTO_GENERAL_INFO: "General Info",
|
||||
PROTO_ADDRESS_REPLY_EN: "Address Reply (EN)",
|
||||
PROTO_GPS_4G: "GPS 4G",
|
||||
PROTO_LBS_4G: "LBS 4G",
|
||||
PROTO_WIFI_4G: "WIFI 4G",
|
||||
PROTO_ALARM_SINGLE_FENCE: "Alarm Single Fence",
|
||||
PROTO_ALARM_MULTI_FENCE: "Alarm Multi Fence",
|
||||
PROTO_ALARM_LBS_4G: "Alarm LBS 4G",
|
||||
PROTO_LBS_4G_ADDRESS_REQ: "LBS 4G Address Request",
|
||||
PROTO_ALARM_WIFI: "Alarm WIFI",
|
||||
PROTO_ATTENDANCE: "Attendance",
|
||||
PROTO_ATTENDANCE_4G: "Attendance 4G",
|
||||
PROTO_BT_PUNCH: "BT Punch",
|
||||
PROTO_BT_LOCATION: "BT Location",
|
||||
}
|
||||
76
app/protocol/crc.py
Normal file
76
app/protocol/crc.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
CRC-ITU Implementation for KKS Badge Protocol
|
||||
|
||||
Uses CRC-16/X-25 (reflected CRC-CCITT):
|
||||
Polynomial: 0x8408 (reflected 0x1021)
|
||||
Initial value: 0xFFFF
|
||||
Final XOR: 0xFFFF
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-computed CRC lookup table (256 entries, reflected polynomial 0x8408)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CRC_TABLE: List[int] = []
|
||||
|
||||
|
||||
def _generate_crc_table() -> List[int]:
|
||||
"""Generate the CRC-16/X-25 lookup table for reflected polynomial 0x8408."""
|
||||
table: List[int] = []
|
||||
for i in range(256):
|
||||
crc = i
|
||||
for _ in range(8):
|
||||
if crc & 1:
|
||||
crc = (crc >> 1) ^ 0x8408
|
||||
else:
|
||||
crc >>= 1
|
||||
table.append(crc)
|
||||
return table
|
||||
|
||||
|
||||
_CRC_TABLE = _generate_crc_table()
|
||||
|
||||
|
||||
def crc_itu(data: bytes) -> int:
|
||||
"""
|
||||
Compute the CRC-ITU checksum for the given data.
|
||||
|
||||
Uses the CRC-16/X-25 algorithm (reflected CRC-CCITT with final XOR).
|
||||
For a KKS protocol packet this should be the bytes from (and including)
|
||||
the packet-length field through the serial-number field.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : bytes
|
||||
The data to compute the CRC over.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
16-bit CRC value.
|
||||
"""
|
||||
crc: int = 0xFFFF
|
||||
for byte in data:
|
||||
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
||||
return crc ^ 0xFFFF
|
||||
|
||||
|
||||
def verify_crc(data: bytes, expected_crc: int) -> bool:
|
||||
"""
|
||||
Verify that *data* produces the *expected_crc*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : bytes
|
||||
The data slice to check (same range used when computing the CRC).
|
||||
expected_crc : int
|
||||
The 16-bit CRC value to compare against.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if the computed CRC matches *expected_crc*.
|
||||
"""
|
||||
return crc_itu(data) == (expected_crc & 0xFFFF)
|
||||
1142
app/protocol/parser.py
Normal file
1142
app/protocol/parser.py
Normal file
File diff suppressed because it is too large
Load Diff
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/alarms.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/alarms.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/attendance.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/attendance.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/beacons.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/beacons.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/bluetooth.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/bluetooth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/commands.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/commands.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/devices.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/devices.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routers/__pycache__/locations.cpython-312.pyc
Normal file
BIN
app/routers/__pycache__/locations.cpython-312.pyc
Normal file
Binary file not shown.
172
app/routers/alarms.py
Normal file
172
app/routers/alarms.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Alarms Router - 报警管理接口
|
||||
API endpoints for alarm record queries, acknowledgement, and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 AlarmRecord
|
||||
from app.schemas import (
|
||||
AlarmAcknowledge,
|
||||
AlarmRecordResponse,
|
||||
APIResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
|
||||
summary="获取报警记录列表 / List alarms",
|
||||
)
|
||||
async def list_alarms(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
|
||||
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
|
||||
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 alarm records with filters for device, alarm type, acknowledged status, and time range.
|
||||
"""
|
||||
query = select(AlarmRecord)
|
||||
count_query = select(func.count(AlarmRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(AlarmRecord.device_id == device_id)
|
||||
count_query = count_query.where(AlarmRecord.device_id == device_id)
|
||||
|
||||
if alarm_type:
|
||||
query = query.where(AlarmRecord.alarm_type == alarm_type)
|
||||
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
|
||||
|
||||
if acknowledged is not None:
|
||||
query = query.where(AlarmRecord.acknowledged == acknowledged)
|
||||
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AlarmRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AlarmRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AlarmRecord.recorded_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(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
alarms = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取报警统计 / Get alarm statistics",
|
||||
)
|
||||
async def alarm_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取报警统计:总数、未确认数、按类型分组统计。
|
||||
Get alarm statistics: total, unacknowledged count, and breakdown by type.
|
||||
"""
|
||||
# Total alarms
|
||||
total_result = await db.execute(select(func.count(AlarmRecord.id)))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Unacknowledged alarms
|
||||
unack_result = await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
|
||||
)
|
||||
unacknowledged = unack_result.scalar() or 0
|
||||
|
||||
# By type
|
||||
type_result = await db.execute(
|
||||
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
|
||||
.group_by(AlarmRecord.alarm_type)
|
||||
.order_by(func.count(AlarmRecord.id).desc())
|
||||
)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"unacknowledged": unacknowledged,
|
||||
"acknowledged": total - unacknowledged,
|
||||
"by_type": by_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{alarm_id}",
|
||||
response_model=APIResponse[AlarmRecordResponse],
|
||||
summary="获取报警详情 / Get alarm details",
|
||||
)
|
||||
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取报警记录详情。
|
||||
Get alarm record details by ID.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
|
||||
)
|
||||
alarm = result.scalar_one_or_none()
|
||||
if alarm is None:
|
||||
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
|
||||
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{alarm_id}/acknowledge",
|
||||
response_model=APIResponse[AlarmRecordResponse],
|
||||
summary="确认报警 / Acknowledge alarm",
|
||||
)
|
||||
async def acknowledge_alarm(
|
||||
alarm_id: int,
|
||||
body: AlarmAcknowledge | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
确认(或取消确认)报警记录。
|
||||
Acknowledge (or un-acknowledge) an alarm record.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
|
||||
)
|
||||
alarm = result.scalar_one_or_none()
|
||||
if alarm is None:
|
||||
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
|
||||
|
||||
acknowledged = body.acknowledged if body else True
|
||||
alarm.acknowledged = acknowledged
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(alarm)
|
||||
return APIResponse(
|
||||
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
|
||||
data=AlarmRecordResponse.model_validate(alarm),
|
||||
)
|
||||
187
app/routers/attendance.py
Normal file
187
app/routers/attendance.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Attendance Router - 考勤管理接口
|
||||
API endpoints for attendance record queries and statistics.
|
||||
"""
|
||||
|
||||
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 AttendanceRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AttendanceRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
summary="获取考勤记录列表 / List attendance records",
|
||||
)
|
||||
async def list_attendance(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
|
||||
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 attendance records with filters for device, type, and time range.
|
||||
"""
|
||||
query = select(AttendanceRecord)
|
||||
count_query = select(func.count(AttendanceRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(AttendanceRecord.device_id == device_id)
|
||||
count_query = count_query.where(AttendanceRecord.device_id == device_id)
|
||||
|
||||
if attendance_type:
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_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(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AttendanceRecordResponse.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(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取考勤统计 / Get attendance statistics",
|
||||
)
|
||||
async def attendance_stats(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
|
||||
Get attendance statistics: total records, breakdown by type and by device.
|
||||
"""
|
||||
base_filter = []
|
||||
if device_id is not None:
|
||||
base_filter.append(AttendanceRecord.device_id == device_id)
|
||||
if start_time:
|
||||
base_filter.append(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
base_filter.append(AttendanceRecord.recorded_at <= end_time)
|
||||
|
||||
# Total count
|
||||
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id))
|
||||
total_result = await db.execute(total_q)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# By type
|
||||
type_q = select(
|
||||
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id)
|
||||
).group_by(AttendanceRecord.attendance_type)
|
||||
if base_filter:
|
||||
type_q = type_q.where(*base_filter)
|
||||
type_result = await db.execute(type_q)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# By device (top 20)
|
||||
device_q = select(
|
||||
AttendanceRecord.device_id, func.count(AttendanceRecord.id)
|
||||
).group_by(AttendanceRecord.device_id).order_by(
|
||||
func.count(AttendanceRecord.id).desc()
|
||||
).limit(20)
|
||||
if base_filter:
|
||||
device_q = device_q.where(*base_filter)
|
||||
device_result = await db.execute(device_q)
|
||||
by_device = {str(row[0]): row[1] for row in device_result.all()}
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"by_type": by_type,
|
||||
"by_device": by_device,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
summary="获取设备考勤记录 / Get device attendance records",
|
||||
)
|
||||
async def device_attendance(
|
||||
device_id: int,
|
||||
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"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取指定设备的考勤记录。
|
||||
Get attendance 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}")
|
||||
|
||||
query = select(AttendanceRecord).where(AttendanceRecord.device_id == device_id)
|
||||
count_query = select(func.count(AttendanceRecord.id)).where(AttendanceRecord.device_id == device_id)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_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(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AttendanceRecordResponse.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,
|
||||
)
|
||||
)
|
||||
102
app/routers/beacons.py
Normal file
102
app/routers/beacons.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Beacons Router - 蓝牙信标管理接口
|
||||
API endpoints for managing Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BeaconConfigCreate,
|
||||
BeaconConfigResponse,
|
||||
BeaconConfigUpdate,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import beacon_service
|
||||
|
||||
router = APIRouter(prefix="/api/beacons", tags=["Beacons / 蓝牙信标"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[BeaconConfigResponse]],
|
||||
summary="获取信标列表 / List beacons",
|
||||
)
|
||||
async def list_beacons(
|
||||
status: str | None = Query(default=None, description="状态筛选 (active/inactive)"),
|
||||
search: str | None = Query(default=None, description="搜索 MAC/名称/区域"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
records, total = await beacon_service.get_beacons(
|
||||
db, page=page, page_size=page_size, status_filter=status, search=search
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BeaconConfigResponse.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(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
summary="获取信标详情 / Get beacon",
|
||||
)
|
||||
async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
beacon = await beacon_service.get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
status_code=201,
|
||||
summary="添加信标 / Create beacon",
|
||||
)
|
||||
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
|
||||
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
|
||||
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))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
summary="更新信标 / Update beacon",
|
||||
)
|
||||
async def update_beacon(
|
||||
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
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))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除信标 / Delete beacon",
|
||||
)
|
||||
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")
|
||||
135
app/routers/bluetooth.py
Normal file
135
app/routers/bluetooth.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Bluetooth Router - 蓝牙数据接口
|
||||
API endpoints for querying Bluetooth punch and location 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 BluetoothRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BluetoothRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
|
||||
summary="获取蓝牙记录列表 / List bluetooth records",
|
||||
)
|
||||
async def list_bluetooth_records(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
|
||||
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 Bluetooth records with filters for device, record type, and time range.
|
||||
"""
|
||||
query = select(BluetoothRecord)
|
||||
count_query = select(func.count(BluetoothRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(BluetoothRecord.device_id == device_id)
|
||||
count_query = count_query.where(BluetoothRecord.device_id == device_id)
|
||||
|
||||
if record_type:
|
||||
query = query.where(BluetoothRecord.record_type == record_type)
|
||||
count_query = count_query.where(BluetoothRecord.record_type == record_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_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(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BluetoothRecordResponse.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(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
|
||||
summary="获取设备蓝牙记录 / Get bluetooth records for device",
|
||||
)
|
||||
async def device_bluetooth_records(
|
||||
device_id: int,
|
||||
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"),
|
||||
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}")
|
||||
|
||||
query = select(BluetoothRecord).where(BluetoothRecord.device_id == device_id)
|
||||
count_query = select(func.count(BluetoothRecord.id)).where(BluetoothRecord.device_id == device_id)
|
||||
|
||||
if record_type:
|
||||
query = query.where(BluetoothRecord.record_type == record_type)
|
||||
count_query = count_query.where(BluetoothRecord.record_type == record_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_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(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BluetoothRecordResponse.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,
|
||||
)
|
||||
)
|
||||
288
app/routers/commands.py
Normal file
288
app/routers/commands.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
Commands Router - 指令管理接口
|
||||
API endpoints for sending commands / messages to devices and viewing command history.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
CommandResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import command_service, device_service
|
||||
|
||||
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas specific to this router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
"""Request body for sending a message (0x82) 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)")
|
||||
message: str = Field(..., max_length=500, description="消息内容 / Message content")
|
||||
|
||||
|
||||
class SendTTSRequest(BaseModel):
|
||||
"""Request body for sending a TTS voice broadcast 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)")
|
||||
text: str = Field(..., min_length=1, max_length=200, description="语音播报文本 / TTS text content")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _resolve_device(
|
||||
db: AsyncSession,
|
||||
device_id: int | None,
|
||||
imei: str | None,
|
||||
):
|
||||
"""Resolve a device from either device_id or imei. Returns the Device ORM instance."""
|
||||
if device_id is None and imei is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either device_id or imei must be provided / 必须提供 device_id 或 imei",
|
||||
)
|
||||
|
||||
if device_id is not None:
|
||||
device = await device_service.get_device(db, device_id)
|
||||
else:
|
||||
device = await device_service.get_device_by_imei(db, imei)
|
||||
|
||||
if device is None:
|
||||
identifier = f"ID={device_id}" if device_id else f"IMEI={imei}"
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Device {identifier} not found / 未找到设备 {identifier}",
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[CommandResponse]],
|
||||
summary="获取指令历史 / List command history",
|
||||
)
|
||||
async def list_commands(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"),
|
||||
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 command history with optional device and status filters.
|
||||
"""
|
||||
commands, total = await command_service.get_commands(
|
||||
db, device_id=device_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[CommandResponse.model_validate(c) for c in commands],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/send",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
status_code=201,
|
||||
summary="发送指令 / Send command to device",
|
||||
)
|
||||
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
向设备发送指令(通过TCP连接下发)。
|
||||
Send a command to a device via the TCP connection.
|
||||
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,
|
||||
command_type=body.command_type,
|
||||
command_content=body.command_content,
|
||||
)
|
||||
|
||||
# Send command via TCP
|
||||
try:
|
||||
await tcp_manager.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),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/message",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
status_code=201,
|
||||
summary="发送留言 / Send message to device (0x82)",
|
||||
)
|
||||
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
向设备发送留言消息(协议号 0x82)。
|
||||
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,
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tts",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
status_code=201,
|
||||
summary="语音下发 / Send TTS voice broadcast to device",
|
||||
)
|
||||
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
向设备发送 TTS 语音播报(通过 0x80 在线指令,TTS 命令格式)。
|
||||
Send a TTS voice broadcast to a device via online command (0x80).
|
||||
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,
|
||||
command_type="tts",
|
||||
command_content=tts_command,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
return APIResponse(
|
||||
message="TTS sent successfully / 语音下发成功",
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{command_id}",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
summary="获取指令详情 / Get command details",
|
||||
)
|
||||
async def get_command(command_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取指令详情。
|
||||
Get command log details by ID.
|
||||
"""
|
||||
command = await command_service.get_command(db, command_id)
|
||||
if command is None:
|
||||
raise HTTPException(status_code=404, detail=f"Command {command_id} not found / 未找到指令{command_id}")
|
||||
return APIResponse(data=CommandResponse.model_validate(command))
|
||||
154
app/routers/devices.py
Normal file
154
app/routers/devices.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Devices Router - 设备管理接口
|
||||
API endpoints for device CRUD operations and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceCreate,
|
||||
DeviceResponse,
|
||||
DeviceUpdate,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[DeviceResponse]],
|
||||
summary="获取设备列表 / List devices",
|
||||
)
|
||||
async def list_devices(
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
status: str | None = Query(default=None, description="状态过滤 / Status filter (online/offline)"),
|
||||
search: str | None = Query(default=None, description="搜索IMEI或名称 / Search by IMEI or name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取设备列表,支持分页、状态过滤和搜索。
|
||||
List devices with pagination, optional status filter, and search.
|
||||
"""
|
||||
devices, total = await device_service.get_devices(
|
||||
db, page=page, page_size=page_size, status_filter=status, search=search
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[DeviceResponse.model_validate(d) for d in devices],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取设备统计 / Get device statistics",
|
||||
)
|
||||
async def device_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取设备统计信息:总数、在线、离线。
|
||||
Get device statistics: total, online, offline counts.
|
||||
"""
|
||||
stats = await device_service.get_device_stats(db)
|
||||
return APIResponse(data=stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/imei/{imei}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
summary="按IMEI查询设备 / Get device by IMEI",
|
||||
)
|
||||
async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按IMEI号查询设备信息。
|
||||
Get device details by IMEI number.
|
||||
"""
|
||||
device = await device_service.get_device_by_imei(db, imei)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device with IMEI {imei} not found / 未找到IMEI为{imei}的设备")
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
summary="获取设备详情 / Get device details",
|
||||
)
|
||||
async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取设备详细信息。
|
||||
Get device details by ID.
|
||||
"""
|
||||
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}")
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
status_code=201,
|
||||
summary="创建设备 / Create device",
|
||||
)
|
||||
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
手动注册新设备。
|
||||
Manually register a new device.
|
||||
"""
|
||||
# Check for duplicate IMEI
|
||||
existing = await device_service.get_device_by_imei(db, device_data.imei)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device with IMEI {device_data.imei} already exists / IMEI {device_data.imei} 已存在",
|
||||
)
|
||||
|
||||
device = await device_service.create_device(db, device_data)
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
summary="更新设备信息 / Update device",
|
||||
)
|
||||
async def update_device(
|
||||
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
更新设备信息(名称、状态等)。
|
||||
Update device information (name, status, etc.).
|
||||
"""
|
||||
device = await device_service.update_device(db, device_id, device_data)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除设备 / Delete device",
|
||||
)
|
||||
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
删除设备及其关联数据。
|
||||
Delete a device and all associated records.
|
||||
"""
|
||||
deleted = await device_service.delete_device(db, device_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
return APIResponse(message="Device deleted successfully / 设备删除成功")
|
||||
115
app/routers/locations.py
Normal file
115
app/routers/locations.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Locations Router - 位置数据接口
|
||||
API endpoints for querying location records and device tracks.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
LocationRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service, location_service
|
||||
|
||||
router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[LocationRecordResponse]],
|
||||
summary="获取位置记录列表 / List location records",
|
||||
)
|
||||
async def list_locations(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
|
||||
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 location records with filters for device, location type, and time range.
|
||||
"""
|
||||
records, total = await location_service.get_locations(
|
||||
db,
|
||||
device_id=device_id,
|
||||
location_type=location_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[LocationRecordResponse.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(
|
||||
"/latest/{device_id}",
|
||||
response_model=APIResponse[LocationRecordResponse | None],
|
||||
summary="获取设备最新位置 / Get latest location",
|
||||
)
|
||||
async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取指定设备的最新位置信息。
|
||||
Get the most recent location record for a 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}")
|
||||
|
||||
record = await location_service.get_latest_location(db, device_id)
|
||||
if record is None:
|
||||
return APIResponse(
|
||||
code=0,
|
||||
message="No location data available / 暂无位置数据",
|
||||
data=None,
|
||||
)
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/track/{device_id}",
|
||||
response_model=APIResponse[list[LocationRecordResponse]],
|
||||
summary="获取设备轨迹 / Get device track",
|
||||
)
|
||||
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)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取设备在指定时间范围内的运动轨迹(按时间正序排列)。
|
||||
Get device movement track within a time range (ordered chronologically).
|
||||
"""
|
||||
# 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}")
|
||||
|
||||
if start_time >= end_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="start_time must be before end_time / 开始时间必须早于结束时间",
|
||||
)
|
||||
|
||||
records = await location_service.get_device_track(db, device_id, start_time, end_time)
|
||||
return APIResponse(
|
||||
data=[LocationRecordResponse.model_validate(r) for r in records]
|
||||
)
|
||||
397
app/schemas.py
Normal file
397
app/schemas.py
Normal file
@@ -0,0 +1,397 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic API response wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class APIResponse(BaseModel, Generic[T]):
|
||||
"""Standard envelope for every API response."""
|
||||
|
||||
code: int = 0
|
||||
message: str = "success"
|
||||
data: T | None = None
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Query parameters for paginated list endpoints."""
|
||||
|
||||
page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
|
||||
page_size: int = Field(default=20, ge=1, le=100, description="Items per page")
|
||||
|
||||
|
||||
class PaginatedList(BaseModel, Generic[T]):
|
||||
"""Paginated result set."""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
|
||||
class DeviceCreate(DeviceBase):
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
iccid: str | None = Field(None, max_length=30)
|
||||
imsi: str | None = Field(None, max_length=20)
|
||||
timezone: str | None = Field(None, max_length=30)
|
||||
language: str | None = Field(None, max_length=10)
|
||||
|
||||
|
||||
class DeviceResponse(DeviceBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: str
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
last_heartbeat: datetime | None = None
|
||||
last_login: datetime | None = None
|
||||
iccid: str | None = None
|
||||
imsi: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceListResponse(APIResponse[PaginatedList[DeviceResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceSingleResponse(APIResponse[DeviceResponse]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocationRecordBase(BaseModel):
|
||||
device_id: int
|
||||
location_type: str = Field(..., max_length=10)
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
gps_satellites: int | None = None
|
||||
gps_positioned: bool = False
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
rssi: int | None = None
|
||||
neighbor_cells: list[dict[str, Any]] | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
report_mode: int | None = None
|
||||
is_realtime: bool = True
|
||||
mileage: float | None = None
|
||||
address: str | None = None
|
||||
raw_data: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class LocationRecordCreate(LocationRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class LocationRecordResponse(LocationRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LocationRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
location_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alarm Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlarmRecordBase(BaseModel):
|
||||
device_id: int
|
||||
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
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
fence_data: dict[str, Any] | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
address: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AlarmRecordCreate(AlarmRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class AlarmRecordResponse(AlarmRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
acknowledged: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlarmAcknowledge(BaseModel):
|
||||
acknowledged: bool = True
|
||||
|
||||
|
||||
class AlarmRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
alarm_type: str | None = None
|
||||
acknowledged: bool | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class AlarmListResponse(APIResponse[PaginatedList[AlarmRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HeartbeatRecordBase(BaseModel):
|
||||
device_id: int
|
||||
protocol_number: int
|
||||
terminal_info: int
|
||||
battery_level: int
|
||||
gsm_signal: int
|
||||
extension_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class HeartbeatRecordCreate(HeartbeatRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class HeartbeatRecordResponse(HeartbeatRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attendance Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AttendanceRecordBase(BaseModel):
|
||||
device_id: int
|
||||
attendance_type: str = Field(..., max_length=20)
|
||||
protocol_number: int
|
||||
gps_positioned: bool = False
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
gps_satellites: int | None = None
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
lbs_data: list[dict[str, Any]] | None = None
|
||||
address: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AttendanceRecordCreate(AttendanceRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttendanceRecordResponse(AttendanceRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AttendanceRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
attendance_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class AttendanceListResponse(APIResponse[PaginatedList[AttendanceRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bluetooth Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BluetoothRecordBase(BaseModel):
|
||||
device_id: int
|
||||
record_type: str = Field(..., max_length=20)
|
||||
protocol_number: int
|
||||
beacon_mac: str | None = None
|
||||
beacon_uuid: str | None = None
|
||||
beacon_major: int | None = None
|
||||
beacon_minor: int | None = None
|
||||
rssi: int | None = None
|
||||
beacon_battery: float | None = None
|
||||
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
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class BluetoothRecordCreate(BluetoothRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class BluetoothRecordResponse(BluetoothRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BluetoothRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
record_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Beacon Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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")
|
||||
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="经度")
|
||||
address: str | None = Field(None, description="详细地址")
|
||||
status: str = Field(default="active", max_length=20, description="状态")
|
||||
|
||||
|
||||
class BeaconConfigCreate(BeaconConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class BeaconConfigUpdate(BaseModel):
|
||||
beacon_uuid: str | None = Field(None, max_length=36)
|
||||
beacon_major: int | None = None
|
||||
beacon_minor: int | None = None
|
||||
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
|
||||
address: str | None = None
|
||||
status: str | None = Field(None, max_length=20)
|
||||
|
||||
|
||||
class BeaconConfigResponse(BeaconConfigBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandCreate(BaseModel):
|
||||
device_id: int
|
||||
command_type: str = Field(..., max_length=30)
|
||||
command_content: str
|
||||
server_flag: str = Field(..., max_length=20)
|
||||
|
||||
|
||||
class CommandUpdate(BaseModel):
|
||||
response_content: str | None = None
|
||||
status: str | None = Field(None, max_length=20)
|
||||
sent_at: datetime | None = None
|
||||
response_at: datetime | None = None
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
device_id: int
|
||||
command_type: str
|
||||
command_content: str
|
||||
server_flag: str
|
||||
response_content: str | None = None
|
||||
status: str
|
||||
sent_at: datetime | None = None
|
||||
response_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
|
||||
pass
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/beacon_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/beacon_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/command_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/command_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/device_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/device_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/location_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/location_service.cpython-312.pyc
Normal file
Binary file not shown.
94
app/services/beacon_service.py
Normal file
94
app/services/beacon_service.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Beacon Service - 蓝牙信标管理服务
|
||||
Provides CRUD operations for Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import BeaconConfig
|
||||
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
||||
|
||||
|
||||
async def get_beacons(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[BeaconConfig], int]:
|
||||
"""Get paginated beacon list with optional filters."""
|
||||
query = select(BeaconConfig)
|
||||
count_query = select(func.count(BeaconConfig.id))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(BeaconConfig.status == status_filter)
|
||||
count_query = count_query.where(BeaconConfig.status == status_filter)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = or_(
|
||||
BeaconConfig.beacon_mac.ilike(like),
|
||||
BeaconConfig.name.ilike(like),
|
||||
BeaconConfig.area.ilike(like),
|
||||
)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(BeaconConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_beacon(db: AsyncSession, beacon_id: int) -> BeaconConfig | None:
|
||||
result = await db.execute(
|
||||
select(BeaconConfig).where(BeaconConfig.id == beacon_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_beacon_by_mac(db: AsyncSession, mac: str) -> BeaconConfig | None:
|
||||
result = await db.execute(
|
||||
select(BeaconConfig).where(BeaconConfig.beacon_mac == mac)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_beacon(db: AsyncSession, data: BeaconConfigCreate) -> BeaconConfig:
|
||||
beacon = BeaconConfig(**data.model_dump())
|
||||
db.add(beacon)
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
return beacon
|
||||
|
||||
|
||||
async def update_beacon(
|
||||
db: AsyncSession, beacon_id: int, data: BeaconConfigUpdate
|
||||
) -> BeaconConfig | None:
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(beacon, key, value)
|
||||
beacon.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
return beacon
|
||||
|
||||
|
||||
async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return False
|
||||
await db.delete(beacon)
|
||||
await db.flush()
|
||||
return True
|
||||
123
app/services/command_service.py
Normal file
123
app/services/command_service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Command Service - 指令管理服务
|
||||
Provides CRUD operations for device command logs.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import CommandLog
|
||||
|
||||
|
||||
async def get_commands(
|
||||
db: AsyncSession,
|
||||
device_id: int | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[CommandLog], int]:
|
||||
"""
|
||||
获取指令列表(分页)/ Get paginated command logs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int, optional
|
||||
Filter by device ID.
|
||||
status : str, optional
|
||||
Filter by command status (pending, sent, success, failed).
|
||||
page : int
|
||||
Page number (1-indexed).
|
||||
page_size : int
|
||||
Number of items per page.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[CommandLog], int]
|
||||
(list of command logs, total count)
|
||||
"""
|
||||
query = select(CommandLog)
|
||||
count_query = select(func.count(CommandLog.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(CommandLog.device_id == device_id)
|
||||
count_query = count_query.where(CommandLog.device_id == device_id)
|
||||
|
||||
if status:
|
||||
query = query.where(CommandLog.status == status)
|
||||
count_query = count_query.where(CommandLog.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(CommandLog.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
commands = list(result.scalars().all())
|
||||
|
||||
return commands, total
|
||||
|
||||
|
||||
async def create_command(
|
||||
db: AsyncSession,
|
||||
device_id: int,
|
||||
command_type: str,
|
||||
command_content: str,
|
||||
server_flag: str = "badge_admin",
|
||||
) -> CommandLog:
|
||||
"""
|
||||
创建指令记录 / Create a new command log entry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Target device ID.
|
||||
command_type : str
|
||||
Type of command.
|
||||
command_content : str
|
||||
Command payload content.
|
||||
server_flag : str
|
||||
Server flag identifier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CommandLog
|
||||
The newly created command log.
|
||||
"""
|
||||
command = CommandLog(
|
||||
device_id=device_id,
|
||||
command_type=command_type,
|
||||
command_content=command_content,
|
||||
server_flag=server_flag,
|
||||
status="pending",
|
||||
)
|
||||
db.add(command)
|
||||
await db.flush()
|
||||
await db.refresh(command)
|
||||
return command
|
||||
|
||||
|
||||
async def get_command(db: AsyncSession, command_id: int) -> CommandLog | None:
|
||||
"""
|
||||
按ID获取指令 / Get command log by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
command_id : int
|
||||
Command log primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CommandLog | None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CommandLog).where(CommandLog.id == command_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
215
app/services/device_service.py
Normal file
215
app/services/device_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Device Service - 设备管理服务
|
||||
Provides CRUD operations and statistics for badge devices.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device
|
||||
from app.schemas import DeviceCreate, DeviceUpdate
|
||||
|
||||
|
||||
async def get_devices(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[Device], int]:
|
||||
"""
|
||||
获取设备列表(分页)/ Get paginated device list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
page : int
|
||||
Page number (1-indexed).
|
||||
page_size : int
|
||||
Number of items per page.
|
||||
status_filter : str, optional
|
||||
Filter by device status (online / offline).
|
||||
search : str, optional
|
||||
Search by IMEI or device name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[Device], int]
|
||||
(list of devices, total count)
|
||||
"""
|
||||
query = select(Device)
|
||||
count_query = select(func.count(Device.id))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Device.status == status_filter)
|
||||
count_query = count_query.where(Device.status == status_filter)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
search_clause = or_(
|
||||
Device.imei.ilike(pattern),
|
||||
Device.name.ilike(pattern),
|
||||
)
|
||||
query = query.where(search_clause)
|
||||
count_query = count_query.where(search_clause)
|
||||
|
||||
# Total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(Device.updated_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
return devices, total
|
||||
|
||||
|
||||
async def get_device(db: AsyncSession, device_id: int) -> Device | None:
|
||||
"""
|
||||
按ID获取设备 / Get device by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
"""
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_device_by_imei(db: AsyncSession, imei: str) -> Device | None:
|
||||
"""
|
||||
按IMEI获取设备 / Get device by IMEI number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
imei : str
|
||||
Device IMEI number.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
"""
|
||||
result = await db.execute(select(Device).where(Device.imei == imei))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_device(db: AsyncSession, device_data: DeviceCreate) -> Device:
|
||||
"""
|
||||
创建设备 / Create a new device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_data : DeviceCreate
|
||||
Device creation data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device
|
||||
The newly created device.
|
||||
"""
|
||||
device = Device(**device_data.model_dump())
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
async def update_device(
|
||||
db: AsyncSession, device_id: int, device_data: DeviceUpdate
|
||||
) -> Device | None:
|
||||
"""
|
||||
更新设备信息 / Update device information.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
device_data : DeviceUpdate
|
||||
Fields to update (only non-None fields are applied).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
The updated device, or None if not found.
|
||||
"""
|
||||
device = await get_device(db, device_id)
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
update_fields = device_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
async def delete_device(db: AsyncSession, device_id: int) -> bool:
|
||||
"""
|
||||
删除设备 / Delete a device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the device was deleted, False if not found.
|
||||
"""
|
||||
device = await get_device(db, device_id)
|
||||
if device is None:
|
||||
return False
|
||||
|
||||
await db.delete(device)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def get_device_stats(db: AsyncSession) -> dict:
|
||||
"""
|
||||
获取设备统计信息 / Get device statistics.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{"total": int, "online": int, "offline": int}
|
||||
"""
|
||||
total_result = await db.execute(select(func.count(Device.id)))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
online_result = await db.execute(
|
||||
select(func.count(Device.id)).where(Device.status == "online")
|
||||
)
|
||||
online = online_result.scalar() or 0
|
||||
|
||||
offline = total - online
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
}
|
||||
138
app/services/location_service.py
Normal file
138
app/services/location_service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Location Service - 位置数据服务
|
||||
Provides query operations for GPS / LBS / WIFI location records.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import LocationRecord
|
||||
|
||||
|
||||
async def get_locations(
|
||||
db: AsyncSession,
|
||||
device_id: int | None = None,
|
||||
location_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[LocationRecord], int]:
|
||||
"""
|
||||
获取位置记录列表(分页)/ Get paginated location records.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int, optional
|
||||
Filter by device ID.
|
||||
location_type : str, optional
|
||||
Filter by location type (gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g).
|
||||
start_time : datetime, optional
|
||||
Filter records after this time.
|
||||
end_time : datetime, optional
|
||||
Filter records before this time.
|
||||
page : int
|
||||
Page number (1-indexed).
|
||||
page_size : int
|
||||
Number of items per page.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[LocationRecord], int]
|
||||
(list of location records, total count)
|
||||
"""
|
||||
query = select(LocationRecord)
|
||||
count_query = select(func.count(LocationRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(LocationRecord.device_id == device_id)
|
||||
count_query = count_query.where(LocationRecord.device_id == device_id)
|
||||
|
||||
if location_type:
|
||||
query = query.where(LocationRecord.location_type == location_type)
|
||||
count_query = count_query.where(LocationRecord.location_type == location_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(LocationRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(LocationRecord.recorded_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(LocationRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return records, total
|
||||
|
||||
|
||||
async def get_latest_location(
|
||||
db: AsyncSession, device_id: int
|
||||
) -> LocationRecord | None:
|
||||
"""
|
||||
获取设备最新位置 / Get the most recent location for a device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device ID.
|
||||
|
||||
Returns
|
||||
-------
|
||||
LocationRecord | None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(LocationRecord)
|
||||
.where(LocationRecord.device_id == device_id)
|
||||
.order_by(LocationRecord.recorded_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_device_track(
|
||||
db: AsyncSession,
|
||||
device_id: int,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[LocationRecord]:
|
||||
"""
|
||||
获取设备轨迹 / Get device movement track within a time range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device ID.
|
||||
start_time : datetime
|
||||
Start of time range.
|
||||
end_time : datetime
|
||||
End of time range.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[LocationRecord]
|
||||
Location records ordered by recorded_at ascending (chronological).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(LocationRecord)
|
||||
.where(
|
||||
LocationRecord.device_id == device_id,
|
||||
LocationRecord.recorded_at >= start_time,
|
||||
LocationRecord.recorded_at <= end_time,
|
||||
)
|
||||
.order_by(LocationRecord.recorded_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
2075
app/static/admin.html
Normal file
2075
app/static/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
2463
app/tcp_server.py
Normal file
2463
app/tcp_server.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user