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:
220
CLAUDE.md
220
CLAUDE.md
@@ -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不支持HTTPS,Key在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 API,LBS/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 "
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
470
app/geocoding.py
470
app/geocoding.py
@@ -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
|
||||||
|
|||||||
80
app/main.py
80
app/main.py
@@ -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}
|
||||||
|
|||||||
@@ -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})>"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
142
app/routers/api_keys.py
Normal 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 / 密钥已停用")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
55
app/routers/geocoding.py
Normal 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 ""}
|
||||||
@@ -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
81
app/routers/ws.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '© 高德地图',
|
||||||
|
}).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;
|
||||||
|
|||||||
@@ -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
89
app/websocket_manager.py
Normal 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()
|
||||||
Reference in New Issue
Block a user