Initial commit: migrate badge-admin from /tmp to /home/gpsystem

via HAPI (https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-17 01:14:40 +00:00
commit 8a18a5ff16
61 changed files with 13106 additions and 0 deletions

437
CLAUDE.md Normal file
View File

@@ -0,0 +1,437 @@
# Badge Admin - KKS P240/P241 蓝牙工牌管理系统
## 项目概览
KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio TCP 服务器。
支持设备管理、实时定位、告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
## 项目结构
```
/tmp/badge-admin/
├── run.py # 启动脚本 (uvicorn)
├── requirements.txt # Python 依赖
├── frpc.toml # FRP 客户端配置 (TCP隧道)
├── badge_admin.db # SQLite 数据库
├── server.log # 服务器日志
├── app/
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
│ ├── config.py # 配置 (端口8088 HTTP, 5000 TCP)
│ ├── database.py # SQLAlchemy async 数据库连接
│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog)
│ ├── 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
│ │
│ ├── routers/
│ │ ├── devices.py # /api/devices (含 /stats 统计接口)
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts)
│ │ ├── locations.py # /api/locations (含 /latest, /track)
│ │ ├── alarms.py # /api/alarms (含 acknowledge)
│ │ ├── attendance.py # /api/attendance
│ │ ├── bluetooth.py # /api/bluetooth
│ │ └── beacons.py # /api/beacons (信标管理 CRUD)
│ │
│ └── 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)
## 启动服务
```bash
# 启动服务
cd /tmp/badge-admin
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
# 启动 FRP 客户端 (TCP隧道)
nohup /tmp/frpc -c /tmp/badge-admin/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地址列表需要地理编码
- **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度
- **高德智能硬件定位 (待接入)**: `apilocate.amap.com/position`需企业认证WiFi+基站混合定位精度 ~30m
- **Mylnikov.org (当前使用)**: 免费无需Key中国基站精度较差 (~16km)
- Google Geolocation API / Unwired Labs API (备选需配置Key)
- **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址
- **天地图 (已接入)**: 免费1万次/天WGS84原生坐标系无需坐标转换
- 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key
- 地址格式: `省市区街道路门牌号 (附近POI)`
- 内置缓存机制,避免重复请求相同基站/坐标
### 天地图 API
- **服务端 Key**: `439fca3920a6f31584014424f89c3ecc` (用于逆地理编码)
- **浏览器端 Key**: `1918548e81a5ae3ff0cb985537341146` (用于前端地图瓦片,暂未使用)
- **API地址**: `http://api.tianditu.gov.cn/geocoder?postStr={JSON}&type=geocode&tk={KEY}`
- **坐标系**: WGS84 (与GPS/Leaflet一致无需转换)
- **配额**: 免费 10,000 次/天
- **注意**: postStr 参数需使用双引号JSON并URL编码
### API 分页
- 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 层 (`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) — 设备确认设置成功
- **当前状态**: 设备接受BT指令但尚未上报0xB2/0xB3数据可能需要信标UUID匹配或通过Tracksolid平台配置
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn)P240/P241 智能电子工牌系列
### 0x94 General Info 子协议
- 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节)
- 子协议 0x09: GPS 卫星状态
- 子协议 0x00: ICCID(10字节)
### 前端字段映射 (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`
- **账号类型**: 个人认证开发者 (正在申请企业认证)
### 已验证可用的 API
- **反地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本
- **坐标转换** (`restapi.amap.com/v3/assistant/coordinate/convert`): WGS-84 → GCJ-02
### 待企业认证后启用
- **智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (需企业认证, 错误码 10012)
- v1.0 GET: `apilocate.amap.com/position`
- v2.0 POST: `restapi.amap.com/v5/position/IoT`
- 参数格式: `bts=mcc,mnc,lac,cellid,signal` / `macs=mac,signal,ssid|mac,signal,ssid|`
### 配额 (个人认证开发者)
- 基础LBS服务: 5,000 次/日 (反地理编码、坐标转换等)
- 在线定位: 50,000 次/日
### 接入步骤 (企业认证通过后)
1.`app/geocoding.py` 中设置 `AMAP_KEY`
2. 实现 `_geocode_amap()` 函数调用智能硬件定位 API
3. 注意返回坐标为 GCJ-02需转换为 WGS-84 用于 Leaflet 地图
4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数
## 已知限制
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km待接入高德智能硬件定位后可达 ~30m
6. **天地图逆地理编码使用 HTTP** - API不支持HTTPSKey在URL中明文传输 (低风险: 免费Key)
## 已修复的问题 (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. **地理编码集成** - 集成 Mylnikov.org APILBS/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** - 修正所有模式名称匹配协议文档
### 0x94 子协议 0x04
- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286`
- 在设备重连/重启后上报
## 待完成功能
1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov大幅提升 WiFi/基站定位精度
2. **天地图瓦片底图** - 使用浏览器端 Key 替换 OpenStreetMap 瓦片 (中国地区显示更准确)
3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
4. **蓝牙信标调试** - P241 接受 BTON/BTSCAN 但未上报BLE数据需确认信标iBeacon广播格式及UUID匹配
## 调试技巧
```bash
# 查看实时日志
tail -f /tmp/badge-admin/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Tianditu" --line-buffered
# 检查数据库
python3 -c "
import sqlite3
conn = sqlite3.connect('/tmp/badge-admin/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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

14
app/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
APP_NAME: str = "KKS Badge Management System"
DATABASE_URL: str = "sqlite+aiosqlite:///./badge_admin.db"
TCP_HOST: str = "0.0.0.0"
TCP_PORT: int = 5000
API_HOST: str = "0.0.0.0"
API_PORT: int = 8088
DEBUG: bool = True
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)

384
app/geocoding.py Normal file
View File

@@ -0,0 +1,384 @@
"""
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
and reverse geocode coordinates to addresses.
Uses free APIs:
- Cell tower: Google Geolocation API (if key available) or unwiredlabs.com
- WiFi: Same APIs support WiFi AP lookup
- Reverse geocoding: 天地图 (Tianditu) - free, WGS84 native
"""
import json
import logging
import os
from collections import OrderedDict
from typing import Optional
from urllib.parse import quote
import aiohttp
logger = logging.getLogger(__name__)
# Google Geolocation API key (set to enable Google geocoding)
GOOGLE_API_KEY: Optional[str] = None
# Unwired Labs API token (free tier: 100 requests/day)
# Sign up at https://unwiredlabs.com/
UNWIRED_API_TOKEN: Optional[str] = None
# 天地图 API key (free tier: 10000 requests/day)
# Sign up at https://lbs.tianditu.gov.cn/
TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc")
# Maximum cache entries (LRU eviction)
_CACHE_MAX_SIZE = 10000
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)
# Cache cell tower lookups to avoid redundant API calls
_cell_cache: LRUCache = LRUCache()
_wifi_cache: LRUCache = LRUCache()
# Cache reverse geocoding results (coord rounded to ~100m -> address)
_address_cache: LRUCache = LRUCache()
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,
) -> tuple[Optional[float], Optional[float]]:
"""
Convert cell tower and/or WiFi AP data to lat/lon.
Parameters
----------
mcc : int - Mobile Country Code
mnc : int - Mobile Network Code
lac : int - Location Area Code
cell_id : int - Cell Tower ID
wifi_list : list[dict] - WiFi APs [{"mac": "AA:BB:CC:DD:EE:FF", "signal": -70}, ...]
neighbor_cells : list[dict] - Neighbor cells [{"lac": ..., "cell_id": ..., "rssi": ...}, ...]
Returns
-------
(latitude, longitude) or (None, None)
"""
# Check cache first (cell tower)
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
# Try Google Geolocation API first
if GOOGLE_API_KEY:
result = await _geocode_google(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
if result[0] is not None:
if mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
# Try Unwired Labs API
if UNWIRED_API_TOKEN:
result = await _geocode_unwired(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells)
if result[0] is not None:
if mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
# Fallback: Mylnikov.org (free, no API key required)
if mcc is not None and lac is not None and cell_id is not None:
result = await _geocode_mylnikov_cell(mcc, mnc or 0, lac, cell_id)
if result[0] is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
# Try WiFi via Mylnikov
if wifi_list:
for ap in wifi_list:
mac = ap.get("mac", "")
if mac:
result = await _geocode_mylnikov_wifi(mac)
if result[0] is not None:
return result
return (None, None)
async def _geocode_google(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells
) -> tuple[Optional[float], Optional[float]]:
"""Use Google Geolocation API."""
url = f"https://www.googleapis.com/geolocation/v1/geolocate?key={GOOGLE_API_KEY}"
body: dict = {}
if mcc is not None:
body["homeMobileCountryCode"] = mcc
if mnc is not None:
body["homeMobileNetworkCode"] = mnc
# Cell towers
towers = []
if lac is not None and cell_id is not None:
towers.append({
"cellId": cell_id,
"locationAreaCode": lac,
"mobileCountryCode": mcc or 0,
"mobileNetworkCode": mnc or 0,
})
if neighbor_cells:
for nc in neighbor_cells:
towers.append({
"cellId": nc.get("cell_id", 0),
"locationAreaCode": nc.get("lac", 0),
"mobileCountryCode": mcc or 0,
"mobileNetworkCode": mnc or 0,
"signalStrength": -(nc.get("rssi", 0)),
})
if towers:
body["cellTowers"] = towers
# WiFi APs
if wifi_list:
aps = []
for ap in wifi_list:
aps.append({
"macAddress": ap.get("mac", ""),
"signalStrength": -(ap.get("signal", 0)),
})
body["wifiAccessPoints"] = aps
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json()
loc = data.get("location", {})
lat = loc.get("lat")
lng = loc.get("lng")
if lat is not None and lng is not None:
logger.info("Google geocode: lat=%.6f, lon=%.6f", lat, lng)
return (lat, lng)
else:
text = await resp.text()
logger.warning("Google geocode failed: %d %s", resp.status, text[:200])
except Exception as e:
logger.warning("Google geocode error: %s", e)
return (None, None)
async def _geocode_unwired(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells
) -> tuple[Optional[float], Optional[float]]:
"""Use Unwired Labs LocationAPI."""
url = "https://us1.unwiredlabs.com/v2/process.php"
body: dict = {"token": UNWIRED_API_TOKEN}
# Cell towers
cells = []
if mcc is not None and lac is not None and cell_id is not None:
cells.append({
"lac": lac,
"cid": cell_id,
"mcc": mcc,
"mnc": mnc or 0,
})
if neighbor_cells:
for nc in neighbor_cells:
cells.append({
"lac": nc.get("lac", 0),
"cid": nc.get("cell_id", 0),
"mcc": mcc or 0,
"mnc": mnc or 0,
"signal": -(nc.get("rssi", 0)),
})
if cells:
body["cells"] = cells
# WiFi APs
if wifi_list:
aps = []
for ap in wifi_list:
aps.append({
"bssid": ap.get("mac", ""),
"signal": -(ap.get("signal", 0)),
})
body["wifi"] = aps
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json()
if data.get("status") == "ok":
lat = data.get("lat")
lon = data.get("lon")
if lat is not None and lon is not None:
logger.info("Unwired geocode: lat=%.6f, lon=%.6f", lat, lon)
return (lat, lon)
else:
logger.warning("Unwired geocode: %s", data.get("message", "unknown error"))
except Exception as e:
logger.warning("Unwired geocode error: %s", e)
return (None, None)
async def _geocode_mylnikov_cell(
mcc: int, mnc: int, lac: int, cell_id: int
) -> tuple[Optional[float], Optional[float]]:
"""Use Mylnikov.org free cell tower geocoding API (no API key required)."""
url = (
f"https://api.mylnikov.org/geolocation/cell"
f"?v=1.1&data=open"
f"&mcc={mcc}&mnc={mnc}&lac={lac}&cellid={cell_id}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("result") == 200:
lat = data.get("data", {}).get("lat")
lon = data.get("data", {}).get("lon")
if lat is not None and lon is not None:
logger.info("Mylnikov cell geocode: lat=%.6f, lon=%.6f", lat, lon)
return (lat, lon)
else:
logger.debug("Mylnikov cell: no result for MCC=%d MNC=%d LAC=%d CellID=%d",
mcc, mnc, lac, cell_id)
else:
logger.warning("Mylnikov cell API HTTP %d", resp.status)
except Exception as e:
logger.warning("Mylnikov cell geocode error: %s", e)
return (None, None)
async def _geocode_mylnikov_wifi(mac: str) -> tuple[Optional[float], Optional[float]]:
"""Use Mylnikov.org free WiFi AP geocoding API."""
# Normalize MAC format (needs colons)
mac = mac.upper().replace("-", ":")
url = f"https://api.mylnikov.org/geolocation/wifi?v=1.1&data=open&bssid={mac}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("result") == 200:
lat = data.get("data", {}).get("lat")
lon = data.get("data", {}).get("lon")
if lat is not None and lon is not None:
logger.info("Mylnikov WiFi geocode: lat=%.6f, lon=%.6f (MAC=%s)", lat, lon, mac)
_wifi_cache.put(mac, (lat, lon))
return (lat, lon)
else:
logger.debug("Mylnikov WiFi API HTTP %d for MAC=%s", resp.status, mac)
except Exception as e:
logger.warning("Mylnikov WiFi geocode error: %s", e)
return (None, None)
# ---------------------------------------------------------------------------
# Reverse Geocoding: coordinates -> address
# ---------------------------------------------------------------------------
async def reverse_geocode(
lat: float, lon: float
) -> Optional[str]:
"""
Convert lat/lon to a human-readable address.
Tries 天地图 (Tianditu) first, which uses WGS84 natively.
Returns None if no reverse geocoding service is available.
"""
# Round to ~100m for cache key to reduce API calls
cache_key = (round(lat, 3), round(lon, 3))
cached = _address_cache.get_cached(cache_key)
if cached is not None:
return cached
if TIANDITU_API_KEY:
result = await _reverse_geocode_tianditu(lat, lon)
if result:
_address_cache.put(cache_key, result)
return result
return None
async def _reverse_geocode_tianditu(
lat: float, lon: float
) -> Optional[str]:
"""
Use 天地图 (Tianditu) reverse geocoding API.
API docs: http://lbs.tianditu.gov.cn/server/geocoding.html
Coordinate system: WGS84 (same as our GPS data, no conversion needed).
Free tier: 10,000 requests/day.
"""
post_str = json.dumps({"lon": lon, "lat": lat, "ver": 1}, separators=(",", ":"))
url = (
f"http://api.tianditu.gov.cn/geocoder"
f"?postStr={quote(post_str)}&type=geocode&tk={TIANDITU_API_KEY}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "0":
result = data.get("result", {})
# Build address from components
addr_comp = result.get("addressComponent", {})
formatted = result.get("formatted_address", "")
if formatted:
# Add nearby POI if available
poi = addr_comp.get("poi", "")
address = formatted
if poi and poi not in formatted:
address = f"{formatted} ({poi})"
logger.info(
"Tianditu reverse geocode: %.6f,%.6f -> %s",
lat, lon, address,
)
return address
else:
logger.warning(
"Tianditu reverse geocode error: %s",
data.get("msg", data),
)
else:
logger.warning("Tianditu reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Tianditu reverse geocode error: %s", e)
return None

100
app/main.py Normal file
View File

@@ -0,0 +1,100 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.database import init_db, async_session
from app.tcp_server import tcp_manager
from app.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons
import asyncio
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@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")
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))
yield
# Shutdown
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 蓝牙工牌管理后台
### 功能模块 / Features:
- **设备管理 / Device Management** - 设备注册、状态监控
- **位置数据 / Location Data** - GPS/LBS/WIFI定位数据查询与轨迹回放
- **报警管理 / Alarm Management** - SOS、围栏、低电等报警处理
- **考勤管理 / Attendance** - 打卡记录查询与统计
- **指令管理 / Commands** - 远程指令下发与留言
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
### 通讯协议 / 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,
)
# Include routers
app.include_router(devices.router)
app.include_router(locations.router)
app.include_router(alarms.router)
app.include_router(attendance.router)
app.include_router(commands.router)
app.include_router(bluetooth.router)
app.include_router(beacons.router)
_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,
}
@app.get("/health", tags=["Root"])
async def health():
return {
"status": "healthy",
"connected_devices": len(tcp_manager.connections),
}

323
app/models.py Normal file
View File

@@ -0,0 +1,323 @@
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(10), 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})>"
)

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",
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,331 @@
"""
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,
) -> bytes:
"""
Build a time sync response (0x1F).
Returns the current UTC time as a 4-byte Unix timestamp.
"""
utc_now = int(time.time())
info = struct.pack("!I", utc_now)
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)
inner_len = 4 + len(cmd_bytes)
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)
inner_len = 4 + len(msg_bytes)
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,
address: str,
phone: str = "",
protocol: int = PROTO_LBS_ADDRESS_REQ,
) -> bytes:
"""
Build a Chinese address reply packet.
Used as a response to protocol 0x17 (LBS Address Request)
or similar address query protocols.
Parameters
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
address : str
Address string (encoded as UTF-16 Big-Endian).
phone : str
Phone number string (BCD encoded, even length, padded with 'F').
protocol : int
Protocol number to respond with (default 0x17).
Returns
-------
bytes
Complete packet.
"""
addr_bytes = address.encode("utf-16-be")
addr_len = len(addr_bytes)
info = struct.pack("!I", server_flag) # 4 bytes server flag
info += struct.pack("!H", addr_len) # 2 bytes address length
info += addr_bytes # N bytes address
if phone:
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
phone_bcd = bytes.fromhex(phone_padded)
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
info += phone_bcd # N bytes phone BCD
else:
info += struct.pack("B", 0) # 0 phone length
return self.build_response(protocol, serial_number, info)
def build_address_reply_en(
self,
serial_number: int,
server_flag: int,
address: str,
phone: str = "",
protocol: int = PROTO_ADDRESS_REPLY_EN,
) -> bytes:
"""
Build an English address reply packet (0x97).
Parameters
----------
serial_number : int
Packet serial number.
server_flag : int
Server flag bits (32-bit).
address : str
Address string (ASCII/UTF-8 encoded).
phone : str
Phone number string (BCD encoded, even length, padded with 'F').
protocol : int
Protocol number to respond with (default 0x97).
Returns
-------
bytes
Complete packet.
"""
addr_bytes = address.encode("utf-8")
addr_len = len(addr_bytes)
info = struct.pack("!I", server_flag) # 4 bytes server flag
info += struct.pack("!H", addr_len) # 2 bytes address length
info += addr_bytes # N bytes address
if phone:
phone_padded = phone if len(phone) % 2 == 0 else phone + "F"
phone_bcd = bytes.fromhex(phone_padded)
info += struct.pack("B", len(phone_bcd)) # 1 byte phone length
info += phone_bcd # N bytes phone BCD
else:
info += struct.pack("B", 0) # 0 phone length
return self.build_response(protocol, serial_number, info)

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

@@ -0,0 +1,172 @@
"""
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,
PROTO_LBS_MULTI,
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
})
# ---------------------------------------------------------------------------
# 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)

1142
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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@@ -0,0 +1,172 @@
"""
Alarms Router - 报警管理接口
API endpoints for alarm record queries, acknowledgement, and statistics.
"""
import math
from datetime import datetime, timezone
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 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"),
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)"),
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, 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 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
query = query.order_by(AlarmRecord.recorded_at.desc()).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",
)
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),
)

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

@@ -0,0 +1,187 @@
"""
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,
)
)

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

@@ -0,0 +1,102 @@
"""
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.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",
)
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)
await db.commit()
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
@router.put(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon",
)
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")
await db.commit()
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
@router.delete(
"/{beacon_id}",
response_model=APIResponse,
summary="删除信标 / Delete beacon",
)
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")
await db.commit()
return APIResponse(message="Beacon deleted")

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

@@ -0,0 +1,135 @@
"""
Bluetooth Router - 蓝牙数据接口
API endpoints for querying Bluetooth punch and location 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 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)"),
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 Bluetooth records with filters for device, record type, 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 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,
)
)
@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 number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取指定设备的蓝牙数据记录。
Get Bluetooth 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(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,
)
)

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

@@ -0,0 +1,288 @@
"""
Commands Router - 指令管理接口
API endpoints for sending commands / messages to devices and viewing command history.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
CommandResponse,
PaginatedList,
)
from app.services import command_service, device_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")
command_content: str = Field(..., 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")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# 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"),
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 and status filters.
"""
commands, total = await command_service.get_commands(
db, device_id=device_id, 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",
)
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)
# Import tcp_manager lazily to avoid circular imports
from app.tcp_server import tcp_manager
# Check if device is connected
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type=body.command_type,
command_content=body.command_content,
)
# Send command via TCP
try:
await tcp_manager.send_command(
device.imei, body.command_type, body.command_content
)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send command / 指令发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Command sent successfully / 指令发送成功",
data=CommandResponse.model_validate(command_log),
)
@router.post(
"/message",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送留言 / Send message to device (0x82)",
)
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)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
# Create command log for the message
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type="message",
command_content=body.message,
)
try:
await tcp_manager.send_message(device.imei, body.message)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send message / 留言发送失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="Message sent successfully / 留言发送成功",
data=CommandResponse.model_validate(command_log),
)
@router.post(
"/tts",
response_model=APIResponse[CommandResponse],
status_code=201,
summary="语音下发 / Send TTS voice broadcast to device",
)
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)
from app.tcp_server import tcp_manager
if device.imei not in tcp_manager.connections:
raise HTTPException(
status_code=400,
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
)
tts_command = f"TTS,{body.text}"
# Create command log entry
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type="tts",
command_content=tts_command,
)
try:
await tcp_manager.send_command(device.imei, "tts", tts_command)
except Exception as e:
command_log.status = "failed"
await db.flush()
await db.refresh(command_log)
raise HTTPException(
status_code=500,
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
)
command_log.status = "sent"
await db.flush()
await db.refresh(command_log)
return APIResponse(
message="TTS sent successfully / 语音下发成功",
data=CommandResponse.model_validate(command_log),
)
@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))

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

@@ -0,0 +1,154 @@
"""
Devices Router - 设备管理接口
API endpoints for device CRUD operations and statistics.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
APIResponse,
DeviceCreate,
DeviceResponse,
DeviceUpdate,
PaginatedList,
)
from app.services import device_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(
"/{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",
)
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",
)
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",
)
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 / 设备删除成功")

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

@@ -0,0 +1,115 @@
"""
Locations Router - 位置数据接口
API endpoints for querying location records and device tracks.
"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
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.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)"),
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)
return APIResponse(
data=[LocationRecordResponse.model_validate(r) for r in records]
)

397
app/schemas.py Normal file
View File

@@ -0,0 +1,397 @@
from datetime import datetime
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, ConfigDict, Field
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, description="IMEI number")
device_type: str = Field(..., max_length=10, description="Device type code")
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)
status: str | None = Field(None, max_length=20)
battery_level: int | None = None
gsm_signal: int | None = None
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 = None
longitude: float | None = None
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=10)
protocol_number: int
latitude: float | None = None
longitude: float | None = None
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 = None
longitude: float | None = None
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 = None
longitude: float | None = None
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, description="信标MAC地址")
beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, description="iBeacon Major")
beacon_minor: int | None = Field(None, 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, description="纬度")
longitude: float | None = Field(None, description="经度")
address: str | None = Field(None, description="详细地址")
status: str = Field(default="active", max_length=20, description="状态")
class BeaconConfigCreate(BeaconConfigBase):
pass
class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = None
beacon_minor: int | None = None
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 = None
longitude: float | None = None
address: str | None = None
status: str | None = Field(None, max_length=20)
class BeaconConfigResponse(BeaconConfigBase):
model_config = ConfigDict(from_attributes=True)
id: int
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
server_flag: str = Field(..., max_length=20)
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

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

Binary file not shown.

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,123 @@
"""
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,
status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CommandLog], int]:
"""
获取指令列表(分页)/ Get paginated command logs.
Parameters
----------
db : AsyncSession
Database session.
device_id : int, optional
Filter by device ID.
status : str, optional
Filter by command status (pending, sent, success, failed).
page : int
Page number (1-indexed).
page_size : int
Number of items per page.
Returns
-------
tuple[list[CommandLog], int]
(list of command logs, total count)
"""
query = select(CommandLog)
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 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,215 @@
"""
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
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_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,138 @@
"""
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_device_track(
db: AsyncSession,
device_id: int,
start_time: datetime,
end_time: datetime,
) -> 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())
)
return list(result.scalars().all())

2075
app/static/admin.html Normal file

File diff suppressed because it is too large Load Diff

2463
app/tcp_server.py Normal file

File diff suppressed because it is too large Load Diff

BIN
badge_admin.db Normal file

Binary file not shown.

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

43
nohup.out Normal file
View File

@@ -0,0 +1,43 @@
INFO: Started server process [2696342]
INFO: Waiting for application startup.
2026-03-15 11:04:52,415 - app.main - INFO - Initializing database...
2026-03-15 11:04:52,417 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2026-03-15 11:04:52,417 - sqlalchemy.engine.Engine - INFO - BEGIN (implicit)
2026-03-15 11:04:52,417 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("devices")
2026-03-15 11:04:52,417 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("devices")
2026-03-15 11:04:52,417 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,417 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,418 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("location_records")
2026-03-15 11:04:52,418 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("location_records")
2026-03-15 11:04:52,418 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,418 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("alarm_records")
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("alarm_records")
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("heartbeat_records")
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("heartbeat_records")
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("attendance_records")
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("attendance_records")
2026-03-15 11:04:52,419 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,419 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,420 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("bluetooth_records")
2026-03-15 11:04:52,420 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("bluetooth_records")
2026-03-15 11:04:52,420 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,420 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,420 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("command_logs")
2026-03-15 11:04:52,420 - sqlalchemy.engine.Engine - INFO - PRAGMA main.table_info("command_logs")
2026-03-15 11:04:52,420 INFO sqlalchemy.engine.Engine [raw sql] ()
2026-03-15 11:04:52,420 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2026-03-15 11:04:52,420 INFO sqlalchemy.engine.Engine COMMIT
2026-03-15 11:04:52,420 - sqlalchemy.engine.Engine - INFO - COMMIT
2026-03-15 11:04:52,420 - app.main - INFO - Starting TCP server on 0.0.0.0:5000
INFO: Application startup complete.
ERROR: [Errno 98] error while attempting to bind on address ('0.0.0.0', 8088): address already in use
INFO: Waiting for application shutdown.
2026-03-15 11:04:52,421 - app.tcp_server - INFO - KKS TCP server listening on ('0.0.0.0', 5000)
2026-03-15 11:04:52,421 - app.main - INFO - Shutting down TCP server...
2026-03-15 11:04:52,421 - app.tcp_server - INFO - KKS TCP server stopped
INFO: Application shutdown complete.

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
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

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

1854
server.log Normal file

File diff suppressed because it is too large Load Diff