Add WebSocket, multi API key, geocoding proxy, beacon map picker, and comprehensive bug fixes

- Multi API Key + permission system (read/write/admin) with SHA-256 hash
- WebSocket real-time push (location, alarm, device_status, attendance, bluetooth)
- Geocoding proxy endpoints for Amap POI search and reverse geocode
- Beacon modal map-based location picker with search and click-to-select
- GCJ-02 ↔ WGS-84 bidirectional coordinate conversion
- Data cleanup scheduler (configurable retention days)
- Fix GPS longitude sign inversion (course_status bit 11: 0=East, 1=West)
- Fix 2G CellID 2→3 bytes across all protocols (0x28, 0x2C, parser.py)
- Fix parser loop guards, alarm_source field length, CommandLog.sent_at
- Fix geocoding IMEI parameterization, require_admin import
- Improve API error messages for 422 validation errors
- Remove beacon floor/area fields (consolidated into name)

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-24 05:10:05 +00:00
parent 7d6040af41
commit 11281e5be2
24 changed files with 1636 additions and 730 deletions

View File

@@ -21,6 +21,7 @@ from sqlalchemy import select, update
from app.config import settings
from app.database import async_session
from app.geocoding import geocode_location, reverse_geocode
from app.websocket_manager import ws_manager
from app.models import (
AlarmRecord,
AttendanceRecord,
@@ -213,44 +214,18 @@ class PacketParser:
class PacketBuilder:
"""Construct KKS protocol response packets.
"""Thin wrapper delegating to app.protocol.builder.PacketBuilder.
Length field semantics match app.protocol.builder:
length = proto(1) + info(N) + serial(2) + crc(2)
CRC is computed over: length_bytes + proto + info + serial
Preserves the (protocol, payload, serial) call signature used throughout tcp_server.py.
"""
from app.protocol.builder import PacketBuilder as _ProtoBuilder
@staticmethod
def build_response(
protocol: int, payload: bytes, serial: int, *, long: bool = False
) -> bytes:
proto_byte = struct.pack("B", protocol)
serial_bytes = struct.pack("!H", serial)
# length = proto(1) + info(N) + serial(2) + crc(2)
payload_len = 1 + len(payload) + 2 + 2
if long or payload_len > 0xFF:
length_bytes = struct.pack("!H", payload_len)
start_marker = START_MARKER_LONG
else:
length_bytes = struct.pack("B", payload_len)
start_marker = START_MARKER_SHORT
# CRC over: length_bytes + proto + info + serial
crc_input = length_bytes + proto_byte + payload + serial_bytes
crc_value = crc_itu(crc_input)
crc_bytes = struct.pack("!H", crc_value)
return (
start_marker
+ length_bytes
+ proto_byte
+ payload
+ serial_bytes
+ crc_bytes
+ STOP_MARKER
)
return PacketBuilder._ProtoBuilder.build_response(protocol, serial, payload)
# ---------------------------------------------------------------------------
@@ -452,6 +427,10 @@ class TCPManager:
.where(Device.imei == imei)
.values(status="offline")
)
# Broadcast device offline
ws_manager.broadcast_nonblocking("device_status", {
"imei": imei, "status": "offline",
})
except Exception:
logger.exception("Failed to set IMEI=%s offline in DB", imei)
@@ -547,8 +526,8 @@ class TCPManager:
# bits 9-0: course (0-360)
is_realtime = bool(course_status & 0x2000)
gps_positioned = bool(course_status & 0x1000)
is_east = bool(course_status & 0x0800)
is_north = bool(course_status & 0x0400)
is_west = bool(course_status & 0x0800) # bit 11: 0=East, 1=West
is_north = bool(course_status & 0x0400) # bit 10: 0=South, 1=North
course = course_status & 0x03FF
latitude = lat_raw / 1_800_000.0
@@ -557,7 +536,7 @@ class TCPManager:
# Apply hemisphere
if not is_north:
latitude = -latitude
if not is_east:
if is_west:
longitude = -longitude
result["latitude"] = latitude
@@ -681,6 +660,10 @@ class TCPManager:
if lang_str:
device.language = lang_str
# Don't overwrite user-set device_type with raw hex code
# Broadcast device online
ws_manager.broadcast_nonblocking("device_status", {
"imei": imei, "status": "online",
})
except Exception:
logger.exception("DB error during login for IMEI=%s", imei)
@@ -986,8 +969,8 @@ class TCPManager:
if location_type == "lbs" and len(content) >= pos + 5:
lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
elif location_type == "lbs_4g" and len(content) >= pos + 12:
lac = struct.unpack("!I", content[pos : pos + 4])[0]
pos += 4
@@ -1015,11 +998,11 @@ class TCPManager:
pos += 4
cell_id = struct.unpack("!Q", content[pos : pos + 8])[0]
pos += 8
elif location_type == "wifi" and len(content) >= pos + 4:
elif location_type == "wifi" and len(content) >= pos + 5:
lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
cell_id = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
neighbor_cells_data: Optional[list] = None
@@ -1039,6 +1022,7 @@ class TCPManager:
cell_id=cell_id,
wifi_list=wifi_data_list,
neighbor_cells=neighbor_cells_data,
imei=imei,
)
if lat is not None and lon is not None:
latitude = lat
@@ -1089,6 +1073,12 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast to WebSocket subscribers
ws_manager.broadcast_nonblocking("location", {
"imei": imei, "device_id": device_id, "location_type": location_type,
"latitude": latitude, "longitude": longitude, "speed": speed,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception(
"DB error storing %s location for IMEI=%s", location_type, imei
@@ -1121,7 +1111,7 @@ class TCPManager:
# Parse stations (main + up to 6 neighbors)
is_4g = location_type in ("lbs_4g", "wifi_4g")
lac_size = 4 if is_4g else 2
cid_size = 8 if is_4g else 2
cid_size = 8 if is_4g else 3
station_size = lac_size + cid_size + 1 # +1 for RSSI
for i in range(7):
@@ -1135,8 +1125,8 @@ class TCPManager:
else:
s_lac = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
s_cid = struct.unpack("!H", content[pos : pos + 2])[0]
pos += 2
s_cid = int.from_bytes(content[pos : pos + 3], "big")
pos += 3
s_rssi = content[pos]
pos += 1
@@ -1386,6 +1376,8 @@ class TCPManager:
cell_id: Optional[int] = None
battery_level: Optional[int] = None
gsm_signal: Optional[int] = None
wifi_data: Optional[list] = None
fence_data: Optional[dict] = None
# For alarm packets (0xA3, 0xA4, 0xA9), the terminal_info byte is
# located after GPS + LBS data. Extract alarm type from terminal_info bits.
@@ -1420,6 +1412,12 @@ class TCPManager:
terminal_info, battery_level, gsm_signal, alarm_type_name, pos = \
self._parse_alarm_tail(content, pos)
# Extract fence_id for 0xA4 multi-fence alarm
if proto == PROTO_ALARM_MULTI_FENCE and len(content) >= pos + 1:
fence_id = content[pos]
fence_data = {"fence_id": fence_id}
pos += 1
elif proto == PROTO_ALARM_LBS_4G:
# 0xA5: NO datetime, NO GPS, NO lbs_length
# mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1)
@@ -1491,6 +1489,9 @@ class TCPManager:
wifi_data_list.append({"mac": mac, "signal": signal})
pos += 7
if wifi_data_list:
wifi_data = wifi_data_list
# alarm_code(1) + language(1)
if len(content) >= pos + 1:
alarm_code = content[pos]
@@ -1504,6 +1505,7 @@ class TCPManager:
lat, lon = await geocode_location(
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
wifi_list=wifi_list_for_geocode,
imei=imei,
)
if lat is not None and lon is not None:
latitude = lat
@@ -1544,9 +1546,17 @@ class TCPManager:
battery_level=battery_level,
gsm_signal=gsm_signal,
address=address,
wifi_data=wifi_data,
fence_data=fence_data,
recorded_at=recorded_at,
)
session.add(record)
# Broadcast alarm to WebSocket subscribers
ws_manager.broadcast_nonblocking("alarm", {
"imei": imei, "device_id": device_id, "alarm_type": alarm_type_name,
"alarm_source": alarm_source, "latitude": latitude, "longitude": longitude,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception(
"DB error storing alarm for IMEI=%s (source=%s)", imei, alarm_source
@@ -1849,6 +1859,12 @@ class TCPManager:
"0xB1" if is_4g else "0xB0", imei, attendance_type,
gps_positioned, latitude, longitude, address,
)
# Broadcast attendance to WebSocket subscribers
ws_manager.broadcast_nonblocking("attendance", {
"imei": imei, "attendance_type": attendance_type,
"latitude": latitude, "longitude": longitude,
"address": address, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing attendance for IMEI=%s", imei)
@@ -1991,6 +2007,12 @@ class TCPManager:
beacon_major, beacon_minor, rssi,
beacon_battery or 0,
)
# Broadcast bluetooth punch
ws_manager.broadcast_nonblocking("bluetooth", {
"imei": imei, "record_type": "punch",
"beacon_mac": beacon_mac, "attendance_type": attendance_type,
"recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing BT punch for IMEI=%s", imei)
@@ -2163,6 +2185,11 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast bluetooth location
ws_manager.broadcast_nonblocking("bluetooth", {
"imei": imei, "record_type": "location",
"beacon_count": beacon_count, "recorded_at": str(recorded_at),
})
except Exception:
logger.exception("DB error storing BT location for IMEI=%s", imei)