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