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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user