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:
2026-03-17 01:14:40 +00:00
commit 8a18a5ff16
61 changed files with 13106 additions and 0 deletions

0
app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

14
app/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

331
app/protocol/builder.py Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

0
app/routers/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

172
app/routers/alarms.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

Binary file not shown.

View 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

View 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()

View 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,
}

View 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

File diff suppressed because it is too large Load Diff

2463
app/tcp_server.py Normal file

File diff suppressed because it is too large Load Diff