Add WebSocket, multi API key, geocoding proxy, beacon map picker, and comprehensive bug fixes

- Multi API Key + permission system (read/write/admin) with SHA-256 hash
- WebSocket real-time push (location, alarm, device_status, attendance, bluetooth)
- Geocoding proxy endpoints for Amap POI search and reverse geocode
- Beacon modal map-based location picker with search and click-to-select
- GCJ-02 ↔ WGS-84 bidirectional coordinate conversion
- Data cleanup scheduler (configurable retention days)
- Fix GPS longitude sign inversion (course_status bit 11: 0=East, 1=West)
- Fix 2G CellID 2→3 bytes across all protocols (0x28, 0x2C, parser.py)
- Fix parser loop guards, alarm_source field length, CommandLog.sent_at
- Fix geocoding IMEI parameterization, require_admin import
- Improve API error messages for 422 validation errors
- Remove beacon floor/area fields (consolidated into name)

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-24 05:10:05 +00:00
parent 7d6040af41
commit 11281e5be2
24 changed files with 1636 additions and 730 deletions

View File

@@ -29,20 +29,21 @@ class Settings(BaseSettings):
RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit")
RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations")
# Geocoding API keys
TIANDITU_API_KEY: str | None = Field(default=None, description="天地图 API key for reverse geocoding")
GOOGLE_API_KEY: str | None = Field(default=None, description="Google Geolocation API key")
UNWIRED_API_TOKEN: str | None = Field(default=None, description="Unwired Labs API token")
# 高德地图 API (geocoding)
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
BAIDU_MAP_AK: str | None = Field(default=None, description="百度地图服务端 AK")
# Geocoding cache
# Geocoding
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries")
# Track query limit
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
# Data retention
DATA_RETENTION_DAYS: int = Field(default=90, description="Days to keep location/heartbeat/alarm/attendance/bluetooth records")
DATA_CLEANUP_INTERVAL_HOURS: int = Field(default=24, description="Hours between automatic cleanup runs")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}

View File

@@ -1,20 +1,85 @@
"""
Shared FastAPI dependencies.
Supports master API key (env) and database-managed API keys with permission levels.
"""
import hashlib
import secrets
from datetime import datetime, timezone
from fastapi import HTTPException, Security
from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Permission hierarchy: admin > write > read
_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3}
async def verify_api_key(api_key: str | None = Security(_api_key_header)):
"""Verify API key if authentication is enabled."""
def _hash_key(key: str) -> str:
"""SHA-256 hash of an API key."""
return hashlib.sha256(key.encode()).hexdigest()
async def verify_api_key(
api_key: str | None = Security(_api_key_header),
db: AsyncSession = Depends(get_db),
) -> dict | None:
"""Verify API key. Returns key info dict or None (auth disabled).
Checks master key first, then database keys.
Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}.
"""
if settings.API_KEY is None:
return # Auth disabled
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
raise HTTPException(status_code=401, detail="Invalid or missing API key")
return None # Auth disabled
if api_key is None:
raise HTTPException(status_code=401, detail="Missing API key / 缺少 API Key")
# Check master key
if secrets.compare_digest(api_key, settings.API_KEY):
return {"permissions": "admin", "key_id": None, "name": "master"}
# Check database keys
from app.models import ApiKey
key_hash = _hash_key(api_key)
result = await db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712
)
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
# Update last_used_at
db_key.last_used_at = datetime.now(timezone.utc)
await db.flush()
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
def require_permission(min_level: str):
"""Factory for permission-checking dependencies."""
async def _check(key_info: dict | None = Depends(verify_api_key)):
if key_info is None:
return # Auth disabled
current = _PERMISSION_LEVELS.get(key_info["permissions"], 0)
required = _PERMISSION_LEVELS.get(min_level, 0)
if current < required:
raise HTTPException(
status_code=403,
detail=f"Insufficient permissions. Requires '{min_level}' / 权限不足,需要 '{min_level}' 权限",
)
return key_info
return _check
require_write = require_permission("write")
require_admin = require_permission("admin")

View File

@@ -2,34 +2,77 @@
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
All services use 高德 (Amap) API exclusively.
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
- Reverse geocoding (coords → address): 高德逆地理编码
"""
import json
import hashlib
import logging
import math
from collections import OrderedDict
from typing import Optional
from urllib.parse import quote
import aiohttp
logger = logging.getLogger(__name__)
# Import keys from centralized config (no more hardcoded values here)
from app.config import settings as _settings
GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY
UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN
TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY
BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
# Maximum cache entries (LRU eviction) — configurable via settings
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
# ---------------------------------------------------------------------------
# WGS-84 → GCJ-02 coordinate conversion (server-side)
# ---------------------------------------------------------------------------
_A = 6378245.0
_EE = 0.00669342162296594
def _out_of_china(lat: float, lon: float) -> bool:
return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
def _transform_lat(x: float, y: float) -> float:
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lon(x: float, y: float) -> float:
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
"""Convert WGS-84 to GCJ-02 (used by 高德)."""
if _out_of_china(lat, lon):
return (lat, lon)
d_lat = _transform_lat(lon - 105.0, lat - 35.0)
d_lon = _transform_lon(lon - 105.0, lat - 35.0)
rad_lat = lat / 180.0 * math.pi
magic = math.sin(rad_lat)
magic = 1 - _EE * magic * magic
sqrt_magic = math.sqrt(magic)
d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi)
d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi)
return (lat + d_lat, lon + d_lon)
# ---------------------------------------------------------------------------
# LRU Cache
# ---------------------------------------------------------------------------
class LRUCache(OrderedDict):
"""Simple LRU cache based on OrderedDict."""
@@ -51,13 +94,29 @@ class LRUCache(OrderedDict):
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()
# ---------------------------------------------------------------------------
# 高德数字签名 (AMAP_SECRET)
# ---------------------------------------------------------------------------
def _amap_sign(params: dict) -> str:
"""Generate 高德 API digital signature (MD5)."""
if not AMAP_SECRET:
return ""
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
raw = sorted_str + AMAP_SECRET
return hashlib.md5(raw.encode()).hexdigest()
# ===========================================================================
# Forward Geocoding: cell/WiFi → lat/lon
# ===========================================================================
async def geocode_location(
mcc: Optional[int] = None,
mnc: Optional[int] = None,
@@ -65,272 +124,129 @@ async def geocode_location(
cell_id: Optional[int] = None,
wifi_list: Optional[list[dict]] = None,
neighbor_cells: Optional[list[dict]] = None,
imei: Optional[str] = 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)
Uses 高德智能硬件定位 API exclusively.
"""
# Check cache first (cell tower)
# Check cache first
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 AMAP_KEY:
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
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
async def _geocode_amap(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
) -> tuple[Optional[float], Optional[float]]:
"""Use Google Geolocation API."""
url = f"https://www.googleapis.com/geolocation/v1/geolocate?key={GOOGLE_API_KEY}"
body: dict = {}
"""
Use 高德智能硬件定位 API (apilocate.amap.com/position).
if mcc is not None:
body["homeMobileCountryCode"] = mcc
if mnc is not None:
body["homeMobileNetworkCode"] = mnc
Returns coordinates (高德 returns GCJ-02).
"""
# Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
bts = ""
if mcc is not None and lac is not None and cell_id is not None:
bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
# 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,
})
# Build nearbts (neighbor cells)
nearbts_parts = []
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
nc_lac = nc.get("lac", 0)
nc_cid = nc.get("cell_id", 0)
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}")
# WiFi APs
# Build macs (WiFi APs): mac,signal,ssid
macs_parts = []
if wifi_list:
aps = []
for ap in wifi_list:
aps.append({
"macAddress": ap.get("mac", ""),
"signalStrength": -(ap.get("signal", 0)),
})
body["wifiAccessPoints"] = aps
mac = ap.get("mac", "").lower().replace(":", "")
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
ssid = ap.get("ssid", "")
macs_parts.append(f"{mac},{signal},{ssid}")
if not bts and not macs_parts:
return (None, None)
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
if bts:
params["bts"] = bts
if nearbts_parts:
params["nearbts"] = "|".join(nearbts_parts)
if macs_parts:
params["macs"] = "|".join(macs_parts)
# Add digital signature
sig = _amap_sign(params)
if sig:
params["sig"] = sig
url = "https://apilocate.amap.com/position"
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:
async with session.get(
url, params=params, 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)
if data.get("status") == "1" and data.get("result"):
result = data["result"]
location = result.get("location", "")
if location and "," in location:
lon_str, lat_str = location.split(",")
lat = float(lat_str)
lon = float(lon_str)
logger.info("Amap 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)
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Mylnikov cell API HTTP %d", resp.status)
logger.warning("Amap geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Mylnikov cell geocode error: %s", e)
logger.warning("Amap 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
# ---------------------------------------------------------------------------
# ===========================================================================
# Reverse Geocoding: coordinates → address
# ===========================================================================
async def reverse_geocode(
lat: float, lon: float
) -> Optional[str]:
"""
Convert lat/lon to a human-readable address.
Convert lat/lon (WGS-84) to a human-readable address.
Priority: Baidu Map > Tianditu (fallback).
Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll).
Returns None if no reverse geocoding service is available.
Uses 高德逆地理编码 API exclusively.
"""
# 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
# Try Baidu Map first (higher quality addresses for China)
if BAIDU_MAP_AK:
result = await _reverse_geocode_baidu(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
# Fallback to Tianditu
if TIANDITU_API_KEY:
result = await _reverse_geocode_tianditu(lat, lon)
if AMAP_KEY:
result = await _reverse_geocode_amap(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
@@ -338,101 +254,55 @@ async def reverse_geocode(
return None
async def _reverse_geocode_baidu(
async def _reverse_geocode_amap(
lat: float, lon: float
) -> Optional[str]:
"""
Use Baidu Map reverse geocoding API.
Use 高德逆地理编码 API.
API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding
Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed).
Free tier: 5,000 requests/day (personal developer).
API: https://restapi.amap.com/v3/geocode/regeo
Input: GCJ-02 coordinates (need to convert from WGS-84).
Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise).
"""
url = (
f"https://api.map.baidu.com/reverse_geocoding/v3/"
f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll"
f"&location={lat},{lon}"
)
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
params = {
"key": AMAP_KEY,
"location": f"{gcj_lon:.6f},{gcj_lat:.6f}",
"extensions": "base",
"output": "json",
}
sig = _amap_sign(params)
if sig:
params["sig"] = sig
url = "https://restapi.amap.com/v3/geocode/regeo"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5)
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == 0:
result = data.get("result", {})
formatted = result.get("formatted_address", "")
if formatted:
# Add sematic_description for more context
sematic = result.get("sematic_description", "")
address = formatted
if sematic and sematic not in formatted:
address = f"{formatted} ({sematic})"
if data.get("status") == "1":
regeocode = data.get("regeocode", {})
formatted = regeocode.get("formatted_address", "")
if formatted and formatted != "[]":
logger.info(
"Baidu reverse geocode: %.6f,%.6f -> %s",
lat, lon, address,
"Amap reverse geocode: %.6f,%.6f -> %s",
lat, lon, formatted,
)
return address
return formatted
else:
logger.warning(
"Baidu reverse geocode error: status=%s, msg=%s",
data.get("status"), data.get("message", ""),
"Amap reverse geocode error: info=%s, infocode=%s",
data.get("info", ""), data.get("infocode", ""),
)
else:
logger.warning("Baidu reverse geocode HTTP %d", resp.status)
logger.warning("Amap reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Baidu reverse geocode error: %s", e)
return None
async def _reverse_geocode_tianditu(
lat: float, lon: float
) -> Optional[str]:
"""
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)
logger.warning("Amap reverse geocode error: %s", e)
return None

View File

@@ -1,6 +1,6 @@
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -13,8 +13,8 @@ from slowapi.errors import RateLimitExceeded
from app.database import init_db, async_session, engine
from app.tcp_server import tcp_manager
from app.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats
from app.dependencies import verify_api_key
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding
from app.dependencies import verify_api_key, require_write, require_admin
import asyncio
import logging
@@ -26,6 +26,46 @@ logger = logging.getLogger(__name__)
from app.extensions import limiter
async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS."""
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
total_deleted = 0
async with async_session() as session:
async with session.begin():
for model, time_col in [
(LocationRecord, LocationRecord.created_at),
(HeartbeatRecord, HeartbeatRecord.created_at),
(AlarmRecord, AlarmRecord.created_at),
(AttendanceRecord, AttendanceRecord.created_at),
(BluetoothRecord, BluetoothRecord.created_at),
]:
result = await session.execute(
delete(model).where(time_col < cutoff)
)
if result.rowcount:
total_deleted += result.rowcount
logger.info("Cleanup: deleted %d old %s records", result.rowcount, model.__tablename__)
return total_deleted
async def _data_cleanup_loop():
"""Background task that runs cleanup periodically."""
while True:
try:
await asyncio.sleep(settings.DATA_CLEANUP_INTERVAL_HOURS * 3600)
deleted = await run_data_cleanup()
if deleted:
logger.info("Data cleanup completed: %d records removed", deleted)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Data cleanup error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
@@ -41,10 +81,28 @@ async def lifespan(app: FastAPI):
logger.info("All devices reset to offline on startup")
except Exception:
logger.exception("Failed to reset device statuses on startup")
# Create missing indexes (safe for existing databases)
try:
from sqlalchemy import text as sa_text
async with engine.begin() as conn:
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
"CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)",
"CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)",
"CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)",
"CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)",
]:
await conn.execute(sa_text(stmt))
logger.info("Database indexes verified/created")
except Exception:
logger.exception("Failed to create indexes")
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))
cleanup_task = asyncio.create_task(_data_cleanup_loop())
yield
# Shutdown
cleanup_task.cancel()
logger.info("Shutting down TCP server...")
await tcp_manager.stop()
tcp_task.cancel()
@@ -119,6 +177,9 @@ app.include_router(commands.router, dependencies=[*_api_deps])
app.include_router(bluetooth.router, dependencies=[*_api_deps])
app.include_router(beacons.router, dependencies=[*_api_deps])
app.include_router(heartbeats.router, dependencies=[*_api_deps])
app.include_router(api_keys.router, dependencies=[*_api_deps])
app.include_router(ws.router) # WebSocket handles auth internally
app.include_router(geocoding.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -156,9 +217,22 @@ async def health():
except Exception:
logger.warning("Health check: database unreachable")
from app.websocket_manager import ws_manager
status = "healthy" if db_ok else "degraded"
return {
"status": status,
"database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections),
"websocket_connections": ws_manager.connection_count,
}
@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else [])
async def manual_cleanup():
"""手动触发数据清理 / Manually trigger data cleanup (admin only)."""
try:
deleted = await run_data_cleanup()
return {"code": 0, "message": f"Cleanup completed: {deleted} records removed", "data": {"deleted": deleted}}
except Exception as e:
logger.exception("Manual cleanup failed")
return {"code": 500, "message": f"Cleanup failed: {str(e)}", "data": None}

View File

@@ -129,7 +129,7 @@ class AlarmRecord(Base):
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
String(20), 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)
@@ -321,3 +321,22 @@ class CommandLog(Base):
f"<CommandLog(id={self.id}, device_id={self.device_id}, "
f"type={self.command_type}, status={self.status})>"
)
class ApiKey(Base):
"""API keys for external system authentication."""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
permissions: Mapped[str] = mapped_column(
String(20), default="read", nullable=False
) # read, write, admin
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"

View File

@@ -123,14 +123,18 @@ class PacketBuilder:
self,
serial_number: int,
protocol: int = PROTO_TIME_SYNC,
language: int = 0x0001,
) -> bytes:
"""
Build a time sync response (0x1F).
Returns the current UTC time as a 4-byte Unix timestamp.
Returns the current UTC time as a 4-byte Unix timestamp + 2-byte language.
For Chinese (0x0001), the timestamp is GMT+8.
"""
utc_now = int(time.time())
info = struct.pack("!I", utc_now)
if language == 0x0001:
utc_now += 8 * 3600 # GMT+8 for Chinese
info = struct.pack("!IH", utc_now, language)
return self.build_response(protocol, serial_number, info)
def build_time_sync_8a_response(self, serial_number: int) -> bytes:
@@ -186,8 +190,8 @@ class PacketBuilder:
Complete packet.
"""
cmd_bytes = command.encode("ascii")
# inner_len = server_flag(4) + cmd_content(N)
inner_len = 4 + len(cmd_bytes)
# inner_len = server_flag(4) + cmd_content(N) + language(2)
inner_len = 4 + len(cmd_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag
@@ -225,8 +229,8 @@ class PacketBuilder:
Complete packet.
"""
msg_bytes = message_text.encode("utf-16-be")
# inner_len = server_flag(4) + msg_content(N)
inner_len = 4 + len(msg_bytes)
# inner_len = server_flag(4) + msg_content(N) + language(2)
inner_len = 4 + len(msg_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag
@@ -238,94 +242,57 @@ class PacketBuilder:
def build_address_reply_cn(
self,
serial_number: int,
server_flag: int,
address: str,
server_flag: int = 0,
address: str = "",
phone: str = "",
protocol: int = PROTO_LBS_ADDRESS_REQ,
is_alarm: bool = False,
) -> bytes:
"""
Build a Chinese address reply packet.
Build a Chinese address reply packet (0x17).
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.
Format: cmd_length(1) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF16BE) + && + phone(21) + ##
"""
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-16-be")
addr_len = len(addr_bytes)
# Phone field: 21 bytes ASCII, zero-padded
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
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
inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
# 0x17 uses 1-byte cmd_length
cmd_len = min(len(inner), 0xFF)
info = bytes([cmd_len]) + inner
return self.build_response(protocol, serial_number, info)
def build_address_reply_en(
self,
serial_number: int,
server_flag: int,
address: str,
server_flag: int = 0,
address: str = "",
phone: str = "",
protocol: int = PROTO_ADDRESS_REPLY_EN,
is_alarm: bool = False,
) -> 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.
Format: cmd_length(2) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF-8) + && + phone(21) + ##
"""
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-8")
addr_len = len(addr_bytes)
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
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
inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
# 0x97 uses 2-byte cmd_length
info = struct.pack("!H", len(inner)) + inner
return self.build_response(protocol, serial_number, info)

View File

@@ -121,7 +121,7 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
PROTO_LBS_ADDRESS_REQ,
PROTO_ADDRESS_QUERY,
PROTO_TIME_SYNC,
PROTO_LBS_MULTI,
# Note: PROTO_LBS_MULTI (0x28) does NOT require response; only 0x2E does
PROTO_HEARTBEAT_EXT,
PROTO_TIME_SYNC_2,
# PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc

View File

@@ -14,9 +14,13 @@ from typing import Any, Dict, List, Tuple
from .constants import (
ALARM_TYPES,
ATTENDANCE_STATUS_MASK,
ATTENDANCE_STATUS_SHIFT,
ATTENDANCE_TYPES,
DATA_REPORT_MODES,
GSM_SIGNAL_LEVELS,
PROTOCOL_NAMES,
VOLTAGE_LEVELS,
PROTO_ADDRESS_QUERY,
PROTO_ALARM_LBS_4G,
PROTO_ALARM_MULTI_FENCE,
@@ -272,29 +276,23 @@ class PacketParser:
speed = data[offset + 9]
course_status = struct.unpack_from("!H", data, offset + 10)[0]
# Decode course/status
is_realtime = bool(course_status & 0x2000) # bit 13 (from MSB: bit 12 if 0-indexed from MSB)
is_gps_positioned = bool(course_status & 0x1000) # bit 12 -> actually bit 11
is_east = bool(course_status & 0x0800) # bit 11 -> bit 10
is_north = bool(course_status & 0x0400) # bit 10 -> bit 9
course = course_status & 0x03FF # lower 10 bits
# Wait -- the standard mapping for this protocol:
# bit 13 (0x2000): real-time GPS
# bit 12 (0x1000): GPS is positioned
# bit 11 (0x0800): East longitude (0=West)
# bit 10 (0x0400): North latitude (0=South, but spec says 1=South sometimes)
# We'll use the most common convention: bit10=1 means South latitude is *negated*.
# Actually, common convention: bit10 = 0 -> South, bit10 = 1 -> North? No --
# In most implementations of this protocol family:
# bit 10 (0x0400): 1 = North latitude, 0 = South
# We'll go with that.
# Decode course/status (per protocol doc):
# bit 13 (0x2000): GPS real-time differential positioning
# bit 12 (0x1000): GPS positioned
# bit 11 (0x0800): 0=East, 1=West (东经/西经)
# bit 10 (0x0400): 0=South, 1=North (南纬/北纬)
# bits 9-0: course (0-360)
is_realtime = bool(course_status & 0x2000)
is_gps_positioned = bool(course_status & 0x1000)
is_west = bool(course_status & 0x0800)
is_north = bool(course_status & 0x0400)
course = course_status & 0x03FF
latitude = lat_raw / 1_800_000.0
longitude = lon_raw / 1_800_000.0
if not is_north:
latitude = -latitude
if not is_east:
if is_west:
longitude = -longitude
return {
@@ -308,7 +306,7 @@ class PacketParser:
"course": course,
"is_realtime": is_realtime,
"is_gps_positioned": is_gps_positioned,
"is_east": is_east,
"is_west": is_west,
"is_north": is_north,
"course_status_raw": course_status,
}
@@ -347,7 +345,7 @@ class PacketParser:
offset: int = 0,
*,
lac_size: int = 2,
cell_id_size: int = 2,
cell_id_size: int = 3,
) -> Tuple[Dict[str, Any], int]:
"""
Parse a single LBS station (LAC + Cell ID + RSSI).
@@ -370,6 +368,8 @@ class PacketParser:
if cell_id_size == 2:
cell_id = struct.unpack_from("!H", data, offset + consumed)[0]
elif cell_id_size == 3:
cell_id = int.from_bytes(data[offset + consumed : offset + consumed + 3], "big")
elif cell_id_size == 4:
cell_id = struct.unpack_from("!I", data, offset + consumed)[0]
else: # 8
@@ -479,8 +479,8 @@ class PacketParser:
result["lac"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
result["cell_id"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
result["cell_id"] = int.from_bytes(info[pos : pos + 3], "big")
pos += 3
# Remaining bytes: phone number (BCD) + alarm_language
if pos < len(info):
@@ -524,7 +524,7 @@ class PacketParser:
return result
def _parse_gps_packet(self, info: bytes) -> Dict[str, Any]:
"""0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(2) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4)."""
"""0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(3) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4)."""
result: Dict[str, Any] = {}
pos = 0
@@ -542,9 +542,10 @@ class PacketParser:
result["lac"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
if len(info) >= pos + 2:
result["cell_id"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# 2G Cell ID is 3 bytes (not 2)
if len(info) >= pos + 3:
result["cell_id"] = int.from_bytes(info[pos:pos + 3], "big")
pos += 3
if len(info) >= pos + 1:
result["acc"] = info[pos]
@@ -579,7 +580,7 @@ class PacketParser:
stations: List[Dict[str, Any]] = []
for i in range(7): # main + 6 neighbors
if len(info) < pos + 5:
if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6
break
station, consumed = self.parse_lbs_station(info, pos)
station["is_main"] = (i == 0)
@@ -614,7 +615,7 @@ class PacketParser:
stations: List[Dict[str, Any]] = []
for i in range(7):
if len(info) < pos + 5:
if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6
break
station, consumed = self.parse_lbs_station(info, pos)
station["is_main"] = (i == 0)
@@ -807,8 +808,44 @@ class PacketParser:
return result
@staticmethod
def _parse_alarm_tail(info: bytes, pos: int) -> Tuple[Dict[str, Any], int]:
"""Parse common alarm tail: terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1)."""
result: Dict[str, Any] = {}
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 1:
voltage_level = info[pos]
result["voltage_level"] = voltage_level
result["voltage_name"] = VOLTAGE_LEVELS.get(voltage_level, "Unknown")
result["battery_level"] = min(voltage_level * 17, 100) if voltage_level <= 6 else None
pos += 1
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 1:
alarm_code = info[pos]
result["alarm_code"] = alarm_code
result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}")
pos += 1
if len(info) >= pos + 1:
result["language"] = info[pos]
pos += 1
return result, pos
def _parse_alarm_single_fence(self, info: bytes) -> Dict[str, Any]:
"""0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage(2) + gsm_signal(1) + alarm_language(2)."""
"""0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1)."""
result: Dict[str, Any] = {}
pos = 0
@@ -836,45 +873,19 @@ class PacketParser:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 2:
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
tail, pos = self._parse_alarm_tail(info, pos)
result.update(tail)
return result
def _parse_alarm_lbs_4g(self, info: bytes) -> Dict[str, Any]:
"""0xA5 LBS 4G Alarm: similar to 0xA3 but LBS-based."""
"""0xA5 LBS 4G Alarm: NO datetime, NO GPS, NO lbs_length.
Content starts directly with MCC(2) + MNC(1-2) + LAC(4) + CellID(8)
+ terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1).
"""
result: Dict[str, Any] = {}
pos = 0
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
if len(info) >= pos + 1:
result["lbs_length"] = info[pos]
pos += 1
pos = 0 # content starts directly with MCC
if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
@@ -889,85 +900,63 @@ class PacketParser:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 2:
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
tail, pos = self._parse_alarm_tail(info, pos)
result.update(tail)
return result
def _parse_alarm_wifi(self, info: bytes) -> Dict[str, Any]:
"""0xA9 WIFI Alarm: datetime + gps + lbs + terminal_info + voltage + gsm + wifi_count + wifi_list + alarm_language."""
"""0xA9 WIFI Alarm: datetime(6) + MCC(2) + MNC(1-2) + cell_type(1) + cell_count(1)
+ [cell_stations] + timing_advance(1) + wifi_count(1) + [wifi_list] + alarm_code(1) + language(1).
No GPS block, no lbs_length. Independent format.
"""
result: Dict[str, Any] = {}
pos = 0
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos)
pos += 12
if len(info) >= pos + 1:
result["lbs_length"] = info[pos]
pos += 1
if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
result.update(mcc_mnc)
pos += consumed
if len(info) >= pos + 4:
result["lac"] = struct.unpack_from("!I", info, pos)[0]
pos += 4
if len(info) >= pos + 8:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
# cell_type(1) + cell_count(1)
cell_type = 0 # 0=2G, 1=4G
cell_count = 0
if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0]
cell_type = info[pos]
cell_count = info[pos + 1]
result["cell_type"] = cell_type
result["cell_count"] = cell_count
pos += 2
# Parse cell stations
stations: List[Dict[str, Any]] = []
for i in range(cell_count):
if cell_type == 1: # 4G: LAC(4) + CI(8) + RSSI(1) = 13 bytes
if len(info) < pos + 13:
break
station, consumed = self.parse_lbs_station(info, pos, lac_size=4, cell_id_size=8)
stations.append(station)
pos += consumed
else: # 2G: LAC(2) + CI(3) + RSSI(1) = 6 bytes
if len(info) < pos + 6:
break
lac_val = struct.unpack_from("!H", info, pos)[0]
ci_val = int.from_bytes(info[pos + 2:pos + 5], "big")
rssi_val = info[pos + 5]
stations.append({"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val})
pos += 6
result["stations"] = stations
# timing_advance(1)
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
result["timing_advance"] = info[pos]
pos += 1
# WiFi APs: wifi_count(1) + [mac(6) + signal(1)]*N
if len(info) >= pos + 1:
wifi_count = info[pos]
result["wifi_count"] = wifi_count
@@ -977,39 +966,85 @@ class PacketParser:
result["wifi_list"] = wifi_list
pos += consumed
if len(info) >= pos + 2:
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# alarm_code(1) + language(1)
if len(info) >= pos + 1:
alarm_code = info[pos]
result["alarm_code"] = alarm_code
result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}")
pos += 1
if len(info) >= pos + 1:
result["language"] = info[pos]
pos += 1
return result
def _parse_attendance(self, info: bytes) -> Dict[str, Any]:
"""0xB0 Attendance: GPS + WIFI + LBS combined attendance data."""
"""0xB0 Attendance: datetime(6) + gps_positioned(1) + reserved(2) + GPS(12)
+ terminal_info(1) + voltage_level(1) + gsm_signal(1) + reserved_ext(2)
+ MCC/MNC + 7 stations(LAC2+CI3+RSSI) + TA(1) + wifi_count(1) + wifi_list.
"""
result: Dict[str, Any] = {}
pos = 0
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
# GPS data
# GPS positioned flag (1 byte)
if len(info) > pos:
result["gps_positioned"] = info[pos] == 1
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
# GPS data (12 bytes)
if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos)
pos += 12
# LBS data
# Terminal info (1 byte) - clock_in/clock_out
if len(info) > pos:
ti = info[pos]
result["terminal_info"] = ti
status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown")
pos += 1
# Voltage level (1 byte)
if len(info) > pos:
vl = info[pos]
result["voltage_level"] = vl
result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None
pos += 1
# GSM signal (1 byte)
if len(info) > pos:
result["gsm_signal"] = info[pos]
pos += 1
# Reserved extension (2 bytes)
if len(info) >= pos + 2:
pos += 2
# LBS: MCC/MNC
if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
result.update(mcc_mnc)
pos += consumed
# 7 stations: LAC(2) + CI(3) + RSSI(1) = 6 bytes each for 2G
stations: List[Dict[str, Any]] = []
for i in range(7):
if len(info) < pos + 5:
if len(info) < pos + 6:
break
station, consumed = self.parse_lbs_station(info, pos)
station["is_main"] = (i == 0)
lac_val = struct.unpack_from("!H", info, pos)[0]
ci_val = int.from_bytes(info[pos + 2:pos + 5], "big")
rssi_val = info[pos + 5]
station = {"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val, "is_main": (i == 0)}
stations.append(station)
pos += consumed
pos += 6
result["stations"] = stations
@@ -1027,31 +1062,66 @@ class PacketParser:
result["wifi_list"] = wifi_list
pos += consumed
# Attendance-specific trailing data
if pos < len(info):
result["attendance_data"] = info[pos:]
return result
def _parse_attendance_4g(self, info: bytes) -> Dict[str, Any]:
"""0xB1 Attendance 4G: 4G version of attendance."""
"""0xB1 Attendance 4G: same layout as 0xB0 but MNC=2B fixed, LAC=4B, CI=8B."""
result: Dict[str, Any] = {}
pos = 0
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
# GPS data
# GPS positioned flag (1 byte)
if len(info) > pos:
result["gps_positioned"] = info[pos] == 1
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
# GPS data (12 bytes)
if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos)
pos += 12
# LBS data (4G variant)
if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
result.update(mcc_mnc)
pos += consumed
# Terminal info (1 byte) - clock_in/clock_out
if len(info) > pos:
ti = info[pos]
result["terminal_info"] = ti
status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown")
pos += 1
# Voltage level (1 byte)
if len(info) > pos:
vl = info[pos]
result["voltage_level"] = vl
result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None
pos += 1
# GSM signal (1 byte)
if len(info) > pos:
result["gsm_signal"] = info[pos]
pos += 1
# Reserved extension (2 bytes)
if len(info) >= pos + 2:
pos += 2
# 4G LBS: MCC(2, clear high bit) + MNC(2, fixed) + LAC(4) + CI(8)
if len(info) >= pos + 2:
mcc_raw = struct.unpack_from("!H", info, pos)[0]
result["mcc"] = mcc_raw & 0x7FFF
pos += 2
if len(info) >= pos + 2:
result["mnc"] = struct.unpack_from("!H", info, pos)[0]
result["mnc_2byte"] = True
pos += 2
# 7 stations: LAC(4) + CI(8) + RSSI(1) = 13 bytes each
stations: List[Dict[str, Any]] = []
for i in range(7):
if len(info) < pos + 13:
@@ -1079,13 +1149,10 @@ class PacketParser:
result["wifi_list"] = wifi_list
pos += consumed
if pos < len(info):
result["attendance_data"] = info[pos:]
return result
def _parse_bt_punch(self, info: bytes) -> Dict[str, Any]:
"""0xB2 BT Punch: bluetooth punch card data."""
"""0xB2 BT Punch: datetime(6) + RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + TerminalInfo(1) + Reserved(2)."""
result: Dict[str, Any] = {}
pos = 0
@@ -1093,14 +1160,63 @@ class PacketParser:
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
# Remaining is BT punch-specific payload
if pos < len(info):
result["bt_data"] = info[pos:]
# RSSI (1 byte, signed)
if len(info) > pos:
result["rssi"] = struct.unpack_from("b", info, pos)[0]
pos += 1
# MAC address (6 bytes)
if len(info) >= pos + 6:
result["beacon_mac"] = ":".join(f"{b:02X}" for b in info[pos:pos + 6])
pos += 6
# UUID (16 bytes)
if len(info) >= pos + 16:
uuid_bytes = info[pos:pos + 16]
result["beacon_uuid"] = (
f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-"
f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-"
f"{uuid_bytes[10:16].hex()}"
).upper()
pos += 16
# Major (2 bytes)
if len(info) >= pos + 2:
result["beacon_major"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# Minor (2 bytes)
if len(info) >= pos + 2:
result["beacon_minor"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# Beacon battery (2 bytes, unit 0.01V)
if len(info) >= pos + 2:
raw_batt = struct.unpack_from("!H", info, pos)[0]
result["beacon_battery"] = raw_batt * 0.01
result["beacon_battery_unit"] = "V"
pos += 2
# Terminal info (1 byte) - clock_in/clock_out
if len(info) > pos:
ti = info[pos]
result["terminal_info"] = ti
status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "clock_in")
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
return result
def _parse_bt_location(self, info: bytes) -> Dict[str, Any]:
"""0xB3 BT Location: bluetooth location data."""
"""0xB3 BT Location: datetime(6) + beacon_count(1) + per-beacon(30 bytes each).
Per beacon: RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + BattUnit(1) = 30 bytes.
"""
result: Dict[str, Any] = {}
pos = 0
@@ -1108,8 +1224,101 @@ class PacketParser:
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
if pos < len(info):
result["bt_data"] = info[pos:]
beacon_count = 0
if len(info) > pos:
beacon_count = info[pos]
result["beacon_count"] = beacon_count
pos += 1
beacons: List[Dict[str, Any]] = []
for _ in range(beacon_count):
if len(info) < pos + 30:
break
rssi = struct.unpack_from("b", info, pos)[0]
pos += 1
mac = ":".join(f"{b:02X}" for b in info[pos:pos + 6])
pos += 6
uuid_bytes = info[pos:pos + 16]
uuid_str = (
f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-"
f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-"
f"{uuid_bytes[10:16].hex()}"
).upper()
pos += 16
major = struct.unpack_from("!H", info, pos)[0]
pos += 2
minor = struct.unpack_from("!H", info, pos)[0]
pos += 2
raw_batt = struct.unpack_from("!H", info, pos)[0]
pos += 2
batt_unit_byte = info[pos]
pos += 1
if batt_unit_byte == 0:
battery_val = raw_batt * 0.01
battery_unit = "V"
else:
battery_val = float(raw_batt)
battery_unit = "%"
beacons.append({
"rssi": rssi,
"mac": mac,
"uuid": uuid_str,
"major": major,
"minor": minor,
"battery": battery_val,
"battery_unit": battery_unit,
})
result["beacons"] = beacons
return result
def _parse_alarm_multi_fence(self, info: bytes) -> Dict[str, Any]:
"""0xA4 Multi Fence Alarm: same as 0xA3 + fence_id(1) at the end."""
result = self._parse_alarm_single_fence(info)
# After the standard alarm fields, 0xA4 has an extra fence_id byte
# We need to re-parse to find the fence_id position
# The simplest approach: fence_id is the last unparsed byte
# Since _parse_alarm_single_fence consumed up to language(1),
# the fence_id follows it. Calculate position from the end.
# Format ends with: ...alarm_code(1) + language(1) + fence_id(1)
if len(info) > 0:
result["fence_id"] = info[-1]
return result
def _parse_online_cmd_reply(self, info: bytes) -> Dict[str, Any]:
"""0x81 Online Command Reply: length(1) + server_flag(4) + content(N) + language(2)."""
result: Dict[str, Any] = {}
if len(info) < 1:
return result
result["cmd_length"] = info[0]
pos = 1
if len(info) >= pos + 4:
result["server_flag"] = struct.unpack_from("!I", info, pos)[0]
pos += 4
# Content is between server_flag and language(2 bytes at end)
if len(info) > pos + 2:
try:
result["response_content"] = info[pos:-2].decode("utf-8", errors="replace")
except Exception:
result["response_content"] = info[pos:-2].hex()
result["language"] = struct.unpack_from("!H", info, len(info) - 2)[0]
elif len(info) > pos:
try:
result["response_content"] = info[pos:].decode("utf-8", errors="replace")
except Exception:
result["response_content"] = info[pos:].hex()
return result
@@ -1133,8 +1342,10 @@ class PacketParser:
PROTO_LBS_4G: _parse_lbs_4g,
PROTO_WIFI_4G: _parse_wifi_4g,
PROTO_ALARM_SINGLE_FENCE: _parse_alarm_single_fence,
PROTO_ALARM_MULTI_FENCE: _parse_alarm_multi_fence,
PROTO_ALARM_LBS_4G: _parse_alarm_lbs_4g,
PROTO_ALARM_WIFI: _parse_alarm_wifi,
PROTO_ONLINE_CMD_REPLY: _parse_online_cmd_reply,
PROTO_ATTENDANCE: _parse_attendance,
PROTO_ATTENDANCE_4G: _parse_attendance_4g,
PROTO_BT_PUNCH: _parse_bt_punch,

View File

@@ -5,11 +5,14 @@ API endpoints for alarm record queries, acknowledgement, and statistics.
import math
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.models import AlarmRecord
from app.schemas import (
@@ -30,16 +33,18 @@ router = APIRouter(prefix="/api/alarms", tags=["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"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
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)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
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.
获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
@@ -52,6 +57,10 @@ async def list_alarms(
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
@@ -68,7 +77,8 @@ async def list_alarms(
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size)
order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
@@ -144,6 +154,7 @@ async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
)
async def acknowledge_alarm(
alarm_id: int,

142
app/routers/api_keys.py Normal file
View File

@@ -0,0 +1,142 @@
"""
API Keys Router - API密钥管理接口
Endpoints for creating, listing, updating, and deactivating API keys.
Admin permission required.
"""
import secrets
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.dependencies import require_admin, _hash_key
from app.models import ApiKey
from app.schemas import (
APIResponse,
ApiKeyCreate,
ApiKeyCreateResponse,
ApiKeyResponse,
ApiKeyUpdate,
PaginatedList,
)
import math
router = APIRouter(prefix="/api/keys", tags=["API Keys / 密钥管理"])
def _generate_key() -> str:
"""Generate a random 32-char hex API key."""
return secrets.token_hex(16)
@router.get(
"",
response_model=APIResponse[PaginatedList[ApiKeyResponse]],
summary="列出API密钥 / List API keys",
dependencies=[Depends(require_admin)],
)
async def list_keys(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""列出所有API密钥不返回密钥值/ List all API keys (key values are never shown)."""
count_result = await db.execute(select(func.count(ApiKey.id)))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(ApiKey).order_by(ApiKey.created_at.desc()).offset(offset).limit(page_size)
)
keys = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[ApiKeyResponse.model_validate(k) for k in keys],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.post(
"",
response_model=APIResponse[ApiKeyCreateResponse],
status_code=201,
summary="创建API密钥 / Create API key",
dependencies=[Depends(require_admin)],
)
async def create_key(body: ApiKeyCreate, db: AsyncSession = Depends(get_db)):
"""创建新的API密钥。明文密钥仅在创建时返回一次。
Create a new API key. The plaintext key is returned only once."""
raw_key = _generate_key()
key_hash = _hash_key(raw_key)
db_key = ApiKey(
key_hash=key_hash,
name=body.name,
permissions=body.permissions,
)
db.add(db_key)
await db.flush()
await db.refresh(db_key)
# Build response with plaintext key included (shown once)
base_data = ApiKeyResponse.model_validate(db_key).model_dump()
base_data["key"] = raw_key
response_data = ApiKeyCreateResponse(**base_data)
return APIResponse(
message="API key created. Store the key securely — it won't be shown again. / API密钥已创建请妥善保管",
data=response_data,
)
@router.put(
"/{key_id}",
response_model=APIResponse[ApiKeyResponse],
summary="更新API密钥 / Update API key",
dependencies=[Depends(require_admin)],
)
async def update_key(
key_id: int, body: ApiKeyUpdate, db: AsyncSession = Depends(get_db)
):
"""更新API密钥的名称、权限或激活状态 / Update key name, permissions, or active status."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_key, field, value)
await db.flush()
await db.refresh(db_key)
return APIResponse(
message="API key updated / 密钥已更新",
data=ApiKeyResponse.model_validate(db_key),
)
@router.delete(
"/{key_id}",
response_model=APIResponse,
summary="停用API密钥 / Deactivate API key",
dependencies=[Depends(require_admin)],
)
async def deactivate_key(key_id: int, db: AsyncSession = Depends(get_db)):
"""停用API密钥软删除/ Deactivate an API key (soft delete)."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
db_key.is_active = False
await db.flush()
return APIResponse(message="API key deactivated / 密钥已停用")

View File

@@ -8,6 +8,8 @@ import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.schemas import (
APIResponse,
@@ -64,6 +66,7 @@ async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BeaconConfigResponse],
status_code=201,
summary="添加信标 / Create beacon",
dependencies=[Depends(require_write)],
)
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
@@ -77,6 +80,7 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon",
dependencies=[Depends(require_write)],
)
async def update_beacon(
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
@@ -91,6 +95,7 @@ async def update_beacon(
"/{beacon_id}",
response_model=APIResponse,
summary="删除信标 / Delete beacon",
dependencies=[Depends(require_write)],
)
async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id)

View File

@@ -5,6 +5,7 @@ API endpoints for querying Bluetooth punch and location records.
import math
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
@@ -30,15 +31,17 @@ router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
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)"),
beacon_mac: str | None = Query(default=None, description="信标MAC / Beacon MAC filter"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
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.
获取蓝牙数据记录列表,支持按设备、记录类型、信标MAC、时间范围过滤。
List Bluetooth records with filters for device, record type, beacon MAC, and time range.
"""
query = select(BluetoothRecord)
count_query = select(func.count(BluetoothRecord.id))
@@ -51,6 +54,10 @@ async def list_bluetooth_records(
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if beacon_mac:
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
count_query = count_query.where(BluetoothRecord.beacon_mac == beacon_mac)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
@@ -63,7 +70,8 @@ async def list_bluetooth_records(
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
order = BluetoothRecord.recorded_at.asc() if sort_order == "asc" else BluetoothRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())

View File

@@ -5,6 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
import logging
import math
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
@@ -21,6 +22,7 @@ from app.schemas import (
CommandResponse,
PaginatedList,
)
from app.dependencies import require_write
from app.services import command_service, device_service
from app.services import tcp_command_service
@@ -127,6 +129,7 @@ async def _send_to_device(
)
command_log.status = "sent"
command_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(command_log)
@@ -148,17 +151,18 @@ async def _send_to_device(
)
async def list_commands(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"),
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.
获取指令历史记录,支持按设备、指令类型和状态过滤。
List command history with optional device, command type and status filters.
"""
commands, total = await command_service.get_commands(
db, device_id=device_id, status=status, page=page, page_size=page_size
db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size
)
return APIResponse(
data=PaginatedList(
@@ -176,6 +180,7 @@ async def list_commands(
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送指令 / Send command to device",
dependencies=[Depends(require_write)],
)
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -201,6 +206,7 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送留言 / Send message to device (0x82)",
dependencies=[Depends(require_write)],
)
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -223,6 +229,7 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse],
status_code=201,
summary="语音下发 / Send TTS voice broadcast to device",
dependencies=[Depends(require_write)],
)
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -249,6 +256,7 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BatchCommandResponse],
status_code=201,
summary="批量发送指令 / Batch send command to multiple devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
@@ -282,6 +290,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
cmd_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(cmd_log)
results.append(BatchCommandResult(

View File

@@ -22,8 +22,10 @@ from app.schemas import (
PaginatedList,
)
from app.config import settings
from app.dependencies import require_write
from app.extensions import limiter
from app.services import device_service
from app.schemas import LocationRecordResponse
from app.services import device_service, location_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@@ -88,11 +90,26 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
return APIResponse(data=DeviceResponse.model_validate(device))
@router.get(
"/all-latest-locations",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取所有在线设备位置 / Get all online device locations",
)
async def all_latest_locations(db: AsyncSession = Depends(get_db)):
"""
获取所有在线设备的最新位置,用于地图总览。
Get latest location for all online devices, for map overview.
"""
records = await location_service.get_all_online_latest_locations(db)
return APIResponse(data=[LocationRecordResponse.model_validate(r) for r in records])
@router.post(
"/batch",
response_model=APIResponse[BatchDeviceCreateResponse],
status_code=201,
summary="批量创建设备 / Batch create devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
@@ -118,6 +135,7 @@ async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest,
"/batch",
response_model=APIResponse[dict],
summary="批量更新设备 / Batch update devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
@@ -138,6 +156,7 @@ async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest,
"/batch-delete",
response_model=APIResponse[dict],
summary="批量删除设备 / Batch delete devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_delete_devices(
@@ -179,6 +198,7 @@ async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[DeviceResponse],
status_code=201,
summary="创建设备 / Create device",
dependencies=[Depends(require_write)],
)
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
"""
@@ -201,6 +221,7 @@ async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(ge
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="更新设备信息 / Update device",
dependencies=[Depends(require_write)],
)
async def update_device(
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
@@ -219,6 +240,7 @@ async def update_device(
"/{device_id}",
response_model=APIResponse,
summary="删除设备 / Delete device",
dependencies=[Depends(require_write)],
)
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""

55
app/routers/geocoding.py Normal file
View File

@@ -0,0 +1,55 @@
"""Geocoding proxy endpoints — keeps AMAP_KEY server-side."""
from fastapi import APIRouter, Query
import httpx
from app.config import settings
from app.geocoding import reverse_geocode, _amap_sign, AMAP_KEY
router = APIRouter(prefix="/api/geocode", tags=["geocoding"])
@router.get("/search")
async def search_location(
keyword: str = Query(..., min_length=1, max_length=100),
city: str = Query(default="", max_length=50),
):
"""Proxy for Amap POI text search. Returns GCJ-02 coordinates."""
if not AMAP_KEY:
return {"results": []}
params = {
"key": AMAP_KEY,
"keywords": keyword,
"output": "json",
"offset": "10",
"page": "1",
}
if city:
params["city"] = city
sig = _amap_sign(params)
if sig:
params["sig"] = sig
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get("https://restapi.amap.com/v3/place/text", params=params)
data = resp.json()
if data.get("status") != "1":
return {"results": []}
results = []
for poi in data.get("pois", [])[:10]:
if poi.get("location"):
results.append({
"name": poi.get("name", ""),
"address": poi.get("address", ""),
"location": poi["location"], # "lng,lat" in GCJ-02
})
return {"results": results}
@router.get("/reverse")
async def reverse_geocode_endpoint(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Reverse geocode WGS-84 coords to address via Amap."""
address = await reverse_geocode(lat, lon)
return {"address": address or ""}

View File

@@ -6,7 +6,7 @@ API endpoints for querying location records and device tracks.
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -85,6 +85,27 @@ async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
return APIResponse(data=LocationRecordResponse.model_validate(record))
@router.post(
"/batch-latest",
response_model=APIResponse[list[LocationRecordResponse | None]],
summary="批量获取设备最新位置 / Batch get latest locations",
)
async def batch_latest_locations(
device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True),
db: AsyncSession = Depends(get_db),
):
"""
传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。
Pass device_ids list, returns latest location per device in input order.
"""
records = await location_service.get_batch_latest_locations(db, device_ids)
result_map = {r.device_id: r for r in records}
return APIResponse(data=[
LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None
for did in device_ids
])
@router.get(
"/track/{device_id}",
response_model=APIResponse[list[LocationRecordResponse]],

81
app/routers/ws.py Normal file
View File

@@ -0,0 +1,81 @@
"""
WebSocket Router - WebSocket 实时推送接口
Real-time data push via WebSocket with topic subscriptions.
"""
import logging
import secrets
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.config import settings
from app.websocket_manager import ws_manager, VALID_TOPICS
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket / 实时推送"])
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
api_key: str | None = Query(default=None, alias="api_key"),
topics: str | None = Query(default=None, description="Comma-separated topics"),
):
"""
WebSocket endpoint for real-time data push.
Connect: ws://host/ws?api_key=xxx&topics=location,alarm
Topics: location, alarm, device_status, attendance, bluetooth
If no topics specified, subscribes to all.
"""
# Authenticate
if settings.API_KEY is not None:
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
# For DB keys, do a simple hash check
if api_key is not None:
from app.dependencies import _hash_key
from app.database import async_session
from sqlalchemy import select
from app.models import ApiKey
try:
async with async_session() as session:
key_hash = _hash_key(api_key)
result = await session.execute(
select(ApiKey.id).where(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True, # noqa: E712
)
)
if result.scalar_one_or_none() is None:
await websocket.close(code=4001, reason="Invalid API key")
return
except Exception:
await websocket.close(code=4001, reason="Auth error")
return
else:
await websocket.close(code=4001, reason="Missing API key")
return
# Parse topics
requested_topics = set()
if topics:
requested_topics = {t.strip() for t in topics.split(",") if t.strip() in VALID_TOPICS}
if not await ws_manager.connect(websocket, requested_topics):
return
try:
# Keep connection alive, handle pings
while True:
data = await websocket.receive_text()
# Client can send "ping" to keep alive
if data.strip().lower() == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception:
logger.debug("WebSocket connection error", exc_info=True)
finally:
ws_manager.disconnect(websocket)

View File

@@ -55,7 +55,6 @@ class DeviceCreate(DeviceBase):
class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
status: Literal["online", "offline"] | None = Field(None, description="Device status")
iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30)
@@ -144,7 +143,7 @@ class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]):
class AlarmRecordBase(BaseModel):
device_id: int
alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=10)
alarm_source: str | None = Field(None, max_length=20)
protocol_number: int
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
@@ -320,7 +319,7 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)")
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID")
beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称")
@@ -337,7 +336,7 @@ class BeaconConfigCreate(BeaconConfigBase):
class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$")
beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100)
@@ -482,3 +481,35 @@ class CommandResponse(BaseModel):
class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
pass
# ---------------------------------------------------------------------------
# API Key schemas
# ---------------------------------------------------------------------------
class ApiKeyCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
class ApiKeyUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
permissions: Literal["read", "write", "admin"] | None = None
is_active: bool | None = None
class ApiKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
permissions: str
is_active: bool
last_used_at: datetime | None = None
created_at: datetime
class ApiKeyCreateResponse(ApiKeyResponse):
"""Returned only on creation — includes the plaintext key (shown once)."""
key: str

View File

@@ -14,30 +14,13 @@ from app.models import CommandLog
async def get_commands(
db: AsyncSession,
device_id: int | None = None,
command_type: str | 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))
@@ -46,6 +29,10 @@ async def get_commands(
query = query.where(CommandLog.device_id == device_id)
count_query = count_query.where(CommandLog.device_id == device_id)
if command_type:
query = query.where(CommandLog.command_type == command_type)
count_query = count_query.where(CommandLog.command_type == command_type)
if status:
query = query.where(CommandLog.status == status)
count_query = count_query.where(CommandLog.status == status)

View File

@@ -101,6 +101,49 @@ async def get_latest_location(
return result.scalar_one_or_none()
async def get_batch_latest_locations(
db: AsyncSession, device_ids: list[int]
) -> list[LocationRecord]:
"""
批量获取多设备最新位置 / Get the most recent location for each device in the list.
Uses a subquery with MAX(id) GROUP BY device_id for efficiency.
"""
if not device_ids:
return []
# Subquery: max id per device_id
subq = (
select(func.max(LocationRecord.id).label("max_id"))
.where(LocationRecord.device_id.in_(device_ids))
.group_by(LocationRecord.device_id)
.subquery()
)
result = await db.execute(
select(LocationRecord).where(LocationRecord.id.in_(select(subq.c.max_id)))
)
return list(result.scalars().all())
async def get_all_online_latest_locations(
db: AsyncSession,
) -> list[LocationRecord]:
"""
获取所有在线设备的最新位置 / Get latest location for all online devices.
"""
from app.models import Device
# Get online device IDs
online_result = await db.execute(
select(Device.id).where(Device.status == "online")
)
online_ids = [row[0] for row in online_result.all()]
if not online_ids:
return []
return await get_batch_latest_locations(db, online_ids)
async def get_device_track(
db: AsyncSession,
device_id: int,

View File

@@ -28,6 +28,9 @@
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
.beacon-search-item:hover { background: #1e3a5f; }
#addBeaconMapDiv .leaflet-pane, #editBeaconMapDiv .leaflet-pane { z-index: 0 !important; }
#addBeaconMapDiv .leaflet-control, #editBeaconMapDiv .leaflet-control { z-index: 1 !important; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; }
.toast.success { background: #059669; }
@@ -938,7 +941,9 @@
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.detail || `HTTP ${response.status}`);
const detail = data.detail;
const msg = data.message || (typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map(e => e.msg || JSON.stringify(e)).join('; ') : null) || `HTTP ${response.status}`;
throw new Error(msg);
}
if (data.code !== undefined && data.code !== 0) {
throw new Error(data.message || '请求失败');
@@ -1082,6 +1087,7 @@
}
function closeModal() {
if (typeof destroyBeaconPickerMap === 'function') destroyBeaconPickerMap();
document.getElementById('modalContainer').innerHTML = '';
}
@@ -1775,6 +1781,19 @@
return [lat + dLat, lng + dLng];
}
function gcj02ToWgs84(gcjLat, gcjLng) {
if (_outOfChina(gcjLat, gcjLng)) return [gcjLat, gcjLng];
let dLat = _transformLat(gcjLng - 105.0, gcjLat - 35.0);
let dLng = _transformLng(gcjLng - 105.0, gcjLat - 35.0);
const radLat = gcjLat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - _gcj_ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI);
return [gcjLat - dLat, gcjLng - dLng];
}
// Convert WGS-84 coords for current map provider
function toMapCoord(lat, lng) {
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
@@ -1910,6 +1929,8 @@
mapMarkers.push(marker);
locationMap.setView([mLat, mLng], 15);
showToast('已显示最新位置');
// Auto-load records table
loadLocationRecords(1);
} catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error');
}
@@ -2225,30 +2246,127 @@
}
}
// ---- Beacon map picker ----
let _beaconPickerMap = null;
let _beaconPickerMarker = null;
let _beaconSearchTimeout = null;
function initBeaconPickerMap(mapDivId, latInputId, lonInputId, addrInputId, initLat, initLon) {
setTimeout(() => {
const defaultCenter = [30.605, 103.936];
const hasInit = initLat && initLon;
const wgsCenter = hasInit ? [initLat, initLon] : defaultCenter;
const [mLat, mLng] = toMapCoord(wgsCenter[0], wgsCenter[1]);
const zoom = hasInit ? 16 : 12;
_beaconPickerMap = L.map(mapDivId, {zoomControl: true}).setView([mLat, mLng], zoom);
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
subdomains: '1234', maxZoom: 18,
attribution: '&copy; 高德地图',
}).addTo(_beaconPickerMap);
if (hasInit) {
_beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
}
_beaconPickerMap.on('click', async (e) => {
const gcjLat = e.latlng.lat, gcjLng = e.latlng.lng;
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
try {
const res = await apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`);
if (res.address) document.getElementById(addrInputId).value = res.address;
} catch (_) {}
});
const syncMarker = () => {
const lat = parseFloat(document.getElementById(latInputId).value);
const lon = parseFloat(document.getElementById(lonInputId).value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
const [mLat, mLng] = toMapCoord(lat, lon);
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([mLat, mLng]);
else _beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
_beaconPickerMap.setView([mLat, mLng], 16);
}
};
document.getElementById(latInputId).addEventListener('change', syncMarker);
document.getElementById(lonInputId).addEventListener('change', syncMarker);
}, 150);
}
async function searchBeaconLocation(query, resultsId, latInputId, lonInputId, addrInputId) {
if (_beaconSearchTimeout) clearTimeout(_beaconSearchTimeout);
const container = document.getElementById(resultsId);
if (!query || query.length < 2) { container.innerHTML = ''; return; }
_beaconSearchTimeout = setTimeout(async () => {
try {
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
if (!res.results || !res.results.length) {
container.innerHTML = '<div style="color:#9ca3af;font-size:12px;padding:8px;">无搜索结果</div>';
return;
}
container.innerHTML = res.results.map(r => {
const [lng, lat] = r.location.split(',').map(Number);
return `<div class="beacon-search-item" onclick="selectBeaconSearchResult(${lat},${lng},'${latInputId}','${lonInputId}','${addrInputId}','${resultsId}')" style="padding:6px 8px;cursor:pointer;border-bottom:1px solid #374151;font-size:13px;">
<div style="color:#93c5fd;">${escapeHtml(r.name)}</div>
<div style="color:#9ca3af;font-size:11px;">${escapeHtml(r.address || '')}</div>
</div>`;
}).join('');
} catch (_) { container.innerHTML = ''; }
}, 400);
}
function selectBeaconSearchResult(gcjLat, gcjLng, latInputId, lonInputId, addrInputId, resultsId) {
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
document.getElementById(resultsId).innerHTML = '';
if (_beaconPickerMap) {
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
_beaconPickerMap.setView([gcjLat, gcjLng], 16);
}
apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`)
.then(res => { if (res.address) document.getElementById(addrInputId).value = res.address; })
.catch(() => {});
}
function destroyBeaconPickerMap() {
if (_beaconPickerMap) { _beaconPickerMap.remove(); _beaconPickerMap = null; }
_beaconPickerMarker = null;
}
function showAddBeaconModal() {
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-blue-400"></i>添加蓝牙信标</h3>
<div class="form-group"><label>MAC 地址 <span class="text-red-400">*</span></label><input type="text" id="addBeaconMac" placeholder="AA:BB:CC:DD:EE:FF" maxlength="20"><p class="text-xs text-gray-500 mt-1">信标的蓝牙 MAC 地址,冒号分隔大写十六进制</p></div>
<div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="addBeaconName" placeholder="如: 前台大门 / A区3楼走廊"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
<div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"></div>
<div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"></div>
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"></div>
<div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>楼层</label><input type="text" id="addBeaconFloor" placeholder="如: 3F"></div>
<div class="form-group"><label>区域</label><input type="text" id="addBeaconArea" placeholder="如: A区会议室"></div>
<p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
<div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
<input type="text" id="addBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'addBeaconSearchResults','addBeaconLat','addBeaconLon','addBeaconAddress')">
<div id="addBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
</div>
<div id="addBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>纬度</label><input type="number" id="addBeaconLat" step="0.000001" placeholder="如: 30.12345"></div>
<div class="form-group"><label>经度</label><input type="number" id="addBeaconLon" step="0.000001" placeholder="如: 120.12345"></div>
</div>
<div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="可选"></div>
<div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="点击地图或搜索自动填充"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="submitAddBeacon()"><i class="fas fa-check"></i> 确认添加</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
</div>
`);
initBeaconPickerMap('addBeaconMapDiv', 'addBeaconLat', 'addBeaconLon', 'addBeaconAddress', null, null);
}
async function submitAddBeacon() {
@@ -2262,8 +2380,6 @@
const uuid = document.getElementById('addBeaconUuid').value.trim();
const major = document.getElementById('addBeaconMajor').value;
const minor = document.getElementById('addBeaconMinor').value;
const floor = document.getElementById('addBeaconFloor').value.trim();
const area = document.getElementById('addBeaconArea').value.trim();
const lat = document.getElementById('addBeaconLat').value;
const lon = document.getElementById('addBeaconLon').value;
const address = document.getElementById('addBeaconAddress').value.trim();
@@ -2271,8 +2387,6 @@
if (uuid) body.beacon_uuid = uuid;
if (major !== '') body.beacon_major = parseInt(major);
if (minor !== '') body.beacon_minor = parseInt(minor);
if (floor) body.floor = floor;
if (area) body.area = area;
if (lat !== '') body.latitude = parseFloat(lat);
if (lon !== '') body.longitude = parseFloat(lon);
if (address) body.address = address;
@@ -2292,33 +2406,39 @@
const b = await apiCall(`${API_BASE}/beacons/${id}`);
showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-edit mr-2 text-yellow-400"></i>编辑信标</h3>
<div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改</p></div>
<div class="form-group"><label>名称</label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"></div>
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"></div>
<div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改,设备通过此地址匹配信标</p></div>
<div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"></div>
<div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"></div>
<div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
<div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>楼层</label><input type="text" id="editBeaconFloor" value="${escapeHtml(b.floor || '')}"></div>
<div class="form-group"><label>区域</label><input type="text" id="editBeaconArea" value="${escapeHtml(b.area || '')}"></div>
<p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
<div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
<input type="text" id="editBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'editBeaconSearchResults','editBeaconLat','editBeaconLon','editBeaconAddress')">
<div id="editBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
</div>
<div id="editBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
<div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>纬度</label><input type="number" id="editBeaconLat" step="0.000001" value="${b.latitude != null ? b.latitude : ''}"></div>
<div class="form-group"><label>经度</label><input type="number" id="editBeaconLon" step="0.000001" value="${b.longitude != null ? b.longitude : ''}"></div>
</div>
<div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"></div>
<div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
<div class="form-group"><label>状态</label>
<select id="editBeaconStatus">
<option value="active" ${b.status === 'active' ? 'selected' : ''}>启用</option>
<option value="inactive" ${b.status !== 'active' ? 'selected' : ''}>停用</option>
</select>
<p class="text-xs text-gray-500 mt-1">停用后蓝牙记录将不再关联此信标位置</p>
</div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="submitEditBeacon(${id})"><i class="fas fa-check"></i> 保存</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
</div>
`);
initBeaconPickerMap('editBeaconMapDiv', 'editBeaconLat', 'editBeaconLon', 'editBeaconAddress',
b.latitude || null, b.longitude || null);
} catch (err) {
showToast('加载信标信息失败: ' + err.message, 'error');
}
@@ -2330,8 +2450,6 @@
const uuid = document.getElementById('editBeaconUuid').value.trim();
const major = document.getElementById('editBeaconMajor').value;
const minor = document.getElementById('editBeaconMinor').value;
const floor = document.getElementById('editBeaconFloor').value.trim();
const area = document.getElementById('editBeaconArea').value.trim();
const lat = document.getElementById('editBeaconLat').value;
const lon = document.getElementById('editBeaconLon').value;
const address = document.getElementById('editBeaconAddress').value.trim();
@@ -2341,8 +2459,6 @@
body.beacon_uuid = uuid || null;
if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null;
if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null;
body.floor = floor || null;
body.area = area || null;
if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null;
if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null;
body.address = address || null;

View File

@@ -21,6 +21,7 @@ from sqlalchemy import select, update
from app.config import settings
from app.database import async_session
from app.geocoding import geocode_location, reverse_geocode
from app.websocket_manager import ws_manager
from app.models import (
AlarmRecord,
AttendanceRecord,
@@ -213,44 +214,18 @@ class PacketParser:
class PacketBuilder:
"""Construct KKS protocol response packets.
"""Thin wrapper delegating to app.protocol.builder.PacketBuilder.
Length field semantics match app.protocol.builder:
length = proto(1) + info(N) + serial(2) + crc(2)
CRC is computed over: length_bytes + proto + info + serial
Preserves the (protocol, payload, serial) call signature used throughout tcp_server.py.
"""
from app.protocol.builder import PacketBuilder as _ProtoBuilder
@staticmethod
def build_response(
protocol: int, payload: bytes, serial: int, *, long: bool = False
) -> bytes:
proto_byte = struct.pack("B", protocol)
serial_bytes = struct.pack("!H", serial)
# length = proto(1) + info(N) + serial(2) + crc(2)
payload_len = 1 + len(payload) + 2 + 2
if long or payload_len > 0xFF:
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 over: length_bytes + proto + info + serial
crc_input = length_bytes + proto_byte + payload + serial_bytes
crc_value = crc_itu(crc_input)
crc_bytes = struct.pack("!H", crc_value)
return (
start_marker
+ length_bytes
+ proto_byte
+ payload
+ serial_bytes
+ crc_bytes
+ STOP_MARKER
)
return PacketBuilder._ProtoBuilder.build_response(protocol, serial, payload)
# ---------------------------------------------------------------------------
@@ -452,6 +427,10 @@ class TCPManager:
.where(Device.imei == imei)
.values(status="offline")
)
# Broadcast device offline
ws_manager.broadcast_nonblocking("device_status", {
"imei": imei, "status": "offline",
})
except Exception:
logger.exception("Failed to set IMEI=%s offline in DB", imei)
@@ -547,8 +526,8 @@ class TCPManager:
# bits 9-0: course (0-360)
is_realtime = bool(course_status & 0x2000)
gps_positioned = bool(course_status & 0x1000)
is_east = bool(course_status & 0x0800)
is_north = bool(course_status & 0x0400)
is_west = bool(course_status & 0x0800) # bit 11: 0=East, 1=West
is_north = bool(course_status & 0x0400) # bit 10: 0=South, 1=North
course = course_status & 0x03FF
latitude = lat_raw / 1_800_000.0
@@ -557,7 +536,7 @@ class TCPManager:
# Apply hemisphere
if not is_north:
latitude = -latitude
if not is_east:
if is_west:
longitude = -longitude
result["latitude"] = latitude
@@ -681,6 +660,10 @@ class TCPManager:
if lang_str:
device.language = lang_str
# Don't overwrite user-set device_type with raw hex code
# Broadcast device online
ws_manager.broadcast_nonblocking("device_status", {
"imei": imei, "status": "online",
})
except Exception:
logger.exception("DB error during login for IMEI=%s", imei)
@@ -986,8 +969,8 @@ class TCPManager:
if location_type == "lbs" and len(content) >= pos + 5:
lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
elif location_type == "lbs_4g" and len(content) >= pos + 12:
lac = struct.unpack("!I", content[pos : pos + 4])[0]
pos += 4
@@ -1015,11 +998,11 @@ class TCPManager:
pos += 4
cell_id = struct.unpack("!Q", content[pos : pos + 8])[0]
pos += 8
elif location_type == "wifi" and len(content) >= pos + 4:
elif location_type == "wifi" and len(content) >= pos + 5:
lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
neighbor_cells_data: Optional[list] = None
@@ -1039,6 +1022,7 @@ class TCPManager:
cell_id=cell_id,
wifi_list=wifi_data_list,
neighbor_cells=neighbor_cells_data,
imei=imei,
)
if lat is not None and lon is not None:
latitude = lat
@@ -1089,6 +1073,12 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast to WebSocket subscribers
ws_manager.broadcast_nonblocking("location", {
"imei": imei, "device_id": device_id, "location_type": location_type,
"latitude": latitude, "longitude": longitude, "speed": speed,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception(
"DB error storing %s location for IMEI=%s", location_type, imei
@@ -1121,7 +1111,7 @@ class TCPManager:
# Parse stations (main + up to 6 neighbors)
is_4g = location_type in ("lbs_4g", "wifi_4g")
lac_size = 4 if is_4g else 2
cid_size = 8 if is_4g else 2
cid_size = 8 if is_4g else 3
station_size = lac_size + cid_size + 1 # +1 for RSSI
for i in range(7):
@@ -1135,8 +1125,8 @@ class TCPManager:
else:
s_lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
s_cid = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
s_cid = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
s_rssi = content[pos]
pos += 1
@@ -1386,6 +1376,8 @@ class TCPManager:
cell_id: Optional[int] = None
battery_level: Optional[int] = None
gsm_signal: Optional[int] = None
wifi_data: Optional[list] = None
fence_data: Optional[dict] = None
# For alarm packets (0xA3, 0xA4, 0xA9), the terminal_info byte is
# located after GPS + LBS data. Extract alarm type from terminal_info bits.
@@ -1420,6 +1412,12 @@ class TCPManager:
terminal_info, battery_level, gsm_signal, alarm_type_name, pos = \
self._parse_alarm_tail(content, pos)
# Extract fence_id for 0xA4 multi-fence alarm
if proto == PROTO_ALARM_MULTI_FENCE and len(content) >= pos + 1:
fence_id = content[pos]
fence_data = {"fence_id": fence_id}
pos += 1
elif proto == PROTO_ALARM_LBS_4G:
# 0xA5: NO datetime, NO GPS, NO lbs_length
# mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1)
@@ -1491,6 +1489,9 @@ class TCPManager:
wifi_data_list.append({"mac": mac, "signal": signal})
pos += 7
if wifi_data_list:
wifi_data = wifi_data_list
# alarm_code(1) + language(1)
if len(content) >= pos + 1:
alarm_code = content[pos]
@@ -1504,6 +1505,7 @@ class TCPManager:
lat, lon = await geocode_location(
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
wifi_list=wifi_list_for_geocode,
imei=imei,
)
if lat is not None and lon is not None:
latitude = lat
@@ -1544,9 +1546,17 @@ class TCPManager:
battery_level=battery_level,
gsm_signal=gsm_signal,
address=address,
wifi_data=wifi_data,
fence_data=fence_data,
recorded_at=recorded_at,
)
session.add(record)
# Broadcast alarm to WebSocket subscribers
ws_manager.broadcast_nonblocking("alarm", {
"imei": imei, "device_id": device_id, "alarm_type": alarm_type_name,
"alarm_source": alarm_source, "latitude": latitude, "longitude": longitude,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception(
"DB error storing alarm for IMEI=%s (source=%s)", imei, alarm_source
@@ -1849,6 +1859,12 @@ class TCPManager:
"0xB1" if is_4g else "0xB0", imei, attendance_type,
gps_positioned, latitude, longitude, address,
)
# Broadcast attendance to WebSocket subscribers
ws_manager.broadcast_nonblocking("attendance", {
"imei": imei, "attendance_type": attendance_type,
"latitude": latitude, "longitude": longitude,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing attendance for IMEI=%s", imei)
@@ -1991,6 +2007,12 @@ class TCPManager:
beacon_major, beacon_minor, rssi,
beacon_battery or 0,
)
# Broadcast bluetooth punch
ws_manager.broadcast_nonblocking("bluetooth", {
"imei": imei, "record_type": "punch",
"beacon_mac": beacon_mac, "attendance_type": attendance_type,
"recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing BT punch for IMEI=%s", imei)
@@ -2163,6 +2185,11 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast bluetooth location
ws_manager.broadcast_nonblocking("bluetooth", {
"imei": imei, "record_type": "location",
"beacon_count": beacon_count, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing BT location for IMEI=%s", imei)

89
app/websocket_manager.py Normal file
View File

@@ -0,0 +1,89 @@
"""
WebSocket Manager - WebSocket 连接管理器
Manages client connections, topic subscriptions, and broadcasting.
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# Maximum concurrent WebSocket connections
MAX_CONNECTIONS = 100
# Valid topics
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"}
class WebSocketManager:
"""Manages WebSocket connections with topic-based subscriptions."""
def __init__(self):
# {websocket: set_of_topics}
self.active_connections: dict[WebSocket, set[str]] = {}
@property
def connection_count(self) -> int:
return len(self.active_connections)
async def connect(self, websocket: WebSocket, topics: set[str]) -> bool:
"""Accept and register a WebSocket connection. Returns False if limit reached."""
if self.connection_count >= MAX_CONNECTIONS:
await websocket.close(code=1013, reason="Max connections reached")
return False
await websocket.accept()
filtered = topics & VALID_TOPICS
self.active_connections[websocket] = filtered if filtered else VALID_TOPICS
logger.info(
"WebSocket connected (%d total), topics: %s",
self.connection_count,
self.active_connections[websocket],
)
return True
def disconnect(self, websocket: WebSocket):
"""Remove a WebSocket connection."""
self.active_connections.pop(websocket, None)
logger.info("WebSocket disconnected (%d remaining)", self.connection_count)
async def broadcast(self, topic: str, data: dict):
"""Broadcast a message to all subscribers of the given topic."""
if topic not in VALID_TOPICS:
return
message = json.dumps(
{"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()},
default=str,
ensure_ascii=False,
)
disconnected = []
# Snapshot dict to avoid RuntimeError from concurrent modification
for ws, topics in list(self.active_connections.items()):
if topic in topics:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.active_connections.pop(ws, None)
def broadcast_nonblocking(self, topic: str, data: dict):
"""Fire-and-forget broadcast (used from TCP handler context)."""
asyncio.create_task(self._safe_broadcast(topic, data))
async def _safe_broadcast(self, topic: str, data: dict):
try:
await self.broadcast(topic, data)
except Exception:
logger.exception("WebSocket broadcast error for topic %s", topic)
# Singleton instance
ws_manager = WebSocketManager()