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 query max points (default: 10000)
|
||||||
# TRACK_MAX_POINTS=10000
|
# TRACK_MAX_POINTS=10000
|
||||||
|
|
||||||
# 天地图 API key (reverse geocoding, free 10k/day)
|
# 高德地图 API
|
||||||
# Sign up: https://lbs.tianditu.gov.cn/
|
# Web服务 key (逆地理编码 + v5 IoT定位, 企业订阅)
|
||||||
# TIANDITU_API_KEY=your_tianditu_key
|
# AMAP_KEY=your_amap_web_service_key
|
||||||
|
# AMAP_SECRET=your_amap_web_service_secret
|
||||||
# Google Geolocation API (optional, for cell/WiFi geocoding)
|
# 智能硬件定位 key (旧版 apilocate.amap.com 回退, 可选)
|
||||||
# GOOGLE_API_KEY=your_google_key
|
# AMAP_HARDWARE_KEY=your_amap_hardware_key
|
||||||
|
# AMAP_HARDWARE_SECRET=your_amap_hardware_secret
|
||||||
# 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
|
|
||||||
|
|
||||||
# Geocoding cache size
|
# Geocoding cache size
|
||||||
# GEOCODING_CACHE_SIZE=10000
|
# GEOCODING_CACHE_SIZE=10000
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
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 directory (where config.py lives → parent = app/ → parent = project root)
|
||||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"
|
_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")
|
RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations")
|
||||||
|
|
||||||
# 高德地图 API (geocoding)
|
# 高德地图 API (geocoding)
|
||||||
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
|
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web服务 key (逆地理编码/POI搜索)")
|
||||||
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
|
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
|
||||||
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
|
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 query limit
|
||||||
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
|
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
|
||||||
DATA_RETENTION_DAYS: int = Field(default=90, description="Days to keep location/heartbeat/alarm/attendance/bluetooth records")
|
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")
|
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")
|
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
|
||||||
|
|
||||||
# Update last_used_at
|
# 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()
|
await db.flush()
|
||||||
|
|
||||||
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
||||||
|
|||||||
269
app/geocoding.py
269
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.
|
and reverse geocode coordinates to addresses.
|
||||||
|
|
||||||
All services use 高德 (Amap) API exclusively.
|
All services use 高德 (Amap) API exclusively.
|
||||||
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
|
- Forward geocoding (cell/WiFi → coords): 高德 IoT 定位 v5 API
|
||||||
- Reverse geocoding (coords → address): 高德逆地理编码
|
- Reverse geocoding (coords → address): 高德逆地理编码
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ from app.config import settings as _settings
|
|||||||
|
|
||||||
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||||||
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
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
|
_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)
|
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
|
# LRU Cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -125,52 +135,229 @@ async def geocode_location(
|
|||||||
wifi_list: Optional[list[dict]] = None,
|
wifi_list: Optional[list[dict]] = None,
|
||||||
neighbor_cells: Optional[list[dict]] = None,
|
neighbor_cells: Optional[list[dict]] = None,
|
||||||
imei: Optional[str] = None,
|
imei: Optional[str] = None,
|
||||||
|
location_type: Optional[str] = None,
|
||||||
) -> tuple[Optional[float], Optional[float]]:
|
) -> tuple[Optional[float], Optional[float]]:
|
||||||
"""
|
"""
|
||||||
Convert cell tower and/or WiFi AP data to lat/lon.
|
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
|
# 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)
|
cache_key = (mcc, mnc or 0, lac, cell_id)
|
||||||
cached = _cell_cache.get_cached(cache_key)
|
cached = _cell_cache.get_cached(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
if AMAP_KEY:
|
api_key = AMAP_KEY
|
||||||
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
|
if not api_key:
|
||||||
if result[0] is not None:
|
return (None, 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
|
|
||||||
|
|
||||||
return (None, None)
|
# Determine network type from location_type
|
||||||
|
is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g")
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
async def _geocode_amap(
|
def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str:
|
||||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
|
"""Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage"""
|
||||||
) -> tuple[Optional[float], Optional[float]]:
|
|
||||||
"""
|
|
||||||
Use 高德智能硬件定位 API (apilocate.amap.com/position).
|
|
||||||
|
|
||||||
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:
|
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:
|
if neighbor_cells:
|
||||||
for nc in neighbor_cells:
|
for nc in neighbor_cells:
|
||||||
nc_lac = nc.get("lac", 0)
|
nc_lac = nc.get("lac", 0)
|
||||||
nc_cid = nc.get("cell_id", 0)
|
nc_cid = nc.get("cell_id", 0)
|
||||||
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
|
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 = []
|
macs_parts = []
|
||||||
if wifi_list:
|
if wifi_list:
|
||||||
for ap in wifi_list:
|
for ap in wifi_list:
|
||||||
@@ -182,7 +369,11 @@ async def _geocode_amap(
|
|||||||
if not bts and not macs_parts:
|
if not bts and not macs_parts:
|
||||||
return (None, None)
|
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:
|
if bts:
|
||||||
params["bts"] = bts
|
params["bts"] = bts
|
||||||
if nearbts_parts:
|
if nearbts_parts:
|
||||||
@@ -190,9 +381,11 @@ async def _geocode_amap(
|
|||||||
if macs_parts:
|
if macs_parts:
|
||||||
params["macs"] = "|".join(macs_parts)
|
params["macs"] = "|".join(macs_parts)
|
||||||
|
|
||||||
# Add digital signature
|
# Only sign if using a key that has its own secret
|
||||||
sig = _amap_sign(params)
|
hw_secret = AMAP_HARDWARE_SECRET
|
||||||
if sig:
|
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
|
params["sig"] = sig
|
||||||
|
|
||||||
url = "https://apilocate.amap.com/position"
|
url = "https://apilocate.amap.com/position"
|
||||||
@@ -209,20 +402,24 @@ async def _geocode_amap(
|
|||||||
location = result.get("location", "")
|
location = result.get("location", "")
|
||||||
if location and "," in location:
|
if location and "," in location:
|
||||||
lon_str, lat_str = location.split(",")
|
lon_str, lat_str = location.split(",")
|
||||||
lat = float(lat_str)
|
gcj_lat = float(lat_str)
|
||||||
lon = float(lon_str)
|
gcj_lon = float(lon_str)
|
||||||
logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon)
|
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)
|
return (lat, lon)
|
||||||
else:
|
else:
|
||||||
infocode = data.get("infocode", "")
|
infocode = data.get("infocode", "")
|
||||||
if infocode == "10012":
|
if infocode == "10012":
|
||||||
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
|
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
|
||||||
else:
|
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:
|
else:
|
||||||
logger.warning("Amap geocode HTTP %d", resp.status)
|
logger.warning("Amap legacy geocode HTTP %d", resp.status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Amap geocode error: %s", e)
|
logger.warning("Amap legacy geocode error: %s", e)
|
||||||
|
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from app.database import init_db, async_session, engine
|
from app.database import init_db, async_session, engine
|
||||||
from app.tcp_server import tcp_manager
|
from app.tcp_server import tcp_manager
|
||||||
from app.config import settings
|
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
|
from app.dependencies import verify_api_key, require_write, require_admin
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -28,11 +28,12 @@ from app.extensions import limiter
|
|||||||
|
|
||||||
async def run_data_cleanup():
|
async def run_data_cleanup():
|
||||||
"""Delete records older than DATA_RETENTION_DAYS."""
|
"""Delete records older than DATA_RETENTION_DAYS."""
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import timedelta
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
|
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
|
total_deleted = 0
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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(commands.router, dependencies=[*_api_deps])
|
||||||
app.include_router(bluetooth.router, dependencies=[*_api_deps])
|
app.include_router(bluetooth.router, dependencies=[*_api_deps])
|
||||||
app.include_router(beacons.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(heartbeats.router, dependencies=[*_api_deps])
|
||||||
app.include_router(api_keys.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(ws.router) # WebSocket handles auth internally
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
@@ -14,13 +14,10 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.dialects.sqlite import JSON
|
from sqlalchemy.dialects.sqlite import JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.config import now_cst as _utcnow
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
class Device(Base):
|
class Device(Base):
|
||||||
"""Registered Bluetooth badge devices."""
|
"""Registered Bluetooth badge devices."""
|
||||||
|
|
||||||
@@ -80,6 +77,7 @@ class LocationRecord(Base):
|
|||||||
device_id: Mapped[int] = mapped_column(
|
device_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
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(
|
location_type: Mapped[str] = mapped_column(
|
||||||
String(10), nullable=False
|
String(10), nullable=False
|
||||||
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
||||||
@@ -125,6 +123,7 @@ class AlarmRecord(Base):
|
|||||||
device_id: Mapped[int] = mapped_column(
|
device_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
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(
|
alarm_type: Mapped[str] = mapped_column(
|
||||||
String(30), nullable=False
|
String(30), nullable=False
|
||||||
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
||||||
@@ -170,6 +169,7 @@ class HeartbeatRecord(Base):
|
|||||||
device_id: Mapped[int] = mapped_column(
|
device_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
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
|
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
|
||||||
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
battery_level: 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(
|
device_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
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(
|
attendance_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False
|
String(20), nullable=False
|
||||||
) # clock_in, clock_out
|
) # clock_in, clock_out
|
||||||
@@ -238,6 +239,7 @@ class BluetoothRecord(Base):
|
|||||||
device_id: Mapped[int] = mapped_column(
|
device_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
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(
|
record_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False
|
String(20), nullable=False
|
||||||
) # punch, location
|
) # punch, location
|
||||||
@@ -291,6 +293,80 @@ class BeaconConfig(Base):
|
|||||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
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):
|
class CommandLog(Base):
|
||||||
"""Log of commands sent to devices."""
|
"""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 logging
|
||||||
import math
|
import math
|
||||||
from datetime import datetime, timezone
|
from app.config import now_cst
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -129,7 +129,7 @@ async def _send_to_device(
|
|||||||
)
|
)
|
||||||
|
|
||||||
command_log.status = "sent"
|
command_log.status = "sent"
|
||||||
command_log.sent_at = datetime.now(timezone.utc)
|
command_log.sent_at = now_cst()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(command_log)
|
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
|
device.imei, body.command_type, body.command_content
|
||||||
)
|
)
|
||||||
cmd_log.status = "sent"
|
cmd_log.status = "sent"
|
||||||
cmd_log.sent_at = datetime.now(timezone.utc)
|
cmd_log.sent_at = now_cst()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(cmd_log)
|
await db.refresh(cmd_log)
|
||||||
results.append(BatchCommandResult(
|
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 datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import require_write
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import LocationRecord
|
from app.models import LocationRecord
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -153,3 +154,22 @@ async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
imei: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -169,6 +170,7 @@ class AlarmRecordResponse(AlarmRecordBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
imei: str | None = None
|
||||||
acknowledged: bool
|
acknowledged: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -211,6 +213,7 @@ class HeartbeatRecordResponse(HeartbeatRecordBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
imei: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ class AttendanceRecordResponse(AttendanceRecordBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
imei: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -298,6 +302,7 @@ class BluetoothRecordResponse(BluetoothRecordBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
imei: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -368,6 +373,100 @@ class BeaconConfigResponse(BaseModel):
|
|||||||
updated_at: datetime | None = None
|
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
|
# Command Log schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ async def update_beacon(
|
|||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(beacon, key, value)
|
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.flush()
|
||||||
await db.refresh(beacon)
|
await db.refresh(beacon)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ Device Service - 设备管理服务
|
|||||||
Provides CRUD operations and statistics for badge devices.
|
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 import func, select, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -158,7 +159,7 @@ async def update_device(
|
|||||||
for field, value in update_fields.items():
|
for field, value in update_fields.items():
|
||||||
setattr(device, field, value)
|
setattr(device, field, value)
|
||||||
|
|
||||||
device.updated_at = datetime.now(timezone.utc)
|
device.updated_at = now_cst()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(device)
|
await db.refresh(device)
|
||||||
return device
|
return device
|
||||||
@@ -245,7 +246,7 @@ async def batch_update_devices(
|
|||||||
devices = await get_devices_by_ids(db, device_ids)
|
devices = await get_devices_by_ids(db, device_ids)
|
||||||
found_map = {d.id: d for d in devices}
|
found_map = {d.id: d for d in devices}
|
||||||
update_fields = update_data.model_dump(exclude_unset=True)
|
update_fields = update_data.model_dump(exclude_unset=True)
|
||||||
now = datetime.now(timezone.utc)
|
now = now_cst()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for device_id in device_ids:
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
@@ -240,7 +240,7 @@ class ConnectionInfo:
|
|||||||
def __init__(self, addr: Tuple[str, int]) -> None:
|
def __init__(self, addr: Tuple[str, int]) -> None:
|
||||||
self.imei: Optional[str] = None
|
self.imei: Optional[str] = None
|
||||||
self.addr = addr
|
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.last_activity = self.connected_at
|
||||||
self.serial_counter: int = 1
|
self.serial_counter: int = 1
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ class TCPManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
recv_buffer += data
|
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",
|
logger.info("Received %d bytes from %s:%d (IMEI=%s): %s",
|
||||||
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
|
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
|
||||||
|
|
||||||
@@ -555,12 +555,15 @@ class TCPManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_datetime(content: bytes, offset: int = 0) -> Optional[datetime]:
|
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:
|
if len(content) < offset + 6:
|
||||||
return None
|
return None
|
||||||
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
|
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
return None
|
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)
|
lang_str = "zh" if lang_code == 1 else "en" if lang_code == 2 else str(lang_code)
|
||||||
|
|
||||||
# Persist device record
|
# Persist device record
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
@@ -731,7 +734,7 @@ class TCPManager:
|
|||||||
if ext_info:
|
if ext_info:
|
||||||
extension_data = ext_info
|
extension_data = ext_info
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -756,6 +759,7 @@ class TCPManager:
|
|||||||
# Store heartbeat record
|
# Store heartbeat record
|
||||||
record = HeartbeatRecord(
|
record = HeartbeatRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
protocol_number=proto,
|
protocol_number=proto,
|
||||||
terminal_info=terminal_info,
|
terminal_info=terminal_info,
|
||||||
battery_level=battery_level if battery_level is not None else 0,
|
battery_level=battery_level if battery_level is not None else 0,
|
||||||
@@ -854,7 +858,7 @@ class TCPManager:
|
|||||||
|
|
||||||
content = pkt["content"]
|
content = pkt["content"]
|
||||||
proto = pkt["protocol"]
|
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
|
# Parse recorded_at from the 6-byte datetime at offset 0
|
||||||
recorded_at = self._parse_datetime(content, 0) or now
|
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")
|
cell_id = int.from_bytes(content[pos : pos + 3], "big")
|
||||||
pos += 3
|
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) ---
|
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
|
||||||
neighbor_cells_data: Optional[list] = None
|
neighbor_cells_data: Optional[list] = None
|
||||||
wifi_data_list: Optional[list] = None
|
wifi_data_list: Optional[list] = None
|
||||||
@@ -1023,6 +1034,7 @@ class TCPManager:
|
|||||||
wifi_list=wifi_data_list,
|
wifi_list=wifi_data_list,
|
||||||
neighbor_cells=neighbor_cells_data,
|
neighbor_cells=neighbor_cells_data,
|
||||||
imei=imei,
|
imei=imei,
|
||||||
|
location_type=location_type,
|
||||||
)
|
)
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
latitude = lat
|
latitude = lat
|
||||||
@@ -1052,6 +1064,7 @@ class TCPManager:
|
|||||||
|
|
||||||
record = LocationRecord(
|
record = LocationRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
location_type=location_type,
|
location_type=location_type,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -1084,6 +1097,29 @@ class TCPManager:
|
|||||||
"DB error storing %s location for IMEI=%s", location_type, imei
|
"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
|
return address
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1247,7 +1283,7 @@ class TCPManager:
|
|||||||
if len(content) >= 8:
|
if len(content) >= 8:
|
||||||
language = struct.unpack("!H", content[6:8])[0]
|
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:
|
if language == 0x0001:
|
||||||
# Chinese: use GMT+8 timestamp
|
# Chinese: use GMT+8 timestamp
|
||||||
ts = int(now.timestamp()) + 8 * 3600
|
ts = int(now.timestamp()) + 8 * 3600
|
||||||
@@ -1269,7 +1305,7 @@ class TCPManager:
|
|||||||
conn_info: ConnectionInfo,
|
conn_info: ConnectionInfo,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS."""
|
"""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(
|
payload = bytes(
|
||||||
[
|
[
|
||||||
now.year % 100,
|
now.year % 100,
|
||||||
@@ -1361,7 +1397,7 @@ class TCPManager:
|
|||||||
|
|
||||||
content = pkt["content"]
|
content = pkt["content"]
|
||||||
proto = pkt["protocol"]
|
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
|
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:
|
if latitude is None and mcc is not None and lac is not None and cell_id is not None:
|
||||||
try:
|
try:
|
||||||
wifi_list_for_geocode = wifi_data_list if proto == PROTO_ALARM_WIFI else None
|
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(
|
lat, lon = await geocode_location(
|
||||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||||
wifi_list=wifi_list_for_geocode,
|
wifi_list=wifi_list_for_geocode,
|
||||||
imei=imei,
|
imei=imei,
|
||||||
|
location_type="lbs_4g" if alarm_is_4g else "lbs",
|
||||||
)
|
)
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
latitude = lat
|
latitude = lat
|
||||||
@@ -1532,6 +1570,7 @@ class TCPManager:
|
|||||||
|
|
||||||
record = AlarmRecord(
|
record = AlarmRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
alarm_type=alarm_type_name,
|
alarm_type=alarm_type_name,
|
||||||
alarm_source=alarm_source,
|
alarm_source=alarm_source,
|
||||||
protocol_number=proto,
|
protocol_number=proto,
|
||||||
@@ -1660,7 +1699,7 @@ class TCPManager:
|
|||||||
imei = conn_info.imei
|
imei = conn_info.imei
|
||||||
content = pkt["content"]
|
content = pkt["content"]
|
||||||
proto = pkt["protocol"]
|
proto = pkt["protocol"]
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
|
||||||
# -- Parse fields --
|
# -- Parse fields --
|
||||||
pos = 0
|
pos = 0
|
||||||
@@ -1797,9 +1836,11 @@ class TCPManager:
|
|||||||
if not gps_positioned or latitude is None:
|
if not gps_positioned or latitude is None:
|
||||||
if mcc is not None and lac is not None and cell_id is not None:
|
if mcc is not None and lac is not None and cell_id is not None:
|
||||||
try:
|
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(
|
lat, lon = await geocode_location(
|
||||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||||
wifi_list=wifi_data_list,
|
wifi_list=wifi_data_list,
|
||||||
|
location_type=att_loc_type,
|
||||||
)
|
)
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
latitude, longitude = lat, lon
|
latitude, longitude = lat, lon
|
||||||
@@ -1834,6 +1875,7 @@ class TCPManager:
|
|||||||
|
|
||||||
record = AttendanceRecord(
|
record = AttendanceRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
attendance_type=attendance_type,
|
attendance_type=attendance_type,
|
||||||
protocol_number=proto,
|
protocol_number=proto,
|
||||||
gps_positioned=gps_positioned,
|
gps_positioned=gps_positioned,
|
||||||
@@ -1886,7 +1928,7 @@ class TCPManager:
|
|||||||
"""
|
"""
|
||||||
content = pkt["content"]
|
content = pkt["content"]
|
||||||
imei = conn_info.imei
|
imei = conn_info.imei
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
|
||||||
# -- Parse 0xB2 fields --
|
# -- Parse 0xB2 fields --
|
||||||
pos = 0
|
pos = 0
|
||||||
@@ -1978,6 +2020,7 @@ class TCPManager:
|
|||||||
|
|
||||||
record = BluetoothRecord(
|
record = BluetoothRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
record_type="punch",
|
record_type="punch",
|
||||||
protocol_number=pkt["protocol"],
|
protocol_number=pkt["protocol"],
|
||||||
beacon_mac=beacon_mac,
|
beacon_mac=beacon_mac,
|
||||||
@@ -2036,7 +2079,7 @@ class TCPManager:
|
|||||||
"""
|
"""
|
||||||
content = pkt["content"]
|
content = pkt["content"]
|
||||||
imei = conn_info.imei
|
imei = conn_info.imei
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
|
||||||
pos = 0
|
pos = 0
|
||||||
recorded_at = self._parse_datetime(content, pos) or now
|
recorded_at = self._parse_datetime(content, pos) or now
|
||||||
@@ -2154,6 +2197,7 @@ class TCPManager:
|
|||||||
cfg = beacon_locations.get(b["mac"])
|
cfg = beacon_locations.get(b["mac"])
|
||||||
record = BluetoothRecord(
|
record = BluetoothRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
record_type="location",
|
record_type="location",
|
||||||
protocol_number=pkt["protocol"],
|
protocol_number=pkt["protocol"],
|
||||||
beacon_mac=b["mac"],
|
beacon_mac=b["mac"],
|
||||||
@@ -2179,6 +2223,7 @@ class TCPManager:
|
|||||||
# No beacons parsed, store raw
|
# No beacons parsed, store raw
|
||||||
record = BluetoothRecord(
|
record = BluetoothRecord(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
imei=conn_info.imei,
|
||||||
record_type="location",
|
record_type="location",
|
||||||
protocol_number=pkt["protocol"],
|
protocol_number=pkt["protocol"],
|
||||||
bluetooth_data={"raw": content.hex(), "beacon_count": beacon_count},
|
bluetooth_data={"raw": content.hex(), "beacon_count": beacon_count},
|
||||||
@@ -2293,7 +2338,7 @@ class TCPManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
response_text = content[5:].hex()
|
response_text = content[5:].hex()
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Manages client connections, topic subscriptions, and broadcasting.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from app.config import now_cst
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
MAX_CONNECTIONS = 100
|
MAX_CONNECTIONS = 100
|
||||||
|
|
||||||
# Valid topics
|
# Valid topics
|
||||||
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"}
|
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth", "fence_attendance"}
|
||||||
|
|
||||||
|
|
||||||
class WebSocketManager:
|
class WebSocketManager:
|
||||||
@@ -57,7 +57,7 @@ class WebSocketManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
message = json.dumps(
|
message = json.dumps(
|
||||||
{"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()},
|
{"topic": topic, "data": data, "timestamp": now_cst().isoformat()},
|
||||||
default=str,
|
default=str,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user