Add batch management APIs, API security, rate limiting, and optimizations
- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500), POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries - Batch command: POST /api/commands/batch with model_validator mutual exclusion - API key auth (X-API-Key header, secrets.compare_digest timing-safe) - Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes) - Real client IP extraction (X-Forwarded-For / CF-Connecting-IP) - Global exception handler (no stack trace leaks, passes HTTPException through) - CORS with auto-disable credentials on wildcard origins - Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns - Heartbeats router, per-ID endpoints for locations/attendance/bluetooth - Input dedup in batch create, result ordering preserved - Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion - Device detail panel with feature toggles and command controls - Side panel for location/beacon pages with auto-select active device via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# KKS Badge Management System - Environment Configuration
|
||||||
|
# Copy to .env and customize values as needed
|
||||||
|
|
||||||
|
# Database (default: SQLite in project root)
|
||||||
|
# DATABASE_URL=sqlite+aiosqlite:///path/to/badge_admin.db
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://user:password@localhost/badge_admin
|
||||||
|
|
||||||
|
# Server ports
|
||||||
|
# TCP_PORT=5000
|
||||||
|
# API_PORT=8088
|
||||||
|
|
||||||
|
# Debug mode (default: false)
|
||||||
|
# DEBUG=true
|
||||||
|
|
||||||
|
# API authentication (uncomment to enable, all /api/ endpoints require X-API-Key header)
|
||||||
|
# API_KEY=your-secret-api-key-here
|
||||||
|
|
||||||
|
# CORS origins (comma-separated, * = allow all)
|
||||||
|
# CORS_ORIGINS=https://example.com,https://admin.example.com
|
||||||
|
|
||||||
|
# Rate limiting (format: "count/period", period = second/minute/hour/day)
|
||||||
|
# RATE_LIMIT_DEFAULT=60/minute
|
||||||
|
# RATE_LIMIT_WRITE=30/minute
|
||||||
|
|
||||||
|
# Track query max points (default: 10000)
|
||||||
|
# TRACK_MAX_POINTS=10000
|
||||||
|
|
||||||
|
# 天地图 API key (reverse geocoding, free 10k/day)
|
||||||
|
# Sign up: https://lbs.tianditu.gov.cn/
|
||||||
|
# TIANDITU_API_KEY=your_tianditu_key
|
||||||
|
|
||||||
|
# Google Geolocation API (optional, for cell/WiFi geocoding)
|
||||||
|
# GOOGLE_API_KEY=your_google_key
|
||||||
|
|
||||||
|
# Unwired Labs API (optional, for cell/WiFi geocoding)
|
||||||
|
# UNWIRED_API_TOKEN=your_unwired_token
|
||||||
|
|
||||||
|
# 高德地图 API (optional, requires enterprise auth for IoT positioning)
|
||||||
|
# AMAP_KEY=your_amap_key
|
||||||
|
# AMAP_SECRET=your_amap_secret
|
||||||
|
|
||||||
|
# Geocoding cache size
|
||||||
|
# GEOCODING_CACHE_SIZE=10000
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,5 @@ __pycache__/
|
|||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
nohup.out
|
nohup.out
|
||||||
|
.env
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
100
CLAUDE.md
100
CLAUDE.md
@@ -10,6 +10,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
```
|
```
|
||||||
/home/gpsystem/
|
/home/gpsystem/
|
||||||
├── run.py # 启动脚本 (uvicorn)
|
├── run.py # 启动脚本 (uvicorn)
|
||||||
|
├── .env.example # 环境变量模板 (复制为 .env 使用)
|
||||||
├── requirements.txt # Python 依赖
|
├── requirements.txt # Python 依赖
|
||||||
├── frpc.toml # FRP 客户端配置 (TCP隧道)
|
├── frpc.toml # FRP 客户端配置 (TCP隧道)
|
||||||
├── badge_admin.db # SQLite 数据库
|
├── badge_admin.db # SQLite 数据库
|
||||||
@@ -17,8 +18,10 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
|
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
|
||||||
│ ├── config.py # 配置 (端口8088 HTTP, 5000 TCP)
|
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
|
||||||
│ ├── database.py # SQLAlchemy async 数据库连接
|
│ ├── database.py # SQLAlchemy async 数据库连接
|
||||||
|
│ ├── dependencies.py # FastAPI 依赖 (API Key 认证)
|
||||||
|
│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取)
|
||||||
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog)
|
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog)
|
||||||
│ ├── schemas.py # Pydantic 请求/响应模型
|
│ ├── schemas.py # Pydantic 请求/响应模型
|
||||||
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
|
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
|
||||||
@@ -31,18 +34,20 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
│ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408)
|
│ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── device_service.py # 设备 CRUD
|
│ │ ├── device_service.py # 设备 CRUD
|
||||||
│ │ ├── command_service.py # 指令日志 CRUD
|
│ │ ├── command_service.py # 指令日志 CRUD
|
||||||
│ │ ├── location_service.py # 位置记录查询
|
│ │ ├── location_service.py # 位置记录查询
|
||||||
│ │ └── beacon_service.py # 蓝牙信标 CRUD
|
│ │ ├── beacon_service.py # 蓝牙信标 CRUD
|
||||||
|
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
|
||||||
│ │
|
│ │
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
│ │ ├── devices.py # /api/devices (含 /stats 统计接口)
|
│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete)
|
||||||
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts)
|
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
|
||||||
│ │ ├── locations.py # /api/locations (含 /latest, /track)
|
│ │ ├── locations.py # /api/locations (含 /latest, /track, /{id})
|
||||||
│ │ ├── alarms.py # /api/alarms (含 acknowledge)
|
│ │ ├── alarms.py # /api/alarms (含 acknowledge)
|
||||||
│ │ ├── attendance.py # /api/attendance
|
│ │ ├── attendance.py # /api/attendance (含 /stats, /{id})
|
||||||
│ │ ├── bluetooth.py # /api/bluetooth
|
│ │ ├── bluetooth.py # /api/bluetooth (含 /{id})
|
||||||
|
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
|
||||||
│ │ └── beacons.py # /api/beacons (信标管理 CRUD)
|
│ │ └── beacons.py # /api/beacons (信标管理 CRUD)
|
||||||
│ │
|
│ │
|
||||||
│ └── static/
|
│ └── static/
|
||||||
@@ -75,11 +80,18 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
- **7000**: FRP 服务端管理端口
|
- **7000**: FRP 服务端管理端口
|
||||||
- **7500**: FRP Dashboard (admin/PassWord0325)
|
- **7500**: FRP Dashboard (admin/PassWord0325)
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值
|
||||||
|
- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用
|
||||||
|
- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录)
|
||||||
|
- 所有 API 密钥 (天地图/Google/Unwired/高德) 集中在 `config.py`,`geocoding.py` 从 `settings` 导入
|
||||||
|
- 端口号有 pydantic 校验 (ge=1, le=65535)
|
||||||
|
|
||||||
## 启动服务
|
## 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动服务
|
# 启动服务
|
||||||
cd /tmp/badge-admin
|
cd /home/gpsystem
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
|
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
|
||||||
|
|
||||||
# 启动 FRP 客户端 (TCP隧道)
|
# 启动 FRP 客户端 (TCP隧道)
|
||||||
@@ -192,6 +204,19 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|||||||
- **配额**: 免费 10,000 次/天
|
- **配额**: 免费 10,000 次/天
|
||||||
- **注意**: postStr 参数需使用双引号JSON并URL编码
|
- **注意**: postStr 参数需使用双引号JSON并URL编码
|
||||||
|
|
||||||
|
### API 认证与限流
|
||||||
|
- **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头
|
||||||
|
- **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`)
|
||||||
|
- **真实 IP**: 从 `X-Forwarded-For` → `CF-Connecting-IP` → `request.client.host` 提取
|
||||||
|
- **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials`
|
||||||
|
|
||||||
|
### 批量操作 API
|
||||||
|
- `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重,结果按输入顺序
|
||||||
|
- `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush
|
||||||
|
- `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids
|
||||||
|
- `POST /api/commands/batch` — 批量发送指令 (最多100),`model_validator` 互斥校验
|
||||||
|
- 所有批量操作使用 WHERE IN 批量查询,避免 N+1
|
||||||
|
|
||||||
### API 分页
|
### API 分页
|
||||||
- page_size 最大限制为 100 (schema 层校验)
|
- page_size 最大限制为 100 (schema 层校验)
|
||||||
- 前端设备选择器使用 page_size=100 (不能超过限制)
|
- 前端设备选择器使用 page_size=100 (不能超过限制)
|
||||||
@@ -209,6 +234,7 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|||||||
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式
|
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式
|
||||||
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
|
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
|
||||||
- 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#`
|
- 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#`
|
||||||
|
- **架构**: `tcp_command_service.py` 作为抽象层解耦 routers↔tcp_server 的循环导入,通过 lazy import 访问 tcp_manager
|
||||||
- **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理
|
- **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理
|
||||||
- **重要**: 服务器启动时自动将所有设备标记为 offline,等待设备重新登录
|
- **重要**: 服务器启动时自动将所有设备标记为 offline,等待设备重新登录
|
||||||
|
|
||||||
@@ -219,7 +245,9 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|||||||
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
||||||
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
|
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
|
||||||
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功
|
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功
|
||||||
- **当前状态**: 设备接受BT指令但尚未上报0xB2/0xB3数据,可能需要信标UUID匹配或通过Tracksolid平台配置
|
- **当前状态**: ✅ 0xB2 蓝牙打卡已验证可用 (2026-03-18),设备成功检测信标并上报数据
|
||||||
|
- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641
|
||||||
|
- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空
|
||||||
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
||||||
|
|
||||||
### 0x94 General Info 子协议
|
### 0x94 General Info 子协议
|
||||||
@@ -289,6 +317,18 @@ remotePort = 5001
|
|||||||
3. 注意返回坐标为 GCJ-02,需转换为 WGS-84 用于 Leaflet 地图
|
3. 注意返回坐标为 GCJ-02,需转换为 WGS-84 用于 Leaflet 地图
|
||||||
4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数
|
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
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
|
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
|
||||||
@@ -296,7 +336,7 @@ remotePort = 5001
|
|||||||
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
|
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
|
||||||
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
|
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
|
||||||
5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km,待接入高德智能硬件定位后可达 ~30m
|
5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km,待接入高德智能硬件定位后可达 ~30m
|
||||||
6. **天地图逆地理编码使用 HTTP** - API不支持HTTPS,Key在URL中明文传输 (低风险: 免费Key)
|
6. **天地图逆地理编码使用 HTTP** - API不支持HTTPS,Key在URL中明文传输 (低风险: 免费Key, 已降级为备选)
|
||||||
|
|
||||||
## 已修复的问题 (Bug Fix 记录)
|
## 已修复的问题 (Bug Fix 记录)
|
||||||
|
|
||||||
@@ -361,6 +401,36 @@ remotePort = 5001
|
|||||||
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
|
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
|
||||||
43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档
|
43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档
|
||||||
|
|
||||||
|
### 架构优化 (2026-03-17)
|
||||||
|
44. **配置集中化** - API密钥从 geocoding.py 硬编码移至 config.py (pydantic-settings),支持 .env 覆盖
|
||||||
|
45. **数据库绝对路径** - DATABASE_URL 从相对路径改为基于 `__file__` 的绝对路径,避免 CWD 依赖
|
||||||
|
46. **tcp_command_service 抽象层** - 新建 services/tcp_command_service.py,通过 lazy import 解耦 routers↔tcp_server 循环依赖
|
||||||
|
47. **commands.py 去重** - send_command/send_message/send_tts 提取 `_send_to_device()` 通用函数
|
||||||
|
48. **协议常量扩展** - constants.py 新增 DEFAULT_DEVICE_TYPE, SERVER_FLAG_BYTES, ATTENDANCE_TYPES, COURSE_BIT_*, MCC_MNC2_FLAG, VOLTAGE_LEVELS
|
||||||
|
49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备
|
||||||
|
50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数,toggleSidePanel 仅在 locations 页调用 invalidateSize
|
||||||
|
|
||||||
|
### 百度地图接入 & 连接修复 (2026-03-19)
|
||||||
|
51. **百度逆地理编码** - 接入百度地图 reverse_geocoding/v3 API (coordtype=wgs84ll),优先于天地图
|
||||||
|
52. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError,补充 import
|
||||||
|
53. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections
|
||||||
|
54. **高德地图瓦片** - 替换天地图瓦片为高德 (GCJ-02, 标准Mercator),添加 WGS84→GCJ02 坐标转换,可切换 provider (`MAP_PROVIDER` 变量)
|
||||||
|
|
||||||
|
### API 安全加固 & 批量管理 (2026-03-20)
|
||||||
|
55. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击
|
||||||
|
56. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min)
|
||||||
|
57. **限流器真实 IP** - `extensions.py` 从 `X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP
|
||||||
|
58. **全局异常处理** - 拦截未处理异常返回 500,不泄露堆栈;放行 HTTPException/ValidationError
|
||||||
|
59. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern
|
||||||
|
60. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数
|
||||||
|
61. **批量设备创建** - `POST /api/devices/batch` (最多500台),WHERE IN 单次查询去重,输入列表内 IMEI 去重
|
||||||
|
62. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush
|
||||||
|
63. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制
|
||||||
|
64. **批量指令发送** - `POST /api/commands/batch` (最多100台),`model_validator` 互斥校验 device_ids/imeis
|
||||||
|
65. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取
|
||||||
|
66. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突
|
||||||
|
67. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh,依赖 service 层
|
||||||
|
|
||||||
### 0x94 子协议 0x04
|
### 0x94 子协议 0x04
|
||||||
- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286`
|
- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286`
|
||||||
- 在设备重连/重启后上报
|
- 在设备重连/重启后上报
|
||||||
@@ -368,9 +438,9 @@ remotePort = 5001
|
|||||||
## 待完成功能
|
## 待完成功能
|
||||||
|
|
||||||
1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov,大幅提升 WiFi/基站定位精度
|
1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov,大幅提升 WiFi/基站定位精度
|
||||||
2. **天地图瓦片底图** - 使用浏览器端 Key 替换 OpenStreetMap 瓦片 (中国地区显示更准确)
|
2. ~~**地图瓦片**~~ - ✅ 已切换为高德瓦片 (GCJ-02),支持 MAP_PROVIDER 切换 ('gaode'|'tianditu')
|
||||||
3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
|
3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
|
||||||
4. **蓝牙信标调试** - P241 接受 BTON/BTSCAN 但未上报BLE数据,需确认信标iBeacon广播格式及UUID匹配
|
4. ~~**蓝牙信标调试**~~ - ✅ 已完成 (2026-03-18),0xB2打卡数据正常上报,信标匹配成功
|
||||||
|
|
||||||
## 调试技巧
|
## 调试技巧
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
# Project root directory (where config.py lives → parent = app/ → parent = project root)
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
APP_NAME: str = "KKS Badge Management System"
|
APP_NAME: str = "KKS Badge Management System"
|
||||||
DATABASE_URL: str = "sqlite+aiosqlite:///./badge_admin.db"
|
DATABASE_URL: str = Field(
|
||||||
|
default=f"sqlite+aiosqlite:///{_DEFAULT_DB_PATH}",
|
||||||
|
description="Database connection URL (absolute path for SQLite)",
|
||||||
|
)
|
||||||
TCP_HOST: str = "0.0.0.0"
|
TCP_HOST: str = "0.0.0.0"
|
||||||
TCP_PORT: int = 5000
|
TCP_PORT: int = Field(default=5000, ge=1, le=65535)
|
||||||
API_HOST: str = "0.0.0.0"
|
API_HOST: str = "0.0.0.0"
|
||||||
API_PORT: int = 8088
|
API_PORT: int = Field(default=8088, ge=1, le=65535)
|
||||||
DEBUG: bool = True
|
DEBUG: bool = Field(default=False, description="Enable debug mode (SQL echo, verbose errors)")
|
||||||
|
|
||||||
|
# API authentication
|
||||||
|
API_KEY: str | None = Field(default=None, description="API key for authentication (None=disabled)")
|
||||||
|
CORS_ORIGINS: str = Field(default="*", description="Comma-separated allowed CORS origins")
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Geocoding API keys
|
||||||
|
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_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
|
||||||
|
BAIDU_MAP_AK: str | None = Field(default=None, description="百度地图服务端 AK")
|
||||||
|
|
||||||
|
# Geocoding cache
|
||||||
|
GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries")
|
||||||
|
|
||||||
|
# Track query limit
|
||||||
|
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
20
app/dependencies.py
Normal file
20
app/dependencies.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Shared FastAPI dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_api_key(api_key: str | None = Security(_api_key_header)):
|
||||||
|
"""Verify API key if authentication is enabled."""
|
||||||
|
if settings.API_KEY is None:
|
||||||
|
return # Auth disabled
|
||||||
|
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||||
22
app/extensions.py
Normal file
22
app/extensions.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Shared extension instances (rate limiter, etc.) to avoid circular imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from slowapi import Limiter
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_real_client_ip(request: Request) -> str:
|
||||||
|
"""Extract real client IP from X-Forwarded-For (behind Cloudflare/nginx) or fallback."""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
cf_ip = request.headers.get("CF-Connecting-IP")
|
||||||
|
if cf_ip:
|
||||||
|
return cf_ip.strip()
|
||||||
|
return request.client.host if request.client else "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=_get_real_client_ip, default_limits=[settings.RATE_LIMIT_DEFAULT])
|
||||||
@@ -10,7 +10,6 @@ Uses free APIs:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -19,19 +18,16 @@ import aiohttp
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Google Geolocation API key (set to enable Google geocoding)
|
# Import keys from centralized config (no more hardcoded values here)
|
||||||
GOOGLE_API_KEY: Optional[str] = None
|
from app.config import settings as _settings
|
||||||
|
|
||||||
# Unwired Labs API token (free tier: 100 requests/day)
|
GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY
|
||||||
# Sign up at https://unwiredlabs.com/
|
UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN
|
||||||
UNWIRED_API_TOKEN: Optional[str] = None
|
TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY
|
||||||
|
BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK
|
||||||
|
|
||||||
# 天地图 API key (free tier: 10000 requests/day)
|
# Maximum cache entries (LRU eviction) — configurable via settings
|
||||||
# Sign up at https://lbs.tianditu.gov.cn/
|
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||||||
TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc")
|
|
||||||
|
|
||||||
# Maximum cache entries (LRU eviction)
|
|
||||||
_CACHE_MAX_SIZE = 10000
|
|
||||||
|
|
||||||
|
|
||||||
class LRUCache(OrderedDict):
|
class LRUCache(OrderedDict):
|
||||||
@@ -315,7 +311,8 @@ async def reverse_geocode(
|
|||||||
"""
|
"""
|
||||||
Convert lat/lon to a human-readable address.
|
Convert lat/lon to a human-readable address.
|
||||||
|
|
||||||
Tries 天地图 (Tianditu) first, which uses WGS84 natively.
|
Priority: Baidu Map > Tianditu (fallback).
|
||||||
|
Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll).
|
||||||
Returns None if no reverse geocoding service is available.
|
Returns None if no reverse geocoding service is available.
|
||||||
"""
|
"""
|
||||||
# Round to ~100m for cache key to reduce API calls
|
# Round to ~100m for cache key to reduce API calls
|
||||||
@@ -324,6 +321,14 @@ async def reverse_geocode(
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
|
# Try Baidu Map first (higher quality addresses for China)
|
||||||
|
if BAIDU_MAP_AK:
|
||||||
|
result = await _reverse_geocode_baidu(lat, lon)
|
||||||
|
if result:
|
||||||
|
_address_cache.put(cache_key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fallback to Tianditu
|
||||||
if TIANDITU_API_KEY:
|
if TIANDITU_API_KEY:
|
||||||
result = await _reverse_geocode_tianditu(lat, lon)
|
result = await _reverse_geocode_tianditu(lat, lon)
|
||||||
if result:
|
if result:
|
||||||
@@ -333,6 +338,55 @@ async def reverse_geocode(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _reverse_geocode_baidu(
|
||||||
|
lat: float, lon: float
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Use Baidu Map reverse geocoding API.
|
||||||
|
|
||||||
|
API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding
|
||||||
|
Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed).
|
||||||
|
Free tier: 5,000 requests/day (personal developer).
|
||||||
|
"""
|
||||||
|
url = (
|
||||||
|
f"https://api.map.baidu.com/reverse_geocoding/v3/"
|
||||||
|
f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll"
|
||||||
|
f"&location={lat},{lon}"
|
||||||
|
)
|
||||||
|
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", {})
|
||||||
|
formatted = result.get("formatted_address", "")
|
||||||
|
if 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(
|
||||||
|
"Baidu reverse geocode: %.6f,%.6f -> %s",
|
||||||
|
lat, lon, address,
|
||||||
|
)
|
||||||
|
return address
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Baidu reverse geocode error: status=%s, msg=%s",
|
||||||
|
data.get("status"), data.get("message", ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Baidu reverse geocode HTTP %d", resp.status)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Baidu reverse geocode error: %s", e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _reverse_geocode_tianditu(
|
async def _reverse_geocode_tianditu(
|
||||||
lat: float, lon: float
|
lat: float, lon: float
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|||||||
92
app/main.py
92
app/main.py
@@ -1,19 +1,31 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from app.database import init_db, async_session
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
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
|
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats
|
||||||
|
from app.dependencies import verify_api_key
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rate limiter (shared instance via app.extensions for router access)
|
||||||
|
from app.extensions import limiter
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
@@ -40,7 +52,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="KKS Badge Management System / KKS工牌管理系统",
|
title="KKS Badge Management System / KKS工牌管理系统",
|
||||||
description="""
|
description="""
|
||||||
## KKS P240 & P241 蓝牙工牌管理后台
|
## KKS P240 & P241 蓝牙工牌管理后台 API
|
||||||
|
|
||||||
### 功能模块 / Features:
|
### 功能模块 / Features:
|
||||||
- **设备管理 / Device Management** - 设备注册、状态监控
|
- **设备管理 / Device Management** - 设备注册、状态监控
|
||||||
@@ -50,6 +62,10 @@ app = FastAPI(
|
|||||||
- **指令管理 / Commands** - 远程指令下发与留言
|
- **指令管理 / Commands** - 远程指令下发与留言
|
||||||
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
|
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
|
||||||
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
|
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
|
||||||
|
- **心跳数据 / Heartbeats** - 设备心跳记录查询
|
||||||
|
|
||||||
|
### 认证 / Authentication:
|
||||||
|
设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。
|
||||||
|
|
||||||
### 通讯协议 / Protocol:
|
### 通讯协议 / Protocol:
|
||||||
- TCP端口: {tcp_port} (设备连接)
|
- TCP端口: {tcp_port} (设备连接)
|
||||||
@@ -61,14 +77,48 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Rate limiter — SlowAPIMiddleware applies default_limits to all routes
|
||||||
app.include_router(devices.router)
|
app.state.limiter = limiter
|
||||||
app.include_router(locations.router)
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
app.include_router(alarms.router)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
app.include_router(attendance.router)
|
|
||||||
app.include_router(commands.router)
|
# CORS
|
||||||
app.include_router(bluetooth.router)
|
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
app.include_router(beacons.router)
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_origins,
|
||||||
|
allow_credentials="*" not in _origins,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global exception handler — prevent stack trace leaks
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
# Let FastAPI handle its own exceptions
|
||||||
|
if isinstance(exc, (StarletteHTTPException, RequestValidationError)):
|
||||||
|
raise exc
|
||||||
|
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"code": 500, "message": "Internal server error", "data": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Include API routers (all under /api/ prefix, protected by API key if configured)
|
||||||
|
_api_deps = [verify_api_key] if settings.API_KEY else []
|
||||||
|
|
||||||
|
app.include_router(devices.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(locations.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(alarms.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(attendance.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(commands.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(bluetooth.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(beacons.router, dependencies=[*_api_deps])
|
||||||
|
app.include_router(heartbeats.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")
|
||||||
@@ -90,11 +140,25 @@ async def root():
|
|||||||
"redoc": "/redoc",
|
"redoc": "/redoc",
|
||||||
"admin": "/admin",
|
"admin": "/admin",
|
||||||
"tcp_port": settings.TCP_PORT,
|
"tcp_port": settings.TCP_PORT,
|
||||||
|
"auth_enabled": settings.API_KEY is not None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["Root"])
|
@app.get("/health", tags=["Root"])
|
||||||
async def health():
|
async def health():
|
||||||
|
"""Health check with database connectivity test."""
|
||||||
|
db_ok = False
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Health check: database unreachable")
|
||||||
|
|
||||||
|
status = "healthy" if db_ok else "degraded"
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": status,
|
||||||
|
"database": "ok" if db_ok else "error",
|
||||||
"connected_devices": len(tcp_manager.connections),
|
"connected_devices": len(tcp_manager.connections),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,57 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
|
|||||||
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec
|
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device Defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_DEVICE_TYPE: str = "P240"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Language Codes (used in 0x80 / 0x82 packets)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LANG_CHINESE: int = 0x0001
|
||||||
|
LANG_ENGLISH: int = 0x0002
|
||||||
|
DEFAULT_LANGUAGE: int = LANG_CHINESE
|
||||||
|
DEFAULT_LANGUAGE_BYTES: bytes = b"\x00\x01"
|
||||||
|
|
||||||
|
# Server flag placeholder (4 bytes, used in command/message packets)
|
||||||
|
SERVER_FLAG_BYTES: bytes = b"\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Attendance Status (from terminal_info byte, bits[5:2])
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ATTENDANCE_STATUS_SHIFT: int = 2
|
||||||
|
ATTENDANCE_STATUS_MASK: int = 0x0F
|
||||||
|
ATTENDANCE_TYPES: Dict[int, str] = {
|
||||||
|
0b0001: "clock_in",
|
||||||
|
0b0010: "clock_out",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GPS Course/Status Bit Fields
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
COURSE_BIT_REALTIME: int = 0x2000 # bit 13
|
||||||
|
COURSE_BIT_POSITIONED: int = 0x1000 # bit 12
|
||||||
|
COURSE_BIT_EAST: int = 0x0800 # bit 11
|
||||||
|
COURSE_BIT_NORTH: int = 0x0400 # bit 10
|
||||||
|
COURSE_MASK: int = 0x03FF # bits 9-0
|
||||||
|
|
||||||
|
# MCC high-bit flag: if set, MNC is 2 bytes instead of 1
|
||||||
|
MCC_MNC2_FLAG: int = 0x8000
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Voltage Levels (0x00-0x06)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
VOLTAGE_LEVELS: Dict[int, str] = {
|
||||||
|
0x00: "shutdown",
|
||||||
|
0x01: "very_low",
|
||||||
|
0x02: "low",
|
||||||
|
0x03: "medium",
|
||||||
|
0x04: "good",
|
||||||
|
0x05: "high",
|
||||||
|
0x06: "full",
|
||||||
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Protocol Number -> Human-Readable Name
|
# Protocol Number -> Human-Readable Name
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -185,3 +185,20 @@ async def device_attendance(
|
|||||||
total_pages=math.ceil(total / page_size) if total else 0,
|
total_pages=math.ceil(total / page_size) if total else 0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
||||||
|
@router.get(
|
||||||
|
"/{attendance_id}",
|
||||||
|
response_model=APIResponse[AttendanceRecordResponse],
|
||||||
|
summary="获取考勤记录详情 / Get attendance record",
|
||||||
|
)
|
||||||
|
async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""按ID获取考勤记录详情 / Get attendance record details by ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||||
|
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
|
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
|
||||||
beacon = await beacon_service.create_beacon(db, body)
|
beacon = await beacon_service.create_beacon(db, body)
|
||||||
await db.commit()
|
|
||||||
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
|
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ async def update_beacon(
|
|||||||
beacon = await beacon_service.update_beacon(db, beacon_id, body)
|
beacon = await beacon_service.update_beacon(db, beacon_id, body)
|
||||||
if beacon is None:
|
if beacon is None:
|
||||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||||
await db.commit()
|
|
||||||
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
|
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
|
||||||
|
|
||||||
|
|
||||||
@@ -98,5 +96,4 @@ 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)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||||
await db.commit()
|
|
||||||
return APIResponse(message="Beacon deleted")
|
return APIResponse(message="Beacon deleted")
|
||||||
|
|||||||
@@ -88,15 +88,14 @@ async def device_bluetooth_records(
|
|||||||
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
|
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
|
||||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
page: int = Query(default=1, ge=1, description="页码"),
|
||||||
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="每页数量"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取指定设备的蓝牙数据记录。
|
获取指定设备的蓝牙数据记录。
|
||||||
Get Bluetooth records for a specific device.
|
Get Bluetooth records for a specific device.
|
||||||
"""
|
"""
|
||||||
# Verify device exists
|
|
||||||
device = await device_service.get_device(db, device_id)
|
device = await device_service.get_device(db, device_id)
|
||||||
if device is None:
|
if device is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||||
@@ -133,3 +132,20 @@ async def device_bluetooth_records(
|
|||||||
total_pages=math.ceil(total / page_size) if total else 0,
|
total_pages=math.ceil(total / page_size) if total else 0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts
|
||||||
|
@router.get(
|
||||||
|
"/{record_id}",
|
||||||
|
response_model=APIResponse[BluetoothRecordResponse],
|
||||||
|
summary="获取蓝牙记录详情 / Get bluetooth record",
|
||||||
|
)
|
||||||
|
async def get_bluetooth_record(record_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""按ID获取蓝牙记录详情 / Get bluetooth record details by ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(BluetoothRecord).where(BluetoothRecord.id == record_id)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Bluetooth record {record_id} not found")
|
||||||
|
return APIResponse(data=BluetoothRecordResponse.model_validate(record))
|
||||||
|
|||||||
@@ -3,19 +3,26 @@ Commands Router - 指令管理接口
|
|||||||
API endpoints for sending commands / messages to devices and viewing command history.
|
API endpoints for sending commands / messages to devices and viewing command history.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.config import settings
|
||||||
|
from app.extensions import limiter
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
|
BatchCommandRequest,
|
||||||
|
BatchCommandResponse,
|
||||||
|
BatchCommandResult,
|
||||||
CommandResponse,
|
CommandResponse,
|
||||||
PaginatedList,
|
PaginatedList,
|
||||||
)
|
)
|
||||||
from app.services import command_service, device_service
|
from app.services import command_service, device_service
|
||||||
|
from app.services import tcp_command_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
|
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
|
||||||
|
|
||||||
@@ -29,8 +36,8 @@ class SendCommandRequest(BaseModel):
|
|||||||
"""Request body for sending a command to a device."""
|
"""Request body for sending a command to a device."""
|
||||||
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
||||||
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
||||||
command_type: str = Field(..., max_length=30, description="指令类型 / Command type")
|
command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
|
||||||
command_content: str = Field(..., description="指令内容 / Command content")
|
command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
@@ -48,7 +55,7 @@ class SendTTSRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helper
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +85,57 @@ async def _resolve_device(
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_to_device(
|
||||||
|
db: AsyncSession,
|
||||||
|
device,
|
||||||
|
command_type: str,
|
||||||
|
command_content: str,
|
||||||
|
executor,
|
||||||
|
success_msg: str,
|
||||||
|
fail_msg: str,
|
||||||
|
):
|
||||||
|
"""Common logic for sending command/message/tts to a device via TCP.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
executor : async callable
|
||||||
|
The actual send function, e.g. tcp_command_service.send_command(...)
|
||||||
|
"""
|
||||||
|
if not tcp_command_service.is_device_online(device.imei):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||||
|
)
|
||||||
|
|
||||||
|
command_log = await command_service.create_command(
|
||||||
|
db,
|
||||||
|
device_id=device.id,
|
||||||
|
command_type=command_type,
|
||||||
|
command_content=command_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await executor()
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).error("Command send failed: %s", e)
|
||||||
|
command_log.status = "failed"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(command_log)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=fail_msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
command_log.status = "sent"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(command_log)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
message=success_msg,
|
||||||
|
data=CommandResponse.model_validate(command_log),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -126,46 +184,15 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
|
|||||||
Requires the device to be online.
|
Requires the device to be online.
|
||||||
"""
|
"""
|
||||||
device = await _resolve_device(db, body.device_id, body.imei)
|
device = await _resolve_device(db, body.device_id, body.imei)
|
||||||
|
return await _send_to_device(
|
||||||
# Import tcp_manager lazily to avoid circular imports
|
db, device,
|
||||||
from app.tcp_server import tcp_manager
|
|
||||||
|
|
||||||
# Check if device is connected
|
|
||||||
if device.imei not in tcp_manager.connections:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create command log entry
|
|
||||||
command_log = await command_service.create_command(
|
|
||||||
db,
|
|
||||||
device_id=device.id,
|
|
||||||
command_type=body.command_type,
|
command_type=body.command_type,
|
||||||
command_content=body.command_content,
|
command_content=body.command_content,
|
||||||
)
|
executor=lambda: tcp_command_service.send_command(
|
||||||
|
|
||||||
# Send command via TCP
|
|
||||||
try:
|
|
||||||
await tcp_manager.send_command(
|
|
||||||
device.imei, body.command_type, body.command_content
|
device.imei, body.command_type, body.command_content
|
||||||
)
|
),
|
||||||
except Exception as e:
|
success_msg="Command sent successfully / 指令发送成功",
|
||||||
command_log.status = "failed"
|
fail_msg="Failed to send command / 指令发送失败",
|
||||||
await db.flush()
|
|
||||||
await db.refresh(command_log)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to send command / 指令发送失败: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
command_log.status = "sent"
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(command_log)
|
|
||||||
|
|
||||||
return APIResponse(
|
|
||||||
message="Command sent successfully / 指令发送成功",
|
|
||||||
data=CommandResponse.model_validate(command_log),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,41 +208,13 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
|
|||||||
Send a text message to a device using protocol 0x82.
|
Send a text message to a device using protocol 0x82.
|
||||||
"""
|
"""
|
||||||
device = await _resolve_device(db, body.device_id, body.imei)
|
device = await _resolve_device(db, body.device_id, body.imei)
|
||||||
|
return await _send_to_device(
|
||||||
from app.tcp_server import tcp_manager
|
db, device,
|
||||||
|
|
||||||
if device.imei not in tcp_manager.connections:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create command log for the message
|
|
||||||
command_log = await command_service.create_command(
|
|
||||||
db,
|
|
||||||
device_id=device.id,
|
|
||||||
command_type="message",
|
command_type="message",
|
||||||
command_content=body.message,
|
command_content=body.message,
|
||||||
)
|
executor=lambda: tcp_command_service.send_message(device.imei, body.message),
|
||||||
|
success_msg="Message sent successfully / 留言发送成功",
|
||||||
try:
|
fail_msg="Failed to send message / 留言发送失败",
|
||||||
await tcp_manager.send_message(device.imei, body.message)
|
|
||||||
except Exception as e:
|
|
||||||
command_log.status = "failed"
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(command_log)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to send message / 留言发送失败: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
command_log.status = "sent"
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(command_log)
|
|
||||||
|
|
||||||
return APIResponse(
|
|
||||||
message="Message sent successfully / 留言发送成功",
|
|
||||||
data=CommandResponse.model_validate(command_log),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -232,43 +231,77 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
The device will use its built-in TTS engine to speak the text aloud.
|
The device will use its built-in TTS engine to speak the text aloud.
|
||||||
"""
|
"""
|
||||||
device = await _resolve_device(db, body.device_id, body.imei)
|
device = await _resolve_device(db, body.device_id, body.imei)
|
||||||
|
|
||||||
from app.tcp_server import tcp_manager
|
|
||||||
|
|
||||||
if device.imei not in tcp_manager.connections:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
|
||||||
)
|
|
||||||
|
|
||||||
tts_command = f"TTS,{body.text}"
|
tts_command = f"TTS,{body.text}"
|
||||||
|
return await _send_to_device(
|
||||||
# Create command log entry
|
db, device,
|
||||||
command_log = await command_service.create_command(
|
|
||||||
db,
|
|
||||||
device_id=device.id,
|
|
||||||
command_type="tts",
|
command_type="tts",
|
||||||
command_content=tts_command,
|
command_content=tts_command,
|
||||||
|
executor=lambda: tcp_command_service.send_command(
|
||||||
|
device.imei, "tts", tts_command
|
||||||
|
),
|
||||||
|
success_msg="TTS sent successfully / 语音下发成功",
|
||||||
|
fail_msg="Failed to send TTS / 语音下发失败",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
await tcp_manager.send_command(device.imei, "tts", tts_command)
|
|
||||||
except Exception as e:
|
|
||||||
command_log.status = "failed"
|
|
||||||
await db.flush()
|
|
||||||
await db.refresh(command_log)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
command_log.status = "sent"
|
@router.post(
|
||||||
await db.flush()
|
"/batch",
|
||||||
await db.refresh(command_log)
|
response_model=APIResponse[BatchCommandResponse],
|
||||||
|
status_code=201,
|
||||||
|
summary="批量发送指令 / Batch send command to multiple devices",
|
||||||
|
)
|
||||||
|
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||||
|
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
向多台设备批量发送同一指令,最多100台。
|
||||||
|
Send the same command to multiple devices (up to 100). Skips offline devices.
|
||||||
|
"""
|
||||||
|
# Resolve devices in single query (mutual exclusion validated by schema)
|
||||||
|
if body.device_ids:
|
||||||
|
devices = await device_service.get_devices_by_ids(db, body.device_ids)
|
||||||
|
else:
|
||||||
|
devices = await device_service.get_devices_by_imeis(db, body.imeis)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for device in devices:
|
||||||
|
if not tcp_command_service.is_device_online(device.imei):
|
||||||
|
results.append(BatchCommandResult(
|
||||||
|
device_id=device.id, imei=device.imei,
|
||||||
|
success=False, error="Device offline",
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd_log = await command_service.create_command(
|
||||||
|
db,
|
||||||
|
device_id=device.id,
|
||||||
|
command_type=body.command_type,
|
||||||
|
command_content=body.command_content,
|
||||||
|
)
|
||||||
|
await tcp_command_service.send_command(
|
||||||
|
device.imei, body.command_type, body.command_content
|
||||||
|
)
|
||||||
|
cmd_log.status = "sent"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(cmd_log)
|
||||||
|
results.append(BatchCommandResult(
|
||||||
|
device_id=device.id, imei=device.imei,
|
||||||
|
success=True, command_id=cmd_log.id,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).error("Batch cmd failed for %s: %s", device.imei, e)
|
||||||
|
results.append(BatchCommandResult(
|
||||||
|
device_id=device.id, imei=device.imei,
|
||||||
|
success=False, error="Send failed",
|
||||||
|
))
|
||||||
|
|
||||||
|
sent = sum(1 for r in results if r.success)
|
||||||
|
failed = len(results) - sent
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
message="TTS sent successfully / 语音下发成功",
|
message=f"Batch command: {sent} sent, {failed} failed",
|
||||||
data=CommandResponse.model_validate(command_log),
|
data=BatchCommandResponse(
|
||||||
|
total=len(results), sent=sent, failed=failed, results=results,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ API endpoints for device CRUD operations and statistics.
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
|
BatchDeviceCreateRequest,
|
||||||
|
BatchDeviceCreateResponse,
|
||||||
|
BatchDeviceCreateResult,
|
||||||
|
BatchDeviceDeleteRequest,
|
||||||
|
BatchDeviceUpdateRequest,
|
||||||
DeviceCreate,
|
DeviceCreate,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
PaginatedList,
|
PaginatedList,
|
||||||
)
|
)
|
||||||
|
from app.config import settings
|
||||||
|
from app.extensions import limiter
|
||||||
from app.services import device_service
|
from app.services import device_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
|
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
|
||||||
@@ -81,6 +88,76 @@ 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.post(
|
||||||
|
"/batch",
|
||||||
|
response_model=APIResponse[BatchDeviceCreateResponse],
|
||||||
|
status_code=201,
|
||||||
|
summary="批量创建设备 / Batch create devices",
|
||||||
|
)
|
||||||
|
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||||
|
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
批量注册设备(最多500台),跳过IMEI重复的设备。
|
||||||
|
Batch register devices (up to 500). Skips devices with duplicate IMEIs.
|
||||||
|
"""
|
||||||
|
results = await device_service.batch_create_devices(db, body.devices)
|
||||||
|
created = sum(1 for r in results if r["success"])
|
||||||
|
failed = len(results) - created
|
||||||
|
return APIResponse(
|
||||||
|
message=f"Batch create: {created} created, {failed} failed",
|
||||||
|
data=BatchDeviceCreateResponse(
|
||||||
|
total=len(results),
|
||||||
|
created=created,
|
||||||
|
failed=failed,
|
||||||
|
results=[BatchDeviceCreateResult(**r) for r in results],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/batch",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="批量更新设备 / Batch update devices",
|
||||||
|
)
|
||||||
|
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||||
|
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
批量更新设备信息(名称、状态等),最多500台。
|
||||||
|
Batch update device fields (name, status, etc.) for up to 500 devices.
|
||||||
|
"""
|
||||||
|
results = await device_service.batch_update_devices(db, body.device_ids, body.update)
|
||||||
|
updated = sum(1 for r in results if r["success"])
|
||||||
|
failed = len(results) - updated
|
||||||
|
return APIResponse(
|
||||||
|
message=f"Batch update: {updated} updated, {failed} failed",
|
||||||
|
data={"total": len(results), "updated": updated, "failed": failed, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/batch-delete",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="批量删除设备 / Batch delete devices",
|
||||||
|
)
|
||||||
|
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||||
|
async def batch_delete_devices(
|
||||||
|
request: Request,
|
||||||
|
body: BatchDeviceDeleteRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
批量删除设备(最多100台)。通过 POST body 传递 device_ids 列表。
|
||||||
|
Batch delete devices (up to 100). Pass device_ids in request body.
|
||||||
|
"""
|
||||||
|
results = await device_service.batch_delete_devices(db, body.device_ids)
|
||||||
|
deleted = sum(1 for r in results if r["success"])
|
||||||
|
failed = len(results) - deleted
|
||||||
|
return APIResponse(
|
||||||
|
message=f"Batch delete: {deleted} deleted, {failed} failed",
|
||||||
|
data={"total": len(results), "deleted": deleted, "failed": failed, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{device_id}",
|
"/{device_id}",
|
||||||
response_model=APIResponse[DeviceResponse],
|
response_model=APIResponse[DeviceResponse],
|
||||||
|
|||||||
92
app/routers/heartbeats.py
Normal file
92
app/routers/heartbeats.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Heartbeats Router - 心跳数据接口
|
||||||
|
API endpoints for querying device heartbeat records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
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.models import HeartbeatRecord
|
||||||
|
from app.schemas import (
|
||||||
|
APIResponse,
|
||||||
|
HeartbeatRecordResponse,
|
||||||
|
PaginatedList,
|
||||||
|
)
|
||||||
|
from app.services import device_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/heartbeats", tags=["Heartbeats / 心跳数据"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=APIResponse[PaginatedList[HeartbeatRecordResponse]],
|
||||||
|
summary="获取心跳记录列表 / List heartbeat records",
|
||||||
|
)
|
||||||
|
async def list_heartbeats(
|
||||||
|
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||||
|
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||||
|
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||||
|
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取心跳记录列表,支持按设备和时间范围过滤。
|
||||||
|
List heartbeat records with optional device and time range filters.
|
||||||
|
"""
|
||||||
|
query = select(HeartbeatRecord)
|
||||||
|
count_query = select(func.count(HeartbeatRecord.id))
|
||||||
|
|
||||||
|
if device_id is not None:
|
||||||
|
query = query.where(HeartbeatRecord.device_id == device_id)
|
||||||
|
count_query = count_query.where(HeartbeatRecord.device_id == device_id)
|
||||||
|
|
||||||
|
if start_time:
|
||||||
|
query = query.where(HeartbeatRecord.created_at >= start_time)
|
||||||
|
count_query = count_query.where(HeartbeatRecord.created_at >= start_time)
|
||||||
|
|
||||||
|
if end_time:
|
||||||
|
query = query.where(HeartbeatRecord.created_at <= end_time)
|
||||||
|
count_query = count_query.where(HeartbeatRecord.created_at <= end_time)
|
||||||
|
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
query = query.order_by(HeartbeatRecord.created_at.desc()).offset(offset).limit(page_size)
|
||||||
|
result = await db.execute(query)
|
||||||
|
records = list(result.scalars().all())
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
data=PaginatedList(
|
||||||
|
items=[HeartbeatRecordResponse.model_validate(r) for r in records],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total_pages=math.ceil(total / page_size) if total else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{heartbeat_id}",
|
||||||
|
response_model=APIResponse[HeartbeatRecordResponse],
|
||||||
|
summary="获取心跳详情 / Get heartbeat details",
|
||||||
|
)
|
||||||
|
async def get_heartbeat(heartbeat_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
按ID获取心跳记录详情。
|
||||||
|
Get heartbeat record details by ID.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(HeartbeatRecord).where(HeartbeatRecord.id == heartbeat_id)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Heartbeat {heartbeat_id} not found")
|
||||||
|
return APIResponse(data=HeartbeatRecordResponse.model_validate(record))
|
||||||
@@ -7,9 +7,11 @@ import math
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models import LocationRecord
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
LocationRecordResponse,
|
LocationRecordResponse,
|
||||||
@@ -92,6 +94,7 @@ async def device_track(
|
|||||||
device_id: int,
|
device_id: int,
|
||||||
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
|
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
|
||||||
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
|
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
|
||||||
|
max_points: int = Query(default=10000, ge=1, le=50000, description="最大轨迹点数 / Max track points"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -109,7 +112,23 @@ async def device_track(
|
|||||||
detail="start_time must be before end_time / 开始时间必须早于结束时间",
|
detail="start_time must be before end_time / 开始时间必须早于结束时间",
|
||||||
)
|
)
|
||||||
|
|
||||||
records = await location_service.get_device_track(db, device_id, start_time, end_time)
|
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points)
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
data=[LocationRecordResponse.model_validate(r) for r in records]
|
data=[LocationRecordResponse.model_validate(r) for r in records]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{location_id}",
|
||||||
|
response_model=APIResponse[LocationRecordResponse],
|
||||||
|
summary="获取位置记录详情 / Get location record",
|
||||||
|
)
|
||||||
|
async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""按ID获取位置记录详情 / Get location record details by ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(LocationRecord).where(LocationRecord.id == location_id)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||||
|
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||||
|
|||||||
147
app/schemas.py
147
app/schemas.py
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Any, Generic, Literal, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -42,8 +42,8 @@ class PaginatedList(BaseModel, Generic[T]):
|
|||||||
|
|
||||||
|
|
||||||
class DeviceBase(BaseModel):
|
class DeviceBase(BaseModel):
|
||||||
imei: str = Field(..., min_length=15, max_length=20, description="IMEI number")
|
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number (digits only)")
|
||||||
device_type: str = Field(..., max_length=10, description="Device type code")
|
device_type: str = Field(..., max_length=10, description="Device type code (e.g. P240, P241)")
|
||||||
name: str | None = Field(None, max_length=100, description="Friendly name")
|
name: str | None = Field(None, max_length=100, description="Friendly name")
|
||||||
timezone: str = Field(default="+8", max_length=30)
|
timezone: str = Field(default="+8", max_length=30)
|
||||||
language: str = Field(default="cn", max_length=10)
|
language: str = Field(default="cn", max_length=10)
|
||||||
@@ -55,9 +55,7 @@ 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: str | None = Field(None, max_length=20)
|
status: Literal["online", "offline"] | None = Field(None, description="Device status")
|
||||||
battery_level: int | None = None
|
|
||||||
gsm_signal: int | None = None
|
|
||||||
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)
|
||||||
@@ -95,8 +93,8 @@ class DeviceSingleResponse(APIResponse[DeviceResponse]):
|
|||||||
class LocationRecordBase(BaseModel):
|
class LocationRecordBase(BaseModel):
|
||||||
device_id: int
|
device_id: int
|
||||||
location_type: str = Field(..., max_length=10)
|
location_type: str = Field(..., max_length=10)
|
||||||
latitude: float | None = None
|
latitude: float | None = Field(None, ge=-90, le=90)
|
||||||
longitude: float | None = None
|
longitude: float | None = Field(None, ge=-180, le=180)
|
||||||
speed: float | None = None
|
speed: float | None = None
|
||||||
course: float | None = None
|
course: float | None = None
|
||||||
gps_satellites: int | None = None
|
gps_satellites: int | None = None
|
||||||
@@ -148,8 +146,8 @@ class AlarmRecordBase(BaseModel):
|
|||||||
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=10)
|
||||||
protocol_number: int
|
protocol_number: int
|
||||||
latitude: float | None = None
|
latitude: float | None = Field(None, ge=-90, le=90)
|
||||||
longitude: float | None = None
|
longitude: float | None = Field(None, ge=-180, le=180)
|
||||||
speed: float | None = None
|
speed: float | None = None
|
||||||
course: float | None = None
|
course: float | None = None
|
||||||
mcc: int | None = None
|
mcc: int | None = None
|
||||||
@@ -231,8 +229,8 @@ class AttendanceRecordBase(BaseModel):
|
|||||||
attendance_type: str = Field(..., max_length=20)
|
attendance_type: str = Field(..., max_length=20)
|
||||||
protocol_number: int
|
protocol_number: int
|
||||||
gps_positioned: bool = False
|
gps_positioned: bool = False
|
||||||
latitude: float | None = None
|
latitude: float | None = Field(None, ge=-90, le=90)
|
||||||
longitude: float | None = None
|
longitude: float | None = Field(None, ge=-180, le=180)
|
||||||
speed: float | None = None
|
speed: float | None = None
|
||||||
course: float | None = None
|
course: float | None = None
|
||||||
gps_satellites: int | None = None
|
gps_satellites: int | None = None
|
||||||
@@ -288,8 +286,8 @@ class BluetoothRecordBase(BaseModel):
|
|||||||
beacon_battery_unit: str | None = None
|
beacon_battery_unit: str | None = None
|
||||||
attendance_type: str | None = None
|
attendance_type: str | None = None
|
||||||
bluetooth_data: dict[str, Any] | None = None
|
bluetooth_data: dict[str, Any] | None = None
|
||||||
latitude: float | None = None
|
latitude: float | None = Field(None, ge=-90, le=90)
|
||||||
longitude: float | None = None
|
longitude: float | None = Field(None, ge=-180, le=180)
|
||||||
recorded_at: datetime
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -321,17 +319,17 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
|
|||||||
|
|
||||||
|
|
||||||
class BeaconConfigBase(BaseModel):
|
class BeaconConfigBase(BaseModel):
|
||||||
beacon_mac: str = Field(..., max_length=20, description="信标MAC地址")
|
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, description="iBeacon UUID")
|
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_major: int | None = Field(None, description="iBeacon Major")
|
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
|
||||||
beacon_minor: int | None = Field(None, 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="信标名称")
|
||||||
floor: str | None = Field(None, max_length=20, description="楼层")
|
floor: str | None = Field(None, max_length=20, description="楼层")
|
||||||
area: str | None = Field(None, max_length=100, description="区域")
|
area: str | None = Field(None, max_length=100, description="区域")
|
||||||
latitude: float | None = Field(None, description="纬度")
|
latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
|
||||||
longitude: float | None = Field(None, description="经度")
|
longitude: float | None = Field(None, ge=-180, le=180, description="经度")
|
||||||
address: str | None = Field(None, description="详细地址")
|
address: str | None = Field(None, description="详细地址")
|
||||||
status: str = Field(default="active", max_length=20, description="状态")
|
status: Literal["active", "inactive"] = Field(default="active", description="状态")
|
||||||
|
|
||||||
|
|
||||||
class BeaconConfigCreate(BeaconConfigBase):
|
class BeaconConfigCreate(BeaconConfigBase):
|
||||||
@@ -339,22 +337,34 @@ class BeaconConfigCreate(BeaconConfigBase):
|
|||||||
|
|
||||||
|
|
||||||
class BeaconConfigUpdate(BaseModel):
|
class BeaconConfigUpdate(BaseModel):
|
||||||
beacon_uuid: str | None = Field(None, max_length=36)
|
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_major: int | None = None
|
beacon_major: int | None = Field(None, ge=0, le=65535)
|
||||||
beacon_minor: int | None = None
|
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)
|
||||||
floor: str | None = Field(None, max_length=20)
|
floor: str | None = Field(None, max_length=20)
|
||||||
area: str | None = Field(None, max_length=100)
|
area: str | None = Field(None, max_length=100)
|
||||||
latitude: float | None = None
|
latitude: float | None = Field(None, ge=-90, le=90)
|
||||||
longitude: float | None = None
|
longitude: float | None = Field(None, ge=-180, le=180)
|
||||||
address: str | None = None
|
address: str | None = None
|
||||||
status: str | None = Field(None, max_length=20)
|
status: Literal["active", "inactive"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class BeaconConfigResponse(BeaconConfigBase):
|
class BeaconConfigResponse(BaseModel):
|
||||||
|
"""Response model — no pattern validation on output (existing data may not conform)."""
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
beacon_mac: str
|
||||||
|
beacon_uuid: str | None = None
|
||||||
|
beacon_major: int | None = None
|
||||||
|
beacon_minor: int | None = None
|
||||||
|
name: str
|
||||||
|
floor: str | None = None
|
||||||
|
area: str | None = None
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
address: str | None = None
|
||||||
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
@@ -367,10 +377,87 @@ class BeaconConfigResponse(BeaconConfigBase):
|
|||||||
class CommandCreate(BaseModel):
|
class CommandCreate(BaseModel):
|
||||||
device_id: int
|
device_id: int
|
||||||
command_type: str = Field(..., max_length=30)
|
command_type: str = Field(..., max_length=30)
|
||||||
command_content: str
|
command_content: str = Field(..., max_length=500)
|
||||||
server_flag: str = Field(..., max_length=20)
|
server_flag: str = Field(..., max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Batch operation schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceCreateItem(BaseModel):
|
||||||
|
"""Single device in a batch create request."""
|
||||||
|
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number")
|
||||||
|
device_type: str = Field(default="P241", max_length=10, description="Device type (P240/P241)")
|
||||||
|
name: str | None = Field(None, max_length=100, description="Friendly name")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceCreateRequest(BaseModel):
|
||||||
|
"""Batch create devices."""
|
||||||
|
devices: list[BatchDeviceCreateItem] = Field(..., min_length=1, max_length=500, description="List of devices to create")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceCreateResult(BaseModel):
|
||||||
|
"""Result of a single device in batch create."""
|
||||||
|
imei: str
|
||||||
|
success: bool
|
||||||
|
device_id: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceCreateResponse(BaseModel):
|
||||||
|
"""Summary of batch create operation."""
|
||||||
|
total: int
|
||||||
|
created: int
|
||||||
|
failed: int
|
||||||
|
results: list[BatchDeviceCreateResult]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCommandRequest(BaseModel):
|
||||||
|
"""Send the same command to multiple devices."""
|
||||||
|
device_ids: list[int] | None = Field(default=None, min_length=1, max_length=100, description="Device IDs (provide device_ids or imeis)")
|
||||||
|
imeis: list[str] | None = Field(default=None, min_length=1, max_length=100, description="IMEI list (alternative to device_ids)")
|
||||||
|
command_type: str = Field(..., max_length=30, description="Command type (e.g. online_cmd)")
|
||||||
|
command_content: str = Field(..., max_length=500, description="Command content")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_device_ids_or_imeis(self):
|
||||||
|
if not self.device_ids and not self.imeis:
|
||||||
|
raise ValueError("Must provide device_ids or imeis / 必须提供 device_ids 或 imeis")
|
||||||
|
if self.device_ids and self.imeis:
|
||||||
|
raise ValueError("Provide device_ids or imeis, not both / 不能同时提供 device_ids 和 imeis")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCommandResult(BaseModel):
|
||||||
|
"""Result of a single command in batch send."""
|
||||||
|
device_id: int
|
||||||
|
imei: str
|
||||||
|
success: bool
|
||||||
|
command_id: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCommandResponse(BaseModel):
|
||||||
|
"""Summary of batch command operation."""
|
||||||
|
total: int
|
||||||
|
sent: int
|
||||||
|
failed: int
|
||||||
|
results: list[BatchCommandResult]
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceDeleteRequest(BaseModel):
|
||||||
|
"""Batch delete devices."""
|
||||||
|
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="Device IDs to delete")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeviceUpdateRequest(BaseModel):
|
||||||
|
"""Batch update devices with the same settings."""
|
||||||
|
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="Device IDs to update")
|
||||||
|
update: DeviceUpdate = Field(..., description="Fields to update")
|
||||||
|
|
||||||
|
|
||||||
class CommandUpdate(BaseModel):
|
class CommandUpdate(BaseModel):
|
||||||
response_content: str | None = None
|
response_content: str | None = None
|
||||||
status: str | None = Field(None, max_length=20)
|
status: str | None = Field(None, max_length=20)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import func, select, or_
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Device
|
from app.models import Device
|
||||||
from app.schemas import DeviceCreate, DeviceUpdate
|
from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem
|
||||||
|
|
||||||
|
|
||||||
async def get_devices(
|
async def get_devices(
|
||||||
@@ -189,6 +189,103 @@ async def delete_device(db: AsyncSession, device_id: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_devices_by_ids(db: AsyncSession, device_ids: list[int]) -> list[Device]:
|
||||||
|
"""Fetch multiple devices by IDs in a single query."""
|
||||||
|
if not device_ids:
|
||||||
|
return []
|
||||||
|
result = await db.execute(select(Device).where(Device.id.in_(device_ids)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_devices_by_imeis(db: AsyncSession, imeis: list[str]) -> list[Device]:
|
||||||
|
"""Fetch multiple devices by IMEIs in a single query."""
|
||||||
|
if not imeis:
|
||||||
|
return []
|
||||||
|
result = await db.execute(select(Device).where(Device.imei.in_(imeis)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_create_devices(
|
||||||
|
db: AsyncSession, items: list[BatchDeviceCreateItem]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Batch create devices, skipping duplicates. Uses single query to check existing."""
|
||||||
|
# One query to find all existing IMEIs
|
||||||
|
imeis = [item.imei for item in items]
|
||||||
|
existing_devices = await get_devices_by_imeis(db, imeis)
|
||||||
|
existing_imeis = {d.imei for d in existing_devices}
|
||||||
|
|
||||||
|
results: list[dict] = [{} for _ in range(len(items))] # preserve input order
|
||||||
|
new_device_indices: list[tuple[int, Device]] = []
|
||||||
|
seen_imeis: set[str] = set()
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
if item.imei in existing_imeis:
|
||||||
|
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": f"IMEI {item.imei} already exists"}
|
||||||
|
continue
|
||||||
|
if item.imei in seen_imeis:
|
||||||
|
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": "Duplicate IMEI in request"}
|
||||||
|
continue
|
||||||
|
seen_imeis.add(item.imei)
|
||||||
|
device = Device(imei=item.imei, device_type=item.device_type, name=item.name)
|
||||||
|
db.add(device)
|
||||||
|
new_device_indices.append((i, device))
|
||||||
|
|
||||||
|
if new_device_indices:
|
||||||
|
await db.flush()
|
||||||
|
for idx, device in new_device_indices:
|
||||||
|
await db.refresh(device)
|
||||||
|
results[idx] = {"imei": device.imei, "success": True, "device_id": device.id, "error": None}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_update_devices(
|
||||||
|
db: AsyncSession, device_ids: list[int], update_data: DeviceUpdate
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Batch update devices with the same settings. Uses single query to fetch all."""
|
||||||
|
devices = await get_devices_by_ids(db, device_ids)
|
||||||
|
found_map = {d.id: d for d in devices}
|
||||||
|
update_fields = update_data.model_dump(exclude_unset=True)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
device = found_map.get(device_id)
|
||||||
|
if device is None:
|
||||||
|
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
|
||||||
|
continue
|
||||||
|
for field, value in update_fields.items():
|
||||||
|
setattr(device, field, value)
|
||||||
|
device.updated_at = now
|
||||||
|
results.append({"device_id": device_id, "success": True, "error": None})
|
||||||
|
|
||||||
|
if any(r["success"] for r in results):
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def batch_delete_devices(
|
||||||
|
db: AsyncSession, device_ids: list[int]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Batch delete devices. Uses single query to fetch all."""
|
||||||
|
devices = await get_devices_by_ids(db, device_ids)
|
||||||
|
found_map = {d.id: d for d in devices}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
device = found_map.get(device_id)
|
||||||
|
if device is None:
|
||||||
|
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
|
||||||
|
continue
|
||||||
|
await db.delete(device)
|
||||||
|
results.append({"device_id": device_id, "success": True, "error": None})
|
||||||
|
|
||||||
|
if any(r["success"] for r in results):
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def get_device_stats(db: AsyncSession) -> dict:
|
async def get_device_stats(db: AsyncSession) -> dict:
|
||||||
"""
|
"""
|
||||||
获取设备统计信息 / Get device statistics.
|
获取设备统计信息 / Get device statistics.
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ async def get_device_track(
|
|||||||
device_id: int,
|
device_id: int,
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
end_time: datetime,
|
end_time: datetime,
|
||||||
|
max_points: int = 10000,
|
||||||
) -> list[LocationRecord]:
|
) -> list[LocationRecord]:
|
||||||
"""
|
"""
|
||||||
获取设备轨迹 / Get device movement track within a time range.
|
获取设备轨迹 / Get device movement track within a time range.
|
||||||
@@ -134,5 +135,6 @@ async def get_device_track(
|
|||||||
LocationRecord.recorded_at <= end_time,
|
LocationRecord.recorded_at <= end_time,
|
||||||
)
|
)
|
||||||
.order_by(LocationRecord.recorded_at.asc())
|
.order_by(LocationRecord.recorded_at.asc())
|
||||||
|
.limit(max_points)
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|||||||
31
app/services/tcp_command_service.py
Normal file
31
app/services/tcp_command_service.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
TCP Command Service — Abstraction layer for sending commands to devices via TCP.
|
||||||
|
|
||||||
|
Breaks the circular import between routers/commands.py and tcp_server.py
|
||||||
|
by lazily importing tcp_manager only when needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tcp_manager():
|
||||||
|
"""Lazily import tcp_manager to avoid circular imports."""
|
||||||
|
from app.tcp_server import tcp_manager
|
||||||
|
return tcp_manager
|
||||||
|
|
||||||
|
|
||||||
|
def is_device_online(imei: str) -> bool:
|
||||||
|
"""Check if a device is currently connected via TCP."""
|
||||||
|
return imei in _get_tcp_manager().connections
|
||||||
|
|
||||||
|
|
||||||
|
async def send_command(imei: str, command_type: str, command_content: str) -> bool:
|
||||||
|
"""Send an online command (0x80) to a connected device."""
|
||||||
|
return await _get_tcp_manager().send_command(imei, command_type, command_content)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(imei: str, message: str) -> bool:
|
||||||
|
"""Send a text message (0x82) to a connected device."""
|
||||||
|
return await _get_tcp_manager().send_message(imei, message)
|
||||||
@@ -26,9 +26,9 @@
|
|||||||
.page.active { display: block; }
|
.page.active { display: block; }
|
||||||
.stat-card { background: #1f2937; border-radius: 12px; padding: 24px; border: 1px solid #374151; transition: transform 0.2s, box-shadow 0.2s; }
|
.stat-card { background: #1f2937; border-radius: 12px; padding: 24px; border: 1px solid #374151; transition: transform 0.2s, box-shadow 0.2s; }
|
||||||
.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: 50; 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; }
|
||||||
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; 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; }
|
||||||
.toast.error { background: #dc2626; }
|
.toast.error { background: #dc2626; }
|
||||||
@@ -99,6 +99,42 @@
|
|||||||
.guide-tips { margin-top: 10px; padding: 10px 14px; background: rgba(59,130,246,0.06); border-radius: 8px; border-left: 3px solid #3b82f6; }
|
.guide-tips { margin-top: 10px; padding: 10px 14px; background: rgba(59,130,246,0.06); border-radius: 8px; border-left: 3px solid #3b82f6; }
|
||||||
.guide-tips p { font-size: 12px; color: #64748b; line-height: 1.6; }
|
.guide-tips p { font-size: 12px; color: #64748b; line-height: 1.6; }
|
||||||
.guide-tips p i { color: #3b82f6; margin-right: 4px; }
|
.guide-tips p i { color: #3b82f6; margin-right: 4px; }
|
||||||
|
/* === Side Panel === */
|
||||||
|
.page-with-panel { display: flex; gap: 16px; height: calc(100vh - 140px); }
|
||||||
|
.side-panel { width: 280px; min-width: 280px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; transition: width 0.3s, min-width 0.3s, opacity 0.3s; }
|
||||||
|
.side-panel.collapsed { width: 0; min-width: 0; border: none; opacity: 0; }
|
||||||
|
.page-main-content { flex: 1; min-width: 0; overflow-y: auto; }
|
||||||
|
.panel-header { padding: 12px 14px; border-bottom: 1px solid #374151; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||||
|
.panel-header .panel-title { font-size: 14px; font-weight: 600; color: #e5e7eb; flex: 1; }
|
||||||
|
.panel-toggle-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 4px; font-size: 14px; }
|
||||||
|
.panel-toggle-btn:hover { color: #e5e7eb; }
|
||||||
|
.panel-search { padding: 8px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; }
|
||||||
|
.panel-search-wrap { position: relative; }
|
||||||
|
.panel-search-wrap i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #6b7280; font-size: 12px; }
|
||||||
|
.panel-search-wrap input { padding-left: 30px; font-size: 13px; }
|
||||||
|
.panel-list { flex: 1; overflow-y: auto; padding: 8px; }
|
||||||
|
.panel-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; border: 1px solid transparent; margin-bottom: 6px; transition: all 0.15s; position: relative; }
|
||||||
|
.panel-item:hover { background: #374151; border-color: #4b5563; }
|
||||||
|
.panel-item.active { background: #1e3a5f; border-color: #2563eb; }
|
||||||
|
.panel-item-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||||
|
.panel-item-name { font-size: 13px; font-weight: 600; color: #e5e7eb; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; }
|
||||||
|
.panel-item-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.panel-item-status.online { background: #34d399; }
|
||||||
|
.panel-item-status.offline { background: #f87171; }
|
||||||
|
.panel-item-status.active { background: #34d399; }
|
||||||
|
.panel-item-status.inactive { background: #f87171; }
|
||||||
|
.panel-item-sub { font-size: 11px; color: #9ca3af; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.panel-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; color: #6b7280; }
|
||||||
|
.battery-bar { width: 28px; height: 10px; background: #374151; border-radius: 2px; border: 1px solid #4b5563; overflow: hidden; display: inline-block; }
|
||||||
|
.battery-bar-fill { height: 100%; border-radius: 1px; }
|
||||||
|
.panel-item-actions { position: absolute; right: 8px; top: 8px; display: none; gap: 4px; }
|
||||||
|
.panel-item:hover .panel-item-actions { display: flex; }
|
||||||
|
.panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.panel-action-btn:hover { background: #2563eb; }
|
||||||
|
.panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; }
|
||||||
|
.panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; }
|
||||||
|
.side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; }
|
||||||
|
@media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -366,50 +402,70 @@
|
|||||||
<div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div>
|
<div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
<div class="page-with-panel">
|
||||||
<select id="locDeviceSelect" style="width:200px">
|
<!-- Left: Device Panel -->
|
||||||
<option value="">选择设备...</option>
|
<div class="side-panel" id="locSidePanel">
|
||||||
</select>
|
<div class="panel-header">
|
||||||
<select id="locTypeFilter" style="width:150px">
|
<i class="fas fa-microchip text-blue-400" style="font-size:14px"></i>
|
||||||
<option value="">全部类型</option>
|
<span class="panel-title">设备列表</span>
|
||||||
<option value="gps">GPS</option>
|
<span id="locPanelCount" style="font-size:11px;color:#6b7280"></span>
|
||||||
<option value="gps_4g">GPS 4G</option>
|
<button class="panel-toggle-btn" onclick="toggleSidePanel('locSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
|
||||||
<option value="wifi">WiFi</option>
|
</div>
|
||||||
<option value="wifi_4g">WiFi 4G</option>
|
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="locPanelSearch" placeholder="搜索设备..." oninput="filterPanelItems('locations')"></div></div>
|
||||||
<option value="lbs">LBS</option>
|
<div class="panel-list" id="locPanelList">
|
||||||
<option value="lbs_4g">LBS 4G</option>
|
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载设备...</p></div>
|
||||||
</select>
|
</div>
|
||||||
<input type="date" id="locStartDate" style="width:160px">
|
<div class="panel-footer" id="locPanelFooter">加载中...</div>
|
||||||
<input type="date" id="locEndDate" style="width:160px">
|
</div>
|
||||||
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
<!-- Right: Main Content -->
|
||||||
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
<div class="page-main-content" style="position:relative">
|
||||||
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
|
||||||
</div>
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
|
<select id="locDeviceSelect" style="width:200px">
|
||||||
<div id="locationMap" style="height: 100%; width: 100%;"></div>
|
<option value="">选择设备...</option>
|
||||||
</div>
|
</select>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
<select id="locTypeFilter" style="width:150px">
|
||||||
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
<option value="">全部类型</option>
|
||||||
<div class="overflow-x-auto">
|
<option value="gps">GPS</option>
|
||||||
<table>
|
<option value="gps_4g">GPS 4G</option>
|
||||||
<thead>
|
<option value="wifi">WiFi</option>
|
||||||
<tr>
|
<option value="wifi_4g">WiFi 4G</option>
|
||||||
<th>设备ID</th>
|
<option value="lbs">LBS</option>
|
||||||
<th>类型</th>
|
<option value="lbs_4g">LBS 4G</option>
|
||||||
<th>纬度</th>
|
</select>
|
||||||
<th>经度</th>
|
<input type="date" id="locStartDate" style="width:160px">
|
||||||
<th>地址</th>
|
<input type="date" id="locEndDate" style="width:160px">
|
||||||
<th>速度</th>
|
<button class="btn btn-primary" onclick="loadTrack()"><i class="fas fa-route"></i> 显示轨迹</button>
|
||||||
<th>卫星数</th>
|
<button class="btn btn-success" onclick="loadLatestPosition()"><i class="fas fa-crosshairs"></i> 最新位置</button>
|
||||||
<th>时间</th>
|
<button class="btn btn-secondary" onclick="loadLocationRecords()"><i class="fas fa-list"></i> 查询记录</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden mb-6" style="height: 500px;">
|
||||||
<tbody id="locationsTableBody">
|
<div id="locationMap" style="height: 100%; width: 100%;"></div>
|
||||||
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
|
</div>
|
||||||
</tbody>
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
||||||
</table>
|
<div id="locationsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>设备ID</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>纬度</th>
|
||||||
|
<th>经度</th>
|
||||||
|
<th>地址</th>
|
||||||
|
<th>速度</th>
|
||||||
|
<th>卫星数</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="locationsTableBody">
|
||||||
|
<tr><td colspan="8" class="text-center text-gray-500 py-8">请选择设备并查询</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="locationsPagination" class="pagination p-4"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="locationsPagination" class="pagination p-4"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -660,43 +716,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters & Controls -->
|
<div class="page-with-panel">
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
<!-- Left: Beacon Panel -->
|
||||||
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
|
<div class="side-panel" id="beaconSidePanel">
|
||||||
<select id="beaconStatusFilter" style="width:150px">
|
<div class="panel-header">
|
||||||
<option value="">全部状态</option>
|
<i class="fas fa-broadcast-tower text-green-400" style="font-size:14px"></i>
|
||||||
<option value="active">启用</option>
|
<span class="panel-title">信标列表</span>
|
||||||
<option value="inactive">停用</option>
|
<span id="beaconPanelCount" style="font-size:11px;color:#6b7280"></span>
|
||||||
</select>
|
<button class="panel-toggle-btn" onclick="toggleSidePanel('beaconSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
|
||||||
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
|
</div>
|
||||||
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="beaconPanelSearch" placeholder="搜索信标..." oninput="filterPanelItems('beacons')"></div></div>
|
||||||
<div style="flex:1"></div>
|
<div class="panel-list" id="beaconPanelList">
|
||||||
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
|
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载信标...</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-footer" id="beaconPanelFooter">加载中...</div>
|
||||||
<!-- Table -->
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
<!-- Right: Main Content -->
|
||||||
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
<div class="page-main-content" style="position:relative">
|
||||||
<div class="overflow-x-auto">
|
<button class="panel-expand-btn" onclick="toggleSidePanel('beaconSidePanel')" title="展开信标面板"><i class="fas fa-chevron-right"></i></button>
|
||||||
<table>
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
<thead>
|
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
|
||||||
<tr>
|
<select id="beaconStatusFilter" style="width:150px">
|
||||||
<th>MAC 地址</th>
|
<option value="">全部状态</option>
|
||||||
<th>名称</th>
|
<option value="active">启用</option>
|
||||||
<th>UUID / Major / Minor</th>
|
<option value="inactive">停用</option>
|
||||||
<th>楼层 / 区域</th>
|
</select>
|
||||||
<th>坐标</th>
|
<button class="btn btn-primary" onclick="loadBeacons()"><i class="fas fa-search"></i> 查询</button>
|
||||||
<th>状态</th>
|
<button class="btn btn-secondary" onclick="loadBeacons()"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||||
<th>更新时间</th>
|
<div style="flex:1"></div>
|
||||||
<th>操作</th>
|
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
|
||||||
<tbody id="beaconsTableBody">
|
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
|
||||||
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
<div class="overflow-x-auto">
|
||||||
</tbody>
|
<table>
|
||||||
</table>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>MAC 地址</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>UUID / Major / Minor</th>
|
||||||
|
<th>楼层 / 区域</th>
|
||||||
|
<th>坐标</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="beaconsTableBody">
|
||||||
|
<tr><td colspan="8" class="text-center text-gray-500 py-8">加载中...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="beaconsPagination" class="pagination p-4"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="beaconsPagination" class="pagination p-4"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -827,6 +900,12 @@
|
|||||||
let dashAlarmChart = null;
|
let dashAlarmChart = null;
|
||||||
let alarmTypeChart = null;
|
let alarmTypeChart = null;
|
||||||
|
|
||||||
|
// Side panel state
|
||||||
|
let panelDevices = [];
|
||||||
|
let panelBeacons = [];
|
||||||
|
let selectedPanelDeviceId = null;
|
||||||
|
let selectedPanelBeaconId = null;
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const pageState = {
|
const pageState = {
|
||||||
devices: { page: 1, pageSize: 20 },
|
devices: { page: 1, pageSize: 20 },
|
||||||
@@ -1028,6 +1107,9 @@
|
|||||||
document.getElementById('pageTitle').textContent = pageTitles[page] || page;
|
document.getElementById('pageTitle').textContent = pageTitles[page] || page;
|
||||||
|
|
||||||
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
|
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; }
|
||||||
|
// Clean up panel state when leaving panel pages
|
||||||
|
if (page !== 'locations') { selectedPanelDeviceId = null; panelDevices = []; }
|
||||||
|
if (page !== 'beacons') { selectedPanelBeaconId = null; panelBeacons = []; }
|
||||||
|
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break;
|
||||||
@@ -1041,6 +1123,166 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== SIDE PANEL ====================
|
||||||
|
// Panel element ID config (centralized, easy to extend for new panel pages)
|
||||||
|
const PANEL_IDS = {
|
||||||
|
locations: { panel: 'locSidePanel', list: 'locPanelList', count: 'locPanelCount', footer: 'locPanelFooter', search: 'locPanelSearch' },
|
||||||
|
beacons: { panel: 'beaconSidePanel', list: 'beaconPanelList', count: 'beaconPanelCount', footer: 'beaconPanelFooter', search: 'beaconPanelSearch' },
|
||||||
|
};
|
||||||
|
const PANEL_TRANSITION_MS = 300; // must match CSS transition duration
|
||||||
|
|
||||||
|
function toggleSidePanel(panelId) {
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (!panel) return;
|
||||||
|
panel.classList.toggle('collapsed');
|
||||||
|
// Only invalidate map size when on locations page
|
||||||
|
if (currentPage === 'locations' && locationMap) {
|
||||||
|
setTimeout(() => locationMap.invalidateSize(), PANEL_TRANSITION_MS + 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(t) {
|
||||||
|
if (!t) return '-';
|
||||||
|
try {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
||||||
|
if (isNaN(diff) || diff < 0) return '-';
|
||||||
|
if (diff < 60) return '刚刚';
|
||||||
|
if (diff < 3600) return Math.floor(diff / 60) + '分钟前';
|
||||||
|
if (diff < 86400) return Math.floor(diff / 3600) + '小时前';
|
||||||
|
if (diff < 2592000) return Math.floor(diff / 86400) + '天前';
|
||||||
|
return formatTime(t);
|
||||||
|
} catch { return '-'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic panel render helper — sets count & footer text, returns container or null */
|
||||||
|
function _initPanelRender(ids, items, statusField, statusValue, emptyText, countFmt, footerFmt) {
|
||||||
|
const container = document.getElementById(ids.list);
|
||||||
|
const countEl = document.getElementById(ids.count);
|
||||||
|
const footerEl = document.getElementById(ids.footer);
|
||||||
|
if (!container) return null;
|
||||||
|
const activeCount = items.filter(it => it[statusField] === statusValue).length;
|
||||||
|
if (countEl) countEl.textContent = countFmt(activeCount, items.length);
|
||||||
|
if (footerEl) footerEl.textContent = footerFmt(activeCount, items.length);
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = `<div style="text-align:center;padding:20px;color:#6b7280;font-size:13px">${emptyText}</div>`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevicePanel(devices) {
|
||||||
|
panelDevices = devices;
|
||||||
|
const container = _initPanelRender(
|
||||||
|
PANEL_IDS.locations, devices, 'status', 'online', '暂无设备',
|
||||||
|
(a, t) => `${a}/${t}`, (a, t) => `共 ${t} 台设备,${a} 台在线`
|
||||||
|
);
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = devices.map(d => {
|
||||||
|
const isActive = (d.id || d.device_id) == selectedPanelDeviceId;
|
||||||
|
const statusClass = d.status === 'online' ? 'online' : 'offline';
|
||||||
|
const deviceId = d.id || d.device_id || '';
|
||||||
|
const imeiShort = d.imei ? '...' + d.imei.slice(-8) : '-';
|
||||||
|
const bp = d.battery_level;
|
||||||
|
const bColor = bp != null ? (bp < 20 ? '#f87171' : bp < 50 ? '#fbbf24' : '#34d399') : '#4b5563';
|
||||||
|
const lastActive = d.last_heartbeat || d.last_login;
|
||||||
|
const timeAgo = lastActive ? formatTimeAgo(lastActive) : '无活动';
|
||||||
|
return `<div class="panel-item ${isActive ? 'active' : ''}" data-device-id="${deviceId}" data-search-text="${(d.name||'').toLowerCase()} ${(d.imei||'').toLowerCase()}" onclick="selectPanelDevice('${deviceId}')">
|
||||||
|
<div class="panel-item-actions">
|
||||||
|
<button class="panel-action-btn" onclick="event.stopPropagation();selectPanelDevice('${deviceId}')" title="定位"><i class="fas fa-crosshairs"></i></button>
|
||||||
|
<button class="panel-action-btn" onclick="event.stopPropagation();showDeviceDetail('${deviceId}')" title="详情"><i class="fas fa-info-circle"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-item-header">
|
||||||
|
<span class="panel-item-name">${escapeHtml(d.name || d.imei || deviceId)}</span>
|
||||||
|
<div class="panel-item-status ${statusClass}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-item-sub">${escapeHtml(imeiShort)}</div>
|
||||||
|
<div class="panel-item-meta">
|
||||||
|
${bp != null ? `<span style="display:flex;align-items:center;gap:3px"><span class="battery-bar"><span class="battery-bar-fill" style="width:${bp}%;background:${bColor}"></span></span><span>${bp}%</span></span>` : ''}
|
||||||
|
${d.gsm_signal != null ? `<span><i class="fas fa-signal" style="font-size:10px"></i> ${d.gsm_signal}</span>` : ''}
|
||||||
|
<span style="margin-left:auto"><i class="far fa-clock" style="font-size:10px"></i> ${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBeaconPanel(beacons) {
|
||||||
|
panelBeacons = beacons;
|
||||||
|
const container = _initPanelRender(
|
||||||
|
PANEL_IDS.beacons, beacons, 'status', 'active', '暂无信标',
|
||||||
|
(a, t) => `${a}/${t}`, (a, t) => `共 ${t} 个信标,${a} 个启用`
|
||||||
|
);
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = beacons.map(b => {
|
||||||
|
const isActive = b.id == selectedPanelBeaconId;
|
||||||
|
const statusClass = b.status === 'active' ? 'active' : 'inactive';
|
||||||
|
const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '未设置';
|
||||||
|
const macShort = b.beacon_mac ? b.beacon_mac.slice(-8) : '-';
|
||||||
|
return `<div class="panel-item ${isActive ? 'active' : ''}" data-beacon-id="${b.id}" data-search-text="${(b.name||'').toLowerCase()} ${(b.beacon_mac||'').toLowerCase()} ${(b.area||'').toLowerCase()}" onclick="selectPanelBeacon(${b.id})">
|
||||||
|
<div class="panel-item-actions">
|
||||||
|
<button class="panel-action-btn" onclick="event.stopPropagation();showEditBeaconModal(${b.id})" title="编辑"><i class="fas fa-edit"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-item-header">
|
||||||
|
<span class="panel-item-name">${escapeHtml(b.name || b.beacon_mac)}</span>
|
||||||
|
<div class="panel-item-status ${statusClass}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-item-sub">${escapeHtml(macShort)}</div>
|
||||||
|
<div class="panel-item-meta">
|
||||||
|
<span><i class="fas fa-layer-group" style="font-size:10px"></i> ${escapeHtml(floorArea)}</span>
|
||||||
|
${b.latitude && b.longitude ? '<span style="color:#34d399"><i class="fas fa-map-pin" style="font-size:10px"></i> 已定位</span>' : '<span style="color:#6b7280">未定位</span>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPanelItems(type) {
|
||||||
|
const ids = PANEL_IDS[type];
|
||||||
|
if (!ids) return;
|
||||||
|
const inputEl = document.getElementById(ids.search);
|
||||||
|
const listEl = document.getElementById(ids.list);
|
||||||
|
if (!inputEl || !listEl) return;
|
||||||
|
const keyword = (inputEl.value || '').toLowerCase().trim();
|
||||||
|
listEl.querySelectorAll('.panel-item').forEach(item => {
|
||||||
|
item.style.display = (!keyword || (item.dataset.searchText || '').includes(keyword)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPanelDevice(deviceId, autoLocate = true) {
|
||||||
|
selectedPanelDeviceId = deviceId;
|
||||||
|
document.querySelectorAll('#locPanelList .panel-item').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.deviceId == deviceId);
|
||||||
|
});
|
||||||
|
const select = document.getElementById('locDeviceSelect');
|
||||||
|
if (select) select.value = deviceId;
|
||||||
|
const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`);
|
||||||
|
if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
if (autoLocate && deviceId) loadLatestPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPanelBeacon(beaconId) {
|
||||||
|
selectedPanelBeaconId = beaconId;
|
||||||
|
document.querySelectorAll('#beaconPanelList .panel-item').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.dataset.beaconId == beaconId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDevicesByActivity(devices) {
|
||||||
|
return [...devices].sort((a, b) => {
|
||||||
|
if (a.status === 'online' && b.status !== 'online') return -1;
|
||||||
|
if (a.status !== 'online' && b.status === 'online') return 1;
|
||||||
|
const tA = new Date(a.last_heartbeat || a.last_login || 0).getTime();
|
||||||
|
const tB = new Date(b.last_heartbeat || b.last_login || 0).getTime();
|
||||||
|
return tB - tA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoSelectActiveDevice(devices) {
|
||||||
|
if (!devices || devices.length === 0) return;
|
||||||
|
const sorted = sortDevicesByActivity(devices);
|
||||||
|
const best = sorted[0];
|
||||||
|
const bestId = best.id || best.device_id;
|
||||||
|
if (bestId) selectPanelDevice(bestId, true);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== DEVICE SELECTOR HELPER ====================
|
// ==================== DEVICE SELECTOR HELPER ====================
|
||||||
let cachedDevices = null;
|
let cachedDevices = null;
|
||||||
|
|
||||||
@@ -1064,6 +1306,12 @@
|
|||||||
});
|
});
|
||||||
if (currentVal) sel.value = currentVal;
|
if (currentVal) sel.value = currentVal;
|
||||||
});
|
});
|
||||||
|
// Render device panel on locations page
|
||||||
|
if (currentPage === 'locations' && document.getElementById('locPanelList')) {
|
||||||
|
const sorted = sortDevicesByActivity(devices);
|
||||||
|
renderDevicePanel(sorted);
|
||||||
|
if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load device selectors:', err);
|
console.error('Failed to load device selectors:', err);
|
||||||
}
|
}
|
||||||
@@ -1264,31 +1512,159 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _locTypeLabel(t) {
|
||||||
|
const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' };
|
||||||
|
return map[t] || t || '-';
|
||||||
|
}
|
||||||
|
function _locModeBadges(locType) {
|
||||||
|
const modes = [
|
||||||
|
{ label: 'GPS', match: ['gps','gps_4g'] },
|
||||||
|
{ label: 'LBS', match: ['lbs','lbs_4g'] },
|
||||||
|
{ label: 'WiFi', match: ['wifi','wifi_4g'] },
|
||||||
|
{ label: '蓝牙', match: ['bluetooth'] },
|
||||||
|
];
|
||||||
|
return modes.map(m => {
|
||||||
|
const active = m.match.includes(locType);
|
||||||
|
const color = active ? '#10b981' : '#4b5563';
|
||||||
|
const bg = active ? 'rgba(16,185,129,0.15)' : 'rgba(75,85,99,0.2)';
|
||||||
|
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:12px;border:1px solid ${color};background:${bg};color:${color};margin:2px">${active?'●':'○'} ${m.label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Quick command sender for device detail panel ---
|
||||||
|
async function _quickCmd(deviceId, cmd, btnEl) {
|
||||||
|
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; }
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`${API_BASE}/commands/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }),
|
||||||
|
});
|
||||||
|
showToast(`已发送: ${cmd}`);
|
||||||
|
// Poll for response (device replies via 0x81)
|
||||||
|
const cmdId = res && res.id;
|
||||||
|
if (cmdId) _pollCmdResponse(cmdId, cmd);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`发送失败: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btnEl) { btnEl.disabled = false; btnEl.style.opacity = '1'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function _pollCmdResponse(cmdId, cmdName) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
try {
|
||||||
|
const cmd = await apiCall(`${API_BASE}/commands/${cmdId}`);
|
||||||
|
if (cmd.response_content) {
|
||||||
|
const el = document.getElementById('detailCmdResult');
|
||||||
|
if (el) el.innerHTML = `<span class="text-gray-400" style="font-size:11px">${escapeHtml(cmdName)}:</span> <span style="font-size:12px;color:#d1d5db">${escapeHtml(cmd.response_content)}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function _quickTts(deviceId) {
|
||||||
|
const input = document.getElementById('detailTtsInput');
|
||||||
|
if (!input || !input.value.trim()) { showToast('请输入语音内容', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await apiCall(`${API_BASE}/commands/tts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ device_id: parseInt(deviceId), text: input.value.trim() }),
|
||||||
|
});
|
||||||
|
showToast('TTS 已发送');
|
||||||
|
input.value = '';
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`TTS失败: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function showDeviceDetail(id) {
|
async function showDeviceDetail(id) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
const device = await apiCall(`${API_BASE}/devices/${id}`);
|
const [device, latestLoc] = await Promise.all([
|
||||||
|
apiCall(`${API_BASE}/devices/${id}`),
|
||||||
|
apiCall(`${API_BASE}/locations/latest/${id}`).catch(() => null),
|
||||||
|
]);
|
||||||
const d = device;
|
const d = device;
|
||||||
|
const did = d.id || d.device_id;
|
||||||
|
const loc = latestLoc;
|
||||||
|
const locType = loc ? loc.location_type : null;
|
||||||
|
const locTime = loc ? formatTime(loc.recorded_at) : '-';
|
||||||
|
const locAddr = loc && loc.address ? escapeHtml(loc.address) : '-';
|
||||||
|
const locCoord = loc && loc.latitude ? `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}` : '-';
|
||||||
|
const online = d.status === 'online';
|
||||||
|
const disabledAttr = online ? '' : 'disabled style="opacity:0.4;cursor:not-allowed"';
|
||||||
|
const _btn = (icon, label, cmd, color) =>
|
||||||
|
`<button class="btn" style="font-size:12px;padding:5px 10px;background:${color};border:none;color:#fff;border-radius:6px;cursor:pointer" ${disabledAttr} onclick="_quickCmd('${did}','${cmd}',this)"><i class="fas fa-${icon}"></i> ${label}</button>`;
|
||||||
|
|
||||||
showModal(`
|
showModal(`
|
||||||
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情</h3>
|
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情 — ${escapeHtml(d.name || d.imei)}</h3>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<!-- 基本信息 -->
|
||||||
<div><span class="text-gray-400">ID:</span><br><span class="font-mono">${escapeHtml(d.id || d.device_id)}</span></div>
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
|
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
|
||||||
<div><span class="text-gray-400">名称:</span><br>${escapeHtml(d.name || '-')}</div>
|
<div><span class="text-gray-400">型号:</span><br>${escapeHtml(d.device_type || '-')}</div>
|
||||||
<div><span class="text-gray-400">类型:</span><br>${escapeHtml(d.device_type || '-')}</div>
|
|
||||||
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
|
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
|
||||||
<div><span class="text-gray-400">电量:</span><br>${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}</div>
|
<div><span class="text-gray-400">电量:</span><br>${d.battery_level != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? ' | 信号: ' + d.gsm_signal : ''}</div>
|
||||||
<div><span class="text-gray-400">信号强度:</span><br>${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}</div>
|
<div><span class="text-gray-400">ICCID:</span><br><span class="font-mono" style="font-size:11px">${escapeHtml(d.iccid || '-')}</span></div>
|
||||||
<div><span class="text-gray-400">固件版本:</span><br>${escapeHtml(d.firmware_version || '-')}</div>
|
<div><span class="text-gray-400">时区/语言:</span><br>${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}</div>
|
||||||
<div><span class="text-gray-400">时区:</span><br>${escapeHtml(d.timezone || '-')}</div>
|
|
||||||
<div><span class="text-gray-400">语言:</span><br>${escapeHtml(d.language || '-')}</div>
|
|
||||||
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
|
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
|
||||||
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
|
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
|
||||||
<div class="col-span-2"><span class="text-gray-400">创建时间:</span><br>${formatTime(d.created_at)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 mt-6">
|
|
||||||
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${d.id || d.device_id}')"><i class="fas fa-edit"></i> 编辑</button>
|
<!-- 定位信息 -->
|
||||||
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${d.id || d.device_id}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
|
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-map-marker-alt mr-1 text-green-400"></i>定位信息</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div><span class="text-gray-400">当前模式:</span><br><span style="color:#10b981;font-weight:600">${_locTypeLabel(locType)}</span></div>
|
||||||
|
<div><span class="text-gray-400">定位时间:</span><br>${locTime}</div>
|
||||||
|
<div class="col-span-2"><span class="text-gray-400">地址:</span><br>${locAddr}</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px">${_locModeBadges(locType)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 功能开关 -->
|
||||||
|
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:10px"><i class="fas fa-sliders-h mr-1 text-yellow-400"></i>功能开关${online ? '' : ' <span style="color:#ef4444;font-weight:400;font-size:11px">(设备离线,无法操作)</span>'}</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
|
||||||
|
${_btn('satellite-dish', 'GPS 开启', 'GPSON#', '#0d9488')}
|
||||||
|
${_btn('bluetooth-b', '蓝牙开启', 'BTON#', '#2563eb')}
|
||||||
|
${_btn('broadcast-tower', 'BLE扫描', 'BTSCAN,1#', '#7c3aed')}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:10px">工作模式:</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
|
||||||
|
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
|
||||||
|
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">信息查询:</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||||||
|
${_btn('info-circle', '设备状态', 'STATUS#', '#374151')}
|
||||||
|
${_btn('cog', '参数查询', 'PARAM#', '#374151')}
|
||||||
|
${_btn('code-branch', '固件版本', 'VERSION#', '#374151')}
|
||||||
|
${_btn('stopwatch', '定时器', 'TIMER#', '#374151')}
|
||||||
|
${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')}
|
||||||
|
</div>
|
||||||
|
<div id="detailCmdResult" style="margin-top:10px;padding:8px;background:#0d1117;border-radius:6px;min-height:20px;font-family:monospace;word-break:break-all;display:${online?'block':'none'}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TTS -->
|
||||||
|
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151${online?'':'display:none'}">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-volume-up mr-1 text-purple-400"></i>语音播报 (TTS)</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input id="detailTtsInput" type="text" placeholder="输入语音内容 (最多200字)" maxlength="200" style="flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:6px 10px;color:#e5e7eb;font-size:13px" ${online?'':'disabled'}>
|
||||||
|
<button class="btn" style="font-size:12px;padding:6px 14px;background:#7c3aed;border:none;color:#fff;border-radius:6px;white-space:nowrap" ${disabledAttr} onclick="_quickTts('${did}')"><i class="fas fa-play"></i> 播报</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 危险操作 -->
|
||||||
|
<div style="margin-top:14px;padding:12px;background:rgba(127,29,29,0.15);border-radius:8px;border:1px solid #7f1d1d${online?'':'display:none'}">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#fca5a5;margin-bottom:8px"><i class="fas fa-exclamation-triangle mr-1"></i>系统操作</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn" style="font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" ${disabledAttr} onclick="if(confirm('确定重启设备?'))_quickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启设备</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 mt-5">
|
||||||
|
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${did}')"><i class="fas fa-edit"></i> 编辑</button>
|
||||||
|
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${did}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></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>
|
||||||
`);
|
`);
|
||||||
@@ -1364,15 +1740,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== MAP PROVIDER ====================
|
||||||
|
// Switch tile provider: 'gaode' | 'tianditu'
|
||||||
|
// 高德: GCJ-02 tiles, need coordinate conversion; 天地图: WGS-84 native
|
||||||
|
const MAP_PROVIDER = 'gaode';
|
||||||
|
|
||||||
|
// WGS-84 → GCJ-02 coordinate conversion (for 高德 tiles)
|
||||||
|
const _gcj_a = 6378245.0, _gcj_ee = 0.00669342162296594;
|
||||||
|
function _outOfChina(lat, lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; }
|
||||||
|
function _transformLat(x, y) {
|
||||||
|
let r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*Math.sqrt(Math.abs(x));
|
||||||
|
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
|
||||||
|
r += (20.0*Math.sin(y*Math.PI) + 40.0*Math.sin(y/3.0*Math.PI)) * 2.0/3.0;
|
||||||
|
r += (160.0*Math.sin(y/12.0*Math.PI) + 320.0*Math.sin(y*Math.PI/30.0)) * 2.0/3.0;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function _transformLng(x, y) {
|
||||||
|
let r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*Math.sqrt(Math.abs(x));
|
||||||
|
r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0;
|
||||||
|
r += (20.0*Math.sin(x*Math.PI) + 40.0*Math.sin(x/3.0*Math.PI)) * 2.0/3.0;
|
||||||
|
r += (150.0*Math.sin(x/12.0*Math.PI) + 300.0*Math.sin(x/30.0*Math.PI)) * 2.0/3.0;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function wgs84ToGcj02(lat, lng) {
|
||||||
|
if (_outOfChina(lat, lng)) return [lat, lng];
|
||||||
|
let dLat = _transformLat(lng - 105.0, lat - 35.0);
|
||||||
|
let dLng = _transformLng(lng - 105.0, lat - 35.0);
|
||||||
|
const radLat = lat / 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 [lat + dLat, lng + dLng];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert WGS-84 coords for current map provider
|
||||||
|
function toMapCoord(lat, lng) {
|
||||||
|
if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng);
|
||||||
|
return [lat, lng]; // tianditu uses WGS-84 natively
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== LOCATIONS ====================
|
// ==================== LOCATIONS ====================
|
||||||
function initLocationMap() {
|
function initLocationMap() {
|
||||||
if (locationMap) return;
|
if (locationMap) return;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10);
|
locationMap = L.map('locationMap').setView(toMapCoord(39.9042, 116.4074), 10);
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
if (MAP_PROVIDER === 'gaode') {
|
||||||
attribution: '© OpenStreetMap contributors',
|
// 高德矢量底图 (GCJ-02, standard Mercator, no API key needed)
|
||||||
maxZoom: 19,
|
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', {
|
||||||
}).addTo(locationMap);
|
subdomains: '1234', maxZoom: 18,
|
||||||
|
attribution: '© 高德地图',
|
||||||
|
}).addTo(locationMap);
|
||||||
|
} else {
|
||||||
|
// 天地图矢量底图 + 中文注记 (WGS-84)
|
||||||
|
const TDT_KEY = '1918548e81a5ae3ff0cb985537341146';
|
||||||
|
L.tileLayer('https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
|
||||||
|
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
|
||||||
|
}).addTo(locationMap);
|
||||||
|
L.tileLayer('https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, {
|
||||||
|
subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18,
|
||||||
|
attribution: '© 天地图',
|
||||||
|
}).addTo(locationMap);
|
||||||
|
}
|
||||||
locationMap.invalidateSize();
|
locationMap.invalidateSize();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -1420,10 +1850,11 @@
|
|||||||
const lat = loc.latitude || loc.lat;
|
const lat = loc.latitude || loc.lat;
|
||||||
const lng = loc.longitude || loc.lng || loc.lon;
|
const lng = loc.longitude || loc.lng || loc.lon;
|
||||||
if (lat && lng) {
|
if (lat && lng) {
|
||||||
latlngs.push([lat, lng]);
|
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||||
|
latlngs.push([mLat, mLng]);
|
||||||
const isFirst = i === 0;
|
const isFirst = i === 0;
|
||||||
const isLast = i === locations.length - 1;
|
const isLast = i === locations.length - 1;
|
||||||
const marker = L.circleMarker([lat, lng], {
|
const marker = L.circleMarker([mLat, mLng], {
|
||||||
radius: isFirst || isLast ? 8 : 4,
|
radius: isFirst || isLast ? 8 : 4,
|
||||||
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
|
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
|
||||||
color: '#fff', weight: 1, fillOpacity: 0.9,
|
color: '#fff', weight: 1, fillOpacity: 0.9,
|
||||||
@@ -1466,7 +1897,8 @@
|
|||||||
const lng = loc.longitude || loc.lng || loc.lon;
|
const lng = loc.longitude || loc.lng || loc.lon;
|
||||||
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; }
|
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; }
|
||||||
|
|
||||||
const marker = L.marker([lat, lng]).addTo(locationMap);
|
const [mLat, mLng] = toMapCoord(lat, lng);
|
||||||
|
const marker = L.marker([mLat, mLng]).addTo(locationMap);
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<b>最新位置</b><br>
|
<b>最新位置</b><br>
|
||||||
类型: ${loc.location_type || '-'}<br>
|
类型: ${loc.location_type || '-'}<br>
|
||||||
@@ -1476,7 +1908,7 @@
|
|||||||
时间: ${formatTime(loc.recorded_at || loc.created_at)}
|
时间: ${formatTime(loc.recorded_at || loc.created_at)}
|
||||||
`).openPopup();
|
`).openPopup();
|
||||||
mapMarkers.push(marker);
|
mapMarkers.push(marker);
|
||||||
locationMap.setView([lat, lng], 15);
|
locationMap.setView([mLat, mLng], 15);
|
||||||
showToast('已显示最新位置');
|
showToast('已显示最新位置');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('获取最新位置失败: ' + err.message, 'error');
|
showToast('获取最新位置失败: ' + err.message, 'error');
|
||||||
@@ -1783,6 +2215,8 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons');
|
renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons');
|
||||||
|
// Render beacon side panel
|
||||||
|
renderBeaconPanel(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('加载信标列表失败: ' + err.message, 'error');
|
showToast('加载信标列表失败: ' + err.message, 'error');
|
||||||
document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from app.protocol.constants import (
|
|||||||
PROTO_ONLINE_CMD_REPLY,
|
PROTO_ONLINE_CMD_REPLY,
|
||||||
PROTO_TIME_SYNC,
|
PROTO_TIME_SYNC,
|
||||||
PROTO_TIME_SYNC_2,
|
PROTO_TIME_SYNC_2,
|
||||||
|
PROTO_ADDRESS_REPLY_EN,
|
||||||
PROTO_WIFI,
|
PROTO_WIFI,
|
||||||
PROTO_WIFI_4G,
|
PROTO_WIFI_4G,
|
||||||
START_MARKER_LONG,
|
START_MARKER_LONG,
|
||||||
@@ -718,6 +719,11 @@ class TCPManager:
|
|||||||
logger.warning("Heartbeat received before login")
|
logger.warning("Heartbeat received before login")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ensure device is tracked in active connections (e.g. after server restart)
|
||||||
|
if imei not in self.connections:
|
||||||
|
self.connections[imei] = (reader, writer, conn_info)
|
||||||
|
logger.info("Device IMEI=%s re-registered via heartbeat", imei)
|
||||||
|
|
||||||
terminal_info: int = 0
|
terminal_info: int = 0
|
||||||
battery_level: Optional[int] = None
|
battery_level: Optional[int] = None
|
||||||
gsm_signal: Optional[int] = None
|
gsm_signal: Optional[int] = None
|
||||||
@@ -752,11 +758,12 @@ class TCPManager:
|
|||||||
logger.warning("Heartbeat for unknown IMEI=%s", imei)
|
logger.warning("Heartbeat for unknown IMEI=%s", imei)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update device record
|
# Update device record (also ensure status=online if heartbeat is coming in)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(Device)
|
update(Device)
|
||||||
.where(Device.id == device_id)
|
.where(Device.id == device_id)
|
||||||
.values(
|
.values(
|
||||||
|
status="online",
|
||||||
battery_level=battery_level,
|
battery_level=battery_level,
|
||||||
gsm_signal=gsm_signal,
|
gsm_signal=gsm_signal,
|
||||||
last_heartbeat=now,
|
last_heartbeat=now,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ sqlalchemy>=2.0.0
|
|||||||
aiosqlite>=0.19.0
|
aiosqlite>=0.19.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
|
slowapi
|
||||||
|
|||||||
Reference in New Issue
Block a user