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

@@ -13,7 +13,7 @@ from __future__ import annotations
import asyncio
import logging
import struct
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from sqlalchemy import select, update
@@ -240,7 +240,7 @@ class ConnectionInfo:
def __init__(self, addr: Tuple[str, int]) -> None:
self.imei: Optional[str] = None
self.addr = addr
self.connected_at = datetime.now(timezone.utc)
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
self.last_activity = self.connected_at
self.serial_counter: int = 1
@@ -331,7 +331,7 @@ class TCPManager:
break
recv_buffer += data
conn_info.last_activity = datetime.now(timezone.utc)
conn_info.last_activity = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
logger.info("Received %d bytes from %s:%d (IMEI=%s): %s",
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
@@ -555,12 +555,15 @@ class TCPManager:
@staticmethod
def _parse_datetime(content: bytes, offset: int = 0) -> Optional[datetime]:
"""Parse a 6-byte datetime field at *offset* and return a UTC datetime."""
"""Parse a 6-byte datetime field at *offset* (UTC) and return CST (UTC+8) naive datetime."""
if len(content) < offset + 6:
return None
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
try:
return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
utc_dt = datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
# Convert to CST (UTC+8) and strip tzinfo for SQLite
cst_dt = utc_dt + timedelta(hours=8)
return cst_dt.replace(tzinfo=None)
except ValueError:
return None
@@ -634,7 +637,7 @@ class TCPManager:
lang_str = "zh" if lang_code == 1 else "en" if lang_code == 2 else str(lang_code)
# Persist device record
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
try:
async with async_session() as session:
async with session.begin():
@@ -731,7 +734,7 @@ class TCPManager:
if ext_info:
extension_data = ext_info
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
try:
async with async_session() as session:
@@ -756,6 +759,7 @@ class TCPManager:
# Store heartbeat record
record = HeartbeatRecord(
device_id=device_id,
imei=conn_info.imei,
protocol_number=proto,
terminal_info=terminal_info,
battery_level=battery_level if battery_level is not None else 0,
@@ -854,7 +858,7 @@ class TCPManager:
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
# Parse recorded_at from the 6-byte datetime at offset 0
recorded_at = self._parse_datetime(content, 0) or now
@@ -1004,6 +1008,13 @@ class TCPManager:
cell_id = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
# --- Skip LBS/WiFi records with empty cell data (device hasn't acquired cells yet) ---
if location_type in ("lbs", "lbs_4g", "wifi", "wifi_4g") and latitude is None:
mcc_val = mcc & 0x7FFF if mcc else 0
if mcc_val == 0 and (lac is None or lac == 0) and (cell_id is None or cell_id == 0):
logger.debug("Skipping empty LBS/WiFi packet for IMEI=%s (no cell data)", imei)
return
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
neighbor_cells_data: Optional[list] = None
wifi_data_list: Optional[list] = None
@@ -1023,6 +1034,7 @@ class TCPManager:
wifi_list=wifi_data_list,
neighbor_cells=neighbor_cells_data,
imei=imei,
location_type=location_type,
)
if lat is not None and lon is not None:
latitude = lat
@@ -1052,6 +1064,7 @@ class TCPManager:
record = LocationRecord(
device_id=device_id,
imei=conn_info.imei,
location_type=location_type,
latitude=latitude,
longitude=longitude,
@@ -1084,6 +1097,29 @@ class TCPManager:
"DB error storing %s location for IMEI=%s", location_type, imei
)
# --- Fence auto-attendance check ---
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
try:
from app.services.fence_checker import check_device_fences
async with async_session() as fence_session:
async with fence_session.begin():
device_id_for_fence = device_id
if device_id_for_fence is None:
# Resolve device_id if not available from above
device_id_for_fence = await _get_device_id(fence_session, imei)
if device_id_for_fence is not None:
fence_events = await check_device_fences(
fence_session, device_id_for_fence, imei,
latitude, longitude, location_type,
address, recorded_at,
)
for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt)
ws_manager.broadcast_nonblocking("attendance", evt)
except Exception:
logger.exception("Fence check failed for IMEI=%s", imei)
return address
@staticmethod
@@ -1247,7 +1283,7 @@ class TCPManager:
if len(content) >= 8:
language = struct.unpack("!H", content[6:8])[0]
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
if language == 0x0001:
# Chinese: use GMT+8 timestamp
ts = int(now.timestamp()) + 8 * 3600
@@ -1269,7 +1305,7 @@ class TCPManager:
conn_info: ConnectionInfo,
) -> None:
"""Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS."""
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
payload = bytes(
[
now.year % 100,
@@ -1361,7 +1397,7 @@ class TCPManager:
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
recorded_at = self._parse_datetime(content, 0) or now
@@ -1502,10 +1538,12 @@ class TCPManager:
if latitude is None and mcc is not None and lac is not None and cell_id is not None:
try:
wifi_list_for_geocode = wifi_data_list if proto == PROTO_ALARM_WIFI else None
alarm_is_4g = proto in (PROTO_ALARM_SINGLE_FENCE, PROTO_ALARM_MULTI_FENCE, PROTO_ALARM_LBS_4G)
lat, lon = await geocode_location(
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
wifi_list=wifi_list_for_geocode,
imei=imei,
location_type="lbs_4g" if alarm_is_4g else "lbs",
)
if lat is not None and lon is not None:
latitude = lat
@@ -1532,6 +1570,7 @@ class TCPManager:
record = AlarmRecord(
device_id=device_id,
imei=conn_info.imei,
alarm_type=alarm_type_name,
alarm_source=alarm_source,
protocol_number=proto,
@@ -1660,7 +1699,7 @@ class TCPManager:
imei = conn_info.imei
content = pkt["content"]
proto = pkt["protocol"]
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
# -- Parse fields --
pos = 0
@@ -1797,9 +1836,11 @@ class TCPManager:
if not gps_positioned or latitude is None:
if mcc is not None and lac is not None and cell_id is not None:
try:
att_loc_type = "wifi_4g" if is_4g and wifi_data_list else ("lbs_4g" if is_4g else "lbs")
lat, lon = await geocode_location(
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
wifi_list=wifi_data_list,
location_type=att_loc_type,
)
if lat is not None and lon is not None:
latitude, longitude = lat, lon
@@ -1834,6 +1875,7 @@ class TCPManager:
record = AttendanceRecord(
device_id=device_id,
imei=conn_info.imei,
attendance_type=attendance_type,
protocol_number=proto,
gps_positioned=gps_positioned,
@@ -1886,7 +1928,7 @@ class TCPManager:
"""
content = pkt["content"]
imei = conn_info.imei
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
# -- Parse 0xB2 fields --
pos = 0
@@ -1978,6 +2020,7 @@ class TCPManager:
record = BluetoothRecord(
device_id=device_id,
imei=conn_info.imei,
record_type="punch",
protocol_number=pkt["protocol"],
beacon_mac=beacon_mac,
@@ -2036,7 +2079,7 @@ class TCPManager:
"""
content = pkt["content"]
imei = conn_info.imei
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
pos = 0
recorded_at = self._parse_datetime(content, pos) or now
@@ -2154,6 +2197,7 @@ class TCPManager:
cfg = beacon_locations.get(b["mac"])
record = BluetoothRecord(
device_id=device_id,
imei=conn_info.imei,
record_type="location",
protocol_number=pkt["protocol"],
beacon_mac=b["mac"],
@@ -2179,6 +2223,7 @@ class TCPManager:
# No beacons parsed, store raw
record = BluetoothRecord(
device_id=device_id,
imei=conn_info.imei,
record_type="location",
protocol_number=pkt["protocol"],
bluetooth_data={"raw": content.hex(), "beacon_count": beacon_count},
@@ -2293,7 +2338,7 @@ class TCPManager:
except Exception:
response_text = content[5:].hex()
now = datetime.now(timezone.utc)
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
try:
async with async_session() as session: