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:
2026-03-27 13:04:11 +00:00
parent cde5146bfe
commit 1d06cc5415
17 changed files with 2303 additions and 187 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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}

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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
View 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} 个设备")

View File

@@ -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")

View File

@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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:

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

View 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

View File

@@ -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:

View File

@@ -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,
) )