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

220
CLAUDE.md
View File

@@ -20,12 +20,13 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器 │ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流) │ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
│ ├── database.py # SQLAlchemy async 数据库连接 │ ├── database.py # SQLAlchemy async 数据库连接
│ ├── dependencies.py # FastAPI 依赖 (API Key 认证) │ ├── dependencies.py # FastAPI 依赖 (API Key认证 + 权限控制: read/write/admin)
│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取) │ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取)
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog) │ ├── websocket_manager.py # WebSocket 连接管理器 (topic订阅, 实时广播)
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog, ApiKey)
│ ├── schemas.py # Pydantic 请求/响应模型 │ ├── schemas.py # Pydantic 请求/响应模型
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化 │ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
│ ├── geocoding.py # 地理编码服务 (基站/WiFi → 经纬度) + 逆地理编码 (经纬度 → 地址) │ ├── geocoding.py # 高德地理编码服务 (基站/WiFi → 经纬度 + 经纬度 → 地址)
│ │ │ │
│ ├── protocol/ │ ├── protocol/
│ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等) │ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等)
@@ -41,14 +42,16 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server) │ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
│ │ │ │
│ ├── routers/ │ ├── routers/
│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete) │ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete, /all-latest-locations)
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch) │ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
│ │ ├── locations.py # /api/locations (含 /latest, /track, /{id}) │ │ ├── locations.py # /api/locations (含 /latest, /batch-latest, /track, /{id})
│ │ ├── alarms.py # /api/alarms (含 acknowledge) │ │ ├── alarms.py # /api/alarms (含 acknowledge, alarm_source过滤)
│ │ ├── attendance.py # /api/attendance (含 /stats, /{id}) │ │ ├── attendance.py # /api/attendance (含 /stats, /{id})
│ │ ├── bluetooth.py # /api/bluetooth (含 /{id}) │ │ ├── bluetooth.py # /api/bluetooth (含 beacon_mac过滤, /{id})
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询) │ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
│ │ ── beacons.py # /api/beacons (信标管理 CRUD) │ │ ── beacons.py # /api/beacons (信标管理 CRUD)
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅)
│ │ │ │
│ └── static/ │ └── static/
│ └── admin.html # 管理后台 SPA (暗色主题, 8个页面) │ └── admin.html # 管理后台 SPA (暗色主题, 8个页面)
@@ -84,7 +87,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值 - `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值
- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用 - `.env.example` 提供所有可配置项模板,复制为 `.env` 使用
- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录) - DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录)
- 所有 API 密钥 (天地图/Google/Unwired/高德) 集中在 `config.py``geocoding.py``settings` 导入 - 高德 API 密钥集中在 `config.py``geocoding.py``settings` 导入
- 端口号有 pydantic 校验 (ge=1, le=65535) - 端口号有 pydantic 校验 (ge=1, le=65535)
## 启动服务 ## 启动服务
@@ -186,34 +189,47 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
- GPS 定位 (0x22/0xA0): 直接包含经纬度坐标,精度最高 (~10m) - GPS 定位 (0x22/0xA0): 直接包含经纬度坐标,精度最高 (~10m)
- LBS 基站定位 (0x28/0xA1): 包含 MCC/MNC/LAC/CellID需要地理编码转换为经纬度 - LBS 基站定位 (0x28/0xA1): 包含 MCC/MNC/LAC/CellID需要地理编码转换为经纬度
- WiFi 定位 (0x2C/0xA2): 包含基站数据 + WiFi AP MAC地址列表需要地理编码 - WiFi 定位 (0x2C/0xA2): 包含基站数据 + WiFi AP MAC地址列表需要地理编码
- **所有地理编码服务统一使用高德 (Amap)**
- **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度 - **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度
- **高德智能硬件定位 (待接入)**: `apilocate.amap.com/position`,需企业认证WiFi+基站混合定位精度 ~30m - **高德智能硬件定位**: `apilocate.amap.com/position`,需企业认证 (认证中,个人账号返回 10012)
- **Mylnikov.org (当前使用)**: 免费无需Key中国基站精度较差 (~16km) - WiFi+基站混合定位精度 ~30m企业认证通过后自动生效
- Google Geolocation API / Unwired Labs API (备选需配置Key)
- **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址 - **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址
- **天地图 (已接入)**: 免费1万次/天WGS84原生坐标系无需坐标转换 - **高德**: `restapi.amap.com/v3/geocode/regeo`,需 WGS84→GCJ02 坐标转换 (服务端 Python 实现)
- 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key - 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key
- 地址格式: `省市区街道路门牌号 (附近POI)` - 内置 LRU 缓存 (maxsize=10000),避免重复请求相同基站/坐标
- 内置缓存机制,避免重复请求相同基站/坐标 - **WGS84→GCJ02 服务端转换**: geocoding.py 内置 `wgs84_to_gcj02()` 函数 (与前端 JS 版一致)
- **高德数字签名**: 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
### 天地图 API
- **服务端 Key**: `439fca3920a6f31584014424f89c3ecc` (用于逆地理编码)
- **浏览器端 Key**: `1918548e81a5ae3ff0cb985537341146` (用于前端地图瓦片,暂未使用)
- **API地址**: `http://api.tianditu.gov.cn/geocoder?postStr={JSON}&type=geocode&tk={KEY}`
- **坐标系**: WGS84 (与GPS/Leaflet一致无需转换)
- **配额**: 免费 10,000 次/天
- **注意**: postStr 参数需使用双引号JSON并URL编码
### API 认证与限流 ### API 认证与限流
- **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头 - **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头
- **多 API Key**: 支持 master key (环境变量) + 数据库管理的 API Key (SHA-256 hash)
- **权限级别**: `read` (只读) < `write` (读写) < `admin` (管理,含 key 管理)
- **权限控制**: 所有 POST/PUT/DELETE 端点需要 `write` 权限,`/api/keys` 需要 `admin` 权限
- **Key 管理**: `POST /api/keys` 创建 key (返回明文一次), `GET /api/keys` 列表, `PUT /api/keys/{id}` 更新, `DELETE /api/keys/{id}` 停用
- **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`) - **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`)
- **真实 IP**: 从 `X-Forwarded-For``CF-Connecting-IP``request.client.host` 提取 - **真实 IP**: 从 `X-Forwarded-For``CF-Connecting-IP``request.client.host` 提取
- **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials` - **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials`
### WebSocket 实时推送
- **端点**: `ws://host/ws?api_key=xxx&topics=location,alarm`
- **Topics**: location, alarm, device_status, attendance, bluetooth
- **认证**: query param api_key (支持 master key + DB key)
- **最大连接**: 100 个 WebSocket 连接
- **消息格式**: JSON `{"topic": "...", "data": {...}, "timestamp": "..."}`
- **广播点**: 位置存储、报警存储、设备上下线、考勤存储、蓝牙存储
- **Ping/Pong**: 客户端发送 "ping" 保活,服务器回复 "pong"
### 数据清理
- **自动清理**: 后台定时任务,每 `DATA_CLEANUP_INTERVAL_HOURS`(默认24) 小时执行
- **保留天数**: `DATA_RETENTION_DAYS`(默认90) 天,删除过期的 location/heartbeat/alarm/attendance/bluetooth 记录
- **手动清理**: `POST /api/system/cleanup` (admin only)
### 批量操作 API ### 批量操作 API
- `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重结果按输入顺序 - `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重结果按输入顺序
- `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush - `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush
- `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids - `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids
- `POST /api/locations/batch-latest` — 批量获取多设备最新位置 (最多100)
- `GET /api/devices/all-latest-locations` — 获取所有在线设备最新位置
- `POST /api/commands/batch` — 批量发送指令 (最多100)`model_validator` 互斥校验 - `POST /api/commands/batch` — 批量发送指令 (最多100)`model_validator` 互斥校验
- 所有批量操作使用 WHERE IN 批量查询,避免 N+1 - 所有批量操作使用 WHERE IN 批量查询,避免 N+1
@@ -254,12 +270,13 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
- 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节) - 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节)
- 子协议 0x09: GPS 卫星状态 - 子协议 0x09: GPS 卫星状态
- 子协议 0x00: ICCID(10字节) - 子协议 0x00: ICCID(10字节)
- 子协议 0x04: 设备配置上报 `ALM2=40;ALM4=E0;MODE=03;IMSI=...`
### 前端字段映射 (admin.html) ### 前端字段映射 (admin.html)
- 设备信号: `d.gsm_signal` (非 `d.signal_strength`) - 设备信号: `d.gsm_signal` (非 `d.signal_strength`)
- 指令响应: `c.response_content` (非 `c.response`) - 指令响应: `c.response_content` (非 `c.response`)
- 响应时间: `c.response_at || c.sent_at` (非 `c.updated_at`) - 响应时间: `c.response_at || c.sent_at` (非 `c.updated_at`)
- 位置地址: `l.address` (天地图逆地理编码结果) - 位置地址: `l.address` (高德逆地理编码结果)
- 卫星数: `l.gps_satellites` (非 `l.accuracy`) - 卫星数: `l.gps_satellites` (非 `l.accuracy`)
- 记录时间: `l.recorded_at` (非 `l.timestamp`) - 记录时间: `l.recorded_at` (非 `l.timestamp`)
- 报警来源: `a.alarm_source` (非 `a.source`) - 报警来源: `a.alarm_source` (非 `a.source`)
@@ -292,42 +309,20 @@ remotePort = 5001
## 高德地图 API ## 高德地图 API
### 已有 Key ### Key
- **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b` - **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b`
- **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5` - **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5`
- **账号类型**: 个人认证开发者 (正在申请企业认证) - **账号类型**: 个人认证开发者 (正在申请企业认证)
### 已验证可用的 API ### 已接入服务
- **地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本 - **✅ 逆地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本,服务端 WGS84→GCJ02 坐标转换
- **坐标转换** (`restapi.amap.com/v3/assistant/coordinate/convert`): WGS-84 → GCJ-02 - **✅ 智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (代码就绪,企业认证通过前返回 10012)
- **✅ 前端地图瓦片**: 高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
### 待企业认证后启用 - **数字签名**: `_amap_sign()` — 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
- **智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (需企业认证, 错误码 10012)
- v1.0 GET: `apilocate.amap.com/position`
- v2.0 POST: `restapi.amap.com/v5/position/IoT`
- 参数格式: `bts=mcc,mnc,lac,cellid,signal` / `macs=mac,signal,ssid|mac,signal,ssid|`
### 配额 (个人认证开发者) ### 配额 (个人认证开发者)
- 基础LBS服务: 5,000 次/日 (地理编码、坐标转换等) - 基础LBS服务: 5,000 次/日 (地理编码等)
- 在线定位: 50,000 次/日 - 在线定位: 50,000 次/日 (企业认证后 1,000,000 次/日)
### 接入步骤 (企业认证通过后)
1.`app/geocoding.py` 中设置 `AMAP_KEY`
2. 实现 `_geocode_amap()` 函数调用智能硬件定位 API
3. 注意返回坐标为 GCJ-02需转换为 WGS-84 用于 Leaflet 地图
4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数
## 百度地图 API
### Key
- **服务端 AK**: `nZ4AyCm7FTn85HbFuQjw0ItSYkgxEuhA`
### 已接入服务
- **✅ 逆地理编码**: `api.map.baidu.com/reverse_geocoding/v3` — 经纬度 → 地址 (coordtype=wgs84ll, 无需坐标转换)
- 优先级: 百度 > 天地图 (fallback)
- 配额: 5,000次/日 (个人开发者)
- **注意**: 百度内部使用 BD-09 坐标系,但逆地理编码接口支持 `coordtype=wgs84ll` 直接传入 WGS-84 坐标
- 百度**无服务端基站/WiFi定位API**,基站定位仍用 Mylnikov
## 已知限制 ## 已知限制
@@ -335,8 +330,7 @@ remotePort = 5001
2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发 2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL 3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询 4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km待接入高德智能硬件定位后可达 ~30m 5. **基站/WiFi定位需企业认证** - 高德智能硬件定位 API 已接入但个人账号返回 10012企业认证通过后自动生效
6. **天地图逆地理编码使用 HTTP** - API不支持HTTPSKey在URL中明文传输 (低风险: 免费Key, 已降级为备选)
## 已修复的问题 (Bug Fix 记录) ## 已修复的问题 (Bug Fix 记录)
@@ -359,7 +353,7 @@ remotePort = 5001
### 定位功能修复 ### 定位功能修复
12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失) 12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失)
13. **地理编码集成** - 集成 Mylnikov.org APILBS/WiFi 定位数据自动转换为经纬度坐标 13. **地理编码集成** - LBS/WiFi 定位数据自动转换为经纬度坐标
14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段 14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段
### 告警功能修复 ### 告警功能修复
@@ -372,7 +366,7 @@ remotePort = 5001
21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码 21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码
### 逆地理编码 ### 逆地理编码
22. **天地图集成** - 位置和报警记录自动获取中文地址 (天地图逆地理编码) 22. **逆地理编码集成** - 位置和报警记录自动获取中文地址 (高德逆地理编码)
23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址 23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址
### 蓝牙与考勤功能 ### 蓝牙与考勤功能
@@ -410,43 +404,101 @@ remotePort = 5001
49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备 49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备
50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数toggleSidePanel 仅在 locations 页调用 invalidateSize 50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数toggleSidePanel 仅在 locations 页调用 invalidateSize
### 百度地图接入 & 连接修复 (2026-03-19) ### 连接修复 (2026-03-19)
51. **百度逆地理编码** - 接入百度地图 reverse_geocoding/v3 API (coordtype=wgs84ll),优先于天地图 51. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError补充 import
52. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError补充 import 52. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections
53. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections
54. **高德地图瓦片** - 替换天地图瓦片为高德 (GCJ-02, 标准Mercator),添加 WGS84→GCJ02 坐标转换,可切换 provider (`MAP_PROVIDER` 变量) ### 全面切换高德 API (2026-03-23)
53. **高德逆地理编码** - 接入 restapi.amap.com/v3/geocode/regeo服务端 WGS84→GCJ02 坐标转换
54. **高德智能硬件定位** - 接入 apilocate.amap.com/position (基站+WiFi定位企业认证通过前返回 10012)
55. **高德数字签名** - 实现 `_amap_sign()` 函数 (参数排序 + AMAP_SECRET + MD5)
56. **服务端坐标转换** - geocoding.py 内置 `wgs84_to_gcj02()` Python 实现
57. **高德地图瓦片** - 前端替换为高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
58. **移除第三方地理编码** - 清理天地图/百度/Google/Unwired/Mylnikov统一使用高德
### API 安全加固 & 批量管理 (2026-03-20) ### API 安全加固 & 批量管理 (2026-03-20)
55. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击 59. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击
56. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min) 60. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min)
57. **限流器真实 IP** - `extensions.py``X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP 61. **限流器真实 IP** - `extensions.py``X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP
58. **全局异常处理** - 拦截未处理异常返回 500不泄露堆栈放行 HTTPException/ValidationError 62. **全局异常处理** - 拦截未处理异常返回 500不泄露堆栈放行 HTTPException/ValidationError
59. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern 63. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern
60. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数 64. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数
61. **批量设备创建** - `POST /api/devices/batch` (最多500台)WHERE IN 单次查询去重,输入列表内 IMEI 去重 65. **批量设备创建** - `POST /api/devices/batch` (最多500台)WHERE IN 单次查询去重,输入列表内 IMEI 去重
62. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush 66. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush
63. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制 67. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制
64. **批量指令发送** - `POST /api/commands/batch` (最多100台)`model_validator` 互斥校验 device_ids/imeis 68. **批量指令发送** - `POST /api/commands/batch` (最多100台)`model_validator` 互斥校验 device_ids/imeis
65. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取 69. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取
66. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突 70. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突
67. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh依赖 service 层 71. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh依赖 service 层
### 0x94 子协议 0x04 ### 全面改进 (2026-03-22)
- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286`
- 在设备重连/重启后上报 #### Protocol 修复
72. **parser.py 0x22 CellID** - 从2字节改为3字节解析
73. **parser.py 0xA5 LBS告警** - 移除错误的 datetime+lbs_length 前缀
74. **parser.py 0xA9 WiFi告警** - 完全重写为 datetime+MCC/MNC+cell_type+cell_count+基站+TA+WiFi+alarm_code
75. **parser.py voltage** - 0xA3/0xA5/0xA9 voltage 从2字节改为1字节
76. **parser.py 0xA4** - 新增多围栏告警解析器 (含 fence_id)
77. **parser.py 0xB2** - 完整 iBeacon 解析 (RSSI/MAC/UUID/Major/Minor/Battery)
78. **parser.py 0xB3** - beacon_count + 每信标30字节解析
79. **parser.py 0xB0/0xB1** - 补充 gps_positioned/terminal_info/voltage/gsm_signal 等中间字段
80. **parser.py 0x81** - 新增指令回复解析器
81. **builder.py 0x1F** - 时间同步添加2字节 language 参数
82. **builder.py 地址回复** - 重写 CN/EN 地址回复完整格式
83. **builder.py 0x80/0x82** - cmd_len 正确包含 language(2)
84. **constants.py** - 从 PROTOCOLS_REQUIRING_RESPONSE 移除 0x28
#### 批量 API + 索引
85. **batch-latest** - `POST /api/locations/batch-latest` 批量获取多设备最新位置
86. **all-latest-locations** - `GET /api/devices/all-latest-locations` 所有在线设备位置
87. **数据库索引** - 新增5个索引 (alarm_type, acknowledged, beacon_mac, location_type, attendance_type)
#### 多 API Key + 权限控制
88. **ApiKey 模型** - SHA-256 hash, permissions(read/write/admin), is_active
89. **多 Key 认证** - master key (env) + DB keys, last_used_at 追踪
90. **权限控制** - require_write/require_admin 依赖,写端点需 write 权限
91. **Key 管理 API** - `/api/keys` CRUD (admin only),创建时返回明文一次
92. **DeviceUpdate** - 移除 status 字段 (设备状态由系统管理)
#### WebSocket 实时推送
93. **WebSocketManager** - 连接管理器topic 订阅最大100连接
94. **WS 端点** - `/ws?api_key=xxx&topics=location,alarm` WebSocket 认证
95. **TCP 广播集成** - 7个广播点 (location, alarm, device_status x2, attendance, bluetooth x2)
#### 数据清理 + 补充
96. **自动数据清理** - 后台定时任务DATA_RETENTION_DAYS=90, DATA_CLEANUP_INTERVAL_HOURS=24
97. **手动清理 API** - `POST /api/system/cleanup` (admin only)
98. **alarm_source 过滤** - alarms 列表新增 alarm_source 参数
99. **beacon_mac 过滤** - bluetooth 列表新增 beacon_mac 参数
100. **command_type 过滤** - commands 列表新增 command_type 参数
101. **sort_order 参数** - alarms/bluetooth 列表支持 asc/desc 排序
#### 协议层统一
102. **PacketBuilder 统一** - tcp_server.py 内嵌 PacketBuilder 改为委托 protocol/builder.py
### 审计修复 (2026-03-23)
103. **require_admin 导入** - main.py 缺少 require_admin 导入,设置 API_KEY 后 cleanup 端点崩溃
104. **0x28/0x2C CellID 3字节** - tcp_server.py 2G 基站 CellID 从2字节修正为3字节 (0x28 LBS, 0x2C WiFi, 邻区解析)
105. **parser.py CellID 3字节** - parse_lbs_station 默认 cell_id_size=2→3新增3字节分支; _parse_lbs_address_req CellID 2→3字节
106. **alarm_source 字段长度** - models.py String(10)→String(20), schemas.py max_length=10→20 ("single_fence"=12字符)
107. **AlarmRecord wifi_data/fence_data** - 0xA9 WiFi报警存储 wifi_data; 0xA4 多围栏报警提取并存储 fence_id
108. **CommandLog.sent_at** - 指令发送成功后设置 sent_at 时间戳 (原来始终为 NULL)
109. **geocoding IMEI 参数化** - 移除硬编码 IMEI新增 GEOCODING_DEFAULT_IMEI 配置项geocode_location 接受 imei 参数TCP 层传递设备 IMEI
110. **parser.py 循环长度检查** - _parse_lbs_multi 和 _parse_wifi 站点循环检查从 pos+5 改为 pos+6 (LAC=2+CellID=3+RSSI=1=6)
111. **batch sent_at** - batch_send_command 批量指令路径也设置 cmd_log.sent_at (与单条路径一致)
112. **GPS 经度符号反转** - course_status bit 11 含义为 0=东经/1=西经 (非 1=东经)`is_east` 改为 `is_west`,修复成都定位到北美洲的问题 (tcp_server.py + parser.py)
## 待完成功能 ## 待完成功能
1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov大幅提升 WiFi/基站定位精度 1. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
2. ~~**地图瓦片**~~ - ✅ 已切换为高德瓦片 (GCJ-02),支持 MAP_PROVIDER 切换 ('gaode'|'tianditu') 2. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知
3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 3. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py
4. ~~**蓝牙信标调试**~~ - ✅ 已完成 (2026-03-18)0xB2打卡数据正常上报信标匹配成功
## 调试技巧 ## 调试技巧
```bash ```bash
# 查看实时日志 # 查看实时日志
tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Tianditu" --line-buffered tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Amap" --line-buffered
# 检查数据库 # 检查数据库
python3 -c " python3 -c "

View File

@@ -29,20 +29,21 @@ class Settings(BaseSettings):
RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit") RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit")
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")
# Geocoding API keys # 高德地图 API (geocoding)
TIANDITU_API_KEY: str | None = Field(default=None, description="天地图 API key for reverse geocoding")
GOOGLE_API_KEY: str | None = Field(default=None, description="Google Geolocation API key")
UNWIRED_API_TOKEN: str | None = Field(default=None, description="Unwired Labs API token")
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key") AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥") AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
BAIDU_MAP_AK: str | None = Field(default=None, description="百度地图服务端 AK")
# Geocoding cache # Geocoding
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries") GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries")
# 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")
# Data retention
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")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}

View File

@@ -1,20 +1,85 @@
""" """
Shared FastAPI dependencies. Shared FastAPI dependencies.
Supports master API key (env) and database-managed API keys with permission levels.
""" """
import hashlib
import secrets import secrets
from datetime import datetime, timezone
from fastapi import HTTPException, Security from fastapi import Depends, HTTPException, Security
from fastapi.security import APIKeyHeader from fastapi.security import APIKeyHeader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.database import get_db
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Permission hierarchy: admin > write > read
_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3}
async def verify_api_key(api_key: str | None = Security(_api_key_header)):
"""Verify API key if authentication is enabled.""" def _hash_key(key: str) -> str:
"""SHA-256 hash of an API key."""
return hashlib.sha256(key.encode()).hexdigest()
async def verify_api_key(
api_key: str | None = Security(_api_key_header),
db: AsyncSession = Depends(get_db),
) -> dict | None:
"""Verify API key. Returns key info dict or None (auth disabled).
Checks master key first, then database keys.
Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}.
"""
if settings.API_KEY is None: if settings.API_KEY is None:
return None # Auth disabled
if api_key is None:
raise HTTPException(status_code=401, detail="Missing API key / 缺少 API Key")
# Check master key
if secrets.compare_digest(api_key, settings.API_KEY):
return {"permissions": "admin", "key_id": None, "name": "master"}
# Check database keys
from app.models import ApiKey
key_hash = _hash_key(api_key)
result = await db.execute(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712
)
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
# Update last_used_at
db_key.last_used_at = datetime.now(timezone.utc)
await db.flush()
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
def require_permission(min_level: str):
"""Factory for permission-checking dependencies."""
async def _check(key_info: dict | None = Depends(verify_api_key)):
if key_info is None:
return # Auth disabled return # Auth disabled
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY): current = _PERMISSION_LEVELS.get(key_info["permissions"], 0)
raise HTTPException(status_code=401, detail="Invalid or missing API key") required = _PERMISSION_LEVELS.get(min_level, 0)
if current < required:
raise HTTPException(
status_code=403,
detail=f"Insufficient permissions. Requires '{min_level}' / 权限不足,需要 '{min_level}' 权限",
)
return key_info
return _check
require_write = require_permission("write")
require_admin = require_permission("admin")

View File

@@ -2,34 +2,77 @@
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates, Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
and reverse geocode coordinates to addresses. and reverse geocode coordinates to addresses.
Uses free APIs: All services use 高德 (Amap) API exclusively.
- Cell tower: Google Geolocation API (if key available) or unwiredlabs.com - Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
- WiFi: Same APIs support WiFi AP lookup - Reverse geocoding (coords → address): 高德逆地理编码
- Reverse geocoding: 天地图 (Tianditu) - free, WGS84 native
""" """
import json import hashlib
import logging import logging
import math
from collections import OrderedDict from collections import OrderedDict
from typing import Optional from typing import Optional
from urllib.parse import quote
import aiohttp import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Import keys from centralized config (no more hardcoded values here)
from app.config import settings as _settings from app.config import settings as _settings
GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY AMAP_KEY: Optional[str] = _settings.AMAP_KEY
UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY
BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK
# Maximum cache entries (LRU eviction) — configurable via settings
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE _CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
# ---------------------------------------------------------------------------
# WGS-84 → GCJ-02 coordinate conversion (server-side)
# ---------------------------------------------------------------------------
_A = 6378245.0
_EE = 0.00669342162296594
def _out_of_china(lat: float, lon: float) -> bool:
return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
def _transform_lat(x: float, y: float) -> float:
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lon(x: float, y: float) -> float:
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
"""Convert WGS-84 to GCJ-02 (used by 高德)."""
if _out_of_china(lat, lon):
return (lat, lon)
d_lat = _transform_lat(lon - 105.0, lat - 35.0)
d_lon = _transform_lon(lon - 105.0, lat - 35.0)
rad_lat = lat / 180.0 * math.pi
magic = math.sin(rad_lat)
magic = 1 - _EE * magic * magic
sqrt_magic = math.sqrt(magic)
d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi)
d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi)
return (lat + d_lat, lon + d_lon)
# ---------------------------------------------------------------------------
# LRU Cache
# ---------------------------------------------------------------------------
class LRUCache(OrderedDict): class LRUCache(OrderedDict):
"""Simple LRU cache based on OrderedDict.""" """Simple LRU cache based on OrderedDict."""
@@ -51,13 +94,29 @@ class LRUCache(OrderedDict):
self.popitem(last=False) self.popitem(last=False)
# Cache cell tower lookups to avoid redundant API calls
_cell_cache: LRUCache = LRUCache() _cell_cache: LRUCache = LRUCache()
_wifi_cache: LRUCache = LRUCache() _wifi_cache: LRUCache = LRUCache()
# Cache reverse geocoding results (coord rounded to ~100m -> address)
_address_cache: LRUCache = LRUCache() _address_cache: LRUCache = LRUCache()
# ---------------------------------------------------------------------------
# 高德数字签名 (AMAP_SECRET)
# ---------------------------------------------------------------------------
def _amap_sign(params: dict) -> str:
"""Generate 高德 API digital signature (MD5)."""
if not AMAP_SECRET:
return ""
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
raw = sorted_str + AMAP_SECRET
return hashlib.md5(raw.encode()).hexdigest()
# ===========================================================================
# Forward Geocoding: cell/WiFi → lat/lon
# ===========================================================================
async def geocode_location( async def geocode_location(
mcc: Optional[int] = None, mcc: Optional[int] = None,
mnc: Optional[int] = None, mnc: Optional[int] = None,
@@ -65,272 +124,129 @@ async def geocode_location(
cell_id: Optional[int] = None, cell_id: Optional[int] = None,
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,
) -> 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.
Parameters Uses 高德智能硬件定位 API exclusively.
----------
mcc : int - Mobile Country Code
mnc : int - Mobile Network Code
lac : int - Location Area Code
cell_id : int - Cell Tower ID
wifi_list : list[dict] - WiFi APs [{"mac": "AA:BB:CC:DD:EE:FF", "signal": -70}, ...]
neighbor_cells : list[dict] - Neighbor cells [{"lac": ..., "cell_id": ..., "rssi": ...}, ...]
Returns
-------
(latitude, longitude) or (None, None)
""" """
# Check cache first (cell tower) # Check cache first
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:
cache_key = (mcc, mnc or 0, lac, cell_id) cache_key = (mcc, mnc or 0, lac, cell_id)
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
# Try Google Geolocation API first if AMAP_KEY:
if GOOGLE_API_KEY: result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
result = await _geocode_google(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
if result[0] is not None: if result[0] is not 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:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result) _cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result return result
# Try Unwired Labs API
if UNWIRED_API_TOKEN:
result = await _geocode_unwired(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
if result[0] is not None:
if mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
# Fallback: Mylnikov.org (free, no API key required)
if mcc is not None and lac is not None and cell_id is not None:
result = await _geocode_mylnikov_cell(mcc, mnc or 0, lac, cell_id)
if result[0] is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
# Try WiFi via Mylnikov
if wifi_list:
for ap in wifi_list:
mac = ap.get("mac", "")
if mac:
result = await _geocode_mylnikov_wifi(mac)
if result[0] is not None:
return result
return (None, None) return (None, None)
async def _geocode_google( async def _geocode_amap(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
) -> tuple[Optional[float], Optional[float]]: ) -> tuple[Optional[float], Optional[float]]:
"""Use Google Geolocation API.""" """
url = f"https://www.googleapis.com/geolocation/v1/geolocate?key={GOOGLE_API_KEY}" Use 高德智能硬件定位 API (apilocate.amap.com/position).
body: dict = {}
if mcc is not None: Returns coordinates (高德 returns GCJ-02).
body["homeMobileCountryCode"] = mcc """
if mnc is not None: # Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
body["homeMobileNetworkCode"] = mnc bts = ""
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"
# Cell towers # Build nearbts (neighbor cells)
towers = [] nearbts_parts = []
if lac is not None and cell_id is not None:
towers.append({
"cellId": cell_id,
"locationAreaCode": lac,
"mobileCountryCode": mcc or 0,
"mobileNetworkCode": mnc or 0,
})
if neighbor_cells: if neighbor_cells:
for nc in neighbor_cells: for nc in neighbor_cells:
towers.append({ nc_lac = nc.get("lac", 0)
"cellId": nc.get("cell_id", 0), nc_cid = nc.get("cell_id", 0)
"locationAreaCode": nc.get("lac", 0), nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
"mobileCountryCode": mcc or 0, nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}")
"mobileNetworkCode": mnc or 0,
"signalStrength": -(nc.get("rssi", 0)),
})
if towers:
body["cellTowers"] = towers
# WiFi APs # Build macs (WiFi APs): mac,signal,ssid
macs_parts = []
if wifi_list: if wifi_list:
aps = []
for ap in wifi_list: for ap in wifi_list:
aps.append({ mac = ap.get("mac", "").lower().replace(":", "")
"macAddress": ap.get("mac", ""), signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
"signalStrength": -(ap.get("signal", 0)), ssid = ap.get("ssid", "")
}) macs_parts.append(f"{mac},{signal},{ssid}")
body["wifiAccessPoints"] = aps
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json()
loc = data.get("location", {})
lat = loc.get("lat")
lng = loc.get("lng")
if lat is not None and lng is not None:
logger.info("Google geocode: lat=%.6f, lon=%.6f", lat, lng)
return (lat, lng)
else:
text = await resp.text()
logger.warning("Google geocode failed: %d %s", resp.status, text[:200])
except Exception as e:
logger.warning("Google geocode error: %s", e)
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}
if bts:
params["bts"] = bts
if nearbts_parts:
params["nearbts"] = "|".join(nearbts_parts)
if macs_parts:
params["macs"] = "|".join(macs_parts)
async def _geocode_unwired( # Add digital signature
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells sig = _amap_sign(params)
) -> tuple[Optional[float], Optional[float]]: if sig:
"""Use Unwired Labs LocationAPI.""" params["sig"] = sig
url = "https://us1.unwiredlabs.com/v2/process.php"
body: dict = {"token": UNWIRED_API_TOKEN}
# Cell towers url = "https://apilocate.amap.com/position"
cells = []
if mcc is not None and lac is not None and cell_id is not None:
cells.append({
"lac": lac,
"cid": cell_id,
"mcc": mcc,
"mnc": mnc or 0,
})
if neighbor_cells:
for nc in neighbor_cells:
cells.append({
"lac": nc.get("lac", 0),
"cid": nc.get("cell_id", 0),
"mcc": mcc or 0,
"mnc": mnc or 0,
"signal": -(nc.get("rssi", 0)),
})
if cells:
body["cells"] = cells
# WiFi APs
if wifi_list:
aps = []
for ap in wifi_list:
aps.append({
"bssid": ap.get("mac", ""),
"signal": -(ap.get("signal", 0)),
})
body["wifi"] = aps
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(
if resp.status == 200: url, params=params, timeout=aiohttp.ClientTimeout(total=5)
data = await resp.json() ) as resp:
if data.get("status") == "ok":
lat = data.get("lat")
lon = data.get("lon")
if lat is not None and lon is not None:
logger.info("Unwired geocode: lat=%.6f, lon=%.6f", lat, lon)
return (lat, lon)
else:
logger.warning("Unwired geocode: %s", data.get("message", "unknown error"))
except Exception as e:
logger.warning("Unwired geocode error: %s", e)
return (None, None)
async def _geocode_mylnikov_cell(
mcc: int, mnc: int, lac: int, cell_id: int
) -> tuple[Optional[float], Optional[float]]:
"""Use Mylnikov.org free cell tower geocoding API (no API key required)."""
url = (
f"https://api.mylnikov.org/geolocation/cell"
f"?v=1.1&data=open"
f"&mcc={mcc}&mnc={mnc}&lac={lac}&cellid={cell_id}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json(content_type=None) data = await resp.json(content_type=None)
if data.get("result") == 200: if data.get("status") == "1" and data.get("result"):
lat = data.get("data", {}).get("lat") result = data["result"]
lon = data.get("data", {}).get("lon") location = result.get("location", "")
if lat is not None and lon is not None: if location and "," in location:
logger.info("Mylnikov cell geocode: lat=%.6f, lon=%.6f", lat, lon) lon_str, lat_str = location.split(",")
lat = float(lat_str)
lon = float(lon_str)
logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon)
return (lat, lon) return (lat, lon)
else: else:
logger.debug("Mylnikov cell: no result for MCC=%d MNC=%d LAC=%d CellID=%d", infocode = data.get("infocode", "")
mcc, mnc, lac, cell_id) if infocode == "10012":
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
else: else:
logger.warning("Mylnikov cell API HTTP %d", resp.status) logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Amap geocode HTTP %d", resp.status)
except Exception as e: except Exception as e:
logger.warning("Mylnikov cell geocode error: %s", e) logger.warning("Amap geocode error: %s", e)
return (None, None) return (None, None)
async def _geocode_mylnikov_wifi(mac: str) -> tuple[Optional[float], Optional[float]]: # ===========================================================================
"""Use Mylnikov.org free WiFi AP geocoding API.""" # Reverse Geocoding: coordinates → address
# Normalize MAC format (needs colons) # ===========================================================================
mac = mac.upper().replace("-", ":")
url = f"https://api.mylnikov.org/geolocation/wifi?v=1.1&data=open&bssid={mac}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("result") == 200:
lat = data.get("data", {}).get("lat")
lon = data.get("data", {}).get("lon")
if lat is not None and lon is not None:
logger.info("Mylnikov WiFi geocode: lat=%.6f, lon=%.6f (MAC=%s)", lat, lon, mac)
_wifi_cache.put(mac, (lat, lon))
return (lat, lon)
else:
logger.debug("Mylnikov WiFi API HTTP %d for MAC=%s", resp.status, mac)
except Exception as e:
logger.warning("Mylnikov WiFi geocode error: %s", e)
return (None, None)
# ---------------------------------------------------------------------------
# Reverse Geocoding: coordinates -> address
# ---------------------------------------------------------------------------
async def reverse_geocode( async def reverse_geocode(
lat: float, lon: float lat: float, lon: float
) -> Optional[str]: ) -> Optional[str]:
""" """
Convert lat/lon to a human-readable address. Convert lat/lon (WGS-84) to a human-readable address.
Priority: Baidu Map > Tianditu (fallback). Uses 高德逆地理编码 API exclusively.
Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll).
Returns None if no reverse geocoding service is available.
""" """
# Round to ~100m for cache key to reduce API calls
cache_key = (round(lat, 3), round(lon, 3)) cache_key = (round(lat, 3), round(lon, 3))
cached = _address_cache.get_cached(cache_key) cached = _address_cache.get_cached(cache_key)
if cached is not None: if cached is not None:
return cached return cached
# Try Baidu Map first (higher quality addresses for China) if AMAP_KEY:
if BAIDU_MAP_AK: result = await _reverse_geocode_amap(lat, lon)
result = await _reverse_geocode_baidu(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
# Fallback to Tianditu
if TIANDITU_API_KEY:
result = await _reverse_geocode_tianditu(lat, lon)
if result: if result:
_address_cache.put(cache_key, result) _address_cache.put(cache_key, result)
return result return result
@@ -338,101 +254,55 @@ async def reverse_geocode(
return None return None
async def _reverse_geocode_baidu( async def _reverse_geocode_amap(
lat: float, lon: float lat: float, lon: float
) -> Optional[str]: ) -> Optional[str]:
""" """
Use Baidu Map reverse geocoding API. Use 高德逆地理编码 API.
API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding API: https://restapi.amap.com/v3/geocode/regeo
Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed). Input: GCJ-02 coordinates (need to convert from WGS-84).
Free tier: 5,000 requests/day (personal developer). Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise).
""" """
url = ( gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
f"https://api.map.baidu.com/reverse_geocoding/v3/"
f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll" params = {
f"&location={lat},{lon}" "key": AMAP_KEY,
) "location": f"{gcj_lon:.6f},{gcj_lat:.6f}",
"extensions": "base",
"output": "json",
}
sig = _amap_sign(params)
if sig:
params["sig"] = sig
url = "https://restapi.amap.com/v3/geocode/regeo"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5) url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) 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)
if data.get("status") == 0: if data.get("status") == "1":
result = data.get("result", {}) regeocode = data.get("regeocode", {})
formatted = result.get("formatted_address", "") formatted = regeocode.get("formatted_address", "")
if formatted: if formatted and formatted != "[]":
# Add sematic_description for more context
sematic = result.get("sematic_description", "")
address = formatted
if sematic and sematic not in formatted:
address = f"{formatted} ({sematic})"
logger.info( logger.info(
"Baidu reverse geocode: %.6f,%.6f -> %s", "Amap reverse geocode: %.6f,%.6f -> %s",
lat, lon, address, lat, lon, formatted,
) )
return address return formatted
else: else:
logger.warning( logger.warning(
"Baidu reverse geocode error: status=%s, msg=%s", "Amap reverse geocode error: info=%s, infocode=%s",
data.get("status"), data.get("message", ""), data.get("info", ""), data.get("infocode", ""),
) )
else: else:
logger.warning("Baidu reverse geocode HTTP %d", resp.status) logger.warning("Amap reverse geocode HTTP %d", resp.status)
except Exception as e: except Exception as e:
logger.warning("Baidu reverse geocode error: %s", e) logger.warning("Amap reverse geocode error: %s", e)
return None
async def _reverse_geocode_tianditu(
lat: float, lon: float
) -> Optional[str]:
"""
Use 天地图 (Tianditu) reverse geocoding API.
API docs: http://lbs.tianditu.gov.cn/server/geocoding.html
Coordinate system: WGS84 (same as our GPS data, no conversion needed).
Free tier: 10,000 requests/day.
"""
post_str = json.dumps({"lon": lon, "lat": lat, "ver": 1}, separators=(",", ":"))
url = (
f"http://api.tianditu.gov.cn/geocoder"
f"?postStr={quote(post_str)}&type=geocode&tk={TIANDITU_API_KEY}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "0":
result = data.get("result", {})
# Build address from components
addr_comp = result.get("addressComponent", {})
formatted = result.get("formatted_address", "")
if formatted:
# Add nearby POI if available
poi = addr_comp.get("poi", "")
address = formatted
if poi and poi not in formatted:
address = f"{formatted} ({poi})"
logger.info(
"Tianditu reverse geocode: %.6f,%.6f -> %s",
lat, lon, address,
)
return address
else:
logger.warning(
"Tianditu reverse geocode error: %s",
data.get("msg", data),
)
else:
logger.warning("Tianditu reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Tianditu reverse geocode error: %s", e)
return None return None

View File

@@ -1,6 +1,6 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -13,8 +13,8 @@ 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 from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding
from app.dependencies import verify_api_key from app.dependencies import verify_api_key, require_write, require_admin
import asyncio import asyncio
import logging import logging
@@ -26,6 +26,46 @@ logger = logging.getLogger(__name__)
from app.extensions import limiter from app.extensions import limiter
async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS."""
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
total_deleted = 0
async with async_session() as session:
async with session.begin():
for model, time_col in [
(LocationRecord, LocationRecord.created_at),
(HeartbeatRecord, HeartbeatRecord.created_at),
(AlarmRecord, AlarmRecord.created_at),
(AttendanceRecord, AttendanceRecord.created_at),
(BluetoothRecord, BluetoothRecord.created_at),
]:
result = await session.execute(
delete(model).where(time_col < cutoff)
)
if result.rowcount:
total_deleted += result.rowcount
logger.info("Cleanup: deleted %d old %s records", result.rowcount, model.__tablename__)
return total_deleted
async def _data_cleanup_loop():
"""Background task that runs cleanup periodically."""
while True:
try:
await asyncio.sleep(settings.DATA_CLEANUP_INTERVAL_HOURS * 3600)
deleted = await run_data_cleanup()
if deleted:
logger.info("Data cleanup completed: %d records removed", deleted)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Data cleanup error")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
@@ -41,10 +81,28 @@ async def lifespan(app: FastAPI):
logger.info("All devices reset to offline on startup") logger.info("All devices reset to offline on startup")
except Exception: except Exception:
logger.exception("Failed to reset device statuses on startup") logger.exception("Failed to reset device statuses on startup")
# Create missing indexes (safe for existing databases)
try:
from sqlalchemy import text as sa_text
async with engine.begin() as conn:
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
"CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)",
"CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)",
"CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)",
"CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)",
]:
await conn.execute(sa_text(stmt))
logger.info("Database indexes verified/created")
except Exception:
logger.exception("Failed to create indexes")
logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT) logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT)
tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT)) tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT))
cleanup_task = asyncio.create_task(_data_cleanup_loop())
yield yield
# Shutdown # Shutdown
cleanup_task.cancel()
logger.info("Shutting down TCP server...") logger.info("Shutting down TCP server...")
await tcp_manager.stop() await tcp_manager.stop()
tcp_task.cancel() tcp_task.cancel()
@@ -119,6 +177,9 @@ 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(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(ws.router) # WebSocket handles auth internally
app.include_router(geocoding.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static" _STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -156,9 +217,22 @@ async def health():
except Exception: except Exception:
logger.warning("Health check: database unreachable") logger.warning("Health check: database unreachable")
from app.websocket_manager import ws_manager
status = "healthy" if db_ok else "degraded" status = "healthy" if db_ok else "degraded"
return { return {
"status": status, "status": status,
"database": "ok" if db_ok else "error", "database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections), "connected_devices": len(tcp_manager.connections),
"websocket_connections": ws_manager.connection_count,
} }
@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else [])
async def manual_cleanup():
"""手动触发数据清理 / Manually trigger data cleanup (admin only)."""
try:
deleted = await run_data_cleanup()
return {"code": 0, "message": f"Cleanup completed: {deleted} records removed", "data": {"deleted": deleted}}
except Exception as e:
logger.exception("Manual cleanup failed")
return {"code": 500, "message": f"Cleanup failed: {str(e)}", "data": None}

View File

@@ -129,7 +129,7 @@ class AlarmRecord(Base):
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, ...
alarm_source: Mapped[str | None] = mapped_column( alarm_source: Mapped[str | None] = mapped_column(
String(10), nullable=True String(20), nullable=True
) # single_fence, multi_fence, lbs, wifi ) # single_fence, multi_fence, lbs, wifi
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True) latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -321,3 +321,22 @@ class CommandLog(Base):
f"<CommandLog(id={self.id}, device_id={self.device_id}, " f"<CommandLog(id={self.id}, device_id={self.device_id}, "
f"type={self.command_type}, status={self.status})>" f"type={self.command_type}, status={self.status})>"
) )
class ApiKey(Base):
"""API keys for external system authentication."""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
permissions: Mapped[str] = mapped_column(
String(20), default="read", nullable=False
) # read, write, admin
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"

View File

@@ -123,14 +123,18 @@ class PacketBuilder:
self, self,
serial_number: int, serial_number: int,
protocol: int = PROTO_TIME_SYNC, protocol: int = PROTO_TIME_SYNC,
language: int = 0x0001,
) -> bytes: ) -> bytes:
""" """
Build a time sync response (0x1F). Build a time sync response (0x1F).
Returns the current UTC time as a 4-byte Unix timestamp. Returns the current UTC time as a 4-byte Unix timestamp + 2-byte language.
For Chinese (0x0001), the timestamp is GMT+8.
""" """
utc_now = int(time.time()) utc_now = int(time.time())
info = struct.pack("!I", utc_now) if language == 0x0001:
utc_now += 8 * 3600 # GMT+8 for Chinese
info = struct.pack("!IH", utc_now, language)
return self.build_response(protocol, serial_number, info) return self.build_response(protocol, serial_number, info)
def build_time_sync_8a_response(self, serial_number: int) -> bytes: def build_time_sync_8a_response(self, serial_number: int) -> bytes:
@@ -186,8 +190,8 @@ class PacketBuilder:
Complete packet. Complete packet.
""" """
cmd_bytes = command.encode("ascii") cmd_bytes = command.encode("ascii")
# inner_len = server_flag(4) + cmd_content(N) # inner_len = server_flag(4) + cmd_content(N) + language(2)
inner_len = 4 + len(cmd_bytes) inner_len = 4 + len(cmd_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag info += struct.pack("!I", server_flag) # 4 bytes server flag
@@ -225,8 +229,8 @@ class PacketBuilder:
Complete packet. Complete packet.
""" """
msg_bytes = message_text.encode("utf-16-be") msg_bytes = message_text.encode("utf-16-be")
# inner_len = server_flag(4) + msg_content(N) # inner_len = server_flag(4) + msg_content(N) + language(2)
inner_len = 4 + len(msg_bytes) inner_len = 4 + len(msg_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag info += struct.pack("!I", server_flag) # 4 bytes server flag
@@ -238,94 +242,57 @@ class PacketBuilder:
def build_address_reply_cn( def build_address_reply_cn(
self, self,
serial_number: int, serial_number: int,
server_flag: int, server_flag: int = 0,
address: str, address: str = "",
phone: str = "", phone: str = "",
protocol: int = PROTO_LBS_ADDRESS_REQ, protocol: int = PROTO_LBS_ADDRESS_REQ,
is_alarm: bool = False,
) -> bytes: ) -> bytes:
""" """
Build a Chinese address reply packet. Build a Chinese address reply packet (0x17).
Used as a response to protocol 0x17 (LBS Address Request) Format: cmd_length(1) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF16BE) + && + phone(21) + ##
or similar address query protocols.
Parameters
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
address : str
Address string (encoded as UTF-16 Big-Endian).
phone : str
Phone number string (BCD encoded, even length, padded with 'F').
protocol : int
Protocol number to respond with (default 0x17).
Returns
-------
bytes
Complete packet.
""" """
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-16-be") addr_bytes = address.encode("utf-16-be")
addr_len = len(addr_bytes) # Phone field: 21 bytes ASCII, zero-padded
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
info = struct.pack("!I", server_flag) # 4 bytes server flag inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
info += struct.pack("!H", addr_len) # 2 bytes address length # 0x17 uses 1-byte cmd_length
info += addr_bytes # N bytes address cmd_len = min(len(inner), 0xFF)
info = bytes([cmd_len]) + inner
if phone:
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
phone_bcd = bytes.fromhex(phone_padded)
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
info += phone_bcd # N bytes phone BCD
else:
info += struct.pack("B", 0) # 0 phone length
return self.build_response(protocol, serial_number, info) return self.build_response(protocol, serial_number, info)
def build_address_reply_en( def build_address_reply_en(
self, self,
serial_number: int, serial_number: int,
server_flag: int, server_flag: int = 0,
address: str, address: str = "",
phone: str = "", phone: str = "",
protocol: int = PROTO_ADDRESS_REPLY_EN, protocol: int = PROTO_ADDRESS_REPLY_EN,
is_alarm: bool = False,
) -> bytes: ) -> bytes:
""" """
Build an English address reply packet (0x97). Build an English address reply packet (0x97).
Parameters Format: cmd_length(2) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF-8) + && + phone(21) + ##
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
address : str
Address string (ASCII/UTF-8 encoded).
phone : str
Phone number string (BCD encoded, even length, padded with 'F').
protocol : int
Protocol number to respond with (default 0x97).
Returns
-------
bytes
Complete packet.
""" """
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-8") addr_bytes = address.encode("utf-8")
addr_len = len(addr_bytes) phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
info = struct.pack("!I", server_flag) # 4 bytes server flag inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
info += struct.pack("!H", addr_len) # 2 bytes address length # 0x97 uses 2-byte cmd_length
info += addr_bytes # N bytes address info = struct.pack("!H", len(inner)) + inner
if phone:
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
phone_bcd = bytes.fromhex(phone_padded)
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
info += phone_bcd # N bytes phone BCD
else:
info += struct.pack("B", 0) # 0 phone length
return self.build_response(protocol, serial_number, info) return self.build_response(protocol, serial_number, info)

View File

@@ -121,7 +121,7 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
PROTO_LBS_ADDRESS_REQ, PROTO_LBS_ADDRESS_REQ,
PROTO_ADDRESS_QUERY, PROTO_ADDRESS_QUERY,
PROTO_TIME_SYNC, PROTO_TIME_SYNC,
PROTO_LBS_MULTI, # Note: PROTO_LBS_MULTI (0x28) does NOT require response; only 0x2E does
PROTO_HEARTBEAT_EXT, PROTO_HEARTBEAT_EXT,
PROTO_TIME_SYNC_2, PROTO_TIME_SYNC_2,
# PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc # PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc

View File

@@ -14,9 +14,13 @@ from typing import Any, Dict, List, Tuple
from .constants import ( from .constants import (
ALARM_TYPES, ALARM_TYPES,
ATTENDANCE_STATUS_MASK,
ATTENDANCE_STATUS_SHIFT,
ATTENDANCE_TYPES,
DATA_REPORT_MODES, DATA_REPORT_MODES,
GSM_SIGNAL_LEVELS, GSM_SIGNAL_LEVELS,
PROTOCOL_NAMES, PROTOCOL_NAMES,
VOLTAGE_LEVELS,
PROTO_ADDRESS_QUERY, PROTO_ADDRESS_QUERY,
PROTO_ALARM_LBS_4G, PROTO_ALARM_LBS_4G,
PROTO_ALARM_MULTI_FENCE, PROTO_ALARM_MULTI_FENCE,
@@ -272,29 +276,23 @@ class PacketParser:
speed = data[offset + 9] speed = data[offset + 9]
course_status = struct.unpack_from("!H", data, offset + 10)[0] course_status = struct.unpack_from("!H", data, offset + 10)[0]
# Decode course/status # Decode course/status (per protocol doc):
is_realtime = bool(course_status & 0x2000) # bit 13 (from MSB: bit 12 if 0-indexed from MSB) # bit 13 (0x2000): GPS real-time differential positioning
is_gps_positioned = bool(course_status & 0x1000) # bit 12 -> actually bit 11 # bit 12 (0x1000): GPS positioned
is_east = bool(course_status & 0x0800) # bit 11 -> bit 10 # bit 11 (0x0800): 0=East, 1=West (东经/西经)
is_north = bool(course_status & 0x0400) # bit 10 -> bit 9 # bit 10 (0x0400): 0=South, 1=North (南纬/北纬)
course = course_status & 0x03FF # lower 10 bits # bits 9-0: course (0-360)
is_realtime = bool(course_status & 0x2000)
# Wait -- the standard mapping for this protocol: is_gps_positioned = bool(course_status & 0x1000)
# bit 13 (0x2000): real-time GPS is_west = bool(course_status & 0x0800)
# bit 12 (0x1000): GPS is positioned is_north = bool(course_status & 0x0400)
# bit 11 (0x0800): East longitude (0=West) course = course_status & 0x03FF
# bit 10 (0x0400): North latitude (0=South, but spec says 1=South sometimes)
# We'll use the most common convention: bit10=1 means South latitude is *negated*.
# Actually, common convention: bit10 = 0 -> South, bit10 = 1 -> North? No --
# In most implementations of this protocol family:
# bit 10 (0x0400): 1 = North latitude, 0 = South
# We'll go with that.
latitude = lat_raw / 1_800_000.0 latitude = lat_raw / 1_800_000.0
longitude = lon_raw / 1_800_000.0 longitude = lon_raw / 1_800_000.0
if not is_north: if not is_north:
latitude = -latitude latitude = -latitude
if not is_east: if is_west:
longitude = -longitude longitude = -longitude
return { return {
@@ -308,7 +306,7 @@ class PacketParser:
"course": course, "course": course,
"is_realtime": is_realtime, "is_realtime": is_realtime,
"is_gps_positioned": is_gps_positioned, "is_gps_positioned": is_gps_positioned,
"is_east": is_east, "is_west": is_west,
"is_north": is_north, "is_north": is_north,
"course_status_raw": course_status, "course_status_raw": course_status,
} }
@@ -347,7 +345,7 @@ class PacketParser:
offset: int = 0, offset: int = 0,
*, *,
lac_size: int = 2, lac_size: int = 2,
cell_id_size: int = 2, cell_id_size: int = 3,
) -> Tuple[Dict[str, Any], int]: ) -> Tuple[Dict[str, Any], int]:
""" """
Parse a single LBS station (LAC + Cell ID + RSSI). Parse a single LBS station (LAC + Cell ID + RSSI).
@@ -370,6 +368,8 @@ class PacketParser:
if cell_id_size == 2: if cell_id_size == 2:
cell_id = struct.unpack_from("!H", data, offset + consumed)[0] cell_id = struct.unpack_from("!H", data, offset + consumed)[0]
elif cell_id_size == 3:
cell_id = int.from_bytes(data[offset + consumed : offset + consumed + 3], "big")
elif cell_id_size == 4: elif cell_id_size == 4:
cell_id = struct.unpack_from("!I", data, offset + consumed)[0] cell_id = struct.unpack_from("!I", data, offset + consumed)[0]
else: # 8 else: # 8
@@ -479,8 +479,8 @@ class PacketParser:
result["lac"] = struct.unpack_from("!H", info, pos)[0] result["lac"] = struct.unpack_from("!H", info, pos)[0]
pos += 2 pos += 2
result["cell_id"] = struct.unpack_from("!H", info, pos)[0] result["cell_id"] = int.from_bytes(info[pos : pos + 3], "big")
pos += 2 pos += 3
# Remaining bytes: phone number (BCD) + alarm_language # Remaining bytes: phone number (BCD) + alarm_language
if pos < len(info): if pos < len(info):
@@ -524,7 +524,7 @@ class PacketParser:
return result return result
def _parse_gps_packet(self, info: bytes) -> Dict[str, Any]: def _parse_gps_packet(self, info: bytes) -> Dict[str, Any]:
"""0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(2) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4).""" """0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(3) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4)."""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
@@ -542,9 +542,10 @@ class PacketParser:
result["lac"] = struct.unpack_from("!H", info, pos)[0] result["lac"] = struct.unpack_from("!H", info, pos)[0]
pos += 2 pos += 2
if len(info) >= pos + 2: # 2G Cell ID is 3 bytes (not 2)
result["cell_id"] = struct.unpack_from("!H", info, pos)[0] if len(info) >= pos + 3:
pos += 2 result["cell_id"] = int.from_bytes(info[pos:pos + 3], "big")
pos += 3
if len(info) >= pos + 1: if len(info) >= pos + 1:
result["acc"] = info[pos] result["acc"] = info[pos]
@@ -579,7 +580,7 @@ class PacketParser:
stations: List[Dict[str, Any]] = [] stations: List[Dict[str, Any]] = []
for i in range(7): # main + 6 neighbors for i in range(7): # main + 6 neighbors
if len(info) < pos + 5: if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6
break break
station, consumed = self.parse_lbs_station(info, pos) station, consumed = self.parse_lbs_station(info, pos)
station["is_main"] = (i == 0) station["is_main"] = (i == 0)
@@ -614,7 +615,7 @@ class PacketParser:
stations: List[Dict[str, Any]] = [] stations: List[Dict[str, Any]] = []
for i in range(7): for i in range(7):
if len(info) < pos + 5: if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6
break break
station, consumed = self.parse_lbs_station(info, pos) station, consumed = self.parse_lbs_station(info, pos)
station["is_main"] = (i == 0) station["is_main"] = (i == 0)
@@ -807,8 +808,44 @@ class PacketParser:
return result return result
@staticmethod
def _parse_alarm_tail(info: bytes, pos: int) -> Tuple[Dict[str, Any], int]:
"""Parse common alarm tail: terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1)."""
result: Dict[str, Any] = {}
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 1:
voltage_level = info[pos]
result["voltage_level"] = voltage_level
result["voltage_name"] = VOLTAGE_LEVELS.get(voltage_level, "Unknown")
result["battery_level"] = min(voltage_level * 17, 100) if voltage_level <= 6 else None
pos += 1
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 1:
alarm_code = info[pos]
result["alarm_code"] = alarm_code
result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}")
pos += 1
if len(info) >= pos + 1:
result["language"] = info[pos]
pos += 1
return result, pos
def _parse_alarm_single_fence(self, info: bytes) -> Dict[str, Any]: def _parse_alarm_single_fence(self, info: bytes) -> Dict[str, Any]:
"""0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage(2) + gsm_signal(1) + alarm_language(2).""" """0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1)."""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
@@ -836,45 +873,19 @@ class PacketParser:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0] result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8 pos += 8
if len(info) >= pos + 1: tail, pos = self._parse_alarm_tail(info, pos)
ti = info[pos] result.update(tail)
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 2:
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
return result return result
def _parse_alarm_lbs_4g(self, info: bytes) -> Dict[str, Any]: def _parse_alarm_lbs_4g(self, info: bytes) -> Dict[str, Any]:
"""0xA5 LBS 4G Alarm: similar to 0xA3 but LBS-based.""" """0xA5 LBS 4G Alarm: NO datetime, NO GPS, NO lbs_length.
Content starts directly with MCC(2) + MNC(1-2) + LAC(4) + CellID(8)
+ terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1).
"""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0 # content starts directly with MCC
result["datetime"] = self.parse_datetime(info, pos)
pos += 6
if len(info) >= pos + 1:
result["lbs_length"] = info[pos]
pos += 1
if len(info) >= pos + 3: if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
@@ -889,85 +900,63 @@ class PacketParser:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0] result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8 pos += 8
if len(info) >= pos + 1: tail, pos = self._parse_alarm_tail(info, pos)
ti = info[pos] result.update(tail)
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
if len(info) >= pos + 1:
result["gsm_signal"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1
if len(info) >= pos + 2:
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
return result return result
def _parse_alarm_wifi(self, info: bytes) -> Dict[str, Any]: def _parse_alarm_wifi(self, info: bytes) -> Dict[str, Any]:
"""0xA9 WIFI Alarm: datetime + gps + lbs + terminal_info + voltage + gsm + wifi_count + wifi_list + alarm_language.""" """0xA9 WIFI Alarm: datetime(6) + MCC(2) + MNC(1-2) + cell_type(1) + cell_count(1)
+ [cell_stations] + timing_advance(1) + wifi_count(1) + [wifi_list] + alarm_code(1) + language(1).
No GPS block, no lbs_length. Independent format.
"""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
result["datetime"] = self.parse_datetime(info, pos) result["datetime"] = self.parse_datetime(info, pos)
pos += 6 pos += 6
if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos)
pos += 12
if len(info) >= pos + 1:
result["lbs_length"] = info[pos]
pos += 1
if len(info) >= pos + 3: if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
result.update(mcc_mnc) result.update(mcc_mnc)
pos += consumed pos += consumed
if len(info) >= pos + 4: # cell_type(1) + cell_count(1)
result["lac"] = struct.unpack_from("!I", info, pos)[0] cell_type = 0 # 0=2G, 1=4G
pos += 4 cell_count = 0
if len(info) >= pos + 8:
result["cell_id"] = struct.unpack_from("!Q", info, pos)[0]
pos += 8
if len(info) >= pos + 1:
ti = info[pos]
result["terminal_info"] = ti
result["terminal_info_bits"] = {
"oil_electricity_connected": bool(ti & 0x80),
"gps_tracking_on": bool(ti & 0x40),
"alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"),
"charging": bool(ti & 0x04),
"acc_on": bool(ti & 0x02),
"armed": bool(ti & 0x01),
}
pos += 1
if len(info) >= pos + 2: if len(info) >= pos + 2:
result["voltage"] = struct.unpack_from("!H", info, pos)[0] cell_type = info[pos]
cell_count = info[pos + 1]
result["cell_type"] = cell_type
result["cell_count"] = cell_count
pos += 2 pos += 2
# Parse cell stations
stations: List[Dict[str, Any]] = []
for i in range(cell_count):
if cell_type == 1: # 4G: LAC(4) + CI(8) + RSSI(1) = 13 bytes
if len(info) < pos + 13:
break
station, consumed = self.parse_lbs_station(info, pos, lac_size=4, cell_id_size=8)
stations.append(station)
pos += consumed
else: # 2G: LAC(2) + CI(3) + RSSI(1) = 6 bytes
if len(info) < pos + 6:
break
lac_val = struct.unpack_from("!H", info, pos)[0]
ci_val = int.from_bytes(info[pos + 2:pos + 5], "big")
rssi_val = info[pos + 5]
stations.append({"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val})
pos += 6
result["stations"] = stations
# timing_advance(1)
if len(info) >= pos + 1: if len(info) >= pos + 1:
result["gsm_signal"] = info[pos] result["timing_advance"] = info[pos]
result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown")
pos += 1 pos += 1
# WiFi APs: wifi_count(1) + [mac(6) + signal(1)]*N
if len(info) >= pos + 1: if len(info) >= pos + 1:
wifi_count = info[pos] wifi_count = info[pos]
result["wifi_count"] = wifi_count result["wifi_count"] = wifi_count
@@ -977,39 +966,85 @@ class PacketParser:
result["wifi_list"] = wifi_list result["wifi_list"] = wifi_list
pos += consumed pos += consumed
if len(info) >= pos + 2: # alarm_code(1) + language(1)
result["alarm_language"] = struct.unpack_from("!H", info, pos)[0] if len(info) >= pos + 1:
pos += 2 alarm_code = info[pos]
result["alarm_code"] = alarm_code
result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}")
pos += 1
if len(info) >= pos + 1:
result["language"] = info[pos]
pos += 1
return result return result
def _parse_attendance(self, info: bytes) -> Dict[str, Any]: def _parse_attendance(self, info: bytes) -> Dict[str, Any]:
"""0xB0 Attendance: GPS + WIFI + LBS combined attendance data.""" """0xB0 Attendance: datetime(6) + gps_positioned(1) + reserved(2) + GPS(12)
+ terminal_info(1) + voltage_level(1) + gsm_signal(1) + reserved_ext(2)
+ MCC/MNC + 7 stations(LAC2+CI3+RSSI) + TA(1) + wifi_count(1) + wifi_list.
"""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
result["datetime"] = self.parse_datetime(info, pos) result["datetime"] = self.parse_datetime(info, pos)
pos += 6 pos += 6
# GPS data # GPS positioned flag (1 byte)
if len(info) > pos:
result["gps_positioned"] = info[pos] == 1
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
# GPS data (12 bytes)
if len(info) >= pos + 12: if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos) result["gps_info"] = self.parse_gps(info, pos)
pos += 12 pos += 12
# LBS data # Terminal info (1 byte) - clock_in/clock_out
if len(info) > pos:
ti = info[pos]
result["terminal_info"] = ti
status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown")
pos += 1
# Voltage level (1 byte)
if len(info) > pos:
vl = info[pos]
result["voltage_level"] = vl
result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None
pos += 1
# GSM signal (1 byte)
if len(info) > pos:
result["gsm_signal"] = info[pos]
pos += 1
# Reserved extension (2 bytes)
if len(info) >= pos + 2:
pos += 2
# LBS: MCC/MNC
if len(info) >= pos + 3: if len(info) >= pos + 3:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) mcc_mnc, consumed = self.parse_mcc_mnc(info, pos)
result.update(mcc_mnc) result.update(mcc_mnc)
pos += consumed pos += consumed
# 7 stations: LAC(2) + CI(3) + RSSI(1) = 6 bytes each for 2G
stations: List[Dict[str, Any]] = [] stations: List[Dict[str, Any]] = []
for i in range(7): for i in range(7):
if len(info) < pos + 5: if len(info) < pos + 6:
break break
station, consumed = self.parse_lbs_station(info, pos) lac_val = struct.unpack_from("!H", info, pos)[0]
station["is_main"] = (i == 0) ci_val = int.from_bytes(info[pos + 2:pos + 5], "big")
rssi_val = info[pos + 5]
station = {"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val, "is_main": (i == 0)}
stations.append(station) stations.append(station)
pos += consumed pos += 6
result["stations"] = stations result["stations"] = stations
@@ -1027,31 +1062,66 @@ class PacketParser:
result["wifi_list"] = wifi_list result["wifi_list"] = wifi_list
pos += consumed pos += consumed
# Attendance-specific trailing data
if pos < len(info):
result["attendance_data"] = info[pos:]
return result return result
def _parse_attendance_4g(self, info: bytes) -> Dict[str, Any]: def _parse_attendance_4g(self, info: bytes) -> Dict[str, Any]:
"""0xB1 Attendance 4G: 4G version of attendance.""" """0xB1 Attendance 4G: same layout as 0xB0 but MNC=2B fixed, LAC=4B, CI=8B."""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
result["datetime"] = self.parse_datetime(info, pos) result["datetime"] = self.parse_datetime(info, pos)
pos += 6 pos += 6
# GPS data # GPS positioned flag (1 byte)
if len(info) > pos:
result["gps_positioned"] = info[pos] == 1
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
# GPS data (12 bytes)
if len(info) >= pos + 12: if len(info) >= pos + 12:
result["gps_info"] = self.parse_gps(info, pos) result["gps_info"] = self.parse_gps(info, pos)
pos += 12 pos += 12
# LBS data (4G variant) # Terminal info (1 byte) - clock_in/clock_out
if len(info) >= pos + 3: if len(info) > pos:
mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) ti = info[pos]
result.update(mcc_mnc) result["terminal_info"] = ti
pos += consumed status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown")
pos += 1
# Voltage level (1 byte)
if len(info) > pos:
vl = info[pos]
result["voltage_level"] = vl
result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None
pos += 1
# GSM signal (1 byte)
if len(info) > pos:
result["gsm_signal"] = info[pos]
pos += 1
# Reserved extension (2 bytes)
if len(info) >= pos + 2:
pos += 2
# 4G LBS: MCC(2, clear high bit) + MNC(2, fixed) + LAC(4) + CI(8)
if len(info) >= pos + 2:
mcc_raw = struct.unpack_from("!H", info, pos)[0]
result["mcc"] = mcc_raw & 0x7FFF
pos += 2
if len(info) >= pos + 2:
result["mnc"] = struct.unpack_from("!H", info, pos)[0]
result["mnc_2byte"] = True
pos += 2
# 7 stations: LAC(4) + CI(8) + RSSI(1) = 13 bytes each
stations: List[Dict[str, Any]] = [] stations: List[Dict[str, Any]] = []
for i in range(7): for i in range(7):
if len(info) < pos + 13: if len(info) < pos + 13:
@@ -1079,13 +1149,10 @@ class PacketParser:
result["wifi_list"] = wifi_list result["wifi_list"] = wifi_list
pos += consumed pos += consumed
if pos < len(info):
result["attendance_data"] = info[pos:]
return result return result
def _parse_bt_punch(self, info: bytes) -> Dict[str, Any]: def _parse_bt_punch(self, info: bytes) -> Dict[str, Any]:
"""0xB2 BT Punch: bluetooth punch card data.""" """0xB2 BT Punch: datetime(6) + RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + TerminalInfo(1) + Reserved(2)."""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
@@ -1093,14 +1160,63 @@ class PacketParser:
result["datetime"] = self.parse_datetime(info, pos) result["datetime"] = self.parse_datetime(info, pos)
pos += 6 pos += 6
# Remaining is BT punch-specific payload # RSSI (1 byte, signed)
if pos < len(info): if len(info) > pos:
result["bt_data"] = info[pos:] result["rssi"] = struct.unpack_from("b", info, pos)[0]
pos += 1
# MAC address (6 bytes)
if len(info) >= pos + 6:
result["beacon_mac"] = ":".join(f"{b:02X}" for b in info[pos:pos + 6])
pos += 6
# UUID (16 bytes)
if len(info) >= pos + 16:
uuid_bytes = info[pos:pos + 16]
result["beacon_uuid"] = (
f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-"
f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-"
f"{uuid_bytes[10:16].hex()}"
).upper()
pos += 16
# Major (2 bytes)
if len(info) >= pos + 2:
result["beacon_major"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# Minor (2 bytes)
if len(info) >= pos + 2:
result["beacon_minor"] = struct.unpack_from("!H", info, pos)[0]
pos += 2
# Beacon battery (2 bytes, unit 0.01V)
if len(info) >= pos + 2:
raw_batt = struct.unpack_from("!H", info, pos)[0]
result["beacon_battery"] = raw_batt * 0.01
result["beacon_battery_unit"] = "V"
pos += 2
# Terminal info (1 byte) - clock_in/clock_out
if len(info) > pos:
ti = info[pos]
result["terminal_info"] = ti
status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK
result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "clock_in")
pos += 1
# Terminal reserved (2 bytes)
if len(info) >= pos + 2:
result["terminal_reserved"] = info[pos:pos + 2]
pos += 2
return result return result
def _parse_bt_location(self, info: bytes) -> Dict[str, Any]: def _parse_bt_location(self, info: bytes) -> Dict[str, Any]:
"""0xB3 BT Location: bluetooth location data.""" """0xB3 BT Location: datetime(6) + beacon_count(1) + per-beacon(30 bytes each).
Per beacon: RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + BattUnit(1) = 30 bytes.
"""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
pos = 0 pos = 0
@@ -1108,8 +1224,101 @@ class PacketParser:
result["datetime"] = self.parse_datetime(info, pos) result["datetime"] = self.parse_datetime(info, pos)
pos += 6 pos += 6
if pos < len(info): beacon_count = 0
result["bt_data"] = info[pos:] if len(info) > pos:
beacon_count = info[pos]
result["beacon_count"] = beacon_count
pos += 1
beacons: List[Dict[str, Any]] = []
for _ in range(beacon_count):
if len(info) < pos + 30:
break
rssi = struct.unpack_from("b", info, pos)[0]
pos += 1
mac = ":".join(f"{b:02X}" for b in info[pos:pos + 6])
pos += 6
uuid_bytes = info[pos:pos + 16]
uuid_str = (
f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-"
f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-"
f"{uuid_bytes[10:16].hex()}"
).upper()
pos += 16
major = struct.unpack_from("!H", info, pos)[0]
pos += 2
minor = struct.unpack_from("!H", info, pos)[0]
pos += 2
raw_batt = struct.unpack_from("!H", info, pos)[0]
pos += 2
batt_unit_byte = info[pos]
pos += 1
if batt_unit_byte == 0:
battery_val = raw_batt * 0.01
battery_unit = "V"
else:
battery_val = float(raw_batt)
battery_unit = "%"
beacons.append({
"rssi": rssi,
"mac": mac,
"uuid": uuid_str,
"major": major,
"minor": minor,
"battery": battery_val,
"battery_unit": battery_unit,
})
result["beacons"] = beacons
return result
def _parse_alarm_multi_fence(self, info: bytes) -> Dict[str, Any]:
"""0xA4 Multi Fence Alarm: same as 0xA3 + fence_id(1) at the end."""
result = self._parse_alarm_single_fence(info)
# After the standard alarm fields, 0xA4 has an extra fence_id byte
# We need to re-parse to find the fence_id position
# The simplest approach: fence_id is the last unparsed byte
# Since _parse_alarm_single_fence consumed up to language(1),
# the fence_id follows it. Calculate position from the end.
# Format ends with: ...alarm_code(1) + language(1) + fence_id(1)
if len(info) > 0:
result["fence_id"] = info[-1]
return result
def _parse_online_cmd_reply(self, info: bytes) -> Dict[str, Any]:
"""0x81 Online Command Reply: length(1) + server_flag(4) + content(N) + language(2)."""
result: Dict[str, Any] = {}
if len(info) < 1:
return result
result["cmd_length"] = info[0]
pos = 1
if len(info) >= pos + 4:
result["server_flag"] = struct.unpack_from("!I", info, pos)[0]
pos += 4
# Content is between server_flag and language(2 bytes at end)
if len(info) > pos + 2:
try:
result["response_content"] = info[pos:-2].decode("utf-8", errors="replace")
except Exception:
result["response_content"] = info[pos:-2].hex()
result["language"] = struct.unpack_from("!H", info, len(info) - 2)[0]
elif len(info) > pos:
try:
result["response_content"] = info[pos:].decode("utf-8", errors="replace")
except Exception:
result["response_content"] = info[pos:].hex()
return result return result
@@ -1133,8 +1342,10 @@ class PacketParser:
PROTO_LBS_4G: _parse_lbs_4g, PROTO_LBS_4G: _parse_lbs_4g,
PROTO_WIFI_4G: _parse_wifi_4g, PROTO_WIFI_4G: _parse_wifi_4g,
PROTO_ALARM_SINGLE_FENCE: _parse_alarm_single_fence, PROTO_ALARM_SINGLE_FENCE: _parse_alarm_single_fence,
PROTO_ALARM_MULTI_FENCE: _parse_alarm_multi_fence,
PROTO_ALARM_LBS_4G: _parse_alarm_lbs_4g, PROTO_ALARM_LBS_4G: _parse_alarm_lbs_4g,
PROTO_ALARM_WIFI: _parse_alarm_wifi, PROTO_ALARM_WIFI: _parse_alarm_wifi,
PROTO_ONLINE_CMD_REPLY: _parse_online_cmd_reply,
PROTO_ATTENDANCE: _parse_attendance, PROTO_ATTENDANCE: _parse_attendance,
PROTO_ATTENDANCE_4G: _parse_attendance_4g, PROTO_ATTENDANCE_4G: _parse_attendance_4g,
PROTO_BT_PUNCH: _parse_bt_punch, PROTO_BT_PUNCH: _parse_bt_punch,

View File

@@ -5,11 +5,14 @@ API endpoints for alarm record queries, acknowledgement, and statistics.
import math import math
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, select
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 AlarmRecord from app.models import AlarmRecord
from app.schemas import ( from app.schemas import (
@@ -30,16 +33,18 @@ router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
async def list_alarms( async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"), device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"), alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"), acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
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)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"), page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取报警记录列表,支持按设备、报警类型、确认状态、时间范围过滤。 获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, acknowledged status, and time range. List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
""" """
query = select(AlarmRecord) query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id)) count_query = select(func.count(AlarmRecord.id))
@@ -52,6 +57,10 @@ async def list_alarms(
query = query.where(AlarmRecord.alarm_type == alarm_type) query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type) count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None: if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged) query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged) count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
@@ -68,7 +77,8 @@ async def list_alarms(
total = total_result.scalar() or 0 total = total_result.scalar() or 0
offset = (page - 1) * page_size offset = (page - 1) * page_size
query = query.order_by(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size) order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query) result = await db.execute(query)
alarms = list(result.scalars().all()) alarms = list(result.scalars().all())
@@ -144,6 +154,7 @@ async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"/{alarm_id}/acknowledge", "/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse], response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm", summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
) )
async def acknowledge_alarm( async def acknowledge_alarm(
alarm_id: int, alarm_id: int,

142
app/routers/api_keys.py Normal file
View File

@@ -0,0 +1,142 @@
"""
API Keys Router - API密钥管理接口
Endpoints for creating, listing, updating, and deactivating API keys.
Admin permission required.
"""
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import require_admin, _hash_key
from app.models import ApiKey
from app.schemas import (
APIResponse,
ApiKeyCreate,
ApiKeyCreateResponse,
ApiKeyResponse,
ApiKeyUpdate,
PaginatedList,
)
import math
router = APIRouter(prefix="/api/keys", tags=["API Keys / 密钥管理"])
def _generate_key() -> str:
"""Generate a random 32-char hex API key."""
return secrets.token_hex(16)
@router.get(
"",
response_model=APIResponse[PaginatedList[ApiKeyResponse]],
summary="列出API密钥 / List API keys",
dependencies=[Depends(require_admin)],
)
async def list_keys(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""列出所有API密钥不返回密钥值/ List all API keys (key values are never shown)."""
count_result = await db.execute(select(func.count(ApiKey.id)))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(ApiKey).order_by(ApiKey.created_at.desc()).offset(offset).limit(page_size)
)
keys = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[ApiKeyResponse.model_validate(k) for k in keys],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.post(
"",
response_model=APIResponse[ApiKeyCreateResponse],
status_code=201,
summary="创建API密钥 / Create API key",
dependencies=[Depends(require_admin)],
)
async def create_key(body: ApiKeyCreate, db: AsyncSession = Depends(get_db)):
"""创建新的API密钥。明文密钥仅在创建时返回一次。
Create a new API key. The plaintext key is returned only once."""
raw_key = _generate_key()
key_hash = _hash_key(raw_key)
db_key = ApiKey(
key_hash=key_hash,
name=body.name,
permissions=body.permissions,
)
db.add(db_key)
await db.flush()
await db.refresh(db_key)
# Build response with plaintext key included (shown once)
base_data = ApiKeyResponse.model_validate(db_key).model_dump()
base_data["key"] = raw_key
response_data = ApiKeyCreateResponse(**base_data)
return APIResponse(
message="API key created. Store the key securely — it won't be shown again. / API密钥已创建请妥善保管",
data=response_data,
)
@router.put(
"/{key_id}",
response_model=APIResponse[ApiKeyResponse],
summary="更新API密钥 / Update API key",
dependencies=[Depends(require_admin)],
)
async def update_key(
key_id: int, body: ApiKeyUpdate, db: AsyncSession = Depends(get_db)
):
"""更新API密钥的名称、权限或激活状态 / Update key name, permissions, or active status."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_key, field, value)
await db.flush()
await db.refresh(db_key)
return APIResponse(
message="API key updated / 密钥已更新",
data=ApiKeyResponse.model_validate(db_key),
)
@router.delete(
"/{key_id}",
response_model=APIResponse,
summary="停用API密钥 / Deactivate API key",
dependencies=[Depends(require_admin)],
)
async def deactivate_key(key_id: int, db: AsyncSession = Depends(get_db)):
"""停用API密钥软删除/ Deactivate an API key (soft delete)."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
db_key.is_active = False
await db.flush()
return APIResponse(message="API key deactivated / 密钥已停用")

View File

@@ -8,6 +8,8 @@ import math
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
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.schemas import ( from app.schemas import (
APIResponse, APIResponse,
@@ -64,6 +66,7 @@ async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BeaconConfigResponse], response_model=APIResponse[BeaconConfigResponse],
status_code=201, status_code=201,
summary="添加信标 / Create beacon", summary="添加信标 / Create beacon",
dependencies=[Depends(require_write)],
) )
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)): async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac) existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
@@ -77,6 +80,7 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
"/{beacon_id}", "/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse], response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon", summary="更新信标 / Update beacon",
dependencies=[Depends(require_write)],
) )
async def update_beacon( async def update_beacon(
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db) beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
@@ -91,6 +95,7 @@ async def update_beacon(
"/{beacon_id}", "/{beacon_id}",
response_model=APIResponse, response_model=APIResponse,
summary="删除信标 / Delete beacon", summary="删除信标 / Delete beacon",
dependencies=[Depends(require_write)],
) )
async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)): async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id) success = await beacon_service.delete_beacon(db, beacon_id)

View File

@@ -5,6 +5,7 @@ API endpoints for querying Bluetooth punch and location records.
import math import math
from datetime import datetime from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -30,15 +31,17 @@ router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
async def list_bluetooth_records( async def list_bluetooth_records(
device_id: int | None = Query(default=None, description="设备ID / Device ID"), device_id: int | None = Query(default=None, description="设备ID / Device ID"),
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"), record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
beacon_mac: str | None = Query(default=None, description="信标MAC / Beacon MAC filter"),
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)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"), page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取蓝牙数据记录列表,支持按设备、记录类型、时间范围过滤。 获取蓝牙数据记录列表,支持按设备、记录类型、信标MAC、时间范围过滤。
List Bluetooth records with filters for device, record type, and time range. List Bluetooth records with filters for device, record type, beacon MAC, and time range.
""" """
query = select(BluetoothRecord) query = select(BluetoothRecord)
count_query = select(func.count(BluetoothRecord.id)) count_query = select(func.count(BluetoothRecord.id))
@@ -51,6 +54,10 @@ async def list_bluetooth_records(
query = query.where(BluetoothRecord.record_type == record_type) query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type) count_query = count_query.where(BluetoothRecord.record_type == record_type)
if beacon_mac:
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
count_query = count_query.where(BluetoothRecord.beacon_mac == beacon_mac)
if start_time: if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time) query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time) count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
@@ -63,7 +70,8 @@ async def list_bluetooth_records(
total = total_result.scalar() or 0 total = total_result.scalar() or 0
offset = (page - 1) * page_size offset = (page - 1) * page_size
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size) order = BluetoothRecord.recorded_at.asc() if sort_order == "asc" else BluetoothRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query) result = await db.execute(query)
records = list(result.scalars().all()) records = list(result.scalars().all())

View File

@@ -5,6 +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 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
@@ -21,6 +22,7 @@ from app.schemas import (
CommandResponse, CommandResponse,
PaginatedList, PaginatedList,
) )
from app.dependencies import require_write
from app.services import command_service, device_service from app.services import command_service, device_service
from app.services import tcp_command_service from app.services import tcp_command_service
@@ -127,6 +129,7 @@ async def _send_to_device(
) )
command_log.status = "sent" command_log.status = "sent"
command_log.sent_at = datetime.now(timezone.utc)
await db.flush() await db.flush()
await db.refresh(command_log) await db.refresh(command_log)
@@ -148,17 +151,18 @@ async def _send_to_device(
) )
async def list_commands( async def list_commands(
device_id: int | None = Query(default=None, description="设备ID / Device ID"), device_id: int | None = Query(default=None, description="设备ID / Device ID"),
command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"),
status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"), status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"), page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取指令历史记录,支持按设备和状态过滤。 获取指令历史记录,支持按设备、指令类型和状态过滤。
List command history with optional device and status filters. List command history with optional device, command type and status filters.
""" """
commands, total = await command_service.get_commands( commands, total = await command_service.get_commands(
db, device_id=device_id, status=status, page=page, page_size=page_size db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size
) )
return APIResponse( return APIResponse(
data=PaginatedList( data=PaginatedList(
@@ -176,6 +180,7 @@ async def list_commands(
response_model=APIResponse[CommandResponse], response_model=APIResponse[CommandResponse],
status_code=201, status_code=201,
summary="发送指令 / Send command to device", summary="发送指令 / Send command to device",
dependencies=[Depends(require_write)],
) )
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)): async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
""" """
@@ -201,6 +206,7 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse], response_model=APIResponse[CommandResponse],
status_code=201, status_code=201,
summary="发送留言 / Send message to device (0x82)", summary="发送留言 / Send message to device (0x82)",
dependencies=[Depends(require_write)],
) )
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)): async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
""" """
@@ -223,6 +229,7 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse], response_model=APIResponse[CommandResponse],
status_code=201, status_code=201,
summary="语音下发 / Send TTS voice broadcast to device", summary="语音下发 / Send TTS voice broadcast to device",
dependencies=[Depends(require_write)],
) )
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)): async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
""" """
@@ -249,6 +256,7 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BatchCommandResponse], response_model=APIResponse[BatchCommandResponse],
status_code=201, status_code=201,
summary="批量发送指令 / Batch send command to multiple devices", summary="批量发送指令 / Batch send command to multiple devices",
dependencies=[Depends(require_write)],
) )
@limiter.limit(settings.RATE_LIMIT_WRITE) @limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)): async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
@@ -282,6 +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)
await db.flush() await db.flush()
await db.refresh(cmd_log) await db.refresh(cmd_log)
results.append(BatchCommandResult( results.append(BatchCommandResult(

View File

@@ -22,8 +22,10 @@ from app.schemas import (
PaginatedList, PaginatedList,
) )
from app.config import settings from app.config import settings
from app.dependencies import require_write
from app.extensions import limiter from app.extensions import limiter
from app.services import device_service from app.schemas import LocationRecordResponse
from app.services import device_service, location_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"]) router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@@ -88,11 +90,26 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
return APIResponse(data=DeviceResponse.model_validate(device)) return APIResponse(data=DeviceResponse.model_validate(device))
@router.get(
"/all-latest-locations",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取所有在线设备位置 / Get all online device locations",
)
async def all_latest_locations(db: AsyncSession = Depends(get_db)):
"""
获取所有在线设备的最新位置,用于地图总览。
Get latest location for all online devices, for map overview.
"""
records = await location_service.get_all_online_latest_locations(db)
return APIResponse(data=[LocationRecordResponse.model_validate(r) for r in records])
@router.post( @router.post(
"/batch", "/batch",
response_model=APIResponse[BatchDeviceCreateResponse], response_model=APIResponse[BatchDeviceCreateResponse],
status_code=201, status_code=201,
summary="批量创建设备 / Batch create devices", summary="批量创建设备 / Batch create devices",
dependencies=[Depends(require_write)],
) )
@limiter.limit(settings.RATE_LIMIT_WRITE) @limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)): async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
@@ -118,6 +135,7 @@ async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest,
"/batch", "/batch",
response_model=APIResponse[dict], response_model=APIResponse[dict],
summary="批量更新设备 / Batch update devices", summary="批量更新设备 / Batch update devices",
dependencies=[Depends(require_write)],
) )
@limiter.limit(settings.RATE_LIMIT_WRITE) @limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)): async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
@@ -138,6 +156,7 @@ async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest,
"/batch-delete", "/batch-delete",
response_model=APIResponse[dict], response_model=APIResponse[dict],
summary="批量删除设备 / Batch delete devices", summary="批量删除设备 / Batch delete devices",
dependencies=[Depends(require_write)],
) )
@limiter.limit(settings.RATE_LIMIT_WRITE) @limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_delete_devices( async def batch_delete_devices(
@@ -179,6 +198,7 @@ async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[DeviceResponse], response_model=APIResponse[DeviceResponse],
status_code=201, status_code=201,
summary="创建设备 / Create device", summary="创建设备 / Create device",
dependencies=[Depends(require_write)],
) )
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)): async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
""" """
@@ -201,6 +221,7 @@ async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(ge
"/{device_id}", "/{device_id}",
response_model=APIResponse[DeviceResponse], response_model=APIResponse[DeviceResponse],
summary="更新设备信息 / Update device", summary="更新设备信息 / Update device",
dependencies=[Depends(require_write)],
) )
async def update_device( async def update_device(
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db) device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
@@ -219,6 +240,7 @@ async def update_device(
"/{device_id}", "/{device_id}",
response_model=APIResponse, response_model=APIResponse,
summary="删除设备 / Delete device", summary="删除设备 / Delete device",
dependencies=[Depends(require_write)],
) )
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)): async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
""" """

55
app/routers/geocoding.py Normal file
View File

@@ -0,0 +1,55 @@
"""Geocoding proxy endpoints — keeps AMAP_KEY server-side."""
from fastapi import APIRouter, Query
import httpx
from app.config import settings
from app.geocoding import reverse_geocode, _amap_sign, AMAP_KEY
router = APIRouter(prefix="/api/geocode", tags=["geocoding"])
@router.get("/search")
async def search_location(
keyword: str = Query(..., min_length=1, max_length=100),
city: str = Query(default="", max_length=50),
):
"""Proxy for Amap POI text search. Returns GCJ-02 coordinates."""
if not AMAP_KEY:
return {"results": []}
params = {
"key": AMAP_KEY,
"keywords": keyword,
"output": "json",
"offset": "10",
"page": "1",
}
if city:
params["city"] = city
sig = _amap_sign(params)
if sig:
params["sig"] = sig
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get("https://restapi.amap.com/v3/place/text", params=params)
data = resp.json()
if data.get("status") != "1":
return {"results": []}
results = []
for poi in data.get("pois", [])[:10]:
if poi.get("location"):
results.append({
"name": poi.get("name", ""),
"address": poi.get("address", ""),
"location": poi["location"], # "lng,lat" in GCJ-02
})
return {"results": results}
@router.get("/reverse")
async def reverse_geocode_endpoint(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Reverse geocode WGS-84 coords to address via Amap."""
address = await reverse_geocode(lat, lon)
return {"address": address or ""}

View File

@@ -6,7 +6,7 @@ API endpoints for querying location records and device tracks.
import math import math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -85,6 +85,27 @@ async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
return APIResponse(data=LocationRecordResponse.model_validate(record)) return APIResponse(data=LocationRecordResponse.model_validate(record))
@router.post(
"/batch-latest",
response_model=APIResponse[list[LocationRecordResponse | None]],
summary="批量获取设备最新位置 / Batch get latest locations",
)
async def batch_latest_locations(
device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True),
db: AsyncSession = Depends(get_db),
):
"""
传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。
Pass device_ids list, returns latest location per device in input order.
"""
records = await location_service.get_batch_latest_locations(db, device_ids)
result_map = {r.device_id: r for r in records}
return APIResponse(data=[
LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None
for did in device_ids
])
@router.get( @router.get(
"/track/{device_id}", "/track/{device_id}",
response_model=APIResponse[list[LocationRecordResponse]], response_model=APIResponse[list[LocationRecordResponse]],

81
app/routers/ws.py Normal file
View File

@@ -0,0 +1,81 @@
"""
WebSocket Router - WebSocket 实时推送接口
Real-time data push via WebSocket with topic subscriptions.
"""
import logging
import secrets
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.config import settings
from app.websocket_manager import ws_manager, VALID_TOPICS
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket / 实时推送"])
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
api_key: str | None = Query(default=None, alias="api_key"),
topics: str | None = Query(default=None, description="Comma-separated topics"),
):
"""
WebSocket endpoint for real-time data push.
Connect: ws://host/ws?api_key=xxx&topics=location,alarm
Topics: location, alarm, device_status, attendance, bluetooth
If no topics specified, subscribes to all.
"""
# Authenticate
if settings.API_KEY is not None:
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
# For DB keys, do a simple hash check
if api_key is not None:
from app.dependencies import _hash_key
from app.database import async_session
from sqlalchemy import select
from app.models import ApiKey
try:
async with async_session() as session:
key_hash = _hash_key(api_key)
result = await session.execute(
select(ApiKey.id).where(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True, # noqa: E712
)
)
if result.scalar_one_or_none() is None:
await websocket.close(code=4001, reason="Invalid API key")
return
except Exception:
await websocket.close(code=4001, reason="Auth error")
return
else:
await websocket.close(code=4001, reason="Missing API key")
return
# Parse topics
requested_topics = set()
if topics:
requested_topics = {t.strip() for t in topics.split(",") if t.strip() in VALID_TOPICS}
if not await ws_manager.connect(websocket, requested_topics):
return
try:
# Keep connection alive, handle pings
while True:
data = await websocket.receive_text()
# Client can send "ping" to keep alive
if data.strip().lower() == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception:
logger.debug("WebSocket connection error", exc_info=True)
finally:
ws_manager.disconnect(websocket)

View File

@@ -55,7 +55,6 @@ class DeviceCreate(DeviceBase):
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100) name: str | None = Field(None, max_length=100)
status: Literal["online", "offline"] | None = Field(None, description="Device status")
iccid: str | None = Field(None, max_length=30) iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20) imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30) timezone: str | None = Field(None, max_length=30)
@@ -144,7 +143,7 @@ class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]):
class AlarmRecordBase(BaseModel): class AlarmRecordBase(BaseModel):
device_id: int device_id: int
alarm_type: str = Field(..., max_length=30) alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=10) alarm_source: str | None = Field(None, max_length=20)
protocol_number: int protocol_number: int
latitude: float | None = Field(None, ge=-90, le=90) latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180) longitude: float | None = Field(None, ge=-180, le=180)
@@ -320,7 +319,7 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
class BeaconConfigBase(BaseModel): class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)") beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)")
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID") beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major") beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor") beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称") name: str = Field(..., max_length=100, description="信标名称")
@@ -337,7 +336,7 @@ class BeaconConfigCreate(BeaconConfigBase):
class BeaconConfigUpdate(BaseModel): class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$") beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = Field(None, ge=0, le=65535) beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = Field(None, ge=0, le=65535) beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100) name: str | None = Field(None, max_length=100)
@@ -482,3 +481,35 @@ class CommandResponse(BaseModel):
class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]): class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
pass pass
# ---------------------------------------------------------------------------
# API Key schemas
# ---------------------------------------------------------------------------
class ApiKeyCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
class ApiKeyUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
permissions: Literal["read", "write", "admin"] | None = None
is_active: bool | None = None
class ApiKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
permissions: str
is_active: bool
last_used_at: datetime | None = None
created_at: datetime
class ApiKeyCreateResponse(ApiKeyResponse):
"""Returned only on creation — includes the plaintext key (shown once)."""
key: str

View File

@@ -14,30 +14,13 @@ from app.models import CommandLog
async def get_commands( async def get_commands(
db: AsyncSession, db: AsyncSession,
device_id: int | None = None, device_id: int | None = None,
command_type: str | None = None,
status: str | None = None, status: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> tuple[list[CommandLog], int]: ) -> tuple[list[CommandLog], int]:
""" """
获取指令列表(分页)/ Get paginated command logs. 获取指令列表(分页)/ Get paginated command logs.
Parameters
----------
db : AsyncSession
Database session.
device_id : int, optional
Filter by device ID.
status : str, optional
Filter by command status (pending, sent, success, failed).
page : int
Page number (1-indexed).
page_size : int
Number of items per page.
Returns
-------
tuple[list[CommandLog], int]
(list of command logs, total count)
""" """
query = select(CommandLog) query = select(CommandLog)
count_query = select(func.count(CommandLog.id)) count_query = select(func.count(CommandLog.id))
@@ -46,6 +29,10 @@ async def get_commands(
query = query.where(CommandLog.device_id == device_id) query = query.where(CommandLog.device_id == device_id)
count_query = count_query.where(CommandLog.device_id == device_id) count_query = count_query.where(CommandLog.device_id == device_id)
if command_type:
query = query.where(CommandLog.command_type == command_type)
count_query = count_query.where(CommandLog.command_type == command_type)
if status: if status:
query = query.where(CommandLog.status == status) query = query.where(CommandLog.status == status)
count_query = count_query.where(CommandLog.status == status) count_query = count_query.where(CommandLog.status == status)

View File

@@ -101,6 +101,49 @@ async def get_latest_location(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_batch_latest_locations(
db: AsyncSession, device_ids: list[int]
) -> list[LocationRecord]:
"""
批量获取多设备最新位置 / Get the most recent location for each device in the list.
Uses a subquery with MAX(id) GROUP BY device_id for efficiency.
"""
if not device_ids:
return []
# Subquery: max id per device_id
subq = (
select(func.max(LocationRecord.id).label("max_id"))
.where(LocationRecord.device_id.in_(device_ids))
.group_by(LocationRecord.device_id)
.subquery()
)
result = await db.execute(
select(LocationRecord).where(LocationRecord.id.in_(select(subq.c.max_id)))
)
return list(result.scalars().all())
async def get_all_online_latest_locations(
db: AsyncSession,
) -> list[LocationRecord]:
"""
获取所有在线设备的最新位置 / Get latest location for all online devices.
"""
from app.models import Device
# Get online device IDs
online_result = await db.execute(
select(Device.id).where(Device.status == "online")
)
online_ids = [row[0] for row in online_result.all()]
if not online_ids:
return []
return await get_batch_latest_locations(db, online_ids)
async def get_device_track( async def get_device_track(
db: AsyncSession, db: AsyncSession,
device_id: int, device_id: int,

View File

@@ -28,6 +28,9 @@
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); } .stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; } .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; } .modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; }
.beacon-search-item:hover { background: #1e3a5f; }
#addBeaconMapDiv .leaflet-pane, #editBeaconMapDiv .leaflet-pane { z-index: 0 !important; }
#addBeaconMapDiv .leaflet-control, #editBeaconMapDiv .leaflet-control { z-index: 1 !important; }
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; } .toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; } .toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; }
.toast.success { background: #059669; } .toast.success { background: #059669; }
@@ -938,7 +941,9 @@
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || data.detail || `HTTP ${response.status}`); const detail = data.detail;
const msg = data.message || (typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map(e => e.msg || JSON.stringify(e)).join('; ') : null) || `HTTP ${response.status}`;
throw new Error(msg);
} }
if (data.code !== undefined && data.code !== 0) { if (data.code !== undefined && data.code !== 0) {
throw new Error(data.message || '请求失败'); throw new Error(data.message || '请求失败');
@@ -1082,6 +1087,7 @@
} }
function closeModal() { function closeModal() {
if (typeof destroyBeaconPickerMap === 'function') destroyBeaconPickerMap();
document.getElementById('modalContainer').innerHTML = ''; document.getElementById('modalContainer').innerHTML = '';
} }
@@ -1775,6 +1781,19 @@
return [lat + dLat, lng + dLng]; return [lat + dLat, lng + dLng];
} }
function gcj02ToWgs84(gcjLat, gcjLng) {
if (_outOfChina(gcjLat, gcjLng)) return [gcjLat, gcjLng];
let dLat = _transformLat(gcjLng - 105.0, gcjLat - 35.0);
let dLng = _transformLng(gcjLng - 105.0, gcjLat - 35.0);
const radLat = gcjLat / 180.0 * Math.PI;
let magic = Math.sin(radLat);
magic = 1 - _gcj_ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI);
dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI);
return [gcjLat - dLat, gcjLng - dLng];
}
// Convert WGS-84 coords for current map provider // Convert WGS-84 coords for current map provider
function toMapCoord(lat, lng) { function toMapCoord(lat, lng) {
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng); if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
@@ -1910,6 +1929,8 @@
mapMarkers.push(marker); mapMarkers.push(marker);
locationMap.setView([mLat, mLng], 15); locationMap.setView([mLat, mLng], 15);
showToast('已显示最新位置'); showToast('已显示最新位置');
// Auto-load records table
loadLocationRecords(1);
} catch (err) { } catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error'); showToast('获取最新位置失败: ' + err.message, 'error');
} }
@@ -2225,30 +2246,127 @@
} }
} }
// ---- Beacon map picker ----
let _beaconPickerMap = null;
let _beaconPickerMarker = null;
let _beaconSearchTimeout = null;
function initBeaconPickerMap(mapDivId, latInputId, lonInputId, addrInputId, initLat, initLon) {
setTimeout(() => {
const defaultCenter = [30.605, 103.936];
const hasInit = initLat && initLon;
const wgsCenter = hasInit ? [initLat, initLon] : defaultCenter;
const [mLat, mLng] = toMapCoord(wgsCenter[0], wgsCenter[1]);
const zoom = hasInit ? 16 : 12;
_beaconPickerMap = L.map(mapDivId, {zoomControl: true}).setView([mLat, mLng], zoom);
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
subdomains: '1234', maxZoom: 18,
attribution: '&copy; 高德地图',
}).addTo(_beaconPickerMap);
if (hasInit) {
_beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
}
_beaconPickerMap.on('click', async (e) => {
const gcjLat = e.latlng.lat, gcjLng = e.latlng.lng;
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
try {
const res = await apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`);
if (res.address) document.getElementById(addrInputId).value = res.address;
} catch (_) {}
});
const syncMarker = () => {
const lat = parseFloat(document.getElementById(latInputId).value);
const lon = parseFloat(document.getElementById(lonInputId).value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
const [mLat, mLng] = toMapCoord(lat, lon);
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([mLat, mLng]);
else _beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap);
_beaconPickerMap.setView([mLat, mLng], 16);
}
};
document.getElementById(latInputId).addEventListener('change', syncMarker);
document.getElementById(lonInputId).addEventListener('change', syncMarker);
}, 150);
}
async function searchBeaconLocation(query, resultsId, latInputId, lonInputId, addrInputId) {
if (_beaconSearchTimeout) clearTimeout(_beaconSearchTimeout);
const container = document.getElementById(resultsId);
if (!query || query.length < 2) { container.innerHTML = ''; return; }
_beaconSearchTimeout = setTimeout(async () => {
try {
const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`);
if (!res.results || !res.results.length) {
container.innerHTML = '<div style="color:#9ca3af;font-size:12px;padding:8px;">无搜索结果</div>';
return;
}
container.innerHTML = res.results.map(r => {
const [lng, lat] = r.location.split(',').map(Number);
return `<div class="beacon-search-item" onclick="selectBeaconSearchResult(${lat},${lng},'${latInputId}','${lonInputId}','${addrInputId}','${resultsId}')" style="padding:6px 8px;cursor:pointer;border-bottom:1px solid #374151;font-size:13px;">
<div style="color:#93c5fd;">${escapeHtml(r.name)}</div>
<div style="color:#9ca3af;font-size:11px;">${escapeHtml(r.address || '')}</div>
</div>`;
}).join('');
} catch (_) { container.innerHTML = ''; }
}, 400);
}
function selectBeaconSearchResult(gcjLat, gcjLng, latInputId, lonInputId, addrInputId, resultsId) {
const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng);
document.getElementById(latInputId).value = wgsLat.toFixed(6);
document.getElementById(lonInputId).value = wgsLng.toFixed(6);
document.getElementById(resultsId).innerHTML = '';
if (_beaconPickerMap) {
if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]);
else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap);
_beaconPickerMap.setView([gcjLat, gcjLng], 16);
}
apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`)
.then(res => { if (res.address) document.getElementById(addrInputId).value = res.address; })
.catch(() => {});
}
function destroyBeaconPickerMap() {
if (_beaconPickerMap) { _beaconPickerMap.remove(); _beaconPickerMap = null; }
_beaconPickerMarker = null;
}
function showAddBeaconModal() { function showAddBeaconModal() {
showModal(` showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-blue-400"></i>添加蓝牙信标</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-broadcast-tower mr-2 text-blue-400"></i>添加蓝牙信标</h3>
<div class="form-group"><label>MAC 地址 <span class="text-red-400">*</span></label><input type="text" id="addBeaconMac" placeholder="AA:BB:CC:DD:EE:FF" maxlength="20"><p class="text-xs text-gray-500 mt-1">信标的蓝牙 MAC 地址,冒号分隔大写十六进制</p></div> <div class="form-group"><label>MAC 地址 <span class="text-red-400">*</span></label><input type="text" id="addBeaconMac" placeholder="AA:BB:CC:DD:EE:FF" maxlength="20"><p class="text-xs text-gray-500 mt-1">信标的蓝牙 MAC 地址,冒号分隔大写十六进制</p></div>
<div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="addBeaconName" placeholder="如: 前台大门 / A区3楼走廊"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div> <div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="addBeaconName" placeholder="如: 前台大门 / A区3楼走廊"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
<div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"></div> <div class="form-group"><label>UUID</label><input type="text" id="addBeaconUuid" placeholder="iBeacon UUID (可选)" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"></div> <div class="form-group"><label>Major</label><input type="number" id="addBeaconMajor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
<div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"></div> <div class="form-group"><label>Minor</label><input type="number" id="addBeaconMinor" placeholder="0-65535" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
<div class="form-group"><label>楼层</label><input type="text" id="addBeaconFloor" placeholder="如: 3F"></div> <div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
<div class="form-group"><label>区域</label><input type="text" id="addBeaconArea" placeholder="如: A区会议室"></div> <input type="text" id="addBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'addBeaconSearchResults','addBeaconLat','addBeaconLon','addBeaconAddress')">
<div id="addBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
</div> </div>
<div id="addBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>纬度</label><input type="number" id="addBeaconLat" step="0.000001" placeholder="如: 30.12345"></div> <div class="form-group"><label>纬度</label><input type="number" id="addBeaconLat" step="0.000001" placeholder="如: 30.12345"></div>
<div class="form-group"><label>经度</label><input type="number" id="addBeaconLon" step="0.000001" placeholder="如: 120.12345"></div> <div class="form-group"><label>经度</label><input type="number" id="addBeaconLon" step="0.000001" placeholder="如: 120.12345"></div>
</div> </div>
<div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="可选"></div> <div class="form-group"><label>详细地址</label><input type="text" id="addBeaconAddress" placeholder="点击地图或搜索自动填充"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="submitAddBeacon()"><i class="fas fa-check"></i> 确认添加</button> <button class="btn btn-primary flex-1" onclick="submitAddBeacon()"><i class="fas fa-check"></i> 确认添加</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button> <button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
</div> </div>
`); `);
initBeaconPickerMap('addBeaconMapDiv', 'addBeaconLat', 'addBeaconLon', 'addBeaconAddress', null, null);
} }
async function submitAddBeacon() { async function submitAddBeacon() {
@@ -2262,8 +2380,6 @@
const uuid = document.getElementById('addBeaconUuid').value.trim(); const uuid = document.getElementById('addBeaconUuid').value.trim();
const major = document.getElementById('addBeaconMajor').value; const major = document.getElementById('addBeaconMajor').value;
const minor = document.getElementById('addBeaconMinor').value; const minor = document.getElementById('addBeaconMinor').value;
const floor = document.getElementById('addBeaconFloor').value.trim();
const area = document.getElementById('addBeaconArea').value.trim();
const lat = document.getElementById('addBeaconLat').value; const lat = document.getElementById('addBeaconLat').value;
const lon = document.getElementById('addBeaconLon').value; const lon = document.getElementById('addBeaconLon').value;
const address = document.getElementById('addBeaconAddress').value.trim(); const address = document.getElementById('addBeaconAddress').value.trim();
@@ -2271,8 +2387,6 @@
if (uuid) body.beacon_uuid = uuid; if (uuid) body.beacon_uuid = uuid;
if (major !== '') body.beacon_major = parseInt(major); if (major !== '') body.beacon_major = parseInt(major);
if (minor !== '') body.beacon_minor = parseInt(minor); if (minor !== '') body.beacon_minor = parseInt(minor);
if (floor) body.floor = floor;
if (area) body.area = area;
if (lat !== '') body.latitude = parseFloat(lat); if (lat !== '') body.latitude = parseFloat(lat);
if (lon !== '') body.longitude = parseFloat(lon); if (lon !== '') body.longitude = parseFloat(lon);
if (address) body.address = address; if (address) body.address = address;
@@ -2292,33 +2406,39 @@
const b = await apiCall(`${API_BASE}/beacons/${id}`); const b = await apiCall(`${API_BASE}/beacons/${id}`);
showModal(` showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-edit mr-2 text-yellow-400"></i>编辑信标</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-edit mr-2 text-yellow-400"></i>编辑信标</h3>
<div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改</p></div> <div class="form-group"><label>MAC 地址</label><input type="text" value="${escapeHtml(b.beacon_mac)}" disabled style="opacity:0.5"><p class="text-xs text-gray-500 mt-1">MAC 地址不可修改,设备通过此地址匹配信标</p></div>
<div class="form-group"><label>名称</label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"></div> <div class="form-group"><label>名称 <span class="text-red-400">*</span></label><input type="text" id="editBeaconName" value="${escapeHtml(b.name || '')}"><p class="text-xs text-gray-500 mt-1">信标安装位置的描述性名称</p></div>
<div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"></div> <div class="form-group"><label>UUID</label><input type="text" id="editBeaconUuid" value="${escapeHtml(b.beacon_uuid || '')}" maxlength="36"><p class="text-xs text-gray-500 mt-1">iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据</p></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"></div> <div class="form-group"><label>Major</label><input type="number" id="editBeaconMajor" value="${b.beacon_major != null ? b.beacon_major : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Major 值,区分信标组</p></div>
<div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"></div> <div class="form-group"><label>Minor</label><input type="number" id="editBeaconMinor" value="${b.beacon_minor != null ? b.beacon_minor : ''}" min="0" max="65535"><p class="text-xs text-gray-500 mt-1">iBeacon Minor 值,区分组内信标</p></div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <p style="font-size:11px;color:#f59e0b;margin:8px 0 4px;"><i class="fas fa-map-marker-alt mr-1"></i>位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标</p>
<div class="form-group"><label>楼层</label><input type="text" id="editBeaconFloor" value="${escapeHtml(b.floor || '')}"></div> <div class="form-group"><label><i class="fas fa-search mr-1"></i>搜索位置</label>
<div class="form-group"><label>区域</label><input type="text" id="editBeaconArea" value="${escapeHtml(b.area || '')}"></div> <input type="text" id="editBeaconSearch" placeholder="输入地址或地点名称搜索..." oninput="searchBeaconLocation(this.value,'editBeaconSearchResults','editBeaconLat','editBeaconLon','editBeaconAddress')">
<div id="editBeaconSearchResults" style="max-height:120px;overflow-y:auto;background:#111827;border-radius:6px;margin-top:4px;"></div>
</div> </div>
<div id="editBeaconMapDiv" style="height:220px;border-radius:8px;margin-bottom:8px;border:1px solid #374151;"></div>
<p style="font-size:11px;color:#9ca3af;margin:-4px 0 12px;"><i class="fas fa-mouse-pointer mr-1"></i>点击地图选择位置,或手动输入坐标</p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-group"><label>纬度</label><input type="number" id="editBeaconLat" step="0.000001" value="${b.latitude != null ? b.latitude : ''}"></div> <div class="form-group"><label>纬度</label><input type="number" id="editBeaconLat" step="0.000001" value="${b.latitude != null ? b.latitude : ''}"></div>
<div class="form-group"><label>经度</label><input type="number" id="editBeaconLon" step="0.000001" value="${b.longitude != null ? b.longitude : ''}"></div> <div class="form-group"><label>经度</label><input type="number" id="editBeaconLon" step="0.000001" value="${b.longitude != null ? b.longitude : ''}"></div>
</div> </div>
<div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"></div> <div class="form-group"><label>详细地址</label><input type="text" id="editBeaconAddress" value="${escapeHtml(b.address || '')}"><p class="text-xs text-gray-500 mt-1">点击地图或搜索位置后自动填充</p></div>
<div class="form-group"><label>状态</label> <div class="form-group"><label>状态</label>
<select id="editBeaconStatus"> <select id="editBeaconStatus">
<option value="active" ${b.status === 'active' ? 'selected' : ''}>启用</option> <option value="active" ${b.status === 'active' ? 'selected' : ''}>启用</option>
<option value="inactive" ${b.status !== 'active' ? 'selected' : ''}>停用</option> <option value="inactive" ${b.status !== 'active' ? 'selected' : ''}>停用</option>
</select> </select>
<p class="text-xs text-gray-500 mt-1">停用后蓝牙记录将不再关联此信标位置</p>
</div> </div>
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="submitEditBeacon(${id})"><i class="fas fa-check"></i> 保存</button> <button class="btn btn-primary flex-1" onclick="submitEditBeacon(${id})"><i class="fas fa-check"></i> 保存</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button> <button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 取消</button>
</div> </div>
`); `);
initBeaconPickerMap('editBeaconMapDiv', 'editBeaconLat', 'editBeaconLon', 'editBeaconAddress',
b.latitude || null, b.longitude || null);
} catch (err) { } catch (err) {
showToast('加载信标信息失败: ' + err.message, 'error'); showToast('加载信标信息失败: ' + err.message, 'error');
} }
@@ -2330,8 +2450,6 @@
const uuid = document.getElementById('editBeaconUuid').value.trim(); const uuid = document.getElementById('editBeaconUuid').value.trim();
const major = document.getElementById('editBeaconMajor').value; const major = document.getElementById('editBeaconMajor').value;
const minor = document.getElementById('editBeaconMinor').value; const minor = document.getElementById('editBeaconMinor').value;
const floor = document.getElementById('editBeaconFloor').value.trim();
const area = document.getElementById('editBeaconArea').value.trim();
const lat = document.getElementById('editBeaconLat').value; const lat = document.getElementById('editBeaconLat').value;
const lon = document.getElementById('editBeaconLon').value; const lon = document.getElementById('editBeaconLon').value;
const address = document.getElementById('editBeaconAddress').value.trim(); const address = document.getElementById('editBeaconAddress').value.trim();
@@ -2341,8 +2459,6 @@
body.beacon_uuid = uuid || null; body.beacon_uuid = uuid || null;
if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null; if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null;
if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null; if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null;
body.floor = floor || null;
body.area = area || null;
if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null; if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null;
if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null; if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null;
body.address = address || null; body.address = address || null;

View File

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

89
app/websocket_manager.py Normal file
View File

@@ -0,0 +1,89 @@
"""
WebSocket Manager - WebSocket 连接管理器
Manages client connections, topic subscriptions, and broadcasting.
"""
import asyncio
import json
import logging
from datetime import datetime, timezone
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# Maximum concurrent WebSocket connections
MAX_CONNECTIONS = 100
# Valid topics
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"}
class WebSocketManager:
"""Manages WebSocket connections with topic-based subscriptions."""
def __init__(self):
# {websocket: set_of_topics}
self.active_connections: dict[WebSocket, set[str]] = {}
@property
def connection_count(self) -> int:
return len(self.active_connections)
async def connect(self, websocket: WebSocket, topics: set[str]) -> bool:
"""Accept and register a WebSocket connection. Returns False if limit reached."""
if self.connection_count >= MAX_CONNECTIONS:
await websocket.close(code=1013, reason="Max connections reached")
return False
await websocket.accept()
filtered = topics & VALID_TOPICS
self.active_connections[websocket] = filtered if filtered else VALID_TOPICS
logger.info(
"WebSocket connected (%d total), topics: %s",
self.connection_count,
self.active_connections[websocket],
)
return True
def disconnect(self, websocket: WebSocket):
"""Remove a WebSocket connection."""
self.active_connections.pop(websocket, None)
logger.info("WebSocket disconnected (%d remaining)", self.connection_count)
async def broadcast(self, topic: str, data: dict):
"""Broadcast a message to all subscribers of the given topic."""
if topic not in VALID_TOPICS:
return
message = json.dumps(
{"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()},
default=str,
ensure_ascii=False,
)
disconnected = []
# Snapshot dict to avoid RuntimeError from concurrent modification
for ws, topics in list(self.active_connections.items()):
if topic in topics:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.active_connections.pop(ws, None)
def broadcast_nonblocking(self, topic: str, data: dict):
"""Fire-and-forget broadcast (used from TCP handler context)."""
asyncio.create_task(self._safe_broadcast(topic, data))
async def _safe_broadcast(self, topic: str, data: dict):
try:
await self.broadcast(topic, data)
except Exception:
logger.exception("WebSocket broadcast error for topic %s", topic)
# Singleton instance
ws_manager = WebSocketManager()