Compare commits

...

2 Commits

Author SHA1 Message Date
891344bfa0 feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息
- 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫)
- LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m)
- 地图图例显示各定位类型颜色和精度范围
- 精度圈添加 bubble:true 防止遮挡轨迹点点击
- 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹)
- 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom
- 最新位置轮询超时从15s延长至30s (适配LBS慢响应)
- 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用)
- 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取)
- 考勤来源字段 attendance_source 区分 device/bluetooth/fence

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-30 04:26:29 +00:00
1d06cc5415 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>
2026-03-27 13:04:11 +00:00
18 changed files with 2817 additions and 203 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,239 @@ 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 # Build cache key (include neighbor cells hash for accuracy)
if mcc is not None and lac is not None and cell_id is not None: nb_hash = tuple(sorted((nc.get("lac", 0), nc.get("cell_id", 0)) for nc in neighbor_cells)) if neighbor_cells else ()
cache_key = (mcc, mnc or 0, lac, cell_id)
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, nb_hash)
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
# Map location_type to v5 network parameter
# Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!)
_NETWORK_MAP = {"lbs_4g": "WCDMA", "wifi_4g": "WCDMA", "gps_4g": "WCDMA"}
network = _NETWORK_MAP.get(location_type or "", "GSM")
result: tuple[Optional[float], Optional[float]] = (None, None)
# Try v5 API first (requires bts with cage field + network param)
if AMAP_KEY: if AMAP_KEY:
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei) result = await _geocode_amap_v5(
if result[0] is not None: mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
if mcc is not None and lac is not None and cell_id is not None: imei=imei, api_key=AMAP_KEY, network=network,
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result) )
return result
return (None, None) # Fallback to legacy API if v5 fails
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(cache_key, result)
return result
async def _geocode_amap( def _build_bts(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
) -> tuple[Optional[float], Optional[float]]: *, include_cage: bool = False,
""" ) -> str:
Use 高德智能硬件定位 API (apilocate.amap.com/position). """Build bts parameter. v5 API uses cage field, legacy does not."""
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" base = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
return f"{base},0" if include_cage else base
return ""
# Build nearbts (neighbor cells)
nearbts_parts = [] def _build_nearbts(
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int],
*, include_cage: bool = False,
) -> 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}") base = f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}"
parts.append(f"{base},0" if include_cage else base)
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, network: str = "GSM",
) -> tuple[Optional[float], Optional[float]]:
"""
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
Key requirements:
- POST method, key in URL params, data in form body
- bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage
- network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!)
- For 4G LTE, use WCDMA as network value
- accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs)
"""
bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True)
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": network,
"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}"
logger.info("Amap v5 request body: %s", body)
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)
logger.info("Amap v5 response: %s", data)
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) body=%s",
data.get("info", ""), infocode, body,
)
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 +379,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,13 +391,17 @@ 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"
logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
@@ -204,25 +409,30 @@ async def _geocode_amap(
) as resp: ) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json(content_type=None) data = await resp.json(content_type=None)
logger.info("Amap legacy response: %s", data)
if data.get("status") == "1" and data.get("result"): if data.get("status") == "1" and data.get("result"):
result = data["result"] result = data["result"]
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,9 +195,14 @@ 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
attendance_source: Mapped[str] = mapped_column(
String(20), nullable=False, default="device",
server_default="device",
) # device (0xB0/0xB1), bluetooth (0xB2), fence (auto)
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True) latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -238,6 +243,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 +297,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

@@ -30,6 +30,7 @@ router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
async def list_attendance( async def list_attendance(
device_id: int | None = Query(default=None, description="设备ID / Device ID"), device_id: int | None = Query(default=None, description="设备ID / Device ID"),
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"), attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
attendance_source: str | None = Query(default=None, description="考勤来源 / Source (device/bluetooth/fence)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"), end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"), page: int = Query(default=1, ge=1, description="页码 / Page number"),
@@ -37,8 +38,8 @@ async def list_attendance(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。 获取考勤记录列表,支持按设备、考勤类型、来源、时间范围过滤。
List attendance records with filters for device, type, and time range. List attendance records with filters for device, type, source, and time range.
""" """
query = select(AttendanceRecord) query = select(AttendanceRecord)
count_query = select(func.count(AttendanceRecord.id)) count_query = select(func.count(AttendanceRecord.id))
@@ -51,6 +52,10 @@ async def list_attendance(
query = query.where(AttendanceRecord.attendance_type == attendance_type) query = query.where(AttendanceRecord.attendance_type == attendance_type)
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type) count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
if attendance_source:
query = query.where(AttendanceRecord.attendance_source == attendance_source)
count_query = count_query.where(AttendanceRecord.attendance_source == attendance_source)
if start_time: if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time) query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time) count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)

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 func, 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 (
@@ -139,6 +140,68 @@ async def device_track(
) )
@router.post(
"/batch-delete",
response_model=APIResponse[dict],
summary="批量删除位置记录 / Batch delete location records",
dependencies=[Depends(require_write)],
)
async def batch_delete_locations(
location_ids: list[int] = Body(..., min_length=1, max_length=500, embed=True),
db: AsyncSession = Depends(get_db),
):
"""批量删除位置记录最多500条"""
result = await db.execute(
delete(LocationRecord).where(LocationRecord.id.in_(location_ids))
)
await db.flush()
return APIResponse(
message=f"已删除 {result.rowcount} 条位置记录",
data={"deleted": result.rowcount, "requested": len(location_ids)},
)
@router.post(
"/delete-no-coords",
response_model=APIResponse[dict],
summary="删除无坐标位置记录 / Delete location records without coordinates",
dependencies=[Depends(require_write)],
)
async def delete_no_coord_locations(
device_id: int | None = Body(default=None, description="设备ID (可选,不传则所有设备)"),
start_time: str | None = Body(default=None, description="开始时间 ISO 8601"),
end_time: str | None = Body(default=None, description="结束时间 ISO 8601"),
db: AsyncSession = Depends(get_db),
):
"""删除经纬度为空的位置记录,可按设备和时间范围过滤。"""
from datetime import datetime as dt
conditions = [
(LocationRecord.latitude.is_(None)) | (LocationRecord.longitude.is_(None))
]
if device_id is not None:
conditions.append(LocationRecord.device_id == device_id)
if start_time:
conditions.append(LocationRecord.recorded_at >= dt.fromisoformat(start_time))
if end_time:
conditions.append(LocationRecord.recorded_at <= dt.fromisoformat(end_time))
# Count first
count_result = await db.execute(
select(func.count(LocationRecord.id)).where(*conditions)
)
count = count_result.scalar() or 0
if count > 0:
await db.execute(delete(LocationRecord).where(*conditions))
await db.flush()
return APIResponse(
message=f"已删除 {count} 条无坐标记录",
data={"deleted": count},
)
@router.get( @router.get(
"/{location_id}", "/{location_id}",
response_model=APIResponse[LocationRecordResponse], response_model=APIResponse[LocationRecordResponse],
@@ -153,3 +216,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
@@ -226,6 +229,7 @@ class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]])
class AttendanceRecordBase(BaseModel): class AttendanceRecordBase(BaseModel):
device_id: int device_id: int
attendance_type: str = Field(..., max_length=20) attendance_type: str = Field(..., max_length=20)
attendance_source: str = Field(default="device", max_length=20) # device, bluetooth, fence
protocol_number: int protocol_number: int
gps_positioned: bool = False gps_positioned: bool = False
latitude: float | None = Field(None, ge=-90, le=90) latitude: float | None = Field(None, ge=-90, le=90)
@@ -240,7 +244,7 @@ class AttendanceRecordBase(BaseModel):
lac: int | None = None lac: int | None = None
cell_id: int | None = None cell_id: int | None = None
wifi_data: list[dict[str, Any]] | None = None wifi_data: list[dict[str, Any]] | None = None
lbs_data: list[dict[str, Any]] | None = None lbs_data: list[dict[str, Any]] | dict[str, Any] | None = None
address: str | None = None address: str | None = None
recorded_at: datetime recorded_at: datetime
@@ -253,6 +257,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 +303,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 +374,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,424 @@
"""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 logging
import math
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import now_cst, settings
from app.models import (
AttendanceRecord,
Device,
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
# ---------------------------------------------------------------------------
# Daily dedup helper
# ---------------------------------------------------------------------------
async def _has_attendance_today(
session: AsyncSession,
device_id: int,
attendance_type: str,
) -> bool:
"""Check if device already has an attendance record of given type today (CST)."""
cst_now = datetime.now(timezone(timedelta(hours=8)))
day_start = cst_now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
day_end = day_start + timedelta(days=1)
result = await session.execute(
select(func.count()).select_from(AttendanceRecord).where(
AttendanceRecord.device_id == device_id,
AttendanceRecord.attendance_type == attendance_type,
AttendanceRecord.recorded_at >= day_start,
AttendanceRecord.recorded_at < day_end,
)
)
return (result.scalar() or 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,
*,
mcc: Optional[int] = None,
mnc: Optional[int] = None,
lac: Optional[int] = None,
cell_id: Optional[int] = None,
) -> 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 []
# Query device for latest battery/signal info (from heartbeats)
device = await session.get(Device, device_id)
device_info = {
"battery_level": device.battery_level if device else None,
"gsm_signal": device.gsm_signal if device else None,
"mcc": mcc,
"mnc": mnc,
"lac": lac,
"cell_id": cell_id,
}
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
# Daily dedup: only one clock_in per device per day
if await _has_attendance_today(session, device_id, "clock_in"):
logger.info(
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
device_id, fence.id, fence.name,
)
else:
attendance = _create_attendance(
device_id, imei, "clock_in", latitude, longitude,
address, recorded_at, fence, device_info,
)
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
# Daily dedup: only one clock_out per device per day
if await _has_attendance_today(session, device_id, "clock_out"):
logger.info(
"Fence skip clock_out: device=%d fence=%d(%s) already clocked out today",
device_id, fence.id, fence.name,
)
else:
attendance = _create_attendance(
device_id, imei, "clock_out", latitude, longitude,
address, recorded_at, fence, device_info,
)
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,
device_info: Optional[dict] = None,
) -> AttendanceRecord:
"""Create an auto-generated fence attendance record."""
info = device_info or {}
return AttendanceRecord(
device_id=device_id,
imei=imei,
attendance_type=attendance_type,
attendance_source="fence",
protocol_number=0, # synthetic, not from device protocol
gps_positioned=True,
latitude=latitude,
longitude=longitude,
address=address,
recorded_at=recorded_at,
battery_level=info.get("battery_level"),
gsm_signal=info.get("gsm_signal"),
mcc=info.get("mcc"),
mnc=info.get("mnc"),
lac=info.get("lac"),
cell_id=info.get("cell_id"),
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,30 @@ 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,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
)
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 +1284,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 +1306,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 +1398,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 +1539,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 +1571,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 +1700,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 +1837,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
@@ -1832,9 +1874,23 @@ class TCPManager:
logger.warning("Attendance for unknown IMEI=%s", imei) logger.warning("Attendance for unknown IMEI=%s", imei)
return attendance_type, reserved_bytes, datetime_bytes return attendance_type, reserved_bytes, datetime_bytes
# Determine attendance source from protocol
_att_source = "bluetooth" if proto == 0xB2 else "device"
# Daily dedup: one clock_in / clock_out per device per day
from app.services.fence_checker import _has_attendance_today
if await _has_attendance_today(session, device_id, attendance_type):
logger.info(
"Attendance dedup: IMEI=%s already has %s today, skip",
imei, attendance_type,
)
return attendance_type, reserved_bytes, datetime_bytes
record = AttendanceRecord( record = AttendanceRecord(
device_id=device_id, device_id=device_id,
imei=conn_info.imei,
attendance_type=attendance_type, attendance_type=attendance_type,
attendance_source=_att_source,
protocol_number=proto, protocol_number=proto,
gps_positioned=gps_positioned, gps_positioned=gps_positioned,
latitude=latitude, latitude=latitude,
@@ -1886,7 +1942,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 +2034,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 +2093,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 +2211,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 +2237,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 +2352,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,
) )