feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤
- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT) - 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段 - 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索 - 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState) - 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖 - TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播 - 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板 - 新增fence_attendance WebSocket topic via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
20
.env.example
20
.env.example
@@ -25,19 +25,13 @@
|
||||
# Track query max points (default: 10000)
|
||||
# TRACK_MAX_POINTS=10000
|
||||
|
||||
# 天地图 API key (reverse geocoding, free 10k/day)
|
||||
# Sign up: https://lbs.tianditu.gov.cn/
|
||||
# TIANDITU_API_KEY=your_tianditu_key
|
||||
|
||||
# Google Geolocation API (optional, for cell/WiFi geocoding)
|
||||
# GOOGLE_API_KEY=your_google_key
|
||||
|
||||
# Unwired Labs API (optional, for cell/WiFi geocoding)
|
||||
# UNWIRED_API_TOKEN=your_unwired_token
|
||||
|
||||
# 高德地图 API (optional, requires enterprise auth for IoT positioning)
|
||||
# AMAP_KEY=your_amap_key
|
||||
# AMAP_SECRET=your_amap_secret
|
||||
# 高德地图 API
|
||||
# Web服务 key (逆地理编码 + v5 IoT定位, 企业订阅)
|
||||
# AMAP_KEY=your_amap_web_service_key
|
||||
# AMAP_SECRET=your_amap_web_service_secret
|
||||
# 智能硬件定位 key (旧版 apilocate.amap.com 回退, 可选)
|
||||
# AMAP_HARDWARE_KEY=your_amap_hardware_key
|
||||
# AMAP_HARDWARE_SECRET=your_amap_hardware_secret
|
||||
|
||||
# Geocoding cache size
|
||||
# GEOCODING_CACHE_SIZE=10000
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_cst() -> datetime:
|
||||
"""Return current time in CST (UTC+8) as naive datetime for SQLite."""
|
||||
return datetime.now(CST).replace(tzinfo=None)
|
||||
|
||||
# Project root directory (where config.py lives → parent = app/ → parent = project root)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"
|
||||
@@ -30,8 +38,10 @@ class Settings(BaseSettings):
|
||||
RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations")
|
||||
|
||||
# 高德地图 API (geocoding)
|
||||
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
|
||||
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
|
||||
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web服务 key (逆地理编码/POI搜索)")
|
||||
AMAP_SECRET: str | None = Field(default=None, description="高德地图 Web服务安全密钥")
|
||||
AMAP_HARDWARE_KEY: str | None = Field(default=None, description="高德地图智能硬件定位 key (基站/WiFi定位)")
|
||||
AMAP_HARDWARE_SECRET: str | None = Field(default=None, description="高德地图智能硬件定位安全密钥 (与 HARDWARE_KEY 配对)")
|
||||
|
||||
# Geocoding
|
||||
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
|
||||
@@ -40,6 +50,12 @@ class Settings(BaseSettings):
|
||||
# Track query limit
|
||||
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
|
||||
|
||||
# Fence auto-attendance
|
||||
FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report")
|
||||
FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check")
|
||||
FENCE_WIFI_TOLERANCE_METERS: int = Field(default=100, description="Extra tolerance (meters) for WiFi locations in fence check")
|
||||
FENCE_MIN_INSIDE_SECONDS: int = Field(default=60, description="Minimum seconds between fence attendance transitions (debounce)")
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -57,7 +57,8 @@ async def verify_api_key(
|
||||
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
|
||||
|
||||
# Update last_used_at
|
||||
db_key.last_used_at = datetime.now(timezone.utc)
|
||||
from app.config import now_cst
|
||||
db_key.last_used_at = now_cst()
|
||||
await db.flush()
|
||||
|
||||
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
||||
|
||||
267
app/geocoding.py
267
app/geocoding.py
@@ -3,7 +3,7 @@ Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
|
||||
and reverse geocode coordinates to addresses.
|
||||
|
||||
All services use 高德 (Amap) API exclusively.
|
||||
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
|
||||
- Forward geocoding (cell/WiFi → coords): 高德 IoT 定位 v5 API
|
||||
- Reverse geocoding (coords → address): 高德逆地理编码
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,8 @@ from app.config import settings as _settings
|
||||
|
||||
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||||
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
||||
AMAP_HARDWARE_KEY: Optional[str] = _settings.AMAP_HARDWARE_KEY
|
||||
AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
|
||||
|
||||
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||||
|
||||
@@ -68,6 +70,14 @@ def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
|
||||
return (lat + d_lat, lon + d_lon)
|
||||
|
||||
|
||||
def gcj02_to_wgs84(lat: float, lon: float) -> tuple[float, float]:
|
||||
"""Convert GCJ-02 to WGS-84 (reverse of wgs84_to_gcj02)."""
|
||||
if _out_of_china(lat, lon):
|
||||
return (lat, lon)
|
||||
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
|
||||
return (lat * 2 - gcj_lat, lon * 2 - gcj_lon)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LRU Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,52 +135,229 @@ async def geocode_location(
|
||||
wifi_list: Optional[list[dict]] = None,
|
||||
neighbor_cells: Optional[list[dict]] = None,
|
||||
imei: Optional[str] = None,
|
||||
location_type: Optional[str] = None,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Convert cell tower and/or WiFi AP data to lat/lon.
|
||||
|
||||
Uses 高德智能硬件定位 API exclusively.
|
||||
Uses 高德 IoT 定位 v5 API (restapi.amap.com/v5/position/IoT).
|
||||
Falls back to legacy API (apilocate.amap.com/position) if v5 fails.
|
||||
|
||||
location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE).
|
||||
"""
|
||||
# Check cache first
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
if wifi_list:
|
||||
wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list)))
|
||||
cached = _wifi_cache.get_cached(wifi_cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
elif 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
|
||||
|
||||
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
|
||||
|
||||
api_key = AMAP_KEY
|
||||
if not api_key:
|
||||
return (None, None)
|
||||
|
||||
# Determine network type from location_type
|
||||
is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g")
|
||||
|
||||
async def _geocode_amap(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Use 高德智能硬件定位 API (apilocate.amap.com/position).
|
||||
# Try v5 API first (POST restapi.amap.com/v5/position/IoT)
|
||||
result = await _geocode_amap_v5(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
|
||||
imei=imei, api_key=api_key, is_4g=is_4g,
|
||||
)
|
||||
|
||||
Returns coordinates (高德 returns GCJ-02).
|
||||
"""
|
||||
# Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
|
||||
bts = ""
|
||||
# Fallback to legacy API if v5 fails and hardware key is available
|
||||
if result[0] is None and AMAP_HARDWARE_KEY:
|
||||
result = await _geocode_amap_legacy(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
|
||||
imei=imei, api_key=AMAP_HARDWARE_KEY,
|
||||
)
|
||||
|
||||
if result[0] is not None:
|
||||
if wifi_list:
|
||||
_wifi_cache.put(wifi_cache_key, result)
|
||||
elif 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
|
||||
|
||||
|
||||
def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str:
|
||||
"""Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage"""
|
||||
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"
|
||||
return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0"
|
||||
return ""
|
||||
|
||||
# Build nearbts (neighbor cells)
|
||||
nearbts_parts = []
|
||||
|
||||
def _build_nearbts(
|
||||
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int]
|
||||
) -> list[str]:
|
||||
"""Build nearbts (neighbor cell) parts."""
|
||||
parts = []
|
||||
if neighbor_cells:
|
||||
for nc in neighbor_cells:
|
||||
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}")
|
||||
parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0")
|
||||
return parts
|
||||
|
||||
# Build macs (WiFi APs): mac,signal,ssid
|
||||
|
||||
def _build_wifi_parts(wifi_list: Optional[list[dict]]) -> list[str]:
|
||||
"""Build WiFi MAC parts: mac,signal,ssid,fresh"""
|
||||
parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
mac = ap.get("mac", "")
|
||||
# v5 API requires colon-separated lowercase MAC
|
||||
if ":" not in mac:
|
||||
# Convert raw hex to colon-separated
|
||||
mac_clean = mac.lower().replace("-", "")
|
||||
if len(mac_clean) == 12:
|
||||
mac = ":".join(mac_clean[i:i+2] for i in range(0, 12, 2))
|
||||
else:
|
||||
mac = mac.lower()
|
||||
else:
|
||||
mac = mac.lower()
|
||||
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
|
||||
ssid = ap.get("ssid", "")
|
||||
parts.append(f"{mac},{signal},{ssid},0")
|
||||
return parts
|
||||
|
||||
|
||||
def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]:
|
||||
"""Select strongest WiFi AP as mmac (connected WiFi), rest as macs.
|
||||
|
||||
v5 API requires mmac when accesstype=2.
|
||||
Returns (mmac_str, remaining_macs_parts).
|
||||
"""
|
||||
if not wifi_parts:
|
||||
return ("", [])
|
||||
# Find strongest signal (most negative = weakest, so max of negative values)
|
||||
# Parts format: "mac,signal,ssid,fresh"
|
||||
best_idx = 0
|
||||
best_signal = -999
|
||||
for i, part in enumerate(wifi_parts):
|
||||
fields = part.split(",")
|
||||
if len(fields) >= 2:
|
||||
try:
|
||||
sig = int(fields[1])
|
||||
if sig > best_signal:
|
||||
best_signal = sig
|
||||
best_idx = i
|
||||
except ValueError:
|
||||
pass
|
||||
mmac = wifi_parts[best_idx]
|
||||
remaining = [p for i, p in enumerate(wifi_parts) if i != best_idx]
|
||||
return (mmac, remaining)
|
||||
|
||||
|
||||
async def _geocode_amap_v5(
|
||||
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
|
||||
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
|
||||
*, imei: Optional[str] = None, api_key: str, is_4g: bool = False,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
|
||||
|
||||
Key differences from legacy:
|
||||
- POST method, key in URL params, data in body
|
||||
- accesstype: 0=未知, 1=移动网络, 2=WiFi
|
||||
- WiFi requires mmac (connected WiFi) + macs (nearby, 2-30)
|
||||
- network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy
|
||||
- diu replaces imei
|
||||
- No digital signature needed
|
||||
- show_fields can return address directly
|
||||
"""
|
||||
bts = _build_bts(mcc, mnc, lac, cell_id)
|
||||
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
|
||||
wifi_parts = _build_wifi_parts(wifi_list)
|
||||
|
||||
if not bts and not wifi_parts:
|
||||
return (None, None)
|
||||
|
||||
# Determine accesstype: 2=WiFi (when we have WiFi data), 1=mobile network
|
||||
has_wifi = len(wifi_parts) >= 2 # v5 requires 2+ WiFi APs
|
||||
accesstype = "2" if has_wifi else "1"
|
||||
|
||||
# Build POST body
|
||||
body: dict[str, str] = {
|
||||
"accesstype": accesstype,
|
||||
"cdma": "0",
|
||||
"network": "LTE" if is_4g else "GSM",
|
||||
"diu": imei or _settings.GEOCODING_DEFAULT_IMEI,
|
||||
"show_fields": "formatted_address",
|
||||
}
|
||||
|
||||
if bts:
|
||||
body["bts"] = bts
|
||||
if nearbts_parts:
|
||||
body["nearbts"] = "|".join(nearbts_parts)
|
||||
|
||||
if has_wifi:
|
||||
mmac, remaining_macs = _select_mmac(wifi_parts)
|
||||
body["mmac"] = mmac
|
||||
if remaining_macs:
|
||||
body["macs"] = "|".join(remaining_macs)
|
||||
elif wifi_parts:
|
||||
# Less than 2 WiFi APs: include as macs anyway, use accesstype=1
|
||||
body["macs"] = "|".join(wifi_parts)
|
||||
|
||||
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, data=body, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == "1":
|
||||
position = data.get("position", {})
|
||||
location = position.get("location", "") if isinstance(position, dict) else ""
|
||||
if location and "," in location:
|
||||
lon_str, lat_str = location.split(",")
|
||||
gcj_lat = float(lat_str)
|
||||
gcj_lon = float(lon_str)
|
||||
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
|
||||
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
|
||||
logger.info(
|
||||
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
|
||||
gcj_lat, gcj_lon, lat, lon, radius,
|
||||
)
|
||||
return (lat, lon)
|
||||
else:
|
||||
infocode = data.get("infocode", "")
|
||||
logger.warning(
|
||||
"Amap v5 geocode error: %s (code=%s)",
|
||||
data.get("info", ""), infocode,
|
||||
)
|
||||
else:
|
||||
logger.warning("Amap v5 geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap v5 geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_amap_legacy(
|
||||
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
|
||||
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
|
||||
*, imei: Optional[str] = None, api_key: str,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Legacy 高德智能硬件定位 API (GET apilocate.amap.com/position).
|
||||
|
||||
Used as fallback when v5 API fails.
|
||||
"""
|
||||
bts = _build_bts(mcc, mnc, lac, cell_id)
|
||||
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
|
||||
|
||||
# Build macs (legacy format without fresh field)
|
||||
macs_parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
@@ -182,7 +369,11 @@ async def _geocode_amap(
|
||||
if not bts and not macs_parts:
|
||||
return (None, None)
|
||||
|
||||
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
|
||||
params: dict[str, str] = {
|
||||
"accesstype": "0",
|
||||
"imei": imei or _settings.GEOCODING_DEFAULT_IMEI,
|
||||
"key": api_key,
|
||||
}
|
||||
if bts:
|
||||
params["bts"] = bts
|
||||
if nearbts_parts:
|
||||
@@ -190,9 +381,11 @@ async def _geocode_amap(
|
||||
if macs_parts:
|
||||
params["macs"] = "|".join(macs_parts)
|
||||
|
||||
# Add digital signature
|
||||
sig = _amap_sign(params)
|
||||
if sig:
|
||||
# Only sign if using a key that has its own secret
|
||||
hw_secret = AMAP_HARDWARE_SECRET
|
||||
if hw_secret:
|
||||
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
sig = hashlib.md5((sorted_str + hw_secret).encode()).hexdigest()
|
||||
params["sig"] = sig
|
||||
|
||||
url = "https://apilocate.amap.com/position"
|
||||
@@ -209,20 +402,24 @@ async def _geocode_amap(
|
||||
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)
|
||||
gcj_lat = float(lat_str)
|
||||
gcj_lon = float(lon_str)
|
||||
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
|
||||
logger.info(
|
||||
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
|
||||
gcj_lat, gcj_lon, lat, lon,
|
||||
)
|
||||
return (lat, lon)
|
||||
else:
|
||||
infocode = data.get("infocode", "")
|
||||
if infocode == "10012":
|
||||
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
|
||||
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
|
||||
else:
|
||||
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||||
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||||
else:
|
||||
logger.warning("Amap geocode HTTP %d", resp.status)
|
||||
logger.warning("Amap legacy geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap geocode error: %s", e)
|
||||
logger.warning("Amap legacy geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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, api_keys, ws, geocoding
|
||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, heartbeats, api_keys, ws, geocoding
|
||||
from app.dependencies import verify_api_key, require_write, require_admin
|
||||
|
||||
import asyncio
|
||||
@@ -28,11 +28,12 @@ from app.extensions import limiter
|
||||
|
||||
async def run_data_cleanup():
|
||||
"""Delete records older than DATA_RETENTION_DAYS."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import delete
|
||||
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
|
||||
from app.config import now_cst
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
|
||||
cutoff = now_cst() - timedelta(days=settings.DATA_RETENTION_DAYS)
|
||||
total_deleted = 0
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
@@ -176,6 +177,7 @@ app.include_router(attendance.router, dependencies=[*_api_deps])
|
||||
app.include_router(commands.router, dependencies=[*_api_deps])
|
||||
app.include_router(bluetooth.router, dependencies=[*_api_deps])
|
||||
app.include_router(beacons.router, dependencies=[*_api_deps])
|
||||
app.include_router(fences.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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
@@ -14,13 +14,10 @@ from sqlalchemy import (
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.config import now_cst as _utcnow
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""Registered Bluetooth badge devices."""
|
||||
|
||||
@@ -80,6 +77,7 @@ class LocationRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
location_type: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False
|
||||
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
||||
@@ -125,6 +123,7 @@ class AlarmRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alarm_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False
|
||||
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
||||
@@ -170,6 +169,7 @@ class HeartbeatRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
|
||||
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -195,6 +195,7 @@ class AttendanceRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
attendance_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # clock_in, clock_out
|
||||
@@ -238,6 +239,7 @@ class BluetoothRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
record_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # punch, location
|
||||
@@ -291,6 +293,80 @@ class BeaconConfig(Base):
|
||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
||||
|
||||
|
||||
class FenceConfig(Base):
|
||||
"""Geofence configuration for area monitoring."""
|
||||
|
||||
__tablename__ = "fence_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
fence_type: Mapped[str] = mapped_column(String(20), nullable=False) # circle / polygon / rectangle
|
||||
# Circle center (WGS-84) or polygon centroid for display
|
||||
center_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
center_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
radius: Mapped[float | None] = mapped_column(Float, nullable=True) # meters, for circle
|
||||
# Polygon/rectangle vertices as JSON: [[lng,lat], [lng,lat], ...] (WGS-84)
|
||||
points: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False)
|
||||
fill_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
fill_opacity: Mapped[float] = mapped_column(Float, default=0.2, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FenceConfig(id={self.id}, name={self.name}, type={self.fence_type})>"
|
||||
|
||||
|
||||
class DeviceFenceBinding(Base):
|
||||
"""Many-to-many binding between devices and geofences."""
|
||||
|
||||
__tablename__ = "device_fence_bindings"
|
||||
__table_args__ = (
|
||||
Index("ix_dfb_device_fence", "device_id", "fence_id", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
fence_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceFenceBinding(device_id={self.device_id}, fence_id={self.fence_id})>"
|
||||
|
||||
|
||||
class DeviceFenceState(Base):
|
||||
"""Runtime state tracking: is a device currently inside a fence?"""
|
||||
|
||||
__tablename__ = "device_fence_states"
|
||||
__table_args__ = (
|
||||
Index("ix_dfs_device_fence", "device_id", "fence_id", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
fence_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
is_inside: Mapped[bool] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_transition_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
last_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceFenceState(device_id={self.device_id}, fence_id={self.fence_id}, inside={self.is_inside})>"
|
||||
|
||||
|
||||
class CommandLog(Base):
|
||||
"""Log of commands sent to devices."""
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from app.config import now_cst
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -129,7 +129,7 @@ async def _send_to_device(
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
command_log.sent_at = datetime.now(timezone.utc)
|
||||
command_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
@@ -290,7 +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)
|
||||
cmd_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(cmd_log)
|
||||
results.append(BatchCommandResult(
|
||||
|
||||
119
app/routers/fences.py
Normal file
119
app/routers/fences.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Fences Router - geofence management API endpoints."""
|
||||
|
||||
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,
|
||||
DeviceFenceBindRequest,
|
||||
FenceConfigCreate,
|
||||
FenceConfigResponse,
|
||||
FenceConfigUpdate,
|
||||
FenceDeviceDetail,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import fence_service
|
||||
|
||||
router = APIRouter(prefix="/api/fences", tags=["Fences"])
|
||||
|
||||
|
||||
@router.get("", response_model=APIResponse[PaginatedList[FenceConfigResponse]])
|
||||
async def list_fences(
|
||||
is_active: bool | None = Query(default=None),
|
||||
search: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
records, total = await fence_service.get_fences(db, page, page_size, is_active, search)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[FenceConfigResponse.model_validate(r) for r in records],
|
||||
total=total, page=page, page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]])
|
||||
async def get_all_active(db: AsyncSession = Depends(get_db)):
|
||||
fences = await fence_service.get_all_active_fences(db)
|
||||
return APIResponse(data=[FenceConfigResponse.model_validate(f) for f in fences])
|
||||
|
||||
|
||||
@router.get("/{fence_id}", response_model=APIResponse[FenceConfigResponse])
|
||||
async def get_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.post("", response_model=APIResponse[FenceConfigResponse], status_code=201, dependencies=[Depends(require_write)])
|
||||
async def create_fence(body: FenceConfigCreate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.create_fence(db, body)
|
||||
return APIResponse(message="Fence created", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.put("/{fence_id}", response_model=APIResponse[FenceConfigResponse], dependencies=[Depends(require_write)])
|
||||
async def update_fence(fence_id: int, body: FenceConfigUpdate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.update_fence(db, fence_id, body)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence updated", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.delete("/{fence_id}", response_model=APIResponse, dependencies=[Depends(require_write)])
|
||||
async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not await fence_service.delete_fence(db, fence_id):
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse[list[FenceDeviceDetail]],
|
||||
summary="获取围栏绑定的设备列表",
|
||||
)
|
||||
async def get_fence_devices(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
items = await fence_service.get_fence_devices(db, fence_id)
|
||||
return APIResponse(data=[FenceDeviceDetail(**item) for item in items])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="绑定设备到围栏",
|
||||
)
|
||||
async def bind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await fence_service.bind_devices_to_fence(db, fence_id, body.device_ids)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return APIResponse(
|
||||
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="解绑设备与围栏",
|
||||
)
|
||||
async def unbind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
count = await fence_service.unbind_devices_from_fence(db, fence_id, body.device_ids)
|
||||
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||
@@ -7,9 +7,10 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_write
|
||||
from app.database import get_db
|
||||
from app.models import LocationRecord
|
||||
from app.schemas import (
|
||||
@@ -153,3 +154,22 @@ async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除位置记录 / Delete location record",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID删除位置记录 / Delete location record by ID."""
|
||||
result = await db.execute(
|
||||
select(LocationRecord).where(LocationRecord.id == location_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return APIResponse(message="Location record deleted")
|
||||
|
||||
@@ -121,6 +121,7 @@ class LocationRecordResponse(LocationRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -169,6 +170,7 @@ class AlarmRecordResponse(AlarmRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
acknowledged: bool
|
||||
created_at: datetime
|
||||
|
||||
@@ -211,6 +213,7 @@ class HeartbeatRecordResponse(HeartbeatRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -253,6 +256,7 @@ class AttendanceRecordResponse(AttendanceRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -298,6 +302,7 @@ class BluetoothRecordResponse(BluetoothRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -368,6 +373,100 @@ class BeaconConfigResponse(BaseModel):
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fence Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FenceConfigCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100, description="围栏名称")
|
||||
fence_type: Literal["circle", "polygon", "rectangle"] = Field(..., description="围栏类型")
|
||||
center_lat: float | None = Field(None, ge=-90, le=90, description="中心纬度 (WGS-84)")
|
||||
center_lng: float | None = Field(None, ge=-180, le=180, description="中心经度 (WGS-84)")
|
||||
radius: float | None = Field(None, ge=0, description="半径 (米)")
|
||||
points: str | None = Field(None, description="多边形顶点 JSON [[lng,lat],...]")
|
||||
color: str = Field(default="#3b82f6", max_length=20, description="边框颜色")
|
||||
fill_color: str | None = Field(None, max_length=20, description="填充颜色")
|
||||
fill_opacity: float = Field(default=0.2, ge=0, le=1, description="填充透明度")
|
||||
description: str | None = Field(None, description="描述")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
|
||||
class FenceConfigUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
fence_type: Literal["circle", "polygon", "rectangle"] | None = None
|
||||
center_lat: float | None = Field(None, ge=-90, le=90)
|
||||
center_lng: float | None = Field(None, ge=-180, le=180)
|
||||
radius: float | None = Field(None, ge=0)
|
||||
points: str | None = None
|
||||
color: str | None = Field(None, max_length=20)
|
||||
fill_color: str | None = Field(None, max_length=20)
|
||||
fill_opacity: float | None = Field(None, ge=0, le=1)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FenceConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
fence_type: str
|
||||
center_lat: float | None = None
|
||||
center_lng: float | None = None
|
||||
radius: float | None = None
|
||||
points: str | None = None
|
||||
color: str
|
||||
fill_color: str | None = None
|
||||
fill_opacity: float
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceFenceBindRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表")
|
||||
|
||||
|
||||
class FenceBindForDeviceRequest(BaseModel):
|
||||
fence_ids: list[int] = Field(..., min_length=1, max_length=100, description="围栏ID列表")
|
||||
|
||||
|
||||
class DeviceFenceBindingResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
device_id: int
|
||||
fence_id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FenceDeviceDetail(BaseModel):
|
||||
"""Binding detail with device info."""
|
||||
binding_id: int
|
||||
device_id: int
|
||||
device_name: str | None = None
|
||||
imei: str | None = None
|
||||
is_inside: bool = False
|
||||
last_check_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceFenceDetail(BaseModel):
|
||||
"""Binding detail with fence info."""
|
||||
binding_id: int
|
||||
fence_id: int
|
||||
fence_name: str | None = None
|
||||
fence_type: str | None = None
|
||||
is_inside: bool = False
|
||||
last_check_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,7 +78,8 @@ async def update_beacon(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(beacon, key, value)
|
||||
beacon.updated_at = datetime.now(timezone.utc)
|
||||
from app.config import now_cst
|
||||
beacon.updated_at = now_cst()
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
|
||||
@@ -3,7 +3,8 @@ Device Service - 设备管理服务
|
||||
Provides CRUD operations and statistics for badge devices.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -158,7 +159,7 @@ async def update_device(
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
device.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
@@ -245,7 +246,7 @@ async def batch_update_devices(
|
||||
devices = await get_devices_by_ids(db, device_ids)
|
||||
found_map = {d.id: d for d in devices}
|
||||
update_fields = update_data.model_dump(exclude_unset=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = now_cst()
|
||||
|
||||
results = []
|
||||
for device_id in device_ids:
|
||||
|
||||
360
app/services/fence_checker.py
Normal file
360
app/services/fence_checker.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Fence checker service - geofence judgment engine with auto-attendance.
|
||||
|
||||
Checks whether a device's reported coordinates fall inside its bound fences.
|
||||
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import now_cst, settings
|
||||
from app.models import (
|
||||
AttendanceRecord,
|
||||
DeviceFenceBinding,
|
||||
DeviceFenceState,
|
||||
FenceConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geometry helpers (WGS-84)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in meters between two WGS-84 points."""
|
||||
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
return _EARTH_RADIUS_M * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def is_inside_circle(
|
||||
lat: float, lon: float,
|
||||
center_lat: float, center_lng: float,
|
||||
radius_m: float,
|
||||
) -> bool:
|
||||
"""Check if point is inside a circle (haversine)."""
|
||||
return haversine_distance(lat, lon, center_lat, center_lng) <= radius_m
|
||||
|
||||
|
||||
def is_inside_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> bool:
|
||||
"""Ray-casting algorithm. vertices = [[lng, lat], ...] in WGS-84."""
|
||||
n = len(vertices)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = vertices[i][0], vertices[i][1] # lng, lat
|
||||
xj, yj = vertices[j][0], vertices[j][1]
|
||||
if ((yi > lat) != (yj > lat)) and (
|
||||
lon < (xj - xi) * (lat - yi) / (yj - yi) + xi
|
||||
):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def is_inside_fence(
|
||||
lat: float, lon: float,
|
||||
fence: FenceConfig,
|
||||
tolerance_m: float = 0,
|
||||
) -> bool:
|
||||
"""Check if a point is inside a fence, with optional tolerance buffer."""
|
||||
if fence.fence_type == "circle":
|
||||
if fence.center_lat is None or fence.center_lng is None or fence.radius is None:
|
||||
return False
|
||||
return is_inside_circle(
|
||||
lat, lon,
|
||||
fence.center_lat, fence.center_lng,
|
||||
fence.radius + tolerance_m,
|
||||
)
|
||||
|
||||
# polygon / rectangle: parse points JSON
|
||||
if not fence.points:
|
||||
return False
|
||||
try:
|
||||
vertices = json.loads(fence.points)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Fence %d has invalid points JSON", fence.id)
|
||||
return False
|
||||
|
||||
if not isinstance(vertices, list) or len(vertices) < 3:
|
||||
return False
|
||||
|
||||
# For polygon with tolerance, check point-in-polygon first
|
||||
if is_inside_polygon(lat, lon, vertices):
|
||||
return True
|
||||
|
||||
# If not inside but tolerance > 0, check distance to nearest edge
|
||||
if tolerance_m > 0:
|
||||
return _min_distance_to_polygon(lat, lon, vertices) <= tolerance_m
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _min_distance_to_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> float:
|
||||
"""Approximate minimum distance from point to polygon edges (meters)."""
|
||||
min_dist = float("inf")
|
||||
n = len(vertices)
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
# Each vertex is [lng, lat]
|
||||
dist = _point_to_segment_distance(
|
||||
lat, lon,
|
||||
vertices[i][1], vertices[i][0],
|
||||
vertices[j][1], vertices[j][0],
|
||||
)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
return min_dist
|
||||
|
||||
|
||||
def _point_to_segment_distance(
|
||||
plat: float, plon: float,
|
||||
alat: float, alon: float,
|
||||
blat: float, blon: float,
|
||||
) -> float:
|
||||
"""Approximate distance from point P to line segment AB (meters)."""
|
||||
# Project P onto AB using flat-earth approximation (good for short segments)
|
||||
dx = blon - alon
|
||||
dy = blat - alat
|
||||
if dx == 0 and dy == 0:
|
||||
return haversine_distance(plat, plon, alat, alon)
|
||||
|
||||
t = max(0, min(1, ((plon - alon) * dx + (plat - alat) * dy) / (dx * dx + dy * dy)))
|
||||
proj_lat = alat + t * dy
|
||||
proj_lon = alon + t * dx
|
||||
return haversine_distance(plat, plon, proj_lat, proj_lon)
|
||||
|
||||
|
||||
def _get_tolerance_for_location_type(location_type: str) -> float:
|
||||
"""Return tolerance in meters based on location type accuracy."""
|
||||
if location_type in ("lbs", "lbs_4g"):
|
||||
return float(settings.FENCE_LBS_TOLERANCE_METERS)
|
||||
if location_type in ("wifi", "wifi_4g"):
|
||||
return float(settings.FENCE_WIFI_TOLERANCE_METERS)
|
||||
# GPS: no extra tolerance
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main fence check entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def check_device_fences(
|
||||
session: AsyncSession,
|
||||
device_id: int,
|
||||
imei: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
location_type: str,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
) -> list[dict]:
|
||||
"""Check all bound active fences for a device. Returns attendance events.
|
||||
|
||||
Called after each location report is stored. Creates automatic
|
||||
AttendanceRecords for fence entry/exit transitions.
|
||||
"""
|
||||
# 1. Query active fences bound to this device
|
||||
result = await session.execute(
|
||||
select(FenceConfig)
|
||||
.join(DeviceFenceBinding, DeviceFenceBinding.fence_id == FenceConfig.id)
|
||||
.where(
|
||||
DeviceFenceBinding.device_id == device_id,
|
||||
FenceConfig.is_active == 1,
|
||||
)
|
||||
)
|
||||
fences = list(result.scalars().all())
|
||||
if not fences:
|
||||
return []
|
||||
|
||||
tolerance = _get_tolerance_for_location_type(location_type)
|
||||
events: list[dict] = []
|
||||
now = now_cst()
|
||||
min_interval = settings.FENCE_MIN_INSIDE_SECONDS
|
||||
|
||||
for fence in fences:
|
||||
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
|
||||
|
||||
# 2. Get or create state record
|
||||
state_result = await session.execute(
|
||||
select(DeviceFenceState).where(
|
||||
DeviceFenceState.device_id == device_id,
|
||||
DeviceFenceState.fence_id == fence.id,
|
||||
)
|
||||
)
|
||||
state = state_result.scalar_one_or_none()
|
||||
|
||||
was_inside = bool(state and state.is_inside)
|
||||
|
||||
# 3. Detect transition
|
||||
if currently_inside and not was_inside:
|
||||
# ENTRY: outside -> inside = clock_in
|
||||
if state and state.last_transition_at:
|
||||
elapsed = (now - state.last_transition_at).total_seconds()
|
||||
if elapsed < min_interval:
|
||||
logger.debug(
|
||||
"Fence %d debounce: %ds < %ds, skip clock_in for device %d",
|
||||
fence.id, int(elapsed), min_interval, device_id,
|
||||
)
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_in", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_in",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_in: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
elif not currently_inside and was_inside:
|
||||
# EXIT: inside -> outside = clock_out
|
||||
if state and state.last_transition_at:
|
||||
elapsed = (now - state.last_transition_at).total_seconds()
|
||||
if elapsed < min_interval:
|
||||
logger.debug(
|
||||
"Fence %d debounce: %ds < %ds, skip clock_out for device %d",
|
||||
fence.id, int(elapsed), min_interval, device_id,
|
||||
)
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_out", latitude, longitude,
|
||||
address, recorded_at, fence,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_out",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_out: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
# 4. Update state
|
||||
if state is None:
|
||||
state = DeviceFenceState(
|
||||
device_id=device_id,
|
||||
fence_id=fence.id,
|
||||
is_inside=currently_inside,
|
||||
last_transition_at=now if (currently_inside != was_inside) else None,
|
||||
last_check_at=now,
|
||||
last_latitude=latitude,
|
||||
last_longitude=longitude,
|
||||
)
|
||||
session.add(state)
|
||||
else:
|
||||
if currently_inside != was_inside:
|
||||
state.last_transition_at = now
|
||||
state.is_inside = currently_inside
|
||||
state.last_check_at = now
|
||||
state.last_latitude = latitude
|
||||
state.last_longitude = longitude
|
||||
|
||||
await session.flush()
|
||||
return events
|
||||
|
||||
|
||||
def _update_state(
|
||||
state: DeviceFenceState,
|
||||
is_inside: bool,
|
||||
now: datetime,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
"""Update state fields without creating a transition."""
|
||||
state.last_check_at = now
|
||||
state.last_latitude = lat
|
||||
state.last_longitude = lon
|
||||
# Don't update is_inside or last_transition_at during debounce
|
||||
|
||||
|
||||
def _create_attendance(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
fence: FenceConfig,
|
||||
) -> AttendanceRecord:
|
||||
"""Create an auto-generated fence attendance record."""
|
||||
return AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=imei,
|
||||
attendance_type=attendance_type,
|
||||
protocol_number=0, # synthetic, not from device protocol
|
||||
gps_positioned=True,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
address=address,
|
||||
recorded_at=recorded_at,
|
||||
lbs_data={
|
||||
"source": "fence_auto",
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_event(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
fence: FenceConfig,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
) -> dict:
|
||||
"""Build a WebSocket broadcast event dict."""
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"imei": imei,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"attendance_type": attendance_type,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"address": address,
|
||||
"recorded_at": recorded_at.isoformat() if recorded_at else None,
|
||||
"source": "fence_auto",
|
||||
}
|
||||
208
app/services/fence_service.py
Normal file
208
app/services/fence_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Fence Service - CRUD operations for geofence configuration and device bindings."""
|
||||
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import delete as sa_delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device, DeviceFenceBinding, DeviceFenceState, FenceConfig
|
||||
from app.schemas import FenceConfigCreate, FenceConfigUpdate
|
||||
|
||||
|
||||
async def get_fences(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[FenceConfig], int]:
|
||||
query = select(FenceConfig)
|
||||
count_query = select(func.count(FenceConfig.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(FenceConfig.is_active == is_active)
|
||||
count_query = count_query.where(FenceConfig.is_active == is_active)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = FenceConfig.name.ilike(like) | FenceConfig.description.ilike(like)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
query.order_by(FenceConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_all_active_fences(db: AsyncSession) -> list[FenceConfig]:
|
||||
result = await db.execute(
|
||||
select(FenceConfig).where(FenceConfig.is_active == 1).order_by(FenceConfig.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_fence(db: AsyncSession, fence_id: int) -> FenceConfig | None:
|
||||
result = await db.execute(select(FenceConfig).where(FenceConfig.id == fence_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_fence(db: AsyncSession, data: FenceConfigCreate) -> FenceConfig:
|
||||
fence = FenceConfig(**data.model_dump())
|
||||
db.add(fence)
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def update_fence(db: AsyncSession, fence_id: int, data: FenceConfigUpdate) -> FenceConfig | None:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return None
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(fence, key, value)
|
||||
fence.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def delete_fence(db: AsyncSession, fence_id: int) -> bool:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return False
|
||||
# CASCADE FK handles bindings/states, but explicit delete for safety
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(DeviceFenceState.fence_id == fence_id)
|
||||
)
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(DeviceFenceBinding.fence_id == fence_id)
|
||||
)
|
||||
await db.delete(fence)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_fence_devices(
|
||||
db: AsyncSession, fence_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get devices bound to a fence, with their current fence state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, Device, DeviceFenceState)
|
||||
.join(Device, Device.id == DeviceFenceBinding.device_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.fence_id == fence_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
items = []
|
||||
for binding, device, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"imei": device.imei,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def get_device_fences(
|
||||
db: AsyncSession, device_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get fences bound to a device, with current state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, FenceConfig, DeviceFenceState)
|
||||
.join(FenceConfig, FenceConfig.id == DeviceFenceBinding.fence_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.device_id == device_id)
|
||||
.order_by(FenceConfig.name)
|
||||
)
|
||||
items = []
|
||||
for binding, fence, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"fence_type": fence.fence_type,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def bind_devices_to_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a fence. Idempotent (skips existing bindings)."""
|
||||
# Verify fence exists
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Fence not found"}
|
||||
|
||||
# Verify devices exist
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
# Check existing bindings
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding.device_id).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids:
|
||||
continue
|
||||
if did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceFenceBinding(device_id=did, fence_id=fence_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a fence. Also cleans up state records."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
# Clean up state records
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(
|
||||
DeviceFenceState.fence_id == fence_id,
|
||||
DeviceFenceState.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -240,7 +240,7 @@ class ConnectionInfo:
|
||||
def __init__(self, addr: Tuple[str, int]) -> None:
|
||||
self.imei: Optional[str] = None
|
||||
self.addr = addr
|
||||
self.connected_at = datetime.now(timezone.utc)
|
||||
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
self.last_activity = self.connected_at
|
||||
self.serial_counter: int = 1
|
||||
|
||||
@@ -331,7 +331,7 @@ class TCPManager:
|
||||
break
|
||||
|
||||
recv_buffer += data
|
||||
conn_info.last_activity = datetime.now(timezone.utc)
|
||||
conn_info.last_activity = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
logger.info("Received %d bytes from %s:%d (IMEI=%s): %s",
|
||||
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
|
||||
|
||||
@@ -555,12 +555,15 @@ class TCPManager:
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(content: bytes, offset: int = 0) -> Optional[datetime]:
|
||||
"""Parse a 6-byte datetime field at *offset* and return a UTC datetime."""
|
||||
"""Parse a 6-byte datetime field at *offset* (UTC) and return CST (UTC+8) naive datetime."""
|
||||
if len(content) < offset + 6:
|
||||
return None
|
||||
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
|
||||
try:
|
||||
return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
|
||||
utc_dt = datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
|
||||
# Convert to CST (UTC+8) and strip tzinfo for SQLite
|
||||
cst_dt = utc_dt + timedelta(hours=8)
|
||||
return cst_dt.replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -634,7 +637,7 @@ class TCPManager:
|
||||
lang_str = "zh" if lang_code == 1 else "en" if lang_code == 2 else str(lang_code)
|
||||
|
||||
# Persist device record
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
@@ -731,7 +734,7 @@ class TCPManager:
|
||||
if ext_info:
|
||||
extension_data = ext_info
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
@@ -756,6 +759,7 @@ class TCPManager:
|
||||
# Store heartbeat record
|
||||
record = HeartbeatRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
protocol_number=proto,
|
||||
terminal_info=terminal_info,
|
||||
battery_level=battery_level if battery_level is not None else 0,
|
||||
@@ -854,7 +858,7 @@ class TCPManager:
|
||||
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# Parse recorded_at from the 6-byte datetime at offset 0
|
||||
recorded_at = self._parse_datetime(content, 0) or now
|
||||
@@ -1004,6 +1008,13 @@ class TCPManager:
|
||||
cell_id = int.from_bytes(content[pos : pos + 3], "big")
|
||||
pos += 3
|
||||
|
||||
# --- Skip LBS/WiFi records with empty cell data (device hasn't acquired cells yet) ---
|
||||
if location_type in ("lbs", "lbs_4g", "wifi", "wifi_4g") and latitude is None:
|
||||
mcc_val = mcc & 0x7FFF if mcc else 0
|
||||
if mcc_val == 0 and (lac is None or lac == 0) and (cell_id is None or cell_id == 0):
|
||||
logger.debug("Skipping empty LBS/WiFi packet for IMEI=%s (no cell data)", imei)
|
||||
return
|
||||
|
||||
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
|
||||
neighbor_cells_data: Optional[list] = None
|
||||
wifi_data_list: Optional[list] = None
|
||||
@@ -1023,6 +1034,7 @@ class TCPManager:
|
||||
wifi_list=wifi_data_list,
|
||||
neighbor_cells=neighbor_cells_data,
|
||||
imei=imei,
|
||||
location_type=location_type,
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude = lat
|
||||
@@ -1052,6 +1064,7 @@ class TCPManager:
|
||||
|
||||
record = LocationRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
location_type=location_type,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -1084,6 +1097,29 @@ class TCPManager:
|
||||
"DB error storing %s location for IMEI=%s", location_type, imei
|
||||
)
|
||||
|
||||
# --- Fence auto-attendance check ---
|
||||
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
|
||||
try:
|
||||
from app.services.fence_checker import check_device_fences
|
||||
|
||||
async with async_session() as fence_session:
|
||||
async with fence_session.begin():
|
||||
device_id_for_fence = device_id
|
||||
if device_id_for_fence is None:
|
||||
# Resolve device_id if not available from above
|
||||
device_id_for_fence = await _get_device_id(fence_session, imei)
|
||||
if device_id_for_fence is not None:
|
||||
fence_events = await check_device_fences(
|
||||
fence_session, device_id_for_fence, imei,
|
||||
latitude, longitude, location_type,
|
||||
address, recorded_at,
|
||||
)
|
||||
for evt in fence_events:
|
||||
ws_manager.broadcast_nonblocking("fence_attendance", evt)
|
||||
ws_manager.broadcast_nonblocking("attendance", evt)
|
||||
except Exception:
|
||||
logger.exception("Fence check failed for IMEI=%s", imei)
|
||||
|
||||
return address
|
||||
|
||||
@staticmethod
|
||||
@@ -1247,7 +1283,7 @@ class TCPManager:
|
||||
if len(content) >= 8:
|
||||
language = struct.unpack("!H", content[6:8])[0]
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
if language == 0x0001:
|
||||
# Chinese: use GMT+8 timestamp
|
||||
ts = int(now.timestamp()) + 8 * 3600
|
||||
@@ -1269,7 +1305,7 @@ class TCPManager:
|
||||
conn_info: ConnectionInfo,
|
||||
) -> None:
|
||||
"""Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS."""
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
payload = bytes(
|
||||
[
|
||||
now.year % 100,
|
||||
@@ -1361,7 +1397,7 @@ class TCPManager:
|
||||
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
recorded_at = self._parse_datetime(content, 0) or now
|
||||
|
||||
@@ -1502,10 +1538,12 @@ class TCPManager:
|
||||
if latitude is None and mcc is not None and lac is not None and cell_id is not None:
|
||||
try:
|
||||
wifi_list_for_geocode = wifi_data_list if proto == PROTO_ALARM_WIFI else None
|
||||
alarm_is_4g = proto in (PROTO_ALARM_SINGLE_FENCE, PROTO_ALARM_MULTI_FENCE, PROTO_ALARM_LBS_4G)
|
||||
lat, lon = await geocode_location(
|
||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||
wifi_list=wifi_list_for_geocode,
|
||||
imei=imei,
|
||||
location_type="lbs_4g" if alarm_is_4g else "lbs",
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude = lat
|
||||
@@ -1532,6 +1570,7 @@ class TCPManager:
|
||||
|
||||
record = AlarmRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
alarm_type=alarm_type_name,
|
||||
alarm_source=alarm_source,
|
||||
protocol_number=proto,
|
||||
@@ -1660,7 +1699,7 @@ class TCPManager:
|
||||
imei = conn_info.imei
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# -- Parse fields --
|
||||
pos = 0
|
||||
@@ -1797,9 +1836,11 @@ class TCPManager:
|
||||
if not gps_positioned or latitude is None:
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
try:
|
||||
att_loc_type = "wifi_4g" if is_4g and wifi_data_list else ("lbs_4g" if is_4g else "lbs")
|
||||
lat, lon = await geocode_location(
|
||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||
wifi_list=wifi_data_list,
|
||||
location_type=att_loc_type,
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude, longitude = lat, lon
|
||||
@@ -1834,6 +1875,7 @@ class TCPManager:
|
||||
|
||||
record = AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
attendance_type=attendance_type,
|
||||
protocol_number=proto,
|
||||
gps_positioned=gps_positioned,
|
||||
@@ -1886,7 +1928,7 @@ class TCPManager:
|
||||
"""
|
||||
content = pkt["content"]
|
||||
imei = conn_info.imei
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# -- Parse 0xB2 fields --
|
||||
pos = 0
|
||||
@@ -1978,6 +2020,7 @@ class TCPManager:
|
||||
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="punch",
|
||||
protocol_number=pkt["protocol"],
|
||||
beacon_mac=beacon_mac,
|
||||
@@ -2036,7 +2079,7 @@ class TCPManager:
|
||||
"""
|
||||
content = pkt["content"]
|
||||
imei = conn_info.imei
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
pos = 0
|
||||
recorded_at = self._parse_datetime(content, pos) or now
|
||||
@@ -2154,6 +2197,7 @@ class TCPManager:
|
||||
cfg = beacon_locations.get(b["mac"])
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="location",
|
||||
protocol_number=pkt["protocol"],
|
||||
beacon_mac=b["mac"],
|
||||
@@ -2179,6 +2223,7 @@ class TCPManager:
|
||||
# No beacons parsed, store raw
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="location",
|
||||
protocol_number=pkt["protocol"],
|
||||
bluetooth_data={"raw": content.hex(), "beacon_count": beacon_count},
|
||||
@@ -2293,7 +2338,7 @@ class TCPManager:
|
||||
except Exception:
|
||||
response_text = content[5:].hex()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -6,7 +6,7 @@ Manages client connections, topic subscriptions, and broadcasting.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from app.config import now_cst
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
MAX_CONNECTIONS = 100
|
||||
|
||||
# Valid topics
|
||||
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"}
|
||||
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth", "fence_attendance"}
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
@@ -57,7 +57,7 @@ class WebSocketManager:
|
||||
return
|
||||
|
||||
message = json.dumps(
|
||||
{"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||
{"topic": topic, "data": data, "timestamp": now_cst().isoformat()},
|
||||
default=str,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user