feat: KKS P240/P241 蓝牙工牌管理系统初始提交

FastAPI + SQLAlchemy + asyncio TCP 服务器,支持设备管理、实时定位、
告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
This commit is contained in:
2026-03-27 10:19:34 +00:00
commit d54e53e0b7
43 changed files with 15078 additions and 0 deletions

43
.env.example Normal file
View 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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.db
*.log
nohup.out
.env
.claude/
.idea/

559
CLAUDE.md Normal file
View File

@@ -0,0 +1,559 @@
# Badge Admin - KKS P240/P241 蓝牙工牌管理系统
## 项目概览
KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio TCP 服务器。
支持设备管理、实时定位、告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
## 项目结构
```
/home/gpsystem/
├── run.py # 启动脚本 (uvicorn)
├── .env.example # 环境变量模板 (复制为 .env 使用)
├── requirements.txt # Python 依赖
├── frpc.toml # FRP 客户端配置 (TCP隧道)
├── badge_admin.db # SQLite 数据库
├── server.log # 服务器日志
├── app/
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
│ ├── database.py # SQLAlchemy async 数据库连接
│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin)
│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取)
│ ├── websocket_manager.py # WebSocket 连接管理器 (topic订阅, 实时广播)
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog, ApiKey)
│ ├── schemas.py # Pydantic 请求/响应模型
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
│ ├── geocoding.py # 高德地理编码服务 (基站/WiFi → 经纬度 + 经纬度 → 地址)
│ │
│ ├── protocol/
│ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等)
│ │ ├── parser.py # 二进制协议解析器
│ │ ├── builder.py # 响应包构建器
│ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408)
│ │
│ ├── services/
│ │ ├── device_service.py # 设备 CRUD
│ │ ├── command_service.py # 指令日志 CRUD
│ │ ├── location_service.py # 位置记录查询
│ │ ├── beacon_service.py # 蓝牙信标 CRUD
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
│ │
│ ├── routers/
│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete, /all-latest-locations)
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
│ │ ├── locations.py # /api/locations (含 /latest, /batch-latest, /track, /{id})
│ │ ├── alarms.py # /api/alarms (含 acknowledge, alarm_source过滤)
│ │ ├── attendance.py # /api/attendance (含 /stats, /{id})
│ │ ├── bluetooth.py # /api/bluetooth (含 beacon_mac过滤, /{id})
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
│ │ ├── beacons.py # /api/beacons (信标管理 CRUD)
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅)
│ │
│ └── static/
│ └── admin.html # 管理后台 SPA (暗色主题, 8个页面)
└── doc/
└── KKS_Protocol_P240_P241.md # 协议文档 (从PDF转换)
```
## 架构设计
### 网络拓扑
```
[P241 工牌] --TCP--> [152.69.205.186:5001 (frps)]
|
FRP Tunnel
|
[Container:5000 (frpc)]
|
[TCP Server (asyncio)]
|
[FastAPI :8088] --CF Tunnel--> [Web 访问]
```
### 端口说明
- **8088**: FastAPI HTTP API + 管理后台 UI
- **5000**: TCP 服务器 (KKS 二进制协议)
- **5001**: 远程服务器 FRP 映射端口 (152.69.205.186)
- **7000**: FRP 服务端管理端口
- **7500**: FRP Dashboard (admin/PassWord0325)
### 配置管理
- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值
- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用
- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录)
- 高德 API 密钥集中在 `config.py``geocoding.py``settings` 导入
- 端口号有 pydantic 校验 (ge=1, le=65535)
## 启动服务
```bash
# 启动服务
cd /home/gpsystem
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
# 启动 FRP 客户端 (TCP隧道)
nohup /tmp/frpc -c /home/gpsystem/frpc.toml > /tmp/frpc.log 2>&1 &
# 检查服务状态
curl http://localhost:8088/health
curl http://localhost:8088/api/devices
```
## 管理后台 UI
访问 `/admin` 路径,包含以下页面:
1. **仪表盘** - 设备统计、告警概览、在线状态
2. **设备管理** - 添加/编辑/删除设备
3. **位置追踪** - Leaflet 地图、轨迹回放、地址显示
4. **告警管理** - 告警列表、确认处理、位置/地址显示
5. **考勤记录** - 上下班打卡记录 (0xB0/0xB1 GPS+LBS+WiFi)
6. **蓝牙记录** - 蓝牙打卡(0xB2)/定位(0xB3)记录含信标MAC/UUID/RSSI等
7. **信标管理** - 蓝牙信标注册、位置配置MAC/UUID/楼层/区域/经纬度)
8. **指令管理** - 发送指令/留言/TTS语音
## 协议详情
KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
### 已支持协议号
| 协议号 | 名称 | 方向 |
|--------|------|------|
| 0x01 | Login 登录 | 终端→服务器 |
| 0x13 | Heartbeat 心跳 | 双向 |
| 0x1F | Time Sync 时间同步 | 双向 |
| 0x22 | GPS 定位 | 终端→服务器 |
| 0x26 | Alarm ACK 告警确认 | 服务器→终端 |
| 0x28 | LBS 多基站 | 终端→服务器 |
| 0x2C | WiFi 定位 | 终端→服务器 |
| 0x36 | Heartbeat Extended 扩展心跳 | 双向 |
| 0x80 | Online Command 在线指令 | 服务器→终端 |
| 0x81 | Online Command Reply | 终端→服务器 |
| 0x82 | Message 留言 | 服务器→终端 |
| 0x94 | General Info (ICCID/IMSI等) | 双向 |
| 0xA0 | 4G GPS 定位 | 终端→服务器 |
| 0xA1 | 4G LBS 多基站 | 终端→服务器 |
| 0xA2 | 4G WiFi 定位 | 终端→服务器 |
| 0xA3-0xA5 | 4G 告警 (围栏/LBS) | 终端→服务器 |
| 0xA9 | WiFi 告警 | 终端→服务器 |
| 0xB0-0xB1 | 考勤打卡 | 双向 |
| 0xB2 | 蓝牙打卡 | 双向 |
| 0xB3 | 蓝牙定位 | 终端→服务器 |
### 数据包格式
```
[Start 0x7878/0x7979] [Length 1-2B] [Protocol 1B] [Content NB] [Serial 2B] [CRC 2B] [Stop 0x0D0A]
```
## 当前设备
| 字段 | 值 |
|------|-----|
| IMEI | 868120334031363 |
| 型号 | P241 |
| 名称 | 测试 |
| DB ID | 1 |
| ICCID | 89860848102570005286 |
| IMSI | 460240388355286 |
| SIM卡 | 中国移动 IoT (MCC=460, MNC=0) |
| 位置 | 成都地区 (~30.605°N, 103.936°E) |
## 重要实现细节
### IMEI 解析
- BCD 编码: 8字节 = 16 hex digits, 首位为填充 0
- 使用 `raw_hex[1:]` 只移除首位填充0 (不用 lstrip 避免移除多个)
### 设备登录
- 登录时不覆盖用户设置的 device_type (避免被覆盖为 raw hex)
- 重连时先关闭旧连接再建立新连接
- 断开连接时检查 writer 是否匹配,避免误将新连接标记为 offline
### 告警处理
- 告警响应使用 **0x26 ACK** (非原始协议号回复)
- 告警类型常量使用 **snake_case** (如 `sos`, `vibration`, `enter_fence`)
- 4G 告警包解析:
- **0xA3/0xA4** (围栏告警): datetime(6) + gps(12) + lbs_length(1) + MCC/MNC + LAC(4) + CellID(8) + terminal_info(1) + voltage_level(**1**字节) + gsm_signal(1) + alarm_code(1) + language(1)
- **0xA5** (LBS告警): **无datetime, 无GPS** — 直接从 MCC 开始
- **0xA9** (WiFi告警): datetime(6) + MCC/MNC + cell_type(1) + cell_count(1) + 基站列表 + timing_advance(1) + WiFi列表 + alarm_code(1) + language(1)
- MCC 高位 `0x8000` 标志: 表示 MNC 为 2 字节 (非默认1字节)
- voltage_level 是 **1字节** (0x00-0x06 等级)不是2字节毫伏值
- LBS/WiFi 报警自动执行前向地理编码(基站→经纬度)+ 逆地理编码(经纬度→地址)
### 位置数据与地理编码
- GPS 定位 (0x22/0xA0): 直接包含经纬度坐标,精度最高 (~10m)
- LBS 基站定位 (0x28/0xA1): 包含 MCC/MNC/LAC/CellID需要地理编码转换为经纬度
- WiFi 定位 (0x2C/0xA2): 包含基站数据 + WiFi AP MAC地址列表需要地理编码
- **所有地理编码服务统一使用高德 (Amap)**
- **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度
- **高德智能硬件定位**: `apilocate.amap.com/position`,需企业认证 (认证中,个人账号返回 10012)
- WiFi+基站混合定位精度 ~30m企业认证通过后自动生效
- **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址
- **高德**: `restapi.amap.com/v3/geocode/regeo`,需 WGS84→GCJ02 坐标转换 (服务端 Python 实现)
- 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key
- 内置 LRU 缓存 (maxsize=10000),避免重复请求相同基站/坐标
- **WGS84→GCJ02 服务端转换**: geocoding.py 内置 `wgs84_to_gcj02()` 函数 (与前端 JS 版一致)
- **高德数字签名**: 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
### API 认证与限流
- **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头
- **多 API Key**: 支持 master key (环境变量) + 数据库管理的 API Key (SHA-256 hash)
- **权限级别**: `read` (只读) < `write` (读写) < `admin` (管理,含 key 管理)
- **权限控制**: 所有 POST/PUT/DELETE 端点需要 `write` 权限,`/api/keys` 需要 `admin` 权限
- **Key 管理**: `POST /api/keys` 创建 key (返回明文一次), `GET /api/keys` 列表, `PUT /api/keys/{id}` 更新, `DELETE /api/keys/{id}` 停用
- **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`)
- **真实 IP**: 从 `X-Forwarded-For``CF-Connecting-IP``request.client.host` 提取
- **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials`
### WebSocket 实时推送
- **端点**: `ws://host/ws?api_key=xxx&topics=location,alarm`
- **Topics**: location, alarm, device_status, attendance, bluetooth
- **认证**: query param api_key (支持 master key + DB key)
- **最大连接**: 100 个 WebSocket 连接
- **消息格式**: JSON `{"topic": "...", "data": {...}, "timestamp": "..."}`
- **广播点**: 位置存储、报警存储、设备上下线、考勤存储、蓝牙存储
- **Ping/Pong**: 客户端发送 "ping" 保活,服务器回复 "pong"
### 数据清理
- **自动清理**: 后台定时任务,每 `DATA_CLEANUP_INTERVAL_HOURS`(默认24) 小时执行
- **保留天数**: `DATA_RETENTION_DAYS`(默认90) 天,删除过期的 location/heartbeat/alarm/attendance/bluetooth 记录
- **手动清理**: `POST /api/system/cleanup` (admin only)
### 批量操作 API
- `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重结果按输入顺序
- `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush
- `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids
- `POST /api/locations/batch-latest` — 批量获取多设备最新位置 (最多100)
- `GET /api/devices/all-latest-locations` — 获取所有在线设备最新位置
- `POST /api/commands/batch` — 批量发送指令 (最多100)`model_validator` 互斥校验
- 所有批量操作使用 WHERE IN 批量查询,避免 N+1
### API 分页
- page_size 最大限制为 100 (schema 层校验)
- 前端设备选择器使用 page_size=100 (不能超过限制)
### CRC 算法
- CRC-ITU / CRC-16/X-25
- Reflected polynomial: 0x8408
- Initial value: 0xFFFF
- Final XOR: 0xFFFF
### TCP 指令下发
- 0x80 (Online Command): ASCII 指令 + 2字节语言字段 (`0x0001`=中文)
- 0x81 (Command Reply): 设备回复,格式 length(1) + server_flag(4) + content(n) + language(2)
- 0x82 (Message): **UTF-16 BE** 编码的文本消息 (非UTF-8)
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
- 已验证可用指令: `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) 管理
- **重要**: 服务器启动时自动将所有设备标记为 offline等待设备重新登录
### 蓝牙信标管理
- **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址
- **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表
- **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功
- **当前状态**: ✅ 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 智能电子工牌系列
### 0x94 General Info 子协议
- 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节)
- 子协议 0x09: GPS 卫星状态
- 子协议 0x00: ICCID(10字节)
- 子协议 0x04: 设备配置上报 `ALM2=40;ALM4=E0;MODE=03;IMSI=...`
### 前端字段映射 (admin.html)
- 设备信号: `d.gsm_signal` (非 `d.signal_strength`)
- 指令响应: `c.response_content` (非 `c.response`)
- 响应时间: `c.response_at || c.sent_at` (非 `c.updated_at`)
- 位置地址: `l.address` (高德逆地理编码结果)
- 卫星数: `l.gps_satellites` (非 `l.accuracy`)
- 记录时间: `l.recorded_at` (非 `l.timestamp`)
- 报警来源: `a.alarm_source` (非 `a.source`)
- 报警信号: `a.gsm_signal` (非 `a.signal_strength`)
- 报警类型: snake_case (如 `vibration`, `power_cut`, `voice_alarm`)
## FRP 配置
```toml
# frpc.toml (容器端)
serverAddr = "152.69.205.186"
serverPort = 7000
auth.method = "token"
auth.token = "PassWord0325"
[[proxies]]
name = "badge-tcp"
type = "tcp"
localIP = "127.0.0.1"
localPort = 5000
remotePort = 5001
```
## 外部服务器
- IP: 152.69.205.186
- OS: Ubuntu 20.04, Python 3.11, 24GB RAM
- FRP Server: frps v0.62.1 (1panel Docker, network_mode: host)
- Config: /opt/1panel/apps/frps/frps/data/frps.toml
## 高德地图 API
### Key
- **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b`
- **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5`
- **账号类型**: 个人认证开发者 (正在申请企业认证)
### 已接入服务
- **✅ 逆地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本,服务端 WGS84→GCJ02 坐标转换
- **✅ 智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (代码就绪,企业认证通过前返回 10012)
- **✅ 前端地图瓦片**: 高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
- **数字签名**: `_amap_sign()` — 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数
### 配额 (个人认证开发者)
- 基础LBS服务: 5,000 次/日 (逆地理编码等)
- 在线定位: 50,000 次/日 (企业认证后 1,000,000 次/日)
## 已知限制
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
5. **基站/WiFi定位需企业认证** - 高德智能硬件定位 API 已接入但个人账号返回 10012企业认证通过后自动生效
## 已修复的问题 (Bug Fix 记录)
### 数据链路修复
1. **IMEI 解析** - 修复 BCD 解码逻辑,确保正确提取 15 位 IMEI
2. **设备类型覆盖** - 登录时不再覆盖用户设置的 device_type
3. **设备重连** - 新连接建立前先关闭旧连接,断开时检查 writer 身份
4. **告警类型提取** - 修正 alarm byte 位置,从 terminal_info+voltage+gsm_signal 之后读取
### 协议修复
5. **0x80 指令包** - 添加缺失的 2 字节语言字段 (`0x0001`)
6. **0x82 消息编码** - 从 UTF-8 改为 UTF-16 BE (协议要求)
7. **0x94 通用信息** - 完善子协议 0x0A (IMEI+IMSI+ICCID) 和 0x09 (GPS卫星状态) 解析
### 前端修复
8. **设备选择器为空** - `page_size=500` 超过 API 最大限制 100返回 422 错误
9. **位置页面崩溃** - API 返回 `data: null` 时未做空值检查
10. **轨迹查询失败** - 缺少必填的 start_time/end_time 参数
11. **字段名不匹配** - 修复 signal_strength→gsm_signal, response→response_content, source→alarm_source 等
### 定位功能修复
12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失)
13. **地理编码集成** - LBS/WiFi 定位数据自动转换为经纬度坐标
14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段
### 告警功能修复
15. **告警响应协议** - 改为使用 0x26 ACK 响应 (原来错误使用地址回复)
16. **voltage_level 解析** - 从2字节改为1字节 (0x00-0x06等级)
17. **0xA5 LBS告警格式** - 移除错误的 datetime/lbs_length 前缀,直接从 MCC 开始
18. **0xA9 WiFi告警** - 独立解析器 (非GPS格式),支持 cell_type/cell_count/WiFi AP 列表
19. **MNC 2字节标志** - `_parse_mcc_mnc_from_content` 正确处理 MCC 高位 0x8000 标志
20. **告警类型常量** - 改为 snake_case新增 voice_alarm(0x16), fake_base_station(0x17), cover_open(0x18), internal_low_battery(0x19)
21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码
### 逆地理编码
22. **逆地理编码集成** - 位置和报警记录自动获取中文地址 (高德逆地理编码)
23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址
### 蓝牙与考勤功能
24. **0xB0/0xB1 考勤解析** - 完整解析 GPS+LBS+WiFi 考勤包含基站邻区、WiFi AP、前向+逆地理编码
25. **0xB2 蓝牙打卡解析** - 解析 iBeacon 数据 (RSSI/MAC/UUID/Major/Minor/电池/打卡类型)
26. **0xB3 蓝牙定位解析** - 解析多信标数据,每信标 30 字节,支持电压(V)/百分比(%)电池单位
27. **考勤类型检测** - 从 terminal_info 字节 bits[5:2] 判断 clock_in(0001)/clock_out(0010)
28. **信标管理系统** - BeaconConfig CRUD注册信标物理位置TCP 自动关联信标经纬度
### 指令发送修复
29. **server_flag_str 未定义** - send_command 中记录日志时变量未定义导致 NameError
30. **TCP 层重复创建日志** - 简化 send_command/send_message 只负责发送CommandLog 由 API 层管理
31. **IMEI lstrip 问题** - `lstrip("0")` 可能删除多个前导零,改为 `raw_hex[1:]`
32. **设备启动状态** - 服务器启动时重置所有设备为 offline避免显示在线但实际未连接
33. **0x81 回复解析** - 去除末尾 2 字节语言字段,正确提取回复文本
### 协议全面审计修复 (2026-03-16)
34. **0x1F 时间同步格式** - 从6字节(YY MM DD HH MM SS)改为4字节Unix时间戳+2字节语言字段
35. **地址回复完整格式** - 重写 `_send_address_reply`,实现完整格式: server_flag(4)+ADDRESS/ALARMSMS(7-8)+&&+UTF16BE地址+&&+电话(21)+##
36. **报警地址回复** - 报警包在发送0x26 ACK后额外发送0x17地址回复(含ALARMSMS标记)
37. **0x82 留言格式** - 添加 server_flag(4字节) + language(2字节) 字段
38. **0x22 GPS Cell ID** - 从2字节改为3字节解析 (2G基站CellID为3字节)
39. **登录包时区/语言** - 解析 content[10:12] 提取时区(bit15-04)和语言(bit00)存入Device
40. **0x94 不需回复** - 移除服务器响应,从 PROTOCOLS_REQUIRING_RESPONSE 中删除
41. **WiFi缓存+LRU淘汰** - 新增 LRUCache(OrderedDict, maxsize=10000)替换所有dict缓存修复WiFi缓存未写入
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
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. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError补充 import
52. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections
### 全面切换高德 API (2026-03-23)
53. **高德逆地理编码** - 接入 restapi.amap.com/v3/geocode/regeo服务端 WGS84→GCJ02 坐标转换
54. **高德智能硬件定位** - 接入 apilocate.amap.com/position (基站+WiFi定位企业认证通过前返回 10012)
55. **高德数字签名** - 实现 `_amap_sign()` 函数 (参数排序 + AMAP_SECRET + MD5)
56. **服务端坐标转换** - geocoding.py 内置 `wgs84_to_gcj02()` Python 实现
57. **高德地图瓦片** - 前端替换为高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换
58. **移除第三方地理编码** - 清理天地图/百度/Google/Unwired/Mylnikov统一使用高德
### API 安全加固 & 批量管理 (2026-03-20)
59. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击
60. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min)
61. **限流器真实 IP** - `extensions.py``X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP
62. **全局异常处理** - 拦截未处理异常返回 500不泄露堆栈放行 HTTPException/ValidationError
63. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern
64. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数
65. **批量设备创建** - `POST /api/devices/batch` (最多500台)WHERE IN 单次查询去重,输入列表内 IMEI 去重
66. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush
67. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制
68. **批量指令发送** - `POST /api/commands/batch` (最多100台)`model_validator` 互斥校验 device_ids/imeis
69. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取
70. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突
71. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh依赖 service 层
### 全面改进 (2026-03-22)
#### Protocol 修复
72. **parser.py 0x22 CellID** - 从2字节改为3字节解析
73. **parser.py 0xA5 LBS告警** - 移除错误的 datetime+lbs_length 前缀
74. **parser.py 0xA9 WiFi告警** - 完全重写为 datetime+MCC/MNC+cell_type+cell_count+基站+TA+WiFi+alarm_code
75. **parser.py voltage** - 0xA3/0xA5/0xA9 voltage 从2字节改为1字节
76. **parser.py 0xA4** - 新增多围栏告警解析器 (含 fence_id)
77. **parser.py 0xB2** - 完整 iBeacon 解析 (RSSI/MAC/UUID/Major/Minor/Battery)
78. **parser.py 0xB3** - beacon_count + 每信标30字节解析
79. **parser.py 0xB0/0xB1** - 补充 gps_positioned/terminal_info/voltage/gsm_signal 等中间字段
80. **parser.py 0x81** - 新增指令回复解析器
81. **builder.py 0x1F** - 时间同步添加2字节 language 参数
82. **builder.py 地址回复** - 重写 CN/EN 地址回复完整格式
83. **builder.py 0x80/0x82** - cmd_len 正确包含 language(2)
84. **constants.py** - 从 PROTOCOLS_REQUIRING_RESPONSE 移除 0x28
#### 批量 API + 索引
85. **batch-latest** - `POST /api/locations/batch-latest` 批量获取多设备最新位置
86. **all-latest-locations** - `GET /api/devices/all-latest-locations` 所有在线设备位置
87. **数据库索引** - 新增5个索引 (alarm_type, acknowledged, beacon_mac, location_type, attendance_type)
#### 多 API Key + 权限控制
88. **ApiKey 模型** - SHA-256 hash, permissions(read/write/admin), is_active
89. **多 Key 认证** - master key (env) + DB keys, last_used_at 追踪
90. **权限控制** - require_write/require_admin 依赖,写端点需 write 权限
91. **Key 管理 API** - `/api/keys` CRUD (admin only),创建时返回明文一次
92. **DeviceUpdate** - 移除 status 字段 (设备状态由系统管理)
#### WebSocket 实时推送
93. **WebSocketManager** - 连接管理器topic 订阅最大100连接
94. **WS 端点** - `/ws?api_key=xxx&topics=location,alarm` WebSocket 认证
95. **TCP 广播集成** - 7个广播点 (location, alarm, device_status x2, attendance, bluetooth x2)
#### 数据清理 + 补充
96. **自动数据清理** - 后台定时任务DATA_RETENTION_DAYS=90, DATA_CLEANUP_INTERVAL_HOURS=24
97. **手动清理 API** - `POST /api/system/cleanup` (admin only)
98. **alarm_source 过滤** - alarms 列表新增 alarm_source 参数
99. **beacon_mac 过滤** - bluetooth 列表新增 beacon_mac 参数
100. **command_type 过滤** - commands 列表新增 command_type 参数
101. **sort_order 参数** - alarms/bluetooth 列表支持 asc/desc 排序
#### 协议层统一
102. **PacketBuilder 统一** - tcp_server.py 内嵌 PacketBuilder 改为委托 protocol/builder.py
### 审计修复 (2026-03-23)
103. **require_admin 导入** - main.py 缺少 require_admin 导入,设置 API_KEY 后 cleanup 端点崩溃
104. **0x28/0x2C CellID 3字节** - tcp_server.py 2G 基站 CellID 从2字节修正为3字节 (0x28 LBS, 0x2C WiFi, 邻区解析)
105. **parser.py CellID 3字节** - parse_lbs_station 默认 cell_id_size=2→3新增3字节分支; _parse_lbs_address_req CellID 2→3字节
106. **alarm_source 字段长度** - models.py String(10)→String(20), schemas.py max_length=10→20 ("single_fence"=12字符)
107. **AlarmRecord wifi_data/fence_data** - 0xA9 WiFi报警存储 wifi_data; 0xA4 多围栏报警提取并存储 fence_id
108. **CommandLog.sent_at** - 指令发送成功后设置 sent_at 时间戳 (原来始终为 NULL)
109. **geocoding IMEI 参数化** - 移除硬编码 IMEI新增 GEOCODING_DEFAULT_IMEI 配置项geocode_location 接受 imei 参数TCP 层传递设备 IMEI
110. **parser.py 循环长度检查** - _parse_lbs_multi 和 _parse_wifi 站点循环检查从 pos+5 改为 pos+6 (LAC=2+CellID=3+RSSI=1=6)
111. **batch sent_at** - batch_send_command 批量指令路径也设置 cmd_log.sent_at (与单条路径一致)
112. **GPS 经度符号反转** - course_status bit 11 含义为 0=东经/1=西经 (非 1=东经)`is_east` 改为 `is_west`,修复成都定位到北美洲的问题 (tcp_server.py + parser.py)
## 待完成功能
1. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
2. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知
3. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py
## 调试技巧
```bash
# 查看实时日志
tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Amap" --line-buffered
# 检查数据库
python3 -c "
import sqlite3
conn = sqlite3.connect('/home/gpsystem/badge_admin.db')
cur = conn.cursor()
cur.execute('SELECT id, imei, device_type, status, battery_level FROM devices')
for row in cur.fetchall(): print(row)
cur.execute('SELECT id, location_type, latitude, longitude, address, recorded_at FROM location_records ORDER BY id DESC LIMIT 5')
for row in cur.fetchall(): print(row)
"
# 测试 API
curl -s http://localhost:8088/api/devices | python3 -m json.tool
curl -s http://localhost:8088/api/locations/latest/1 | python3 -m json.tool
curl -s http://localhost:8088/health
# 发送指令
curl -s -X POST http://localhost:8088/api/commands/send \
-H "Content-Type: application/json" \
-d '{"device_id":1,"command_type":"online_cmd","command_content":"GPSON#"}' | python3 -m json.tool
# 开启蓝牙扫描
curl -s -X POST http://localhost:8088/api/commands/send \
-H "Content-Type: application/json" \
-d '{"device_id":1,"command_type":"online_cmd","command_content":"BTON#"}' | python3 -m json.tool
# 管理信标
curl -s http://localhost:8088/api/beacons | python3 -m json.tool
curl -s -X POST http://localhost:8088/api/beacons \
-H "Content-Type: application/json" \
-d '{"beacon_mac":"AA:BB:CC:DD:EE:FF","name":"前台","floor":"1F","latitude":30.27,"longitude":120.15}' | python3 -m json.tool
# 检查 FRP 连接
ps aux | grep frpc
# FRP Dashboard: http://152.69.205.186:7500 (admin/PassWord0325)
# 检查端口
python3 -c "
with open('/proc/net/tcp') as f:
for line in f:
parts = line.split()
if len(parts)>=2 and ':' in parts[1]:
port = int(parts[1].split(':')[1], 16)
if port in (8088, 5000): print(f'Port {port}: listening')
"
# 测试地理编码
python3 -c "
import asyncio
from app.geocoding import geocode_location, reverse_geocode
async def test():
lat, lon = await geocode_location(mcc=460, mnc=0, lac=32775, cell_id=205098688)
print(f'lat={lat}, lon={lon}')
if lat: print(await reverse_geocode(lat, lon))
asyncio.run(test())
"
```

0
app/__init__.py Normal file
View File

50
app/config.py Normal file
View File

@@ -0,0 +1,50 @@
from pathlib import Path
from typing import Literal
from pydantic import Field
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):
APP_NAME: str = "KKS Badge Management System"
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_PORT: int = Field(default=5000, ge=1, le=65535)
API_HOST: str = "0.0.0.0"
API_PORT: int = Field(default=8088, ge=1, le=65535)
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")
# 高德地图 API (geocoding)
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
# Geocoding
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries")
# Track query limit
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
# Data retention
DATA_RETENTION_DAYS: int = Field(default=90, description="Days to keep location/heartbeat/alarm/attendance/bluetooth records")
DATA_CLEANUP_INTERVAL_HOURS: int = Field(default=24, description="Hours between automatic cleanup runs")
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
settings = Settings()

49
app/database.py Normal file
View File

@@ -0,0 +1,49 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
connect_args={"check_same_thread": False},
)
async_session = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""Dependency injection for async database sessions."""
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Create all database tables."""
async with engine.begin() as conn:
from app.models import ( # noqa: F401
AlarmRecord,
AttendanceRecord,
BluetoothRecord,
CommandLog,
Device,
HeartbeatRecord,
LocationRecord,
)
await conn.run_sync(Base.metadata.create_all)

85
app/dependencies.py Normal file
View File

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

22
app/extensions.py Normal file
View 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])

308
app/geocoding.py Normal file
View File

@@ -0,0 +1,308 @@
"""
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
and reverse geocode coordinates to addresses.
All services use 高德 (Amap) API exclusively.
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
- Reverse geocoding (coords → address): 高德逆地理编码
"""
import hashlib
import logging
import math
from collections import OrderedDict
from typing import Optional
import aiohttp
logger = logging.getLogger(__name__)
from app.config import settings as _settings
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
# ---------------------------------------------------------------------------
# WGS-84 → GCJ-02 coordinate conversion (server-side)
# ---------------------------------------------------------------------------
_A = 6378245.0
_EE = 0.00669342162296594
def _out_of_china(lat: float, lon: float) -> bool:
return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
def _transform_lat(x: float, y: float) -> float:
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
return ret
def _transform_lon(x: float, y: float) -> float:
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
return ret
def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
"""Convert WGS-84 to GCJ-02 (used by 高德)."""
if _out_of_china(lat, lon):
return (lat, lon)
d_lat = _transform_lat(lon - 105.0, lat - 35.0)
d_lon = _transform_lon(lon - 105.0, lat - 35.0)
rad_lat = lat / 180.0 * math.pi
magic = math.sin(rad_lat)
magic = 1 - _EE * magic * magic
sqrt_magic = math.sqrt(magic)
d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi)
d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi)
return (lat + d_lat, lon + d_lon)
# ---------------------------------------------------------------------------
# LRU Cache
# ---------------------------------------------------------------------------
class LRUCache(OrderedDict):
"""Simple LRU cache based on OrderedDict."""
def __init__(self, maxsize: int = _CACHE_MAX_SIZE):
super().__init__()
self._maxsize = maxsize
def get_cached(self, key):
if key in self:
self.move_to_end(key)
return self[key]
return None
def put(self, key, value):
if key in self:
self.move_to_end(key)
self[key] = value
while len(self) > self._maxsize:
self.popitem(last=False)
_cell_cache: LRUCache = LRUCache()
_wifi_cache: LRUCache = LRUCache()
_address_cache: LRUCache = LRUCache()
# ---------------------------------------------------------------------------
# 高德数字签名 (AMAP_SECRET)
# ---------------------------------------------------------------------------
def _amap_sign(params: dict) -> str:
"""Generate 高德 API digital signature (MD5)."""
if not AMAP_SECRET:
return ""
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
raw = sorted_str + AMAP_SECRET
return hashlib.md5(raw.encode()).hexdigest()
# ===========================================================================
# Forward Geocoding: cell/WiFi → lat/lon
# ===========================================================================
async def geocode_location(
mcc: Optional[int] = None,
mnc: Optional[int] = None,
lac: Optional[int] = None,
cell_id: Optional[int] = None,
wifi_list: Optional[list[dict]] = None,
neighbor_cells: Optional[list[dict]] = None,
imei: Optional[str] = None,
) -> tuple[Optional[float], Optional[float]]:
"""
Convert cell tower and/or WiFi AP data to lat/lon.
Uses 高德智能硬件定位 API exclusively.
"""
# Check cache first
if mcc is not None and lac is not None and cell_id is not None:
cache_key = (mcc, mnc or 0, lac, cell_id)
cached = _cell_cache.get_cached(cache_key)
if cached is not None:
return cached
if AMAP_KEY:
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
if result[0] is not None:
if mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
return (None, None)
async def _geocode_amap(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
) -> tuple[Optional[float], Optional[float]]:
"""
Use 高德智能硬件定位 API (apilocate.amap.com/position).
Returns coordinates (高德 returns GCJ-02).
"""
# Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
bts = ""
if mcc is not None and lac is not None and cell_id is not None:
bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
# Build nearbts (neighbor cells)
nearbts_parts = []
if neighbor_cells:
for nc in neighbor_cells:
nc_lac = nc.get("lac", 0)
nc_cid = nc.get("cell_id", 0)
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}")
# Build macs (WiFi APs): mac,signal,ssid
macs_parts = []
if wifi_list:
for ap in wifi_list:
mac = ap.get("mac", "").lower().replace(":", "")
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
ssid = ap.get("ssid", "")
macs_parts.append(f"{mac},{signal},{ssid}")
if not bts and not macs_parts:
return (None, None)
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
if bts:
params["bts"] = bts
if nearbts_parts:
params["nearbts"] = "|".join(nearbts_parts)
if macs_parts:
params["macs"] = "|".join(macs_parts)
# Add digital signature
sig = _amap_sign(params)
if sig:
params["sig"] = sig
url = "https://apilocate.amap.com/position"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "1" and data.get("result"):
result = data["result"]
location = result.get("location", "")
if location and "," in location:
lon_str, lat_str = location.split(",")
lat = float(lat_str)
lon = float(lon_str)
logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon)
return (lat, lon)
else:
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Amap geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap geocode error: %s", e)
return (None, None)
# ===========================================================================
# Reverse Geocoding: coordinates → address
# ===========================================================================
async def reverse_geocode(
lat: float, lon: float
) -> Optional[str]:
"""
Convert lat/lon (WGS-84) to a human-readable address.
Uses 高德逆地理编码 API exclusively.
"""
cache_key = (round(lat, 3), round(lon, 3))
cached = _address_cache.get_cached(cache_key)
if cached is not None:
return cached
if AMAP_KEY:
result = await _reverse_geocode_amap(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
return None
async def _reverse_geocode_amap(
lat: float, lon: float
) -> Optional[str]:
"""
Use 高德逆地理编码 API.
API: https://restapi.amap.com/v3/geocode/regeo
Input: GCJ-02 coordinates (need to convert from WGS-84).
Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise).
"""
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
params = {
"key": AMAP_KEY,
"location": f"{gcj_lon:.6f},{gcj_lat:.6f}",
"extensions": "base",
"output": "json",
}
sig = _amap_sign(params)
if sig:
params["sig"] = sig
url = "https://restapi.amap.com/v3/geocode/regeo"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "1":
regeocode = data.get("regeocode", {})
formatted = regeocode.get("formatted_address", "")
if formatted and formatted != "[]":
logger.info(
"Amap reverse geocode: %.6f,%.6f -> %s",
lat, lon, formatted,
)
return formatted
else:
logger.warning(
"Amap reverse geocode error: info=%s, infocode=%s",
data.get("info", ""), data.get("infocode", ""),
)
else:
logger.warning("Amap reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap reverse geocode error: %s", e)
return None

238
app/main.py Normal file
View File

@@ -0,0 +1,238 @@
from pathlib import Path
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
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.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding
from app.dependencies import verify_api_key, require_write, require_admin
import asyncio
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Rate limiter (shared instance via app.extensions for router access)
from app.extensions import limiter
async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS."""
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
total_deleted = 0
async with async_session() as session:
async with session.begin():
for model, time_col in [
(LocationRecord, LocationRecord.created_at),
(HeartbeatRecord, HeartbeatRecord.created_at),
(AlarmRecord, AlarmRecord.created_at),
(AttendanceRecord, AttendanceRecord.created_at),
(BluetoothRecord, BluetoothRecord.created_at),
]:
result = await session.execute(
delete(model).where(time_col < cutoff)
)
if result.rowcount:
total_deleted += result.rowcount
logger.info("Cleanup: deleted %d old %s records", result.rowcount, model.__tablename__)
return total_deleted
async def _data_cleanup_loop():
"""Background task that runs cleanup periodically."""
while True:
try:
await asyncio.sleep(settings.DATA_CLEANUP_INTERVAL_HOURS * 3600)
deleted = await run_data_cleanup()
if deleted:
logger.info("Data cleanup completed: %d records removed", deleted)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Data cleanup error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Initializing database...")
await init_db()
# Reset all devices to offline on startup (stale state from previous run)
try:
from sqlalchemy import update
from app.models import Device
async with async_session() as session:
async with session.begin():
await session.execute(update(Device).values(status="offline"))
logger.info("All devices reset to offline on startup")
except Exception:
logger.exception("Failed to reset device statuses on startup")
# Create missing indexes (safe for existing databases)
try:
from sqlalchemy import text as sa_text
async with engine.begin() as conn:
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
"CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)",
"CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)",
"CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)",
"CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)",
]:
await conn.execute(sa_text(stmt))
logger.info("Database indexes verified/created")
except Exception:
logger.exception("Failed to create indexes")
logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT)
tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT))
cleanup_task = asyncio.create_task(_data_cleanup_loop())
yield
# Shutdown
cleanup_task.cancel()
logger.info("Shutting down TCP server...")
await tcp_manager.stop()
tcp_task.cancel()
app = FastAPI(
title="KKS Badge Management System / KKS工牌管理系统",
description="""
## KKS P240 & P241 蓝牙工牌管理后台 API
### 功能模块 / Features:
- **设备管理 / Device Management** - 设备注册、状态监控
- **位置数据 / Location Data** - GPS/LBS/WIFI定位数据查询与轨迹回放
- **报警管理 / Alarm Management** - SOS、围栏、低电等报警处理
- **考勤管理 / Attendance** - 打卡记录查询与统计
- **指令管理 / Commands** - 远程指令下发与留言
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
- **心跳数据 / Heartbeats** - 设备心跳记录查询
### 认证 / Authentication:
设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。
### 通讯协议 / Protocol:
- TCP端口: {tcp_port} (设备连接)
- 支持协议: KKS P240/P241 通讯协议
""".format(tcp_port=settings.TCP_PORT),
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
# Rate limiter — SlowAPIMiddleware applies default_limits to all routes
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
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])
app.include_router(api_keys.router, dependencies=[*_api_deps])
app.include_router(ws.router) # WebSocket handles auth internally
app.include_router(geocoding.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@app.get("/admin", response_class=HTMLResponse, tags=["Admin"])
async def admin_page():
"""管理后台页面 / Admin Dashboard"""
html_path = _STATIC_DIR / "admin.html"
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
@app.get("/", tags=["Root"])
async def root():
return {
"name": settings.APP_NAME,
"version": "1.0.0",
"docs": "/docs",
"redoc": "/redoc",
"admin": "/admin",
"tcp_port": settings.TCP_PORT,
"auth_enabled": settings.API_KEY is not None,
}
@app.get("/health", tags=["Root"])
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")
from app.websocket_manager import ws_manager
status = "healthy" if db_ok else "degraded"
return {
"status": status,
"database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections),
"websocket_connections": ws_manager.connection_count,
}
@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else [])
async def manual_cleanup():
"""手动触发数据清理 / Manually trigger data cleanup (admin only)."""
try:
deleted = await run_data_cleanup()
return {"code": 0, "message": f"Cleanup completed: {deleted} records removed", "data": {"deleted": deleted}}
except Exception as e:
logger.exception("Manual cleanup failed")
return {"code": 500, "message": f"Cleanup failed: {str(e)}", "data": None}

342
app/models.py Normal file
View File

@@ -0,0 +1,342 @@
from datetime import datetime, timezone
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
)
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Device(Base):
"""Registered Bluetooth badge devices."""
__tablename__ = "devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
imei: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
device_type: Mapped[str] = mapped_column(String(10), nullable=False)
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="offline", nullable=False)
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
iccid: Mapped[str | None] = mapped_column(String(30), nullable=True)
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
# Relationships
locations: Mapped[list["LocationRecord"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
alarms: Mapped[list["AlarmRecord"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
heartbeats: Mapped[list["HeartbeatRecord"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
attendance_records: Mapped[list["AttendanceRecord"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
bluetooth_records: Mapped[list["BluetoothRecord"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
command_logs: Mapped[list["CommandLog"]] = relationship(
back_populates="device", cascade="all, delete-orphan", lazy="noload"
)
def __repr__(self) -> str:
return f"<Device(id={self.id}, imei={self.imei}, status={self.status})>"
class LocationRecord(Base):
"""GPS / LBS / WIFI location records."""
__tablename__ = "location_records"
__table_args__ = (
Index("ix_location_device_time", "device_id", "recorded_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
location_type: Mapped[str] = mapped_column(
String(10), nullable=False
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
course: Mapped[float | None] = mapped_column(Float, nullable=True)
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
neighbor_cells: Mapped[dict | None] = mapped_column(JSON, nullable=True)
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
report_mode: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_realtime: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
mileage: Mapped[float | None] = mapped_column(Float, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="locations")
def __repr__(self) -> str:
return (
f"<LocationRecord(id={self.id}, device_id={self.device_id}, "
f"type={self.location_type})>"
)
class AlarmRecord(Base):
"""Alarm events raised by devices."""
__tablename__ = "alarm_records"
__table_args__ = (
Index("ix_alarm_device_time", "device_id", "recorded_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
alarm_type: Mapped[str] = mapped_column(
String(30), nullable=False
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
alarm_source: Mapped[str | None] = mapped_column(
String(20), nullable=True
) # single_fence, multi_fence, lbs, wifi
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
course: Mapped[float | None] = mapped_column(Float, nullable=True)
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
fence_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="alarms")
def __repr__(self) -> str:
return (
f"<AlarmRecord(id={self.id}, device_id={self.device_id}, "
f"type={self.alarm_type})>"
)
class HeartbeatRecord(Base):
"""Heartbeat history from devices."""
__tablename__ = "heartbeat_records"
__table_args__ = (
Index("ix_heartbeat_device_time", "device_id", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
gsm_signal: Mapped[int] = mapped_column(Integer, nullable=False)
extension_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="heartbeats")
def __repr__(self) -> str:
return f"<HeartbeatRecord(id={self.id}, device_id={self.device_id})>"
class AttendanceRecord(Base):
"""Attendance punch records from badges."""
__tablename__ = "attendance_records"
__table_args__ = (
Index("ix_attendance_device_time", "device_id", "recorded_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
attendance_type: Mapped[str] = mapped_column(
String(20), nullable=False
) # clock_in, clock_out
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
course: Mapped[float | None] = mapped_column(Float, nullable=True)
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
lbs_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="attendance_records")
def __repr__(self) -> str:
return (
f"<AttendanceRecord(id={self.id}, device_id={self.device_id}, "
f"type={self.attendance_type})>"
)
class BluetoothRecord(Base):
"""Bluetooth punch card and location records."""
__tablename__ = "bluetooth_records"
__table_args__ = (
Index("ix_bluetooth_device_time", "device_id", "recorded_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
record_type: Mapped[str] = mapped_column(
String(20), nullable=False
) # punch, location
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
beacon_mac: Mapped[str | None] = mapped_column(String(20), nullable=True)
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
beacon_battery: Mapped[float | None] = mapped_column(Float, nullable=True)
beacon_battery_unit: Mapped[str | None] = mapped_column(String(10), nullable=True)
attendance_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
bluetooth_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
def __repr__(self) -> str:
return (
f"<BluetoothRecord(id={self.id}, device_id={self.device_id}, "
f"type={self.record_type})>"
)
class BeaconConfig(Base):
"""Registered Bluetooth beacon configuration for indoor positioning."""
__tablename__ = "beacon_configs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
beacon_mac: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
floor: Mapped[str | None] = mapped_column(String(20), nullable=True)
area: Mapped[str | None] = mapped_column(String(100), nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
def __repr__(self) -> str:
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
class CommandLog(Base):
"""Log of commands sent to devices."""
__tablename__ = "command_logs"
__table_args__ = (
Index("ix_command_device_status", "device_id", "status"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
command_type: Mapped[str] = mapped_column(String(30), nullable=False)
command_content: Mapped[str] = mapped_column(Text, nullable=False)
server_flag: Mapped[str] = mapped_column(String(20), nullable=False)
response_content: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False
) # pending, sent, success, failed
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
response_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
device: Mapped["Device"] = relationship(back_populates="command_logs")
def __repr__(self) -> str:
return (
f"<CommandLog(id={self.id}, device_id={self.device_id}, "
f"type={self.command_type}, status={self.status})>"
)
class ApiKey(Base):
"""API keys for external system authentication."""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
permissions: Mapped[str] = mapped_column(
String(20), default="read", nullable=False
) # read, write, admin
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"

20
app/protocol/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""
KKS Bluetooth Badge Protocol
Provides packet parsing, building, and CRC computation for the
KKS Bluetooth badge communication protocol over TCP.
"""
from .constants import * # noqa: F401,F403
from .crc import crc_itu, verify_crc
from .parser import PacketParser
from .builder import PacketBuilder
__all__ = [
# CRC
"crc_itu",
"verify_crc",
# Classes
"PacketParser",
"PacketBuilder",
]

298
app/protocol/builder.py Normal file
View File

@@ -0,0 +1,298 @@
"""
KKS Bluetooth Badge Protocol Packet Builder
Constructs server response packets for the KKS badge protocol.
"""
from __future__ import annotations
import struct
import time
from datetime import datetime, timezone
from typing import Optional
from .constants import (
PROTO_HEARTBEAT,
PROTO_HEARTBEAT_EXT,
PROTO_LBS_ADDRESS_REQ,
PROTO_LBS_MULTI_REPLY,
PROTO_LOGIN,
PROTO_MESSAGE,
PROTO_ONLINE_CMD,
PROTO_TIME_SYNC,
PROTO_TIME_SYNC_2,
PROTO_ADDRESS_REPLY_EN,
START_MARKER_LONG,
START_MARKER_SHORT,
STOP_MARKER,
)
from .crc import crc_itu
class PacketBuilder:
"""Builds server response packets for the KKS badge protocol."""
# ------------------------------------------------------------------
# Core builder
# ------------------------------------------------------------------
@staticmethod
def build_response(
protocol_number: int,
serial_number: int,
info_content: bytes = b"",
) -> bytes:
"""
Build a complete response packet.
Packet layout (short form, 0x7878):
START(2) + LENGTH(1) + PROTO(1) + INFO(N) + SERIAL(2) + CRC(2) + STOP(2)
LENGTH = 1(proto) + N(info) + 2(serial) + 2(crc)
If the payload exceeds 255 bytes the long form (0x7979, 2-byte
length) is used automatically.
Parameters
----------
protocol_number : int
Protocol number byte.
serial_number : int
Packet serial number (16-bit).
info_content : bytes
Information content (may be empty).
Returns
-------
bytes
The fully assembled packet.
"""
proto_byte = struct.pack("B", protocol_number)
serial_bytes = struct.pack("!H", serial_number)
# Payload for length calculation: proto + info + serial + crc
payload_len = 1 + len(info_content) + 2 + 2 # proto + info + serial + crc
if payload_len > 0xFF:
# Long packet
length_bytes = struct.pack("!H", payload_len)
start_marker = START_MARKER_LONG
else:
length_bytes = struct.pack("B", payload_len)
start_marker = START_MARKER_SHORT
# CRC is computed over: length_bytes + proto + info + serial
crc_input = length_bytes + proto_byte + info_content + serial_bytes
crc_value = crc_itu(crc_input)
crc_bytes = struct.pack("!H", crc_value)
return (
start_marker
+ length_bytes
+ proto_byte
+ info_content
+ serial_bytes
+ crc_bytes
+ STOP_MARKER
)
# ------------------------------------------------------------------
# Specific response builders
# ------------------------------------------------------------------
def build_login_response(self, serial_number: int) -> bytes:
"""
Build a login response (0x01).
The server responds with an empty info content to acknowledge login.
"""
return self.build_response(PROTO_LOGIN, serial_number)
def build_heartbeat_response(
self,
serial_number: int,
protocol: int = PROTO_HEARTBEAT,
) -> bytes:
"""
Build a heartbeat response.
Works for both standard heartbeat (0x13) and extended heartbeat (0x36).
"""
return self.build_response(protocol, serial_number)
def build_time_sync_response(
self,
serial_number: int,
protocol: int = PROTO_TIME_SYNC,
language: int = 0x0001,
) -> bytes:
"""
Build a time sync response (0x1F).
Returns the current UTC time as a 4-byte Unix timestamp + 2-byte language.
For Chinese (0x0001), the timestamp is GMT+8.
"""
utc_now = int(time.time())
if language == 0x0001:
utc_now += 8 * 3600 # GMT+8 for Chinese
info = struct.pack("!IH", utc_now, language)
return self.build_response(protocol, serial_number, info)
def build_time_sync_8a_response(self, serial_number: int) -> bytes:
"""
Build a Time Sync 2 response (0x8A).
Returns the current UTC time as YY MM DD HH MM SS (6 bytes).
"""
now = datetime.now(timezone.utc)
info = struct.pack(
"BBBBBB",
now.year - 2000,
now.month,
now.day,
now.hour,
now.minute,
now.second,
)
return self.build_response(PROTO_TIME_SYNC_2, serial_number, info)
def build_lbs_multi_response(self, serial_number: int) -> bytes:
"""
Build an LBS Multi Reply response (0x2E).
The server acknowledges with an empty info content.
"""
return self.build_response(PROTO_LBS_MULTI_REPLY, serial_number)
def build_online_command(
self,
serial_number: int,
server_flag: int,
command: str,
language: int = 0x0001,
) -> bytes:
"""
Build an online command packet (0x80).
Parameters
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
command : str
The command string to send (ASCII).
language : int
Language code (default 0x0001 = Chinese).
Returns
-------
bytes
Complete packet.
"""
cmd_bytes = command.encode("ascii")
# inner_len = server_flag(4) + cmd_content(N) + language(2)
inner_len = 4 + len(cmd_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag
info += cmd_bytes # N bytes command
info += struct.pack("!H", language) # 2 bytes language
return self.build_response(PROTO_ONLINE_CMD, serial_number, info)
def build_message(
self,
serial_number: int,
server_flag: int,
message_text: str,
language: int = 0x0001,
) -> bytes:
"""
Build a message packet (0x82).
The message is encoded in UTF-16 Big-Endian.
Parameters
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
message_text : str
The message string to send.
language : int
Language code (default 0x0001 = Chinese).
Returns
-------
bytes
Complete packet.
"""
msg_bytes = message_text.encode("utf-16-be")
# inner_len = server_flag(4) + msg_content(N) + language(2)
inner_len = 4 + len(msg_bytes) + 2
info = struct.pack("B", inner_len) # 1 byte inner length
info += struct.pack("!I", server_flag) # 4 bytes server flag
info += msg_bytes # N bytes message (UTF16BE)
info += struct.pack("!H", language) # 2 bytes language
return self.build_response(PROTO_MESSAGE, serial_number, info)
def build_address_reply_cn(
self,
serial_number: int,
server_flag: int = 0,
address: str = "",
phone: str = "",
protocol: int = PROTO_LBS_ADDRESS_REQ,
is_alarm: bool = False,
) -> bytes:
"""
Build a Chinese address reply packet (0x17).
Format: cmd_length(1) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF16BE) + && + phone(21) + ##
"""
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-16-be")
# Phone field: 21 bytes ASCII, zero-padded
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
# 0x17 uses 1-byte cmd_length
cmd_len = min(len(inner), 0xFF)
info = bytes([cmd_len]) + inner
return self.build_response(protocol, serial_number, info)
def build_address_reply_en(
self,
serial_number: int,
server_flag: int = 0,
address: str = "",
phone: str = "",
protocol: int = PROTO_ADDRESS_REPLY_EN,
is_alarm: bool = False,
) -> bytes:
"""
Build an English address reply packet (0x97).
Format: cmd_length(2) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF-8) + && + phone(21) + ##
"""
flag_bytes = struct.pack("!I", server_flag)
marker = b"ALARMSMS" if is_alarm else b"ADDRESS"
separator = b"&&"
terminator = b"##"
addr_bytes = address.encode("utf-8")
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator
# 0x97 uses 2-byte cmd_length
info = struct.pack("!H", len(inner)) + inner
return self.build_response(protocol, serial_number, info)

223
app/protocol/constants.py Normal file
View File

@@ -0,0 +1,223 @@
"""
KKS Bluetooth Badge Protocol Constants
Defines all protocol markers, protocol numbers, alarm types,
signal strength levels, data report modes, and related mappings.
"""
from typing import Dict, FrozenSet
# ---------------------------------------------------------------------------
# Start / Stop Markers
# ---------------------------------------------------------------------------
START_MARKER_SHORT: bytes = b'\x78\x78' # 1-byte packet length field
START_MARKER_LONG: bytes = b'\x79\x79' # 2-byte packet length field
STOP_MARKER: bytes = b'\x0D\x0A'
# ---------------------------------------------------------------------------
# Protocol Numbers
# ---------------------------------------------------------------------------
PROTO_LOGIN: int = 0x01
PROTO_HEARTBEAT: int = 0x13
PROTO_LBS_ADDRESS_REQ: int = 0x17
PROTO_ADDRESS_QUERY: int = 0x1A
PROTO_TIME_SYNC: int = 0x1F
PROTO_GPS: int = 0x22
PROTO_LBS_MULTI: int = 0x28
PROTO_LBS_MULTI_REPLY: int = 0x2E
PROTO_WIFI: int = 0x2C
PROTO_HEARTBEAT_EXT: int = 0x36
PROTO_ONLINE_CMD: int = 0x80
PROTO_ONLINE_CMD_REPLY: int = 0x81
PROTO_MESSAGE: int = 0x82
PROTO_TIME_SYNC_2: int = 0x8A
PROTO_GENERAL_INFO: int = 0x94
PROTO_ADDRESS_REPLY_EN: int = 0x97
PROTO_GPS_4G: int = 0xA0
PROTO_LBS_4G: int = 0xA1
PROTO_WIFI_4G: int = 0xA2
PROTO_ALARM_SINGLE_FENCE: int = 0xA3
PROTO_ALARM_MULTI_FENCE: int = 0xA4
PROTO_ALARM_LBS_4G: int = 0xA5
PROTO_LBS_4G_ADDRESS_REQ: int = 0xA7
PROTO_ALARM_ACK: int = 0x26
PROTO_ALARM_WIFI: int = 0xA9
PROTO_ATTENDANCE: int = 0xB0
PROTO_ATTENDANCE_4G: int = 0xB1
PROTO_BT_PUNCH: int = 0xB2
PROTO_BT_LOCATION: int = 0xB3
# ---------------------------------------------------------------------------
# Alarm Types (bit-pattern -> name)
# ---------------------------------------------------------------------------
ALARM_TYPES: Dict[int, str] = {
0x00: "normal",
0x01: "sos",
0x02: "power_cut",
0x03: "vibration",
0x04: "enter_fence",
0x05: "exit_fence",
0x06: "over_speed",
0x09: "displacement",
0x0A: "enter_gps_dead_zone",
0x0B: "exit_gps_dead_zone",
0x0C: "power_on",
0x0D: "gps_first_fix",
0x0E: "low_battery",
0x0F: "low_battery_protection",
0x10: "sim_change",
0x11: "power_off",
0x12: "airplane_mode",
0x13: "remove",
0x14: "door",
0x15: "shutdown",
0x16: "voice_alarm",
0x17: "fake_base_station",
0x18: "cover_open",
0x19: "internal_low_battery",
0xFE: "acc_on",
0xFF: "acc_off",
}
# ---------------------------------------------------------------------------
# GSM Signal Strength Levels
# ---------------------------------------------------------------------------
GSM_SIGNAL_LEVELS: Dict[int, str] = {
0x00: "No Signal",
0x01: "Very Weak",
0x02: "Weak",
0x03: "Good",
0x04: "Strong",
}
# ---------------------------------------------------------------------------
# Data Report Mode (0x00 - 0x0F)
# ---------------------------------------------------------------------------
DATA_REPORT_MODES: Dict[int, str] = {
0x00: "Timing Upload", # 定时上报
0x01: "Distance Upload", # 定距上报
0x02: "Turn Point Upload", # 拐点上传
0x03: "ACC Status Changed", # ACC状态改变上传
0x04: "Last Point After Stop", # 运动→静止补传最后定位点
0x05: "Reconnect Upload", # 断网重连上报最后有效点
0x06: "Ephemeris Force Upload", # 星历更新强制上传GPS点
0x07: "Button Upload", # 按键上传定位点
0x08: "Power On Upload", # 开机上报位置信息
0x09: "Unused", # 未使用
0x0A: "Static Update", # 设备静止后上报(时间更新)
0x0B: "WiFi Parsed Upload", # WIFI解析经纬度上传
0x0C: "LJDW Upload", # 立即定位指令上报
0x0D: "Static Last Point", # 设备静止后上报最后经纬度
0x0E: "GPSDUP Upload", # 静止状态定时上传
0x0F: "Exit Tracking Mode", # 退出追踪模式
}
# ---------------------------------------------------------------------------
# Protocol Numbers That Require a Server Response
# ---------------------------------------------------------------------------
PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
PROTO_LOGIN,
PROTO_HEARTBEAT,
PROTO_LBS_ADDRESS_REQ,
PROTO_ADDRESS_QUERY,
PROTO_TIME_SYNC,
# Note: PROTO_LBS_MULTI (0x28) does NOT require response; only 0x2E does
PROTO_HEARTBEAT_EXT,
PROTO_TIME_SYNC_2,
# PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc
PROTO_ALARM_SINGLE_FENCE,
PROTO_ALARM_MULTI_FENCE,
PROTO_ALARM_LBS_4G,
PROTO_LBS_4G_ADDRESS_REQ,
PROTO_ALARM_WIFI,
PROTO_ATTENDANCE,
PROTO_ATTENDANCE_4G,
PROTO_BT_PUNCH,
# 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_NAMES: Dict[int, str] = {
PROTO_LOGIN: "Login",
PROTO_HEARTBEAT: "Heartbeat",
PROTO_LBS_ADDRESS_REQ: "LBS Address Request",
PROTO_ADDRESS_QUERY: "Address Query",
PROTO_TIME_SYNC: "Time Sync",
PROTO_ALARM_ACK: "Alarm ACK",
PROTO_GPS: "GPS",
PROTO_LBS_MULTI: "LBS Multi",
PROTO_LBS_MULTI_REPLY: "LBS Multi Reply",
PROTO_WIFI: "WIFI",
PROTO_HEARTBEAT_EXT: "Heartbeat Extended",
PROTO_ONLINE_CMD: "Online Command",
PROTO_ONLINE_CMD_REPLY: "Online Command Reply",
PROTO_MESSAGE: "Message",
PROTO_TIME_SYNC_2: "Time Sync 2",
PROTO_GENERAL_INFO: "General Info",
PROTO_ADDRESS_REPLY_EN: "Address Reply (EN)",
PROTO_GPS_4G: "GPS 4G",
PROTO_LBS_4G: "LBS 4G",
PROTO_WIFI_4G: "WIFI 4G",
PROTO_ALARM_SINGLE_FENCE: "Alarm Single Fence",
PROTO_ALARM_MULTI_FENCE: "Alarm Multi Fence",
PROTO_ALARM_LBS_4G: "Alarm LBS 4G",
PROTO_LBS_4G_ADDRESS_REQ: "LBS 4G Address Request",
PROTO_ALARM_WIFI: "Alarm WIFI",
PROTO_ATTENDANCE: "Attendance",
PROTO_ATTENDANCE_4G: "Attendance 4G",
PROTO_BT_PUNCH: "BT Punch",
PROTO_BT_LOCATION: "BT Location",
}

76
app/protocol/crc.py Normal file
View File

@@ -0,0 +1,76 @@
"""
CRC-ITU Implementation for KKS Badge Protocol
Uses CRC-16/X-25 (reflected CRC-CCITT):
Polynomial: 0x8408 (reflected 0x1021)
Initial value: 0xFFFF
Final XOR: 0xFFFF
"""
from typing import List
# ---------------------------------------------------------------------------
# Pre-computed CRC lookup table (256 entries, reflected polynomial 0x8408)
# ---------------------------------------------------------------------------
_CRC_TABLE: List[int] = []
def _generate_crc_table() -> List[int]:
"""Generate the CRC-16/X-25 lookup table for reflected polynomial 0x8408."""
table: List[int] = []
for i in range(256):
crc = i
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0x8408
else:
crc >>= 1
table.append(crc)
return table
_CRC_TABLE = _generate_crc_table()
def crc_itu(data: bytes) -> int:
"""
Compute the CRC-ITU checksum for the given data.
Uses the CRC-16/X-25 algorithm (reflected CRC-CCITT with final XOR).
For a KKS protocol packet this should be the bytes from (and including)
the packet-length field through the serial-number field.
Parameters
----------
data : bytes
The data to compute the CRC over.
Returns
-------
int
16-bit CRC value.
"""
crc: int = 0xFFFF
for byte in data:
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
return crc ^ 0xFFFF
def verify_crc(data: bytes, expected_crc: int) -> bool:
"""
Verify that *data* produces the *expected_crc*.
Parameters
----------
data : bytes
The data slice to check (same range used when computing the CRC).
expected_crc : int
The 16-bit CRC value to compare against.
Returns
-------
bool
``True`` if the computed CRC matches *expected_crc*.
"""
return crc_itu(data) == (expected_crc & 0xFFFF)

1353
app/protocol/parser.py Normal file

File diff suppressed because it is too large Load Diff

0
app/routers/__init__.py Normal file
View File

183
app/routers/alarms.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Alarms Router - 报警管理接口
API endpoints for alarm record queries, acknowledgement, and statistics.
"""
import math
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.models import AlarmRecord
from app.schemas import (
AlarmAcknowledge,
AlarmRecordResponse,
APIResponse,
PaginatedList,
)
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
summary="获取报警记录列表 / List alarms",
)
async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
if device_id is not None:
query = query.where(AlarmRecord.device_id == device_id)
count_query = count_query.where(AlarmRecord.device_id == device_id)
if alarm_type:
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
if start_time:
query = query.where(AlarmRecord.recorded_at >= start_time)
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
if end_time:
query = query.where(AlarmRecord.recorded_at <= end_time)
count_query = count_query.where(AlarmRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取报警统计 / Get alarm statistics",
)
async def alarm_stats(db: AsyncSession = Depends(get_db)):
"""
获取报警统计:总数、未确认数、按类型分组统计。
Get alarm statistics: total, unacknowledged count, and breakdown by type.
"""
# Total alarms
total_result = await db.execute(select(func.count(AlarmRecord.id)))
total = total_result.scalar() or 0
# Unacknowledged alarms
unack_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
)
unacknowledged = unack_result.scalar() or 0
# By type
type_result = await db.execute(
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_type)
.order_by(func.count(AlarmRecord.id).desc())
)
by_type = {row[0]: row[1] for row in type_result.all()}
return APIResponse(
data={
"total": total,
"unacknowledged": unacknowledged,
"acknowledged": total - unacknowledged,
"by_type": by_type,
}
)
@router.get(
"/{alarm_id}",
response_model=APIResponse[AlarmRecordResponse],
summary="获取报警详情 / Get alarm details",
)
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取报警记录详情。
Get alarm record details by ID.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
@router.put(
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
)
async def acknowledge_alarm(
alarm_id: int,
body: AlarmAcknowledge | None = None,
db: AsyncSession = Depends(get_db),
):
"""
确认(或取消确认)报警记录。
Acknowledge (or un-acknowledge) an alarm record.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
acknowledged = body.acknowledged if body else True
alarm.acknowledged = acknowledged
await db.flush()
await db.refresh(alarm)
return APIResponse(
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
data=AlarmRecordResponse.model_validate(alarm),
)

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

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

204
app/routers/attendance.py Normal file
View File

@@ -0,0 +1,204 @@
"""
Attendance Router - 考勤管理接口
API endpoints for attendance record queries and statistics.
"""
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 AttendanceRecord
from app.schemas import (
APIResponse,
AttendanceRecordResponse,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
summary="获取考勤记录列表 / List attendance records",
)
async def list_attendance(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
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 attendance records with filters for device, type, and time range.
"""
query = select(AttendanceRecord)
count_query = select(func.count(AttendanceRecord.id))
if device_id is not None:
query = query.where(AttendanceRecord.device_id == device_id)
count_query = count_query.where(AttendanceRecord.device_id == device_id)
if attendance_type:
query = query.where(AttendanceRecord.attendance_type == attendance_type)
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
if end_time:
query = query.where(AttendanceRecord.recorded_at <= end_time)
count_query = count_query.where(AttendanceRecord.recorded_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(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AttendanceRecordResponse.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(
"/stats",
response_model=APIResponse[dict],
summary="获取考勤统计 / Get attendance statistics",
)
async def attendance_stats(
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
db: AsyncSession = Depends(get_db),
):
"""
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
Get attendance statistics: total records, breakdown by type and by device.
"""
base_filter = []
if device_id is not None:
base_filter.append(AttendanceRecord.device_id == device_id)
if start_time:
base_filter.append(AttendanceRecord.recorded_at >= start_time)
if end_time:
base_filter.append(AttendanceRecord.recorded_at <= end_time)
# Total count
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id))
total_result = await db.execute(total_q)
total = total_result.scalar() or 0
# By type
type_q = select(
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.attendance_type)
if base_filter:
type_q = type_q.where(*base_filter)
type_result = await db.execute(type_q)
by_type = {row[0]: row[1] for row in type_result.all()}
# By device (top 20)
device_q = select(
AttendanceRecord.device_id, func.count(AttendanceRecord.id)
).group_by(AttendanceRecord.device_id).order_by(
func.count(AttendanceRecord.id).desc()
).limit(20)
if base_filter:
device_q = device_q.where(*base_filter)
device_result = await db.execute(device_q)
by_device = {str(row[0]): row[1] for row in device_result.all()}
return APIResponse(
data={
"total": total,
"by_type": by_type,
"by_device": by_device,
}
)
@router.get(
"/device/{device_id}",
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
summary="获取设备考勤记录 / Get device attendance records",
)
async def device_attendance(
device_id: int,
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
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),
):
"""
获取指定设备的考勤记录。
Get attendance records for a specific device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
query = select(AttendanceRecord).where(AttendanceRecord.device_id == device_id)
count_query = select(func.count(AttendanceRecord.id)).where(AttendanceRecord.device_id == device_id)
if start_time:
query = query.where(AttendanceRecord.recorded_at >= start_time)
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
if end_time:
query = query.where(AttendanceRecord.recorded_at <= end_time)
count_query = count_query.where(AttendanceRecord.recorded_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(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AttendanceRecordResponse.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,
)
)
# 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))

104
app/routers/beacons.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Beacons Router - 蓝牙信标管理接口
API endpoints for managing Bluetooth beacon configuration.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.schemas import (
APIResponse,
BeaconConfigCreate,
BeaconConfigResponse,
BeaconConfigUpdate,
PaginatedList,
)
from app.services import beacon_service
router = APIRouter(prefix="/api/beacons", tags=["Beacons / 蓝牙信标"])
@router.get(
"",
response_model=APIResponse[PaginatedList[BeaconConfigResponse]],
summary="获取信标列表 / List beacons",
)
async def list_beacons(
status: str | None = Query(default=None, description="状态筛选 (active/inactive)"),
search: str | None = Query(default=None, description="搜索 MAC/名称/区域"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
records, total = await beacon_service.get_beacons(
db, page=page, page_size=page_size, status_filter=status, search=search
)
return APIResponse(
data=PaginatedList(
items=[BeaconConfigResponse.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(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="获取信标详情 / Get beacon",
)
async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
beacon = await beacon_service.get_beacon(db, beacon_id)
if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found")
return APIResponse(data=BeaconConfigResponse.model_validate(beacon))
@router.post(
"",
response_model=APIResponse[BeaconConfigResponse],
status_code=201,
summary="添加信标 / Create beacon",
dependencies=[Depends(require_write)],
)
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
if existing:
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
beacon = await beacon_service.create_beacon(db, body)
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
@router.put(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon",
dependencies=[Depends(require_write)],
)
async def update_beacon(
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
):
beacon = await beacon_service.update_beacon(db, beacon_id, body)
if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found")
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
@router.delete(
"/{beacon_id}",
response_model=APIResponse,
summary="删除信标 / Delete beacon",
dependencies=[Depends(require_write)],
)
async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id)
if not success:
raise HTTPException(status_code=404, detail="Beacon not found")
return APIResponse(message="Beacon deleted")

159
app/routers/bluetooth.py Normal file
View File

@@ -0,0 +1,159 @@
"""
Bluetooth Router - 蓝牙数据接口
API endpoints for querying Bluetooth punch and location records.
"""
import math
from datetime import datetime
from typing import Literal
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 BluetoothRecord
from app.schemas import (
APIResponse,
BluetoothRecordResponse,
PaginatedList,
)
from app.services import device_service
router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
@router.get(
"",
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
summary="获取蓝牙记录列表 / List bluetooth records",
)
async def list_bluetooth_records(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
beacon_mac: str | None = Query(default=None, description="信标MAC / Beacon MAC filter"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取蓝牙数据记录列表支持按设备、记录类型、信标MAC、时间范围过滤。
List Bluetooth records with filters for device, record type, beacon MAC, and time range.
"""
query = select(BluetoothRecord)
count_query = select(func.count(BluetoothRecord.id))
if device_id is not None:
query = query.where(BluetoothRecord.device_id == device_id)
count_query = count_query.where(BluetoothRecord.device_id == device_id)
if record_type:
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if beacon_mac:
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
count_query = count_query.where(BluetoothRecord.beacon_mac == beacon_mac)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
if end_time:
query = query.where(BluetoothRecord.recorded_at <= end_time)
count_query = count_query.where(BluetoothRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
order = BluetoothRecord.recorded_at.asc() if sort_order == "asc" else BluetoothRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[BluetoothRecordResponse.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(
"/device/{device_id}",
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
summary="获取设备蓝牙记录 / Get bluetooth records for device",
)
async def device_bluetooth_records(
device_id: int,
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""
获取指定设备的蓝牙数据记录。
Get Bluetooth records for a specific device.
"""
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
query = select(BluetoothRecord).where(BluetoothRecord.device_id == device_id)
count_query = select(func.count(BluetoothRecord.id)).where(BluetoothRecord.device_id == device_id)
if record_type:
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
if end_time:
query = query.where(BluetoothRecord.recorded_at <= end_time)
count_query = count_query.where(BluetoothRecord.recorded_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(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[BluetoothRecordResponse.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,
)
)
# 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))

330
app/routers/commands.py Normal file
View File

@@ -0,0 +1,330 @@
"""
Commands Router - 指令管理接口
API endpoints for sending commands / messages to devices and viewing command history.
"""
import logging
import math
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.extensions import limiter
from app.schemas import (
APIResponse,
BatchCommandRequest,
BatchCommandResponse,
BatchCommandResult,
CommandResponse,
PaginatedList,
)
from app.dependencies import require_write
from app.services import command_service, device_service
from app.services import tcp_command_service
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
# ---------------------------------------------------------------------------
# Request schemas specific to this router
# ---------------------------------------------------------------------------
class SendCommandRequest(BaseModel):
"""Request body for sending a command to a device."""
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)")
command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
class SendMessageRequest(BaseModel):
"""Request body for sending a message (0x82) to a device."""
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)")
message: str = Field(..., max_length=500, description="消息内容 / Message content")
class SendTTSRequest(BaseModel):
"""Request body for sending a TTS voice broadcast to a device."""
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)")
text: str = Field(..., min_length=1, max_length=200, description="语音播报文本 / TTS text content")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _resolve_device(
db: AsyncSession,
device_id: int | None,
imei: str | None,
):
"""Resolve a device from either device_id or imei. Returns the Device ORM instance."""
if device_id is None and imei is None:
raise HTTPException(
status_code=400,
detail="Either device_id or imei must be provided / 必须提供 device_id 或 imei",
)
if device_id is not None:
device = await device_service.get_device(db, device_id)
else:
device = await device_service.get_device_by_imei(db, imei)
if device is None:
identifier = f"ID={device_id}" if device_id else f"IMEI={imei}"
raise HTTPException(
status_code=404,
detail=f"Device {identifier} not found / 未找到设备 {identifier}",
)
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"
command_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(command_log)
return APIResponse(
message=success_msg,
data=CommandResponse.model_validate(command_log),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"",
response_model=APIResponse[PaginatedList[CommandResponse]],
summary="获取指令历史 / List command history",
)
async def list_commands(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"),
status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"),
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 command history with optional device, command type and status filters.
"""
commands, total = await command_service.get_commands(
db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size
)
return APIResponse(
data=PaginatedList(
items=[CommandResponse.model_validate(c) for c in commands],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.post(
"/send",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送指令 / Send command to device",
dependencies=[Depends(require_write)],
)
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送指令通过TCP连接下发
Send a command to a device via the TCP connection.
Requires the device to be online.
"""
device = await _resolve_device(db, body.device_id, body.imei)
return await _send_to_device(
db, device,
command_type=body.command_type,
command_content=body.command_content,
executor=lambda: tcp_command_service.send_command(
device.imei, body.command_type, body.command_content
),
success_msg="Command sent successfully / 指令发送成功",
fail_msg="Failed to send command / 指令发送失败",
)
@router.post(
"/message",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送留言 / Send message to device (0x82)",
dependencies=[Depends(require_write)],
)
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送留言消息(协议号 0x82
Send a text message to a device using protocol 0x82.
"""
device = await _resolve_device(db, body.device_id, body.imei)
return await _send_to_device(
db, device,
command_type="message",
command_content=body.message,
executor=lambda: tcp_command_service.send_message(device.imei, body.message),
success_msg="Message sent successfully / 留言发送成功",
fail_msg="Failed to send message / 留言发送失败",
)
@router.post(
"/tts",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="语音下发 / Send TTS voice broadcast to device",
dependencies=[Depends(require_write)],
)
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
"""
向设备发送 TTS 语音播报(通过 0x80 在线指令TTS 命令格式)。
Send a TTS voice broadcast to a device via online command (0x80).
The device will use its built-in TTS engine to speak the text aloud.
"""
device = await _resolve_device(db, body.device_id, body.imei)
tts_command = f"TTS,{body.text}"
return await _send_to_device(
db, device,
command_type="tts",
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 / 语音下发失败",
)
@router.post(
"/batch",
response_model=APIResponse[BatchCommandResponse],
status_code=201,
summary="批量发送指令 / Batch send command to multiple devices",
dependencies=[Depends(require_write)],
)
@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"
cmd_log.sent_at = datetime.now(timezone.utc)
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(
message=f"Batch command: {sent} sent, {failed} failed",
data=BatchCommandResponse(
total=len(results), sent=sent, failed=failed, results=results,
),
)
@router.get(
"/{command_id}",
response_model=APIResponse[CommandResponse],
summary="获取指令详情 / Get command details",
)
async def get_command(command_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取指令详情。
Get command log details by ID.
"""
command = await command_service.get_command(db, command_id)
if command is None:
raise HTTPException(status_code=404, detail=f"Command {command_id} not found / 未找到指令{command_id}")
return APIResponse(data=CommandResponse.model_validate(command))

253
app/routers/devices.py Normal file
View File

@@ -0,0 +1,253 @@
"""
Devices Router - 设备管理接口
API endpoints for device CRUD operations and statistics.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
BatchDeviceCreateRequest,
BatchDeviceCreateResponse,
BatchDeviceCreateResult,
BatchDeviceDeleteRequest,
BatchDeviceUpdateRequest,
DeviceCreate,
DeviceResponse,
DeviceUpdate,
PaginatedList,
)
from app.config import settings
from app.dependencies import require_write
from app.extensions import limiter
from app.schemas import LocationRecordResponse
from app.services import device_service, location_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[DeviceResponse]],
summary="获取设备列表 / List devices",
)
async def list_devices(
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
status: str | None = Query(default=None, description="状态过滤 / Status filter (online/offline)"),
search: str | None = Query(default=None, description="搜索IMEI或名称 / Search by IMEI or name"),
db: AsyncSession = Depends(get_db),
):
"""
获取设备列表,支持分页、状态过滤和搜索。
List devices with pagination, optional status filter, and search.
"""
devices, total = await device_service.get_devices(
db, page=page, page_size=page_size, status_filter=status, search=search
)
return APIResponse(
data=PaginatedList(
items=[DeviceResponse.model_validate(d) for d in devices],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.get(
"/stats",
response_model=APIResponse[dict],
summary="获取设备统计 / Get device statistics",
)
async def device_stats(db: AsyncSession = Depends(get_db)):
"""
获取设备统计信息:总数、在线、离线。
Get device statistics: total, online, offline counts.
"""
stats = await device_service.get_device_stats(db)
return APIResponse(data=stats)
@router.get(
"/imei/{imei}",
response_model=APIResponse[DeviceResponse],
summary="按IMEI查询设备 / Get device by IMEI",
)
async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
"""
按IMEI号查询设备信息。
Get device details by IMEI number.
"""
device = await device_service.get_device_by_imei(db, imei)
if device is None:
raise HTTPException(status_code=404, detail=f"Device with IMEI {imei} not found / 未找到IMEI为{imei}的设备")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.get(
"/all-latest-locations",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取所有在线设备位置 / Get all online device locations",
)
async def all_latest_locations(db: AsyncSession = Depends(get_db)):
"""
获取所有在线设备的最新位置,用于地图总览。
Get latest location for all online devices, for map overview.
"""
records = await location_service.get_all_online_latest_locations(db)
return APIResponse(data=[LocationRecordResponse.model_validate(r) for r in records])
@router.post(
"/batch",
response_model=APIResponse[BatchDeviceCreateResponse],
status_code=201,
summary="批量创建设备 / Batch create devices",
dependencies=[Depends(require_write)],
)
@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",
dependencies=[Depends(require_write)],
)
@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",
dependencies=[Depends(require_write)],
)
@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(
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="获取设备详情 / Get device details",
)
async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取设备详细信息。
Get device details by ID.
"""
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.post(
"",
response_model=APIResponse[DeviceResponse],
status_code=201,
summary="创建设备 / Create device",
dependencies=[Depends(require_write)],
)
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
"""
手动注册新设备。
Manually register a new device.
"""
# Check for duplicate IMEI
existing = await device_service.get_device_by_imei(db, device_data.imei)
if existing is not None:
raise HTTPException(
status_code=400,
detail=f"Device with IMEI {device_data.imei} already exists / IMEI {device_data.imei} 已存在",
)
device = await device_service.create_device(db, device_data)
return APIResponse(data=DeviceResponse.model_validate(device))
@router.put(
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="更新设备信息 / Update device",
dependencies=[Depends(require_write)],
)
async def update_device(
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
):
"""
更新设备信息(名称、状态等)。
Update device information (name, status, etc.).
"""
device = await device_service.update_device(db, device_id, device_data)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(data=DeviceResponse.model_validate(device))
@router.delete(
"/{device_id}",
response_model=APIResponse,
summary="删除设备 / Delete device",
dependencies=[Depends(require_write)],
)
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""
删除设备及其关联数据。
Delete a device and all associated records.
"""
deleted = await device_service.delete_device(db, device_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
return APIResponse(message="Device deleted successfully / 设备删除成功")

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

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

92
app/routers/heartbeats.py Normal file
View 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))

155
app/routers/locations.py Normal file
View File

@@ -0,0 +1,155 @@
"""
Locations Router - 位置数据接口
API endpoints for querying location records and device tracks.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import LocationRecord
from app.schemas import (
APIResponse,
LocationRecordResponse,
PaginatedList,
)
from app.services import device_service, location_service
router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
@router.get(
"",
response_model=APIResponse[PaginatedList[LocationRecordResponse]],
summary="获取位置记录列表 / List location records",
)
async def list_locations(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
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 location records with filters for device, location type, and time range.
"""
records, total = await location_service.get_locations(
db,
device_id=device_id,
location_type=location_type,
start_time=start_time,
end_time=end_time,
page=page,
page_size=page_size,
)
return APIResponse(
data=PaginatedList(
items=[LocationRecordResponse.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(
"/latest/{device_id}",
response_model=APIResponse[LocationRecordResponse | None],
summary="获取设备最新位置 / Get latest location",
)
async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
"""
获取指定设备的最新位置信息。
Get the most recent location record for a device.
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
record = await location_service.get_latest_location(db, device_id)
if record is None:
return APIResponse(
code=0,
message="No location data available / 暂无位置数据",
data=None,
)
return APIResponse(data=LocationRecordResponse.model_validate(record))
@router.post(
"/batch-latest",
response_model=APIResponse[list[LocationRecordResponse | None]],
summary="批量获取设备最新位置 / Batch get latest locations",
)
async def batch_latest_locations(
device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True),
db: AsyncSession = Depends(get_db),
):
"""
传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。
Pass device_ids list, returns latest location per device in input order.
"""
records = await location_service.get_batch_latest_locations(db, device_ids)
result_map = {r.device_id: r for r in records}
return APIResponse(data=[
LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None
for did in device_ids
])
@router.get(
"/track/{device_id}",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取设备轨迹 / Get device track",
)
async def device_track(
device_id: int,
start_time: datetime = Query(..., description="开始时间 / Start 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),
):
"""
获取设备在指定时间范围内的运动轨迹(按时间正序排列)。
Get device movement track within a time range (ordered chronologically).
"""
# Verify device exists
device = await device_service.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
if start_time >= end_time:
raise HTTPException(
status_code=400,
detail="start_time must be before end_time / 开始时间必须早于结束时间",
)
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points)
return APIResponse(
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))

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

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

515
app/schemas.py Normal file
View File

@@ -0,0 +1,515 @@
from datetime import datetime
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field, model_validator
T = TypeVar("T")
# ---------------------------------------------------------------------------
# Generic API response wrapper
# ---------------------------------------------------------------------------
class APIResponse(BaseModel, Generic[T]):
"""Standard envelope for every API response."""
code: int = 0
message: str = "success"
data: T | None = None
class PaginationParams(BaseModel):
"""Query parameters for paginated list endpoints."""
page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
page_size: int = Field(default=20, ge=1, le=100, description="Items per page")
class PaginatedList(BaseModel, Generic[T]):
"""Paginated result set."""
items: list[T]
total: int
page: int
page_size: int
total_pages: int
# ---------------------------------------------------------------------------
# Device schemas
# ---------------------------------------------------------------------------
class DeviceBase(BaseModel):
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 (e.g. P240, P241)")
name: str | None = Field(None, max_length=100, description="Friendly name")
timezone: str = Field(default="+8", max_length=30)
language: str = Field(default="cn", max_length=10)
class DeviceCreate(DeviceBase):
pass
class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30)
language: str | None = Field(None, max_length=10)
class DeviceResponse(DeviceBase):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
battery_level: int | None = None
gsm_signal: int | None = None
last_heartbeat: datetime | None = None
last_login: datetime | None = None
iccid: str | None = None
imsi: str | None = None
created_at: datetime
updated_at: datetime | None = None
class DeviceListResponse(APIResponse[PaginatedList[DeviceResponse]]):
pass
class DeviceSingleResponse(APIResponse[DeviceResponse]):
pass
# ---------------------------------------------------------------------------
# Location Record schemas
# ---------------------------------------------------------------------------
class LocationRecordBase(BaseModel):
device_id: int
location_type: str = Field(..., max_length=10)
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
gps_positioned: bool = False
mcc: int | None = None
mnc: int | None = None
lac: int | None = None
cell_id: int | None = None
rssi: int | None = None
neighbor_cells: list[dict[str, Any]] | None = None
wifi_data: list[dict[str, Any]] | None = None
report_mode: int | None = None
is_realtime: bool = True
mileage: float | None = None
address: str | None = None
raw_data: str | None = None
recorded_at: datetime
class LocationRecordCreate(LocationRecordBase):
pass
class LocationRecordResponse(LocationRecordBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class LocationRecordFilters(BaseModel):
device_id: int | None = None
location_type: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]):
pass
# ---------------------------------------------------------------------------
# Alarm Record schemas
# ---------------------------------------------------------------------------
class AlarmRecordBase(BaseModel):
device_id: int
alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=20)
protocol_number: int
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
mcc: int | None = None
mnc: int | None = None
lac: int | None = None
cell_id: int | None = None
battery_level: int | None = None
gsm_signal: int | None = None
fence_data: dict[str, Any] | None = None
wifi_data: list[dict[str, Any]] | None = None
address: str | None = None
recorded_at: datetime
class AlarmRecordCreate(AlarmRecordBase):
pass
class AlarmRecordResponse(AlarmRecordBase):
model_config = ConfigDict(from_attributes=True)
id: int
acknowledged: bool
created_at: datetime
class AlarmAcknowledge(BaseModel):
acknowledged: bool = True
class AlarmRecordFilters(BaseModel):
device_id: int | None = None
alarm_type: str | None = None
acknowledged: bool | None = None
start_time: datetime | None = None
end_time: datetime | None = None
class AlarmListResponse(APIResponse[PaginatedList[AlarmRecordResponse]]):
pass
# ---------------------------------------------------------------------------
# Heartbeat Record schemas
# ---------------------------------------------------------------------------
class HeartbeatRecordBase(BaseModel):
device_id: int
protocol_number: int
terminal_info: int
battery_level: int
gsm_signal: int
extension_data: dict[str, Any] | None = None
class HeartbeatRecordCreate(HeartbeatRecordBase):
pass
class HeartbeatRecordResponse(HeartbeatRecordBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]]):
pass
# ---------------------------------------------------------------------------
# Attendance Record schemas
# ---------------------------------------------------------------------------
class AttendanceRecordBase(BaseModel):
device_id: int
attendance_type: str = Field(..., max_length=20)
protocol_number: int
gps_positioned: bool = False
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
battery_level: int | None = None
gsm_signal: int | None = None
mcc: int | None = None
mnc: int | None = None
lac: int | None = None
cell_id: int | None = None
wifi_data: list[dict[str, Any]] | None = None
lbs_data: list[dict[str, Any]] | None = None
address: str | None = None
recorded_at: datetime
class AttendanceRecordCreate(AttendanceRecordBase):
pass
class AttendanceRecordResponse(AttendanceRecordBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class AttendanceRecordFilters(BaseModel):
device_id: int | None = None
attendance_type: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
class AttendanceListResponse(APIResponse[PaginatedList[AttendanceRecordResponse]]):
pass
# ---------------------------------------------------------------------------
# Bluetooth Record schemas
# ---------------------------------------------------------------------------
class BluetoothRecordBase(BaseModel):
device_id: int
record_type: str = Field(..., max_length=20)
protocol_number: int
beacon_mac: str | None = None
beacon_uuid: str | None = None
beacon_major: int | None = None
beacon_minor: int | None = None
rssi: int | None = None
beacon_battery: float | None = None
beacon_battery_unit: str | None = None
attendance_type: str | None = None
bluetooth_data: dict[str, Any] | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
recorded_at: datetime
class BluetoothRecordCreate(BluetoothRecordBase):
pass
class BluetoothRecordResponse(BluetoothRecordBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class BluetoothRecordFilters(BaseModel):
device_id: int | None = None
record_type: str | None = None
start_time: datetime | None = None
end_time: datetime | None = None
class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]]):
pass
# ---------------------------------------------------------------------------
# Beacon Config schemas
# ---------------------------------------------------------------------------
class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)")
beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称")
floor: str | None = Field(None, max_length=20, description="楼层")
area: str | None = Field(None, max_length=100, description="区域")
latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
longitude: float | None = Field(None, ge=-180, le=180, description="经度")
address: str | None = Field(None, description="详细地址")
status: Literal["active", "inactive"] = Field(default="active", description="状态")
class BeaconConfigCreate(BeaconConfigBase):
pass
class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100)
floor: str | None = Field(None, max_length=20)
area: str | None = Field(None, max_length=100)
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
address: str | None = None
status: Literal["active", "inactive"] | None = None
class BeaconConfigResponse(BaseModel):
"""Response model — no pattern validation on output (existing data may not conform)."""
model_config = ConfigDict(from_attributes=True)
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
updated_at: datetime | None = None
# ---------------------------------------------------------------------------
# Command Log schemas
# ---------------------------------------------------------------------------
class CommandCreate(BaseModel):
device_id: int
command_type: str = Field(..., max_length=30)
command_content: str = Field(..., max_length=500)
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):
response_content: str | None = None
status: str | None = Field(None, max_length=20)
sent_at: datetime | None = None
response_at: datetime | None = None
class CommandResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
device_id: int
command_type: str
command_content: str
server_flag: str
response_content: str | None = None
status: str
sent_at: datetime | None = None
response_at: datetime | None = None
created_at: datetime
class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
pass
# ---------------------------------------------------------------------------
# API Key schemas
# ---------------------------------------------------------------------------
class ApiKeyCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
class ApiKeyUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
permissions: Literal["read", "write", "admin"] | None = None
is_active: bool | None = None
class ApiKeyResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
permissions: str
is_active: bool
last_used_at: datetime | None = None
created_at: datetime
class ApiKeyCreateResponse(ApiKeyResponse):
"""Returned only on creation — includes the plaintext key (shown once)."""
key: str

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,94 @@
"""
Beacon Service - 蓝牙信标管理服务
Provides CRUD operations for Bluetooth beacon configuration.
"""
from datetime import datetime, timezone
from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import BeaconConfig
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
async def get_beacons(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status_filter: str | None = None,
search: str | None = None,
) -> tuple[list[BeaconConfig], int]:
"""Get paginated beacon list with optional filters."""
query = select(BeaconConfig)
count_query = select(func.count(BeaconConfig.id))
if status_filter:
query = query.where(BeaconConfig.status == status_filter)
count_query = count_query.where(BeaconConfig.status == status_filter)
if search:
like = f"%{search}%"
cond = or_(
BeaconConfig.beacon_mac.ilike(like),
BeaconConfig.name.ilike(like),
BeaconConfig.area.ilike(like),
)
query = query.where(cond)
count_query = count_query.where(cond)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(BeaconConfig.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
return list(result.scalars().all()), total
async def get_beacon(db: AsyncSession, beacon_id: int) -> BeaconConfig | None:
result = await db.execute(
select(BeaconConfig).where(BeaconConfig.id == beacon_id)
)
return result.scalar_one_or_none()
async def get_beacon_by_mac(db: AsyncSession, mac: str) -> BeaconConfig | None:
result = await db.execute(
select(BeaconConfig).where(BeaconConfig.beacon_mac == mac)
)
return result.scalar_one_or_none()
async def create_beacon(db: AsyncSession, data: BeaconConfigCreate) -> BeaconConfig:
beacon = BeaconConfig(**data.model_dump())
db.add(beacon)
await db.flush()
await db.refresh(beacon)
return beacon
async def update_beacon(
db: AsyncSession, beacon_id: int, data: BeaconConfigUpdate
) -> BeaconConfig | None:
beacon = await get_beacon(db, beacon_id)
if beacon is None:
return None
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(beacon, key, value)
beacon.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(beacon)
return beacon
async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
beacon = await get_beacon(db, beacon_id)
if beacon is None:
return False
await db.delete(beacon)
await db.flush()
return True

View File

@@ -0,0 +1,110 @@
"""
Command Service - 指令管理服务
Provides CRUD operations for device command logs.
"""
from datetime import datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CommandLog
async def get_commands(
db: AsyncSession,
device_id: int | None = None,
command_type: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CommandLog], int]:
"""
获取指令列表(分页)/ Get paginated command logs.
"""
query = select(CommandLog)
count_query = select(func.count(CommandLog.id))
if device_id is not None:
query = query.where(CommandLog.device_id == device_id)
count_query = count_query.where(CommandLog.device_id == device_id)
if command_type:
query = query.where(CommandLog.command_type == command_type)
count_query = count_query.where(CommandLog.command_type == command_type)
if status:
query = query.where(CommandLog.status == status)
count_query = count_query.where(CommandLog.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(CommandLog.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
commands = list(result.scalars().all())
return commands, total
async def create_command(
db: AsyncSession,
device_id: int,
command_type: str,
command_content: str,
server_flag: str = "badge_admin",
) -> CommandLog:
"""
创建指令记录 / Create a new command log entry.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Target device ID.
command_type : str
Type of command.
command_content : str
Command payload content.
server_flag : str
Server flag identifier.
Returns
-------
CommandLog
The newly created command log.
"""
command = CommandLog(
device_id=device_id,
command_type=command_type,
command_content=command_content,
server_flag=server_flag,
status="pending",
)
db.add(command)
await db.flush()
await db.refresh(command)
return command
async def get_command(db: AsyncSession, command_id: int) -> CommandLog | None:
"""
按ID获取指令 / Get command log by ID.
Parameters
----------
db : AsyncSession
Database session.
command_id : int
Command log primary key.
Returns
-------
CommandLog | None
"""
result = await db.execute(
select(CommandLog).where(CommandLog.id == command_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,312 @@
"""
Device Service - 设备管理服务
Provides CRUD operations and statistics for badge devices.
"""
from datetime import datetime, timezone
from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Device
from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem
async def get_devices(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status_filter: str | None = None,
search: str | None = None,
) -> tuple[list[Device], int]:
"""
获取设备列表(分页)/ Get paginated device list.
Parameters
----------
db : AsyncSession
Database session.
page : int
Page number (1-indexed).
page_size : int
Number of items per page.
status_filter : str, optional
Filter by device status (online / offline).
search : str, optional
Search by IMEI or device name.
Returns
-------
tuple[list[Device], int]
(list of devices, total count)
"""
query = select(Device)
count_query = select(func.count(Device.id))
if status_filter:
query = query.where(Device.status == status_filter)
count_query = count_query.where(Device.status == status_filter)
if search:
pattern = f"%{search}%"
search_clause = or_(
Device.imei.ilike(pattern),
Device.name.ilike(pattern),
)
query = query.where(search_clause)
count_query = count_query.where(search_clause)
# Total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginated results
offset = (page - 1) * page_size
query = query.order_by(Device.updated_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
devices = list(result.scalars().all())
return devices, total
async def get_device(db: AsyncSession, device_id: int) -> Device | None:
"""
按ID获取设备 / Get device by ID.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Device primary key.
Returns
-------
Device | None
"""
result = await db.execute(select(Device).where(Device.id == device_id))
return result.scalar_one_or_none()
async def get_device_by_imei(db: AsyncSession, imei: str) -> Device | None:
"""
按IMEI获取设备 / Get device by IMEI number.
Parameters
----------
db : AsyncSession
Database session.
imei : str
Device IMEI number.
Returns
-------
Device | None
"""
result = await db.execute(select(Device).where(Device.imei == imei))
return result.scalar_one_or_none()
async def create_device(db: AsyncSession, device_data: DeviceCreate) -> Device:
"""
创建设备 / Create a new device.
Parameters
----------
db : AsyncSession
Database session.
device_data : DeviceCreate
Device creation data.
Returns
-------
Device
The newly created device.
"""
device = Device(**device_data.model_dump())
db.add(device)
await db.flush()
await db.refresh(device)
return device
async def update_device(
db: AsyncSession, device_id: int, device_data: DeviceUpdate
) -> Device | None:
"""
更新设备信息 / Update device information.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Device primary key.
device_data : DeviceUpdate
Fields to update (only non-None fields are applied).
Returns
-------
Device | None
The updated device, or None if not found.
"""
device = await get_device(db, device_id)
if device is None:
return None
update_fields = device_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(device, field, value)
device.updated_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(device)
return device
async def delete_device(db: AsyncSession, device_id: int) -> bool:
"""
删除设备 / Delete a device.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Device primary key.
Returns
-------
bool
True if the device was deleted, False if not found.
"""
device = await get_device(db, device_id)
if device is None:
return False
await db.delete(device)
await db.flush()
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:
"""
获取设备统计信息 / Get device statistics.
Returns
-------
dict
{"total": int, "online": int, "offline": int}
"""
total_result = await db.execute(select(func.count(Device.id)))
total = total_result.scalar() or 0
online_result = await db.execute(
select(func.count(Device.id)).where(Device.status == "online")
)
online = online_result.scalar() or 0
offline = total - online
return {
"total": total,
"online": online,
"offline": offline,
}

View File

@@ -0,0 +1,183 @@
"""
Location Service - 位置数据服务
Provides query operations for GPS / LBS / WIFI location records.
"""
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import LocationRecord
async def get_locations(
db: AsyncSession,
device_id: int | None = None,
location_type: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[LocationRecord], int]:
"""
获取位置记录列表(分页)/ Get paginated location records.
Parameters
----------
db : AsyncSession
Database session.
device_id : int, optional
Filter by device ID.
location_type : str, optional
Filter by location type (gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g).
start_time : datetime, optional
Filter records after this time.
end_time : datetime, optional
Filter records before this time.
page : int
Page number (1-indexed).
page_size : int
Number of items per page.
Returns
-------
tuple[list[LocationRecord], int]
(list of location records, total count)
"""
query = select(LocationRecord)
count_query = select(func.count(LocationRecord.id))
if device_id is not None:
query = query.where(LocationRecord.device_id == device_id)
count_query = count_query.where(LocationRecord.device_id == device_id)
if location_type:
query = query.where(LocationRecord.location_type == location_type)
count_query = count_query.where(LocationRecord.location_type == location_type)
if start_time:
query = query.where(LocationRecord.recorded_at >= start_time)
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
if end_time:
query = query.where(LocationRecord.recorded_at <= end_time)
count_query = count_query.where(LocationRecord.recorded_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(LocationRecord.recorded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())
return records, total
async def get_latest_location(
db: AsyncSession, device_id: int
) -> LocationRecord | None:
"""
获取设备最新位置 / Get the most recent location for a device.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Device ID.
Returns
-------
LocationRecord | None
"""
result = await db.execute(
select(LocationRecord)
.where(LocationRecord.device_id == device_id)
.order_by(LocationRecord.recorded_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_batch_latest_locations(
db: AsyncSession, device_ids: list[int]
) -> list[LocationRecord]:
"""
批量获取多设备最新位置 / Get the most recent location for each device in the list.
Uses a subquery with MAX(id) GROUP BY device_id for efficiency.
"""
if not device_ids:
return []
# Subquery: max id per device_id
subq = (
select(func.max(LocationRecord.id).label("max_id"))
.where(LocationRecord.device_id.in_(device_ids))
.group_by(LocationRecord.device_id)
.subquery()
)
result = await db.execute(
select(LocationRecord).where(LocationRecord.id.in_(select(subq.c.max_id)))
)
return list(result.scalars().all())
async def get_all_online_latest_locations(
db: AsyncSession,
) -> list[LocationRecord]:
"""
获取所有在线设备的最新位置 / Get latest location for all online devices.
"""
from app.models import Device
# Get online device IDs
online_result = await db.execute(
select(Device.id).where(Device.status == "online")
)
online_ids = [row[0] for row in online_result.all()]
if not online_ids:
return []
return await get_batch_latest_locations(db, online_ids)
async def get_device_track(
db: AsyncSession,
device_id: int,
start_time: datetime,
end_time: datetime,
max_points: int = 10000,
) -> list[LocationRecord]:
"""
获取设备轨迹 / Get device movement track within a time range.
Parameters
----------
db : AsyncSession
Database session.
device_id : int
Device ID.
start_time : datetime
Start of time range.
end_time : datetime
End of time range.
Returns
-------
list[LocationRecord]
Location records ordered by recorded_at ascending (chronological).
"""
result = await db.execute(
select(LocationRecord)
.where(
LocationRecord.device_id == device_id,
LocationRecord.recorded_at >= start_time,
LocationRecord.recorded_at <= end_time,
)
.order_by(LocationRecord.recorded_at.asc())
.limit(max_points)
)
return list(result.scalars().all())

View 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)

2625
app/static/admin.html Normal file

File diff suppressed because it is too large Load Diff

2497
app/tcp_server.py Normal file

File diff suppressed because it is too large Load Diff

89
app/websocket_manager.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

12
frpc.toml Normal file
View File

@@ -0,0 +1,12 @@
serverAddr = "152.69.205.186"
serverPort = 7000
auth.method = "token"
auth.token = "PassWord0325"
[[proxies]]
name = "badge-tcp"
type = "tcp"
localIP = "127.0.0.1"
localPort = 5000
remotePort = 5001

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
sqlalchemy>=2.0.0
aiosqlite>=0.19.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
slowapi

10
run.py Normal file
View File

@@ -0,0 +1,10 @@
import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run(
"app.main:app",
host=settings.API_HOST,
port=settings.API_PORT,
reload=settings.DEBUG,
)

1686
通讯协议.md Normal file

File diff suppressed because it is too large Load Diff