Merge feature/dev → master: 智慧水务完整项目合并
合并 feature/dev 分支的全部代码到 master 主分支。 包含模块: - wm-production, wm-revenue, wm-data-engine, wm-patrol (核心业务) - wm-system, wm-dispatch, wm-config (系统核心) - wm-iot (IoT物联网) - wm-bi (BI看板) - wm-dma (DMA分区管理) - wm-bpm, wm-bpm-engine (流程引擎) - wm-base (基础框架) - wm-mobile-app, mobile-app (移动端) - frontend (前端) - db/ (数据库迁移: PostgreSQL + TDengine) - docker/ (容器化部署) - docs/ (技术文档) 冲突解决: README.md, frontend/index.html 采用 feature/dev 版本 操作者: bot_pm (小云)
This commit is contained in:
@@ -1,31 +1,299 @@
|
||||
# 智慧水务管理系统
|
||||
|
||||
**精河县供水工程综合管理平台**
|
||||
# 水务管理系统 - 数据接入层
|
||||
|
||||
## 项目概述
|
||||
|
||||
覆盖物联网接入→数据治理→生产管理→营业收费→巡检运维→移动端的全栈智慧水务解决方案。
|
||||
本项目是水务管理系统中的数据接入层,实现了多源数据接入、实时WebSocket推送和批量数据导入功能。
|
||||
|
||||
## 核心系统
|
||||
## 功能特性
|
||||
|
||||
- **物联网平台** — 多协议设备统一接入与感知层标准
|
||||
- **数据引擎** — 数据汇聚/治理/服务全生命周期
|
||||
- **供水生产管理平台** — 在线监测/GIS/水质/调度/报警/数据中心
|
||||
- **营业收费系统** — 营收/报装/收费/表务/客服/微信网厅/远传集抄/工单
|
||||
- **巡检管理系统** — 巡检任务/路线/统计分析/巡检APP
|
||||
- **移动APP** — 供水+巡检+营业收费三合一
|
||||
- **大数据分析系统** — BI决策支持
|
||||
- **业务流程引擎** — 跨系统流程统一编排
|
||||
### 1. REST API 数据接入
|
||||
- **IoT设备数据接入**:支持实时接收传感器数据
|
||||
- **手动数据录入**:支持人工录入数据
|
||||
- **批量API导入**:支持通过API批量导入数据
|
||||
- **数据查询接口**:提供按数据类型、时间范围等条件的数据查询
|
||||
- **统计分析接口**:提供数据统计和分析功能
|
||||
|
||||
## 适用场景
|
||||
### 2. WebSocket 实时推送
|
||||
- **实时数据推送**:传感器数据实时推送到客户端
|
||||
- **连接管理**:支持多客户端连接和订阅管理
|
||||
- **数据历史**:新连接客户端可以获取历史数据
|
||||
- **警报推送**:支持实时警报推送
|
||||
- **心跳检测**:支持连接状态监控
|
||||
|
||||
精河县及6个片区(精芒、八家户、托里、大镇阿合其、托托)供水管理。
|
||||
### 3. 批量数据导入
|
||||
- **多格式支持**:支持CSV、Excel、JSON格式导入
|
||||
- **数据验证**:内置数据验证和错误处理
|
||||
- **字段映射**:支持水利行业标准字段映射
|
||||
- **单位转换**:自动进行单位转换和标准化
|
||||
- **批量处理**:支持大批量数据处理
|
||||
|
||||
## 技术架构
|
||||
## 技术栈
|
||||
|
||||
架构选型详见 [docs/architecture.md](docs/architecture.md)
|
||||
- **后端框架**:FastAPI
|
||||
- **WebSocket**:websockets
|
||||
- **数据处理**:pandas, numpy
|
||||
- **异步处理**:asyncio
|
||||
- **数据验证**:pydantic
|
||||
- **文件处理**:aiofiles
|
||||
|
||||
## 文档
|
||||
## 项目结构
|
||||
|
||||
- [需求规格清单](docs/requirements.md)
|
||||
- [工作量评估](docs/estimation.md)
|
||||
```
|
||||
water-management-system/
|
||||
├── src/
|
||||
│ ├── api/ # REST API模块
|
||||
│ │ └── rest_api.py # 主API服务器
|
||||
│ ├── websocket/ # WebSocket模块
|
||||
│ │ └── websocket_server.py # WebSocket服务器
|
||||
│ ├── batch/ # 批量导入模块
|
||||
│ │ └── batch_import.py # 批量导入功能
|
||||
│ ├── utils/ # 工具模块
|
||||
│ │ └── data_utils.py # 数据处理工具
|
||||
│ └── models/ # 数据模型
|
||||
│ └── models.py # 数据模型定义
|
||||
├── main.py # 主程序入口
|
||||
├── requirements.txt # 依赖文件
|
||||
├── config.json # 配置文件
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## API 接口文档
|
||||
|
||||
### REST API 端点
|
||||
|
||||
#### 1. IoT 数据接收
|
||||
```
|
||||
POST /api/iot/data
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": "device_001",
|
||||
"data_type": "LL",
|
||||
"value": 25.5,
|
||||
"timestamp": "2024-01-01T12:00:00",
|
||||
"location": "A区"
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 手动数据录入
|
||||
```
|
||||
POST /api/manual/data
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "manual",
|
||||
"data_type": "YL",
|
||||
"value": 0.8,
|
||||
"timestamp": "2024-01-01T12:00:00",
|
||||
"operator": "张三",
|
||||
"notes": "定期数据录入"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 批量导入
|
||||
```
|
||||
POST /api/batch/import
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"batch_id": "batch_001",
|
||||
"data_source": "system_import",
|
||||
"records": [
|
||||
{
|
||||
"device_id": "device_002",
|
||||
"data_type": "SW",
|
||||
"value": 5.2,
|
||||
"timestamp": "2024-01-01T12:00:00",
|
||||
"location": "B区"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 数据查询
|
||||
```
|
||||
GET /api/data/{data_type}?limit=100&offset=0
|
||||
GET /api/data/recent?hours=24&limit=100
|
||||
GET /api/stats
|
||||
```
|
||||
|
||||
### WebSocket 连接
|
||||
|
||||
#### 连接端点
|
||||
```
|
||||
ws://localhost:8765
|
||||
```
|
||||
|
||||
#### 消息格式
|
||||
|
||||
**发送消息**:
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"subscription": "LL" // 订阅特定类型数据,"all"订阅所有
|
||||
}
|
||||
```
|
||||
|
||||
**接收消息**:
|
||||
```json
|
||||
{
|
||||
"type": "sensor_data",
|
||||
"data_type": "LL",
|
||||
"device_id": "device_001",
|
||||
"value": 25.5,
|
||||
"location": "A区",
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 安装和运行
|
||||
|
||||
### 1. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 启动系统
|
||||
```bash
|
||||
# 普通模式
|
||||
python main.py
|
||||
|
||||
# 演示模式(自动生成数据)
|
||||
python main.py --demo
|
||||
|
||||
# 指定配置文件
|
||||
python main.py --config custom_config.json
|
||||
```
|
||||
|
||||
### 3. 初始化项目
|
||||
```bash
|
||||
python main.py --init
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
|
||||
创建 `config.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000
|
||||
},
|
||||
"websocket": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765
|
||||
},
|
||||
"batch": {
|
||||
"max_file_size_mb": 100,
|
||||
"supported_formats": ["csv", "excel", "json"]
|
||||
},
|
||||
"demo_mode": true,
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": "water_management.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 数据类型说明
|
||||
|
||||
支持的水利行业标准数据类型:
|
||||
|
||||
| 数据类型 | 描述 | 单位 |
|
||||
|---------|------|------|
|
||||
| LL | 流量 | m³/h |
|
||||
| YL | 压力 | MPa |
|
||||
| SW | 水位 | m |
|
||||
| ZD | 浊度 | NTU |
|
||||
| PH | pH值 | - |
|
||||
| WD | 温度 | °C |
|
||||
| DD | 电导率 | μS/cm |
|
||||
| YD | 硬度 | mg/L |
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码结构
|
||||
- 遵循模块化设计,功能解耦
|
||||
- 使用异步编程提高性能
|
||||
- 统一的错误处理机制
|
||||
- 完整的日志记录
|
||||
|
||||
### 数据处理
|
||||
- 数据验证和清洗
|
||||
- 标准化字段映射
|
||||
- 单位自动转换
|
||||
- 质量评分机制
|
||||
|
||||
### 安全考虑
|
||||
- 输入数据验证
|
||||
- 文件大小限制
|
||||
- 连接状态监控
|
||||
- 错误信息脱敏
|
||||
|
||||
## 测试和验证
|
||||
|
||||
### 数据验证
|
||||
```python
|
||||
from src.utils.data_utils import data_converter
|
||||
|
||||
# 验证传感器数据
|
||||
validation_result = data_converter.validate_sensor_data({
|
||||
"device_id": "device_001",
|
||||
"data_type": "LL",
|
||||
"value": 25.5,
|
||||
"location": "A区"
|
||||
})
|
||||
|
||||
print(validation_result)
|
||||
```
|
||||
|
||||
### 批量导入测试
|
||||
```python
|
||||
import asyncio
|
||||
from src.batch.batch_import import batch_manager
|
||||
|
||||
async def test_import():
|
||||
result = await batch_manager.import_file(
|
||||
file_path="data.csv",
|
||||
batch_id="test_batch",
|
||||
data_source="test"
|
||||
)
|
||||
print(result)
|
||||
|
||||
asyncio.run(test_import())
|
||||
```
|
||||
|
||||
## 部署建议
|
||||
|
||||
### 1. 生产环境配置
|
||||
- 使用反向代理(Nginx)
|
||||
- 配置SSL证书
|
||||
- 设置防火墙规则
|
||||
- 监控系统资源使用
|
||||
|
||||
### 2. 性能优化
|
||||
- 数据库连接池
|
||||
- 缓存机制
|
||||
- 异步处理优化
|
||||
- 连接数限制
|
||||
|
||||
### 3. 监控和日志
|
||||
- 应用性能监控
|
||||
- 错误日志收集
|
||||
- 性能指标统计
|
||||
- 告警机制
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目遵循 MIT 许可证。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎提交 Issue 和 Pull Request来贡献代码。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请通过以下方式联系:
|
||||
- 邮箱:bot_dev1@xayunmei.com
|
||||
- 项目地址:http://git.xayunmei.com/bot_ym/water-management-system
|
||||
@@ -0,0 +1,50 @@
|
||||
# 水务管理系统 BI 前端
|
||||
|
||||
这是一个简单的HTML前端,用于展示水务管理系统的BI看板和数据可视化功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 自助BI看板浏览
|
||||
- 图表数据可视化
|
||||
- 实时数据展示
|
||||
- 响应式设计
|
||||
- 移动端兼容
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── index.html # 主页面
|
||||
├── css/
|
||||
│ └── styles.css # 样式文件
|
||||
├── js/
|
||||
│ ├── main.js # 主要JavaScript逻辑
|
||||
│ ├── api.js # API调用封装
|
||||
│ └── charts.js # 图表绘制逻辑
|
||||
└── lib/
|
||||
└── echarts.min.js # ECharts图表库
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 确保后端API服务器正在运行
|
||||
2. 打开 `index.html` 文件
|
||||
3. 选择要查看的看板
|
||||
4. 查看实时数据和图表
|
||||
|
||||
## API接口
|
||||
|
||||
前端主要通过以下API接口与后端交互:
|
||||
|
||||
- `/bi-api/dashboards/overview` - 获取概览看板
|
||||
- `/bi-api/dashboards/{id}/data` - 获取看板数据
|
||||
- `/bi-api/charts/{id}/data` - 获取图表数据
|
||||
- `/bi-api/search` - 搜索功能
|
||||
|
||||
## 技术栈
|
||||
|
||||
- HTML5
|
||||
- CSS3
|
||||
- JavaScript (ES6+)
|
||||
- ECharts 图表库
|
||||
- Fetch API
|
||||
@@ -0,0 +1,412 @@
|
||||
/* 基础样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #45a049;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #1976D2;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background-color: #ff7043;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #f4511e;
|
||||
}
|
||||
|
||||
/* 看板选择器样式 */
|
||||
.dashboard-selector {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.dashboard-selector h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.dashboard-card h3 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.dashboard-card p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dashboard-card .tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-card .charts-count {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 看板详情样式 */
|
||||
.dashboard-detail {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
margin-left: 15px;
|
||||
color: #333;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.charts-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item h4 {
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.search-result-item p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-result-item .type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.search-result-item .type.chart {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.search-result-item .type.dashboard {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(-50px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.charts-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 10% auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #ddd;
|
||||
border-top: 2px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
border-left: 4px solid #c62828;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
+61
-6
@@ -1,12 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智慧水务管理系统</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>水务管理系统 - BI看板</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<script src="lib/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div class="container">
|
||||
<!-- 头部 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>水务管理系统 BI 看板</h1>
|
||||
<div class="header-actions">
|
||||
<button id="refreshBtn" class="btn btn-primary">刷新数据</button>
|
||||
<button id="searchBtn" class="btn btn-secondary">搜索</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<!-- 看板选择 -->
|
||||
<section class="dashboard-selector" id="dashboardSelector">
|
||||
<h2>选择看板</h2>
|
||||
<div class="dashboard-grid" id="dashboardGrid">
|
||||
<!-- 看板卡片将通过JavaScript动态加载 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 看板详情 -->
|
||||
<section class="dashboard-detail" id="dashboardDetail" style="display: none;">
|
||||
<div class="dashboard-header">
|
||||
<button id="backBtn" class="btn btn-back">← 返回</button>
|
||||
<h2 id="dashboardTitle"></h2>
|
||||
</div>
|
||||
<div class="charts-container" id="chartsContainer">
|
||||
<!-- 图表将通过JavaScript动态加载 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 搜索模态框 -->
|
||||
<div id="searchModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>搜索看板和图表</h3>
|
||||
<button id="closeSearchBtn" class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="searchInput" placeholder="输入搜索关键词..." class="search-input">
|
||||
<div id="searchResults" class="search-results">
|
||||
<!-- 搜索结果将在这里显示 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript文件 -->
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/charts.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* API 封装模块
|
||||
* 提供与后端API的交互功能
|
||||
*/
|
||||
|
||||
class BIAPIClient {
|
||||
constructor(baseURL = '') {
|
||||
this.baseURL = baseURL || window.location.origin;
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, defaultOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET请求
|
||||
async get(endpoint, params = {}) {
|
||||
const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin);
|
||||
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== undefined && params[key] !== null) {
|
||||
url.searchParams.append(key, params[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return this.request(url.pathname + url.search);
|
||||
}
|
||||
|
||||
// POST请求
|
||||
async post(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
async put(endpoint, data = {}) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE请求
|
||||
async delete(endpoint) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// BI相关API
|
||||
async getOverviewDashboards() {
|
||||
return this.get('/bi-api/dashboards/overview');
|
||||
}
|
||||
|
||||
async getDashboardData(dashboardId) {
|
||||
return this.get(`/bi-api/dashboards/${dashboardId}/data`);
|
||||
}
|
||||
|
||||
async getChartData(chartId) {
|
||||
return this.get(`/bi-api/charts/${chartId}/data`);
|
||||
}
|
||||
|
||||
async searchBIObjects(keyword) {
|
||||
return this.get('/bi-api/search', { keyword });
|
||||
}
|
||||
|
||||
async getChartTypes() {
|
||||
return this.get('/bi-api/charts/types');
|
||||
}
|
||||
|
||||
async getDataSourceTypes() {
|
||||
return this.get('/bi-api/data-sources/types');
|
||||
}
|
||||
|
||||
async getPopularTags() {
|
||||
return this.get('/bi-api/popular-tags');
|
||||
}
|
||||
|
||||
async getQuickStats() {
|
||||
return this.get('/bi-api/quick-stats');
|
||||
}
|
||||
|
||||
async getChartSuggestions() {
|
||||
return this.get('/bi-api/chart-suggestions');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局API客户端实例
|
||||
const apiClient = new BIAPIClient();
|
||||
|
||||
/**
|
||||
* 数据获取函数
|
||||
*/
|
||||
|
||||
// 获取概览看板列表
|
||||
export async function getDashboards() {
|
||||
try {
|
||||
return await apiClient.getOverviewDashboards();
|
||||
} catch (error) {
|
||||
console.error('获取看板列表失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取看板详细数据
|
||||
export async function getDashboardDetail(dashboardId) {
|
||||
try {
|
||||
return await apiClient.getDashboardData(dashboardId);
|
||||
} catch (error) {
|
||||
console.error(`获取看板 ${dashboardId} 数据失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图表数据
|
||||
export async function getChartData(chartId) {
|
||||
try {
|
||||
return await apiClient.getChartData(chartId);
|
||||
} catch (error) {
|
||||
console.error(`获取图表 ${chartId} 数据失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索BI对象
|
||||
export async function searchBIObjects(keyword) {
|
||||
try {
|
||||
return await apiClient.searchBIObjects(keyword);
|
||||
} catch (error) {
|
||||
console.error(`搜索 ${keyword} 失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取支持的图表类型
|
||||
export async function getChartTypes() {
|
||||
try {
|
||||
return await apiClient.getChartTypes();
|
||||
} catch (error) {
|
||||
console.error('获取图表类型失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取支持的数据源类型
|
||||
export async function getDataSourceTypes() {
|
||||
try {
|
||||
return await apiClient.getDataSourceTypes();
|
||||
} catch (error) {
|
||||
console.error('获取数据源类型失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取热门标签
|
||||
export async function getPopularTags() {
|
||||
try {
|
||||
return await apiClient.getPopularTags();
|
||||
} catch (error) {
|
||||
console.error('获取热门标签失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取快速统计信息
|
||||
export async function getQuickStats() {
|
||||
try {
|
||||
return await apiClient.getQuickStats();
|
||||
} catch (error) {
|
||||
console.error('获取快速统计失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图表建议
|
||||
export async function getChartSuggestions() {
|
||||
try {
|
||||
return await apiClient.getChartSuggestions();
|
||||
} catch (error) {
|
||||
console.error('获取图表建议失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具函数
|
||||
*/
|
||||
|
||||
// 格式化日期
|
||||
export function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
export function formatNumber(num, decimals = 2) {
|
||||
return parseFloat(num).toFixed(decimals);
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
export function showLoading(element) {
|
||||
element.innerHTML = '<div class="loading">加载中...</div>';
|
||||
}
|
||||
|
||||
// 显示错误状态
|
||||
export function showError(element, message) {
|
||||
element.innerHTML = `<div class="error">${message}</div>`;
|
||||
}
|
||||
|
||||
// 显示空状态
|
||||
export function showEmpty(element, message = '暂无数据') {
|
||||
element.innerHTML = `<div class="empty">${message}</div>`;
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// 节流函数
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* 图表绘制模块
|
||||
* 使用ECharts绘制各种图表
|
||||
*/
|
||||
|
||||
class ChartRenderer {
|
||||
constructor() {
|
||||
this.charts = new Map(); // 存储图表实例
|
||||
}
|
||||
|
||||
// 创建图表容器
|
||||
createChartContainer(chartId, title) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'chart-container';
|
||||
container.innerHTML = `
|
||||
<h3>${title}</h3>
|
||||
<div class="chart" id="chart-${chartId}"></div>
|
||||
`;
|
||||
return container;
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
initChart(chartId, options = {}) {
|
||||
const chartDom = document.getElementById(`chart-${chartId}`);
|
||||
if (!chartDom) {
|
||||
console.error(`图表容器 ${chartId} 不存在`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清除之前的图表实例
|
||||
if (this.charts.has(chartId)) {
|
||||
this.charts.get(chartId).dispose();
|
||||
}
|
||||
|
||||
const chart = echarts.init(chartDom);
|
||||
this.charts.set(chartId, chart);
|
||||
|
||||
// 应用配置
|
||||
chart.setOption(options);
|
||||
|
||||
// 响应式处理
|
||||
const resizeHandler = () => {
|
||||
chart.resize();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
// 保存resize处理函数以便后续清理
|
||||
chartDom._resizeHandler = resizeHandler;
|
||||
|
||||
return chart;
|
||||
}
|
||||
|
||||
// 销毁图表
|
||||
destroyChart(chartId) {
|
||||
const chart = this.charts.get(chartId);
|
||||
if (chart) {
|
||||
chart.dispose();
|
||||
this.charts.delete(chartId);
|
||||
|
||||
// 移除resize监听器
|
||||
const chartDom = document.getElementById(`chart-${chartId}`);
|
||||
if (chartDom && chartDom._resizeHandler) {
|
||||
window.removeEventListener('resize', chartDom._resizeHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制折线图
|
||||
renderLineChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '趋势图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: options.legendData || ['数据'],
|
||||
top: 'bottom'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(item => item[options.xAxis || 'timestamp']),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: options.yAxisName || '数值'
|
||||
},
|
||||
series: options.seriesData || [{
|
||||
name: '数据',
|
||||
type: 'line',
|
||||
data: data.map(item => item.value),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.1
|
||||
}
|
||||
}],
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true
|
||||
}
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制柱状图
|
||||
renderBarChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '柱状图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(item => item[options.xAxis || 'category']),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: options.yAxisName || '数值'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'bar',
|
||||
data: data.map(item => item.value),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#83bff6' },
|
||||
{ offset: 0.5, color: '#188df0' },
|
||||
{ offset: 1, color: '#188df0' }
|
||||
])
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#2378f7' },
|
||||
{ offset: 0.7, color: '#2378f7' },
|
||||
{ offset: 1, color: '#83bff6' }
|
||||
])
|
||||
}
|
||||
}
|
||||
}],
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true
|
||||
}
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制饼图
|
||||
renderPieChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '饼图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'pie',
|
||||
radius: options.radius || ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '20',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data.map(item => ({
|
||||
name: item.name,
|
||||
value: item.value
|
||||
}))
|
||||
}]
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制散点图
|
||||
renderScatterChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '散点图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: options.xAxisName || 'X轴'
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: options.yAxisName || 'Y轴'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'scatter',
|
||||
data: data.map(item => [item.x, item.y]),
|
||||
symbolSize: function (data) {
|
||||
return Math.sqrt(data[2]) || 10;
|
||||
},
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.RadialGradient(0.5, 0.5, 1, [
|
||||
{ offset: 0, color: 'rgba(58,77,233,0.8)' },
|
||||
{ offset: 1, color: 'rgba(58,77,233,0.1)' }
|
||||
])
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制面积图
|
||||
renderAreaChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '面积图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: options.legendData || ['数据'],
|
||||
top: 'bottom'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(item => item[options.xAxis || 'timestamp']),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: options.yAxisName || '数值'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'line',
|
||||
data: data.map(item => item.value),
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
areaStyle: {
|
||||
opacity: 0.3,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(128, 255, 165, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(1, 191, 236, 0.1)' }
|
||||
])
|
||||
}
|
||||
}],
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
containLabel: true
|
||||
}
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制仪表盘
|
||||
renderGaugeChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '仪表盘',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
formatter: '{b}: {c}%'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'gauge',
|
||||
min: options.min || 0,
|
||||
max: options.max || 100,
|
||||
splitNumber: options.splitNumber || 10,
|
||||
radius: options.radius || '80%',
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 10,
|
||||
color: [
|
||||
[0.3, '#ff6e76'],
|
||||
[0.7, '#fddd60'],
|
||||
[1, '#7cffb2']
|
||||
]
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
itemStyle: {
|
||||
color: 'auto'
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
distance: -15,
|
||||
length: 8,
|
||||
lineStyle: {
|
||||
color: '#999',
|
||||
width: 2
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
distance: -20,
|
||||
length: 15,
|
||||
lineStyle: {
|
||||
color: '#999',
|
||||
width: 3
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
distance: 30,
|
||||
fontSize: 12
|
||||
},
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
fontSize: 20,
|
||||
offsetCenter: [0, '60%']
|
||||
},
|
||||
data: [{
|
||||
value: data.value || 0,
|
||||
name: options.name || '数据'
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, mergedOptions);
|
||||
}
|
||||
|
||||
// 绘制表格
|
||||
renderTableChart(chartId, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '表格',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
show: false
|
||||
},
|
||||
grid: {
|
||||
top: '10%',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(item => item[options.xAxis || 'name']),
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'bar',
|
||||
data: data.map(item => item.value),
|
||||
itemStyle: {
|
||||
color: '#5470c6'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
const mergedOptions = this.mergeOptions(defaultOptions, options);
|
||||
this.initChart(chartId, defaultOptions); // 表格不需要复杂配置
|
||||
}
|
||||
|
||||
// 合并配置选项
|
||||
mergeOptions(defaultOptions, customOptions) {
|
||||
return {
|
||||
...defaultOptions,
|
||||
...customOptions,
|
||||
series: customOptions.series || defaultOptions.series
|
||||
};
|
||||
}
|
||||
|
||||
// 根据图表类型渲染图表
|
||||
renderChartByType(chartId, chartData) {
|
||||
const { chart_type: chartType, data, options = {} } = chartData;
|
||||
|
||||
switch (chartType) {
|
||||
case 'line':
|
||||
this.renderLineChart(chartId, data, options);
|
||||
break;
|
||||
case 'bar':
|
||||
this.renderBarChart(chartId, data, options);
|
||||
break;
|
||||
case 'pie':
|
||||
this.renderPieChart(chartId, data, options);
|
||||
break;
|
||||
case 'scatter':
|
||||
this.renderScatterChart(chartId, data, options);
|
||||
break;
|
||||
case 'area':
|
||||
this.renderAreaChart(chartId, data, options);
|
||||
break;
|
||||
case 'gauge':
|
||||
this.renderGaugeChart(chartId, data, options);
|
||||
break;
|
||||
case 'table':
|
||||
this.renderTableChart(chartId, data, options);
|
||||
break;
|
||||
default:
|
||||
// 默认使用折线图
|
||||
this.renderLineChart(chartId, data, options);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量渲染图表
|
||||
renderCharts(chartsData) {
|
||||
chartsData.forEach(chartData => {
|
||||
const { chart_id: chartId, chart_name: chartName, chart_type: chartType, data, options = {} } = chartData;
|
||||
|
||||
const chartContainer = this.createChartContainer(chartId, chartName);
|
||||
document.getElementById('chartsContainer').appendChild(chartContainer);
|
||||
|
||||
this.renderChartByType(chartId, {
|
||||
chart_type: chartType,
|
||||
data,
|
||||
options: {
|
||||
...options,
|
||||
title: chartName
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 清理所有图表
|
||||
clearAllCharts() {
|
||||
this.charts.forEach((chart, chartId) => {
|
||||
this.destroyChart(chartId);
|
||||
});
|
||||
|
||||
const chartsContainer = document.getElementById('chartsContainer');
|
||||
if (chartsContainer) {
|
||||
chartsContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局图表渲染器实例
|
||||
const chartRenderer = new ChartRenderer();
|
||||
|
||||
export default chartRenderer;
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
水务管理系统主程序
|
||||
集成REST API、WebSocket和批量导入功能
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
# 导入各个模块
|
||||
from src.api.rest_api import app as rest_api_app
|
||||
from src.websocket.websocket_server import websocket_server
|
||||
from src.batch.batch_import import batch_manager
|
||||
from src.utils.data_utils import data_converter, data_formatter, quality_checker
|
||||
from src.models.models import validator
|
||||
from src.iot.app import create_iot_app
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('water_management.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WaterManagementSystem:
|
||||
"""水务管理系统主类"""
|
||||
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
self.config_file = config_file
|
||||
self.running = False
|
||||
self.tasks = []
|
||||
self.config = self.load_config()
|
||||
|
||||
def load_config(self) -> dict:
|
||||
"""加载配置文件"""
|
||||
config_path = Path(self.config_file)
|
||||
if config_path.exists():
|
||||
import json
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
# 默认配置
|
||||
return {
|
||||
"api": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000
|
||||
},
|
||||
"websocket": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765
|
||||
},
|
||||
"batch": {
|
||||
"max_file_size_mb": 100,
|
||||
"supported_formats": ["csv", "excel", "json"]
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": "water_management.log"
|
||||
}
|
||||
}
|
||||
|
||||
async def start_api_server(self):
|
||||
"""启动API服务器"""
|
||||
import uvicorn
|
||||
logger.info("启动REST API服务器...")
|
||||
|
||||
# 创建组合应用(包含所有模块)
|
||||
from werkzeug.middleware.dispatcher import DispatcherMiddleware
|
||||
from werkzeug.serving import WSGIRequestHandler
|
||||
|
||||
# IoT应用
|
||||
iot_app = create_iot_app()
|
||||
|
||||
# 组合所有应用
|
||||
def combined_app(environ, start_response):
|
||||
path = environ.get('PATH_INFO', '')
|
||||
if path.startswith('/api/iot/'):
|
||||
return iot_app(environ, start_response)
|
||||
else:
|
||||
return rest_api_app(environ, start_response)
|
||||
|
||||
# 配置uvicorn
|
||||
api_config = uvicorn.Config(
|
||||
app=combined_app,
|
||||
host=self.config["api"]["host"],
|
||||
port=self.config["api"]["port"],
|
||||
log_level="info"
|
||||
)
|
||||
api_server = uvicorn.Server(api_config)
|
||||
|
||||
# 在后台任务中运行
|
||||
await api_server.serve()
|
||||
|
||||
async def start_websocket_server(self):
|
||||
"""启动WebSocket服务器"""
|
||||
logger.info("启动WebSocket服务器...")
|
||||
|
||||
# 启动WebSocket服务器
|
||||
server = await websocket_server.start_server()
|
||||
|
||||
# 添加服务器关闭处理
|
||||
def cleanup():
|
||||
logger.info("关闭WebSocket服务器...")
|
||||
server.close()
|
||||
asyncio.create_task(server.wait_closed())
|
||||
|
||||
return server, cleanup
|
||||
|
||||
async def start_data_generator(self):
|
||||
"""启动数据生成器(用于演示)"""
|
||||
logger.info("启动数据生成器...")
|
||||
|
||||
while self.running:
|
||||
# 生成模拟数据
|
||||
import random
|
||||
sensor_types = ["LL", "YL", "SW", "ZD"]
|
||||
sensor_type = random.choice(sensor_types)
|
||||
|
||||
# 根据传感器类型生成合理的数值范围
|
||||
if sensor_type == "LL": # 流量
|
||||
value = random.uniform(10, 100)
|
||||
elif sensor_type == "YL": # 压力
|
||||
value = random.uniform(0.1, 1.0)
|
||||
elif sensor_type == "SW": # 水位
|
||||
value = random.uniform(0, 10)
|
||||
else: # ZD 浊度
|
||||
value = random.uniform(0, 50)
|
||||
|
||||
sensor_data = {
|
||||
"data_type": sensor_type,
|
||||
"device_id": f"device_{random.randint(1, 10)}",
|
||||
"value": round(value, 2),
|
||||
"location": random.choice(["A区", "B区", "C区", "D区"])
|
||||
}
|
||||
|
||||
# 通过WebSocket发送数据
|
||||
await websocket_server.send_sensor_data(sensor_data)
|
||||
|
||||
# 等待5秒
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def handle_batch_import(self, file_path: str, batch_id: str, data_source: str):
|
||||
"""处理批量导入请求"""
|
||||
try:
|
||||
logger.info(f"开始批量导入: {file_path}")
|
||||
|
||||
# 验证文件
|
||||
validation_result = await batch_manager.validate_file(file_path)
|
||||
if not validation_result["valid"]:
|
||||
raise Exception(f"文件验证失败: {validation_result['error']}")
|
||||
|
||||
# 导入文件
|
||||
result = await batch_manager.import_file(
|
||||
file_path=file_path,
|
||||
batch_id=batch_id,
|
||||
data_source=data_source,
|
||||
file_type="auto"
|
||||
)
|
||||
|
||||
logger.info(f"批量导入完成: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量导入失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def start_system(self):
|
||||
"""启动系统"""
|
||||
logger.info("启动水务管理系统...")
|
||||
|
||||
# 标记系统为运行状态
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
# 启动WebSocket服务器
|
||||
ws_server, ws_cleanup = await self.start_websocket_server()
|
||||
self.tasks.append(ws_server)
|
||||
|
||||
# 启动数据生成器(如果启用)
|
||||
if self.config.get("demo_mode", False):
|
||||
generator_task = asyncio.create_task(self.start_data_generator())
|
||||
self.tasks.append(generator_task)
|
||||
|
||||
# 启动API服务器
|
||||
await self.start_api_server()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"系统启动失败: {str(e)}")
|
||||
await self.stop_system()
|
||||
raise
|
||||
|
||||
async def stop_system(self):
|
||||
"""停止系统"""
|
||||
logger.info("停止水务管理系统...")
|
||||
|
||||
self.running = False
|
||||
|
||||
# 取消所有任务
|
||||
for task in self.tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 清理WebSocket服务器
|
||||
if hasattr(websocket_server, 'server') and websocket_server.server:
|
||||
websocket_server.server.close()
|
||||
await websocket_server.server.wait_closed()
|
||||
|
||||
logger.info("水务管理系统已停止")
|
||||
|
||||
async def run(self):
|
||||
"""运行系统"""
|
||||
# 设置信号处理
|
||||
def signal_handler():
|
||||
logger.info("收到停止信号...")
|
||||
asyncio.create_task(self.stop_system())
|
||||
|
||||
for sig in [signal.SIGINT, signal.SIGTERM]:
|
||||
signal.signal(sig, signal_handler)
|
||||
|
||||
try:
|
||||
await self.start_system()
|
||||
|
||||
# 保持运行直到收到停止信号
|
||||
while self.running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到键盘中断信号")
|
||||
except Exception as e:
|
||||
logger.error(f"系统运行时出错: {str(e)}")
|
||||
finally:
|
||||
await self.stop_system()
|
||||
|
||||
def create_sample_config():
|
||||
"""创建示例配置文件"""
|
||||
sample_config = {
|
||||
"api": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000
|
||||
},
|
||||
"websocket": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765
|
||||
},
|
||||
"batch": {
|
||||
"max_file_size_mb": 100,
|
||||
"supported_formats": ["csv", "excel", "json"]
|
||||
},
|
||||
"demo_mode": True,
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": "water_management.log"
|
||||
}
|
||||
}
|
||||
|
||||
import json
|
||||
with open("config.json", 'w', encoding='utf-8') as f:
|
||||
json.dump(sample_config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info("示例配置文件已创建: config.json")
|
||||
|
||||
def create_requirements():
|
||||
"""创建requirements.txt文件"""
|
||||
requirements = [
|
||||
"fastapi==0.104.1",
|
||||
"uvicorn[standard]==0.24.0",
|
||||
"websockets==12.0",
|
||||
"pandas==2.1.3",
|
||||
"openpyxl==3.1.2",
|
||||
"aiofiles==23.2.1",
|
||||
"python-multipart==0.0.6",
|
||||
"jinja2==3.1.2",
|
||||
"paho-mqtt==1.6.1",
|
||||
"flask==2.3.3"
|
||||
]
|
||||
|
||||
with open("requirements.txt", 'w') as f:
|
||||
f.write('\n'.join(requirements))
|
||||
|
||||
logger.info("依赖文件已创建: requirements.txt")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description="水务管理系统")
|
||||
parser.add_argument("--config", "-c", default="config.json", help="配置文件路径")
|
||||
parser.add_argument("--init", action="store_true", help="初始化项目(创建配置文件和依赖)")
|
||||
parser.add_argument("--demo", action="store_true", help="启动演示模式")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.init:
|
||||
create_sample_config()
|
||||
create_requirements()
|
||||
logger.info("项目初始化完成")
|
||||
return
|
||||
|
||||
# 创建系统实例
|
||||
system = WaterManagementSystem(args.config)
|
||||
|
||||
# 如果启用演示模式
|
||||
if args.demo:
|
||||
system.config["demo_mode"] = True
|
||||
logger.info("启用演示模式")
|
||||
|
||||
# 运行系统
|
||||
try:
|
||||
asyncio.run(system.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("程序已退出")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
websockets==12.0
|
||||
pandas==2.1.3
|
||||
openpyxl==3.1.2
|
||||
aiofiles==23.2.1
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.2
|
||||
requests==2.31.0
|
||||
python-dateutil==2.8.2
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
BI前端API模块
|
||||
为前端提供BI相关的API接口,简化前端调用
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import List, Optional, Dict, Any
|
||||
import json
|
||||
|
||||
# 创建BI路由器
|
||||
router = APIRouter(prefix="/bi-api", tags=["BI API"])
|
||||
|
||||
@router.get("/dashboards/overview")
|
||||
async def get_overview_dashboards():
|
||||
"""获取概览看板数据"""
|
||||
return {
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "operation_overview",
|
||||
"name": "水务运营总览",
|
||||
"description": "水务系统整体运营情况综合看板",
|
||||
"charts_count": 4,
|
||||
"is_public": True,
|
||||
"tags": ["运营", "总览", "综合"]
|
||||
},
|
||||
{
|
||||
"id": "device_management",
|
||||
"name": "设备管理看板",
|
||||
"description": "设备状态监控和维护管理",
|
||||
"charts_count": 1,
|
||||
"is_public": False,
|
||||
"tags": ["设备", "管理", "监控"]
|
||||
},
|
||||
{
|
||||
"id": "security_monitoring",
|
||||
"name": "安全监控看板",
|
||||
"description": "系统安全和警报监控",
|
||||
"charts_count": 1,
|
||||
"is_public": False,
|
||||
"tags": ["安全", "监控", "警报"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/charts/{chart_id}/data")
|
||||
async def get_chart_data_frontend(chart_id: str):
|
||||
"""获取图表数据(前端友好格式)"""
|
||||
# 这里调用BI服务获取数据,简化前端调用
|
||||
try:
|
||||
from ..bi.services import BIService
|
||||
bi_service = BIService()
|
||||
data = bi_service.get_chart_data_api(chart_id)
|
||||
|
||||
if "error" in data:
|
||||
raise HTTPException(status_code=404, detail=data["error"])
|
||||
|
||||
# 格式化为前端友好的数据格式
|
||||
formatted_data = {
|
||||
"chartId": chart_id,
|
||||
"chartName": data.get("chart_name", ""),
|
||||
"chartType": data.get("chart_type", ""),
|
||||
"data": data.get("data", []),
|
||||
"options": data.get("options", {}),
|
||||
"columns": data.get("columns", [])
|
||||
}
|
||||
|
||||
return formatted_data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/dashboards/{dashboard_id}/data")
|
||||
async def get_dashboard_data_frontend(dashboard_id: str):
|
||||
"""获取看板数据(前端友好格式)"""
|
||||
try:
|
||||
from ..bi.services import BIService
|
||||
bi_service = BIService()
|
||||
data = bi_service.get_dashboard_data_api(dashboard_id)
|
||||
|
||||
if "error" in data:
|
||||
raise HTTPException(status_code=404, detail=data["error"])
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/charts/types")
|
||||
async def get_chart_types():
|
||||
"""获取支持的图表类型"""
|
||||
return {
|
||||
"line": {"name": "折线图", "description": "适合展示趋势数据"},
|
||||
"bar": {"name": "柱状图", "description": "适合展示分类数据"},
|
||||
"pie": {"name": "饼图", "description": "适合展示比例数据"},
|
||||
"scatter": {"name": "散点图", "description": "适合展示关系数据"},
|
||||
"area": {"name": "面积图", "description": "适合展示累计数据"},
|
||||
"gauge": {"name": "仪表盘", "description": "适合展示进度或状态"},
|
||||
"table": {"name": "表格", "description": "适合展示详细数据"},
|
||||
"heatmap": {"name": "热力图", "description": "适合展示密度数据"}
|
||||
}
|
||||
|
||||
@router.get("/data-sources/types")
|
||||
async def get_data_source_types():
|
||||
"""获取支持的数据源类型"""
|
||||
return {
|
||||
"sensor_data": {"name": "传感器数据", "description": "IoT传感器实时和历史数据"},
|
||||
"device_data": {"name": "设备数据", "description": "设备状态和配置信息"},
|
||||
"alert_data": {"name": "警报数据", "description": "系统警报和通知记录"},
|
||||
"system_stats": {"name": "系统统计", "description": "系统运行性能统计"},
|
||||
"batch_data": {"name": "批量数据", "description": "批量导入的数据"}
|
||||
}
|
||||
|
||||
@router.get("/search")
|
||||
async def search_bi_objects(keyword: str = Query(..., description="搜索关键词")):
|
||||
"""搜索BI对象(图表和看板)"""
|
||||
try:
|
||||
from ..bi.services import BIService
|
||||
bi_service = BIService()
|
||||
|
||||
# 搜索图表
|
||||
charts = bi_service.search_charts(keyword)
|
||||
charts_data = [chart.to_dict() for chart in charts]
|
||||
|
||||
# 搜索看板
|
||||
dashboards = bi_service.search_dashboards(keyword)
|
||||
dashboards_data = [dashboard.to_dict() for dashboard in dashboards]
|
||||
|
||||
return {
|
||||
"charts": charts_data,
|
||||
"dashboards": dashboards_data,
|
||||
"total": len(charts_data) + len(dashboards_data)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/popular-tags")
|
||||
async def get_popular_tags():
|
||||
"""获取热门标签"""
|
||||
try:
|
||||
from ..bi.services import BIService
|
||||
bi_service = BIService()
|
||||
|
||||
# 收集所有标签
|
||||
all_tags = set()
|
||||
for chart in bi_service.get_all_charts():
|
||||
all_tags.update(chart.tags)
|
||||
for dashboard in bi_service.get_all_dashboards():
|
||||
all_tags.update(dashboard.tags)
|
||||
|
||||
# 返回热门标签(按字母排序)
|
||||
return {"tags": sorted(list(all_tags))}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/quick-stats")
|
||||
async def get_quick_stats():
|
||||
"""获取快速统计信息"""
|
||||
try:
|
||||
from ..bi.services import BIService
|
||||
bi_service = BIService()
|
||||
|
||||
charts = bi_service.get_all_charts()
|
||||
dashboards = bi_service.get_all_dashboards()
|
||||
public_dashboards = bi_service.get_public_dashboards()
|
||||
|
||||
# 统计图表类型分布
|
||||
chart_type_stats = {}
|
||||
for chart in charts:
|
||||
chart_type = chart.chart_type.value
|
||||
chart_type_stats[chart_type] = chart_type_stats.get(chart_type, 0) + 1
|
||||
|
||||
# 统计标签分布
|
||||
tag_stats = {}
|
||||
for chart in charts:
|
||||
for tag in chart.tags:
|
||||
tag_stats[tag] = tag_stats.get(tag, 0) + 1
|
||||
for dashboard in dashboards:
|
||||
for tag in dashboard.tags:
|
||||
tag_stats[tag] = tag_stats.get(tag, 0) + 1
|
||||
|
||||
return {
|
||||
"total_charts": len(charts),
|
||||
"total_dashboards": len(dashboards),
|
||||
"public_dashboards": len(public_dashboards),
|
||||
"chart_types": chart_type_stats,
|
||||
"popular_tags": dict(sorted(tag_stats.items(), key=lambda x: x[1], reverse=True)[:10])
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/chart-suggestions")
|
||||
async def get_chart_suggestions():
|
||||
"""获取图表建议"""
|
||||
return {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "flow_analysis",
|
||||
"name": "流量分析建议",
|
||||
"description": "基于历史流量数据,分析流量趋势和异常",
|
||||
"charts": ["flow_trend", "flow_comparison"],
|
||||
"tags": ["流量", "分析", "趋势"]
|
||||
},
|
||||
{
|
||||
"id": "device_performance",
|
||||
"name": "设备性能分析",
|
||||
"description": "分析设备运行状态和性能指标",
|
||||
"charts": ["device_status_distribution", "device_uptime"],
|
||||
"tags": ["设备", "性能", "分析"]
|
||||
},
|
||||
{
|
||||
"id": "security_dashboard",
|
||||
"name": "安全监控看板",
|
||||
"description": "集中监控系统安全和警报信息",
|
||||
"charts": ["alert_level_stats", "alert_trend"],
|
||||
"tags": ["安全", "监控", "警报"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
REST API 数据接入模块
|
||||
支持 IoT 设备数据、手动录入和 API 批量导入
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
import uvicorn
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# 导入BI模块
|
||||
from ..bi.controllers import router as bi_router
|
||||
from .bi_api import router as bi_api_router
|
||||
|
||||
# 将BI路由添加到主应用
|
||||
app.include_router(bi_router)
|
||||
app.include_router(bi_api_router)
|
||||
|
||||
# 创建FastAPI应用
|
||||
app = FastAPI(title="Water Management System Data API", version="1.0.0")
|
||||
|
||||
# CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 数据模型
|
||||
class IoTData(BaseModel):
|
||||
device_id: str
|
||||
data_type: str # "LL", "YL", "SW", "ZD" 等
|
||||
value: float
|
||||
timestamp: datetime
|
||||
location: str
|
||||
|
||||
class ManualInputData(BaseModel):
|
||||
source: str
|
||||
data_type: str
|
||||
value: float
|
||||
timestamp: datetime
|
||||
operator: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class BatchImportRequest(BaseModel):
|
||||
batch_id: str
|
||||
data_source: str
|
||||
records: List[Dict[str, Any]]
|
||||
|
||||
# 数据存储(示例,实际应该用数据库)
|
||||
data_store = []
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""API根路径"""
|
||||
return {"message": "Water Management System API", "version": "1.0.0"}
|
||||
|
||||
@app.post("/api/iot/data")
|
||||
async def receive_iot_data(data: IoTData):
|
||||
"""接收IoT设备数据"""
|
||||
try:
|
||||
data_dict = data.dict()
|
||||
data_store.append({
|
||||
**data_dict,
|
||||
"id": len(data_store) + 1,
|
||||
"type": "iot"
|
||||
})
|
||||
return {"status": "success", "id": len(data_store), "message": "IoT data received"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.post("/api/manual/data")
|
||||
async def receive_manual_data(data: ManualInputData):
|
||||
"""接收手动录入数据"""
|
||||
try:
|
||||
data_dict = data.dict()
|
||||
data_store.append({
|
||||
**data_dict,
|
||||
"id": len(data_store) + 1,
|
||||
"type": "manual"
|
||||
})
|
||||
return {"status": "success", "id": len(data_store), "message": "Manual data received"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.post("/api/batch/import")
|
||||
async def batch_import(request: BatchImportRequest):
|
||||
"""批量导入数据"""
|
||||
try:
|
||||
imported_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for record in request.records:
|
||||
# 验证记录
|
||||
if not all(k in record for k in ['device_id', 'data_type', 'value']):
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 创建数据对象
|
||||
data_record = {
|
||||
"device_id": record['device_id'],
|
||||
"data_type": record['data_type'],
|
||||
"value": float(record['value']),
|
||||
"timestamp": record.get('timestamp', datetime.now()),
|
||||
"location": record.get('location', 'unknown'),
|
||||
"batch_id": request.batch_id,
|
||||
"source": request.data_source,
|
||||
"id": len(data_store) + 1,
|
||||
"type": "batch"
|
||||
}
|
||||
|
||||
data_store.append(data_record)
|
||||
imported_count += 1
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"imported_count": imported_count,
|
||||
"failed_count": failed_count,
|
||||
"message": f"Batch import completed: {imported_count} records imported, {failed_count} failed"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.get("/api/data/{data_type}")
|
||||
async def get_data_by_type(data_type: str, limit: int = 100, offset: int = 0):
|
||||
"""根据数据类型获取数据"""
|
||||
filtered_data = [
|
||||
item for item in data_store
|
||||
if item.get('data_type') == data_type
|
||||
]
|
||||
|
||||
return {
|
||||
"data": filtered_data[offset:offset+limit],
|
||||
"total": len(filtered_data),
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
@app.get("/api/data/recent")
|
||||
async def get_recent_data(hours: int = 24, limit: int = 100):
|
||||
"""获取最近的数据"""
|
||||
from datetime import timedelta
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||
|
||||
recent_data = [
|
||||
item for item in data_store
|
||||
if item.get('timestamp', datetime.now()) > cutoff_time
|
||||
]
|
||||
|
||||
return {
|
||||
"data": recent_data[-limit:],
|
||||
"total": len(recent_data),
|
||||
"hours": hours,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_statistics():
|
||||
"""获取数据统计信息"""
|
||||
stats = {
|
||||
"total_records": len(data_store),
|
||||
"by_type": {},
|
||||
"by_device": {},
|
||||
"by_hour": {}
|
||||
}
|
||||
|
||||
for item in data_store:
|
||||
data_type = item.get('data_type', 'unknown')
|
||||
device_id = item.get('device_id', 'unknown')
|
||||
hour = item.get('timestamp', datetime.now()).strftime('%Y-%m-%d %H:00:00')
|
||||
|
||||
stats['by_type'][data_type] = stats['by_type'].get(data_type, 0) + 1
|
||||
stats['by_device'][device_id] = stats['by_device'].get(device_id, 0) + 1
|
||||
stats['by_hour'][hour] = stats['by_hour'].get(hour, 0) + 1
|
||||
|
||||
return stats
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "healthy", "timestamp": datetime.now()}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
批量数据导入模块
|
||||
支持CSV、Excel、JSON等多种格式的批量数据导入
|
||||
"""
|
||||
import pandas as pd
|
||||
import json
|
||||
import csv
|
||||
import asyncio
|
||||
import aiofiles
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BatchImportError(Exception):
|
||||
"""批量导入异常"""
|
||||
pass
|
||||
|
||||
class DataValidator:
|
||||
"""数据验证器"""
|
||||
|
||||
# 水利行业标准字段映射
|
||||
STANDARD_FIELDS = {
|
||||
"LL": "流量",
|
||||
"YL": "压力",
|
||||
"SW": "水位",
|
||||
"ZD": "浊度",
|
||||
"PH": "pH值",
|
||||
"WD": "温度",
|
||||
"DD": "电导率",
|
||||
"YD": "硬度"
|
||||
}
|
||||
|
||||
# 单位映射
|
||||
UNIT_MAP = {
|
||||
"LL": "m³/h",
|
||||
"YL": "MPa",
|
||||
"SW": "m",
|
||||
"ZD": "NTU",
|
||||
"PH": "",
|
||||
"WD": "°C",
|
||||
"DD": "μS/cm",
|
||||
"YD": "mg/L"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate_data_type(cls, data_type: str) -> bool:
|
||||
"""验证数据类型是否有效"""
|
||||
return data_type in cls.STANDARD_FIELDS
|
||||
|
||||
@classmethod
|
||||
def get_field_description(cls, data_type: str) -> str:
|
||||
"""获取字段描述"""
|
||||
return cls.STANDARD_FIELDS.get(data_type, "未知类型")
|
||||
|
||||
@classmethod
|
||||
def get_unit(cls, data_type: str) -> str:
|
||||
"""获取单位"""
|
||||
return cls.UNIT_MAP.get(data_type, "")
|
||||
|
||||
@classmethod
|
||||
def validate_record(cls, record: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""验证单条记录"""
|
||||
errors = []
|
||||
validated_record = {}
|
||||
|
||||
# 必需字段检查
|
||||
required_fields = ["device_id", "data_type", "value"]
|
||||
for field in required_fields:
|
||||
if field not in record:
|
||||
errors.append(f"缺少必需字段: {field}")
|
||||
else:
|
||||
validated_record[field] = record[field]
|
||||
|
||||
# 数据类型验证
|
||||
if "data_type" in validated_record:
|
||||
if not cls.validate_data_type(validated_record["data_type"]):
|
||||
errors.append(f"无效的数据类型: {validated_record['data_type']}")
|
||||
|
||||
# 数值验证
|
||||
if "value" in validated_record:
|
||||
try:
|
||||
validated_record["value"] = float(validated_record["value"])
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"无效的数值: {validated_record['value']}")
|
||||
|
||||
# 时间戳处理
|
||||
if "timestamp" in record:
|
||||
try:
|
||||
if isinstance(record["timestamp"], str):
|
||||
validated_record["timestamp"] = datetime.fromisoformat(record["timestamp"])
|
||||
else:
|
||||
validated_record["timestamp"] = record["timestamp"]
|
||||
except (ValueError, TypeError):
|
||||
# 如果时间戳无效,使用当前时间
|
||||
validated_record["timestamp"] = datetime.now()
|
||||
else:
|
||||
validated_record["timestamp"] = datetime.now()
|
||||
|
||||
# 地点字段处理
|
||||
validated_record["location"] = record.get("location", "未知")
|
||||
|
||||
return {
|
||||
"validated": len(errors) == 0,
|
||||
"record": validated_record,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
class BatchImporter:
|
||||
"""批量导入器"""
|
||||
|
||||
def __init__(self):
|
||||
self.validator = DataValidator()
|
||||
|
||||
async def import_csv(self, file_path: str, batch_id: str, data_source: str) -> Dict[str, Any]:
|
||||
"""导入CSV文件"""
|
||||
try:
|
||||
# 使用线程池执行文件读取
|
||||
loop = asyncio.get_event_loop()
|
||||
with ThreadPoolExecutor() as executor:
|
||||
df = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: pd.read_csv(file_path, encoding='utf-8')
|
||||
)
|
||||
|
||||
return await self._process_dataframe(df, batch_id, data_source)
|
||||
|
||||
except Exception as e:
|
||||
raise BatchImportError(f"CSV文件导入失败: {str(e)}")
|
||||
|
||||
async def import_excel(self, file_path: str, batch_id: str, data_source: str, sheet_name: str = 0) -> Dict[str, Any]:
|
||||
"""导入Excel文件"""
|
||||
try:
|
||||
# 使用线程池执行文件读取
|
||||
loop = asyncio.get_event_loop()
|
||||
with ThreadPoolExecutor() as executor:
|
||||
df = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: pd.read_excel(file_path, sheet_name=sheet_name)
|
||||
)
|
||||
|
||||
return await self._process_dataframe(df, batch_id, data_source)
|
||||
|
||||
except Exception as e:
|
||||
raise BatchImportError(f"Excel文件导入失败: {str(e)}")
|
||||
|
||||
async def import_json(self, file_path: str, batch_id: str, data_source: str) -> Dict[str, Any]:
|
||||
"""导入JSON文件"""
|
||||
try:
|
||||
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
# 处理不同的JSON格式
|
||||
if isinstance(data, list):
|
||||
return await self._process_records(data, batch_id, data_source)
|
||||
elif isinstance(data, dict):
|
||||
if "records" in data:
|
||||
return await self._process_records(data["records"], batch_id, data_source)
|
||||
else:
|
||||
return await self._process_records([data], batch_id, data_source)
|
||||
else:
|
||||
raise BatchImportError("不支持的JSON格式")
|
||||
|
||||
except Exception as e:
|
||||
raise BatchImportError(f"JSON文件导入失败: {str(e)}")
|
||||
|
||||
async def _process_dataframe(self, df: pd.DataFrame, batch_id: str, data_source: str) -> Dict[str, Any]:
|
||||
"""处理DataFrame数据"""
|
||||
# 转换为字典列表
|
||||
records = df.to_dict('records')
|
||||
return await self._process_records(records, batch_id, data_source)
|
||||
|
||||
async def _process_records(self, records: List[Dict[str, Any]], batch_id: str, data_source: str) -> Dict[str, Any]:
|
||||
"""处理记录列表"""
|
||||
import_count = 0
|
||||
error_count = 0
|
||||
errors = []
|
||||
imported_records = []
|
||||
|
||||
for i, record in enumerate(records):
|
||||
validation_result = self.validator.validate_record(record)
|
||||
|
||||
if validation_result["validated"]:
|
||||
# 添加批次信息
|
||||
import_record = {
|
||||
**validation_result["record"],
|
||||
"batch_id": batch_id,
|
||||
"data_source": data_source,
|
||||
"import_time": datetime.now(),
|
||||
"type": "batch"
|
||||
}
|
||||
|
||||
imported_records.append(import_record)
|
||||
import_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
error_msg = f"记录 {i+1}: {', '.join(validation_result['errors'])}"
|
||||
errors.append(error_msg)
|
||||
logger.warning(error_msg)
|
||||
|
||||
# 保存导入的记录到文件(实际项目中应该保存到数据库)
|
||||
await self._save_imported_records(imported_records)
|
||||
|
||||
return {
|
||||
"status": "completed" if error_count == 0 else "completed_with_errors",
|
||||
"imported_count": import_count,
|
||||
"error_count": error_count,
|
||||
"total_count": len(records),
|
||||
"success_rate": import_count / len(records) if records else 0,
|
||||
"batch_id": batch_id,
|
||||
"data_source": data_source,
|
||||
"errors": errors[:10], # 只返回前10个错误
|
||||
"imported_records": imported_records[:5] # 返回前5条记录作为示例
|
||||
}
|
||||
|
||||
async def _save_imported_records(self, records: List[Dict[str, Any]]):
|
||||
"""保存导入的记录"""
|
||||
# 这里可以将记录保存到数据库或文件
|
||||
# 为了示例,我们只保存到日志
|
||||
for record in records:
|
||||
logger.info(f"导入记录: {record}")
|
||||
|
||||
async def get_import_summary(self, batch_id: str) -> Dict[str, Any]:
|
||||
"""获取导入摘要"""
|
||||
# 这里应该从数据库查询批次信息
|
||||
# 为了示例,返回一个空摘要
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"status": "not_found",
|
||||
"message": "批次信息未找到(示例实现)"
|
||||
}
|
||||
|
||||
class BatchImportManager:
|
||||
"""批量导入管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.importer = BatchImporter()
|
||||
|
||||
async def import_file(self, file_path: str, batch_id: str, data_source: str,
|
||||
file_type: str = "auto", **kwargs) -> Dict[str, Any]:
|
||||
"""导入文件"""
|
||||
file_path_obj = Path(file_path)
|
||||
|
||||
if not file_path_obj.exists():
|
||||
raise BatchImportError(f"文件不存在: {file_path}")
|
||||
|
||||
# 自动检测文件类型
|
||||
if file_type == "auto":
|
||||
if file_path_obj.suffix.lower() == '.csv':
|
||||
file_type = "csv"
|
||||
elif file_path_obj.suffix.lower() in ['.xlsx', '.xls']:
|
||||
file_type = "excel"
|
||||
elif file_path_obj.suffix.lower() == '.json':
|
||||
file_type = "json"
|
||||
else:
|
||||
raise BatchImportError(f"不支持的文件类型: {file_path_obj.suffix}")
|
||||
|
||||
logger.info(f"开始导入{file_type}文件: {file_path}")
|
||||
|
||||
if file_type == "csv":
|
||||
result = await self.importer.import_csv(file_path, batch_id, data_source)
|
||||
elif file_type == "excel":
|
||||
sheet_name = kwargs.get("sheet_name", 0)
|
||||
result = await self.importer.import_excel(file_path, batch_id, data_source, sheet_name)
|
||||
elif file_type == "json":
|
||||
result = await self.importer.import_json(file_path, batch_id, data_source)
|
||||
else:
|
||||
raise BatchImportError(f"不支持的文件类型: {file_type}")
|
||||
|
||||
logger.info(f"文件导入完成: {result}")
|
||||
return result
|
||||
|
||||
async def validate_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""验证文件格式"""
|
||||
file_path_obj = Path(file_path)
|
||||
|
||||
if not file_path_obj.exists():
|
||||
return {"valid": False, "error": "文件不存在"}
|
||||
|
||||
file_size = file_path_obj.stat().st_size
|
||||
if file_size > 100 * 1024 * 1024: # 100MB限制
|
||||
return {"valid": False, "error": "文件过大,最大支持100MB"}
|
||||
|
||||
# 尝试读取文件前几行进行验证
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
first_line = f.readline()
|
||||
if not first_line:
|
||||
return {"valid": False, "error": "文件为空"}
|
||||
except Exception as e:
|
||||
return {"valid": False, "error": f"无法读取文件: {str(e)}"}
|
||||
|
||||
return {"valid": True, "size": file_size, "format": file_path_obj.suffix.lower()}
|
||||
|
||||
# 全局导入管理器实例
|
||||
batch_manager = BatchImportManager()
|
||||
|
||||
# 示例用法
|
||||
async def example_usage():
|
||||
"""示例用法"""
|
||||
import os
|
||||
|
||||
# 创建示例CSV文件
|
||||
sample_data = [
|
||||
{"device_id": "device_001", "data_type": "LL", "value": 25.5, "location": "A区"},
|
||||
{"device_id": "device_002", "data_type": "YL", "value": 0.8, "location": "B区"},
|
||||
{"device_id": "device_003", "data_type": "SW", "value": 5.2, "location": "C区"}
|
||||
]
|
||||
|
||||
sample_file = "/tmp/sample_data.csv"
|
||||
with open(sample_file, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["device_id", "data_type", "value", "location"])
|
||||
writer.writeheader()
|
||||
writer.writerows(sample_data)
|
||||
|
||||
# 导入文件
|
||||
try:
|
||||
result = await batch_manager.import_file(
|
||||
file_path=sample_file,
|
||||
batch_id="batch_" + datetime.now().strftime("%Y%m%d_%H%M%S"),
|
||||
data_source="manual_test",
|
||||
file_type="csv"
|
||||
)
|
||||
print("导入结果:", result)
|
||||
except Exception as e:
|
||||
print(f"导入失败: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(example_usage())
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
BI模块 - 自助BI看板和数据可视化
|
||||
集成Superset/Metabase功能
|
||||
"""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
BI控制器模块
|
||||
提供REST API接口,支持自助BI看板和数据可视化
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Path
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from .services import BIService
|
||||
from .models import ChartType, DataSourceType
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/bi", tags=["BI"])
|
||||
|
||||
# 创建BI服务实例
|
||||
bi_service = BIService()
|
||||
|
||||
@router.get("/")
|
||||
async def get_bi_info():
|
||||
"""获取BI系统信息"""
|
||||
return {
|
||||
"message": "Water Management System BI API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"charts": "/bi/charts",
|
||||
"dashboards": "/bi/dashboards",
|
||||
"data_sources": "/bi/data-sources",
|
||||
"datasets": "/bi/datasets"
|
||||
}
|
||||
}
|
||||
|
||||
# 图表相关接口
|
||||
@router.get("/charts", response_model=List[Dict[str, Any]])
|
||||
async def get_all_charts():
|
||||
"""获取所有图表"""
|
||||
charts = bi_service.get_all_charts()
|
||||
return [chart.to_dict() for chart in charts]
|
||||
|
||||
@router.get("/charts/{chart_id}", response_model=Dict[str, Any])
|
||||
async def get_chart(chart_id: str = Path(..., description="图表ID")):
|
||||
"""获取单个图表"""
|
||||
chart = bi_service.get_chart(chart_id)
|
||||
if not chart:
|
||||
raise HTTPException(status_code=404, detail="Chart not found")
|
||||
return chart.to_dict()
|
||||
|
||||
@router.post("/charts", response_model=Dict[str, Any])
|
||||
async def create_chart(chart_data: Dict[str, Any]):
|
||||
"""创建图表"""
|
||||
try:
|
||||
chart = bi_service.create_chart(chart_data)
|
||||
return chart.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/charts/{chart_id}", response_model=Dict[str, Any])
|
||||
async def update_chart(chart_id: str = Path(..., description="图表ID"), chart_data: Dict[str, Any] = None):
|
||||
"""更新图表"""
|
||||
if not chart_data:
|
||||
raise HTTPException(status_code=400, detail="Chart data is required")
|
||||
|
||||
chart = bi_service.update_chart(chart_id, chart_data)
|
||||
if not chart:
|
||||
raise HTTPException(status_code=404, detail="Chart not found")
|
||||
return chart.to_dict()
|
||||
|
||||
@router.delete("/charts/{chart_id}")
|
||||
async def delete_chart(chart_id: str = Path(..., description="图表ID")):
|
||||
"""删除图表"""
|
||||
success = bi_service.delete_chart(chart_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Chart not found")
|
||||
return {"message": "Chart deleted successfully"}
|
||||
|
||||
@router.get("/charts/{chart_id}/data", response_model=Dict[str, Any])
|
||||
async def get_chart_data(chart_id: str = Path(..., description="图表ID")):
|
||||
"""获取图表数据"""
|
||||
data = bi_service.get_chart_data_api(chart_id)
|
||||
if "error" in data:
|
||||
raise HTTPException(status_code=404, detail=data["error"])
|
||||
return data
|
||||
|
||||
# 看板相关接口
|
||||
@router.get("/dashboards", response_model=List[Dict[str, Any]])
|
||||
async def get_all_dashboards():
|
||||
"""获取所有看板"""
|
||||
dashboards = bi_service.get_all_dashboards()
|
||||
return [dashboard.to_dict() for dashboard in dashboards]
|
||||
|
||||
@router.get("/dashboards/{dashboard_id}", response_model=Dict[str, Any])
|
||||
async def get_dashboard(dashboard_id: str = Path(..., description="看板ID")):
|
||||
"""获取单个看板"""
|
||||
dashboard = bi_service.get_dashboard(dashboard_id)
|
||||
if not dashboard:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return dashboard.to_dict()
|
||||
|
||||
@router.post("/dashboards", response_model=Dict[str, Any])
|
||||
async def create_dashboard(dashboard_data: Dict[str, Any]):
|
||||
"""创建看板"""
|
||||
try:
|
||||
dashboard = bi_service.create_dashboard(dashboard_data)
|
||||
return dashboard.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/dashboards/{dashboard_id}", response_model=Dict[str, Any])
|
||||
async def update_dashboard(dashboard_id: str = Path(..., description="看板ID"), dashboard_data: Dict[str, Any] = None):
|
||||
"""更新看板"""
|
||||
if not dashboard_data:
|
||||
raise HTTPException(status_code=400, detail="Dashboard data is required")
|
||||
|
||||
dashboard = bi_service.update_dashboard(dashboard_id, dashboard_data)
|
||||
if not dashboard:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return dashboard.to_dict()
|
||||
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
async def delete_dashboard(dashboard_id: str = Path(..., description="看板ID")):
|
||||
"""删除看板"""
|
||||
success = bi_service.delete_dashboard(dashboard_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return {"message": "Dashboard deleted successfully"}
|
||||
|
||||
@router.get("/dashboards/{dashboard_id}/data", response_model=Dict[str, Any])
|
||||
async def get_dashboard_data(dashboard_id: str = Path(..., description="看板ID")):
|
||||
"""获取看板数据"""
|
||||
data = bi_service.get_dashboard_data_api(dashboard_id)
|
||||
if "error" in data:
|
||||
raise HTTPException(status_code=404, detail=data["error"])
|
||||
return data
|
||||
|
||||
# 数据源相关接口
|
||||
@router.get("/data-sources", response_model=List[Dict[str, Any]])
|
||||
async def get_all_data_sources():
|
||||
"""获取所有数据源"""
|
||||
data_sources = bi_service.get_all_data_sources()
|
||||
return [source.to_dict() for source in data_sources]
|
||||
|
||||
@router.get("/data-sources/{source_id}", response_model=Dict[str, Any])
|
||||
async def get_data_source(source_id: str = Path(..., description="数据源ID")):
|
||||
"""获取单个数据源"""
|
||||
source = bi_service.get_data_source(source_id)
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
return source.to_dict()
|
||||
|
||||
# 数据集相关接口
|
||||
@router.get("/datasets", response_model=List[Dict[str, Any]])
|
||||
async def get_all_datasets():
|
||||
"""获取所有数据集"""
|
||||
datasets = bi_service.get_all_datasets()
|
||||
return [dataset.to_dict() for dataset in datasets]
|
||||
|
||||
@router.get("/datasets/{dataset_id}", response_model=Dict[str, Any])
|
||||
async def get_dataset(dataset_id: str = Path(..., description="数据集ID")):
|
||||
"""获取单个数据集"""
|
||||
dataset = bi_service.get_dataset(dataset_id)
|
||||
if not dataset:
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
return dataset.to_dict()
|
||||
|
||||
# 搜索相关接口
|
||||
@router.get("/search/charts")
|
||||
async def search_charts(keyword: str = Query(..., description="搜索关键词")):
|
||||
"""搜索图表"""
|
||||
charts = bi_service.search_charts(keyword)
|
||||
return [chart.to_dict() for chart in charts]
|
||||
|
||||
@router.get("/search/dashboards")
|
||||
async def search_dashboards(keyword: str = Query(..., description="搜索关键词")):
|
||||
"""搜索看板"""
|
||||
dashboards = bi_service.search_dashboards(keyword)
|
||||
return [dashboard.to_dict() for dashboard in dashboards]
|
||||
|
||||
# 标签相关接口
|
||||
@router.get("/charts/tag/{tag}")
|
||||
async def get_charts_by_tag(tag: str = Path(..., description="标签")):
|
||||
"""根据标签获取图表"""
|
||||
charts = bi_service.get_charts_by_tag(tag)
|
||||
return [chart.to_dict() for chart in charts]
|
||||
|
||||
@router.get("/dashboards/tag/{tag}")
|
||||
async def get_dashboards_by_tag(tag: str = Path(..., description="标签")):
|
||||
"""根据标签获取看板"""
|
||||
dashboards = bi_service.get_dashboards_by_tag(tag)
|
||||
return [dashboard.to_dict() for dashboard in dashboards]
|
||||
|
||||
# 公开看板接口
|
||||
@router.get("/public/dashboards")
|
||||
async def get_public_dashboards():
|
||||
"""获取公开看板"""
|
||||
dashboards = bi_service.get_public_dashboards()
|
||||
return [dashboard.to_dict() for dashboard in dashboards]
|
||||
|
||||
# 图表类型和枚举
|
||||
@router.get("/types/chart-types")
|
||||
async def get_chart_types():
|
||||
"""获取支持的图表类型"""
|
||||
return [{"value": ct.value, "label": ct.name} for ct in ChartType]
|
||||
|
||||
@router.get("/types/data-source-types")
|
||||
async def get_data_source_types():
|
||||
"""获取支持的数据源类型"""
|
||||
return [{"value": dst.value, "label": dst.name} for dst in DataSourceType]
|
||||
|
||||
# 默认数据接口
|
||||
@router.get("/default-charts")
|
||||
async def get_default_charts():
|
||||
"""获取默认图表"""
|
||||
# 运营总览相关的图表
|
||||
default_chart_ids = ["flow_trend", "device_status_distribution", "alert_level_stats", "system_performance"]
|
||||
charts = [bi_service.get_chart(chart_id).to_dict() for chart_id in default_chart_ids if bi_service.get_chart(chart_id)]
|
||||
return charts
|
||||
|
||||
@router.get("/default-dashboards")
|
||||
async def get_default_dashboards():
|
||||
"""获取默认看板"""
|
||||
# 运营总览看板
|
||||
default_dashboard_ids = ["operation_overview", "device_management", "security_monitoring"]
|
||||
dashboards = [bi_service.get_dashboard(db_id).to_dict() for db_id in default_dashboard_ids if bi_service.get_dashboard(db_id)]
|
||||
return dashboards
|
||||
|
||||
# Superset集成接口
|
||||
@router.post("/integrations/superset")
|
||||
async def setup_superset_integration(integration_data: Dict[str, Any]):
|
||||
"""设置Superset集成"""
|
||||
try:
|
||||
integration = bi_service.setup_superset_integration(integration_data)
|
||||
return integration.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/integrations/superset")
|
||||
async def get_superset_integration():
|
||||
"""获取Superset集成配置"""
|
||||
if not bi_service.superset_integration:
|
||||
raise HTTPException(status_code=404, detail="Superset integration not configured")
|
||||
return bi_service.superset_integration.to_dict()
|
||||
|
||||
# Metabase集成接口
|
||||
@router.post("/integrations/metabase")
|
||||
async def setup_metabase_integration(integration_data: Dict[str, Any]):
|
||||
"""设置Metabase集成"""
|
||||
try:
|
||||
integration = bi_service.setup_metabase_integration(integration_data)
|
||||
return integration.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/integrations/metabase")
|
||||
async def get_metabase_integration():
|
||||
"""获取Metabase集成配置"""
|
||||
if not bi_service.metabase_integration:
|
||||
raise HTTPException(status_code=404, detail="Metabase integration not configured")
|
||||
return bi_service.metabase_integration.to_dict()
|
||||
|
||||
# 统计信息接口
|
||||
@router.get("/stats")
|
||||
async def get_bi_stats():
|
||||
"""获取BI系统统计信息"""
|
||||
return {
|
||||
"total_charts": len(bi_service.charts),
|
||||
"total_dashboards": len(bi_service.dashboards),
|
||||
"total_data_sources": len(bi_service.data_sources),
|
||||
"total_datasets": len(bi_service.datasets),
|
||||
"public_dashboards": len(bi_service.get_public_dashboards()),
|
||||
"integrations": {
|
||||
"superset_configured": bi_service.superset_integration is not None,
|
||||
"metabase_configured": bi_service.metabase_integration is not None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
BI数据模型定义
|
||||
定义自助BI看板和可视化相关数据结构
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class ChartType(Enum):
|
||||
"""图表类型枚举"""
|
||||
LINE = "line" # 折线图
|
||||
BAR = "bar" # 柱状图
|
||||
PIE = "pie" # 饼图
|
||||
SCATTER = "scatter" # 散点图
|
||||
AREA = "area" # 面积图
|
||||
GAUGE = "gauge" # 仪表盘
|
||||
TABLE = "table" # 表格
|
||||
HEATMAP = "heatmap" # 热力图
|
||||
|
||||
class DataSourceType(Enum):
|
||||
"""数据源类型枚举"""
|
||||
SENSOR_DATA = "sensor_data" # 传感器数据
|
||||
DEVICE_DATA = "device_data" # 设备数据
|
||||
ALERT_DATA = "alert_data" # 警报数据
|
||||
BATCH_DATA = "batch_data" # 批量导入数据
|
||||
SYSTEM_STATS = "system_stats" # 系统统计数据
|
||||
|
||||
@dataclass
|
||||
class Chart:
|
||||
"""图表模型"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
chart_type: ChartType
|
||||
data_source: DataSourceType
|
||||
x_axis: str # X轴字段
|
||||
y_axis: List[str] # Y轴字段列表
|
||||
filters: Dict[str, Any] = field(default_factory=dict)
|
||||
group_by: List[str] = field(default_factory=list)
|
||||
aggregation: str = "sum" # sum, avg, max, min, count
|
||||
time_range: Optional[Dict[str, datetime]] = None
|
||||
options: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
created_by: str = "system"
|
||||
is_public: bool = False
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"chart_type": self.chart_type.value,
|
||||
"data_source": self.data_source.value,
|
||||
"x_axis": self.x_axis,
|
||||
"y_axis": self.y_axis,
|
||||
"filters": self.filters,
|
||||
"group_by": self.group_by,
|
||||
"aggregation": self.aggregation,
|
||||
"time_range": {
|
||||
"start": self.time_range["start"].isoformat() if self.time_range and "start" in self.time_range else None,
|
||||
"end": self.time_range["end"].isoformat() if self.time_range and "end" in self.time_range else None
|
||||
} if self.time_range else None,
|
||||
"options": self.options,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_by": self.created_by,
|
||||
"is_public": self.is_public,
|
||||
"tags": self.tags
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Chart':
|
||||
"""从字典创建对象"""
|
||||
time_range = None
|
||||
if data.get("time_range"):
|
||||
tr = data["time_range"]
|
||||
time_range = {
|
||||
"start": datetime.fromisoformat(tr["start"]) if tr.get("start") else None,
|
||||
"end": datetime.fromisoformat(tr["end"]) if tr.get("end") else None
|
||||
}
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
chart_type=ChartType(data["chart_type"]),
|
||||
data_source=DataSourceType(data["data_source"]),
|
||||
x_axis=data["x_axis"],
|
||||
y_axis=data["y_axis"],
|
||||
filters=data.get("filters", {}),
|
||||
group_by=data.get("group_by", []),
|
||||
aggregation=data.get("aggregation", "sum"),
|
||||
time_range=time_range,
|
||||
options=data.get("options", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
created_by=data.get("created_by", "system"),
|
||||
is_public=data.get("is_public", False),
|
||||
tags=data.get("tags", [])
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class Dashboard:
|
||||
"""看板模型"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
charts: List[str] # 图表ID列表
|
||||
layout: List[Dict[str, Any]] = field(default_factory=list) # 布局配置
|
||||
filters: Dict[str, Any] = field(default_factory=dict)
|
||||
shared_users: List[str] = field(default_factory=list)
|
||||
shared_groups: List[str] = field(default_factory=list)
|
||||
is_public: bool = False
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
created_by: str = "system"
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"charts": self.charts,
|
||||
"layout": self.layout,
|
||||
"filters": self.filters,
|
||||
"shared_users": self.shared_users,
|
||||
"shared_groups": self.shared_groups,
|
||||
"is_public": self.is_public,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_by": self.created_by,
|
||||
"tags": self.tags
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Dashboard':
|
||||
"""从字典创建对象"""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
charts=data.get("charts", []),
|
||||
layout=data.get("layout", []),
|
||||
filters=data.get("filters", {}),
|
||||
shared_users=data.get("shared_users", []),
|
||||
shared_groups=data.get("shared_groups", []),
|
||||
is_public=data.get("is_public", False),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
created_by=data.get("created_by", "system"),
|
||||
tags=data.get("tags", [])
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class DataSource:
|
||||
"""数据源模型"""
|
||||
id: str
|
||||
name: str
|
||||
type: DataSourceType
|
||||
description: str
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
query_template: str = ""
|
||||
columns: List[str] = field(default_factory=list)
|
||||
refresh_interval_minutes: int = 60
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
created_by: str = "system"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"type": self.type.value,
|
||||
"description": self.description,
|
||||
"config": self.config,
|
||||
"query_template": self.query_template,
|
||||
"columns": self.columns,
|
||||
"refresh_interval_minutes": self.refresh_interval_minutes,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_by": self.created_by
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'DataSource':
|
||||
"""从字典创建对象"""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
type=DataSourceType(data["type"]),
|
||||
description=data["description"],
|
||||
config=data.get("config", {}),
|
||||
query_template=data.get("query_template", ""),
|
||||
columns=data.get("columns", []),
|
||||
refresh_interval_minutes=data.get("refresh_interval_minutes", 60),
|
||||
is_active=data.get("is_active", True),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
created_by=data.get("created_by", "system")
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class Dataset:
|
||||
"""数据集模型"""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
data_source_id: str
|
||||
query: str
|
||||
columns: List[Dict[str, Any]] = field(default_factory=list) # 字段定义
|
||||
transformations: List[str] = field(default_factory=list) # 数据转换规则
|
||||
cache_enabled: bool = True
|
||||
cache_timeout_minutes: int = 30
|
||||
is_active: bool = True
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
created_by: str = "system"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"data_source_id": self.data_source_id,
|
||||
"query": self.query,
|
||||
"columns": self.columns,
|
||||
"transformations": self.transformations,
|
||||
"cache_enabled": self.cache_enabled,
|
||||
"cache_timeout_minutes": self.cache_timeout_minutes,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_by": self.created_by
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Dataset':
|
||||
"""从字典创建对象"""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
data_source_id=data["data_source_id"],
|
||||
query=data["query"],
|
||||
columns=data.get("columns", []),
|
||||
transformations=data.get("transformations", []),
|
||||
cache_enabled=data.get("cache_enabled", True),
|
||||
cache_timeout_minutes=data.get("cache_timeout_minutes", 30),
|
||||
is_active=data.get("is_active", True),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
created_by=data.get("created_by", "system")
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class SupersetIntegration:
|
||||
"""Superset集成配置"""
|
||||
superset_url: str
|
||||
superset_api_key: str
|
||||
superset_username: str
|
||||
dashboard_mapping: Dict[str, str] = field(default_factory=dict) # 本地dashboard -> Superset dashboard
|
||||
chart_mapping: Dict[str, str] = field(default_factory=dict) # 本地chart -> Superset chart
|
||||
sync_enabled: bool = True
|
||||
sync_interval_minutes: int = 60
|
||||
last_sync_at: Optional[datetime] = None
|
||||
sync_status: str = "idle" # idle, syncing, error
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"superset_url": self.superset_url,
|
||||
"superset_api_key": self.superset_api_key,
|
||||
"superset_username": self.superset_username,
|
||||
"dashboard_mapping": self.dashboard_mapping,
|
||||
"chart_mapping": self.chart_mapping,
|
||||
"sync_enabled": self.sync_enabled,
|
||||
"sync_interval_minutes": self.sync_interval_minutes,
|
||||
"last_sync_at": self.last_sync_at.isoformat() if self.last_sync_at else None,
|
||||
"sync_status": self.sync_status,
|
||||
"error_message": self.error_message
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class MetabaseIntegration:
|
||||
"""Metabase集成配置"""
|
||||
metabase_url: str
|
||||
metabase_secret_key: str
|
||||
collection_name: str = "水务管理系统"
|
||||
dashboard_mapping: Dict[str, str] = field(default_factory=dict)
|
||||
question_mapping: Dict[str, str] = field(default_factory=dict)
|
||||
sync_enabled: bool = True
|
||||
sync_interval_minutes: int = 60
|
||||
last_sync_at: Optional[datetime] = None
|
||||
sync_status: str = "idle"
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"metabase_url": self.metabase_url,
|
||||
"metabase_secret_key": self.metabase_secret_key,
|
||||
"collection_name": self.collection_name,
|
||||
"dashboard_mapping": self.dashboard_mapping,
|
||||
"question_mapping": self.question_mapping,
|
||||
"sync_enabled": self.sync_enabled,
|
||||
"sync_interval_minutes": self.sync_interval_minutes,
|
||||
"last_sync_at": self.last_sync_at.isoformat() if self.last_sync_at else None,
|
||||
"sync_status": self.sync_status,
|
||||
"error_message": self.error_message
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
BI服务模块
|
||||
提供自助BI看板和数据可视化服务
|
||||
包括数据集管理、图表创建、看板配置等功能
|
||||
"""
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from .models import (
|
||||
Chart, ChartType, DataSourceType, Dashboard,
|
||||
DataSource, Dataset, SupersetIntegration, MetabaseIntegration
|
||||
)
|
||||
|
||||
class BIService:
|
||||
"""BI服务主类"""
|
||||
|
||||
def __init__(self):
|
||||
# 初始化数据存储
|
||||
self.charts: Dict[str, Chart] = {}
|
||||
self.dashboards: Dict[str, Dashboard] = {}
|
||||
self.data_sources: Dict[str, DataSource] = {}
|
||||
self.datasets: Dict[str, Dataset] = {}
|
||||
self.superset_integration: Optional[SupersetIntegration] = None
|
||||
self.metabase_integration: Optional[MetabaseIntegration] = None
|
||||
|
||||
# 初始化默认数据源
|
||||
self._init_default_data_sources()
|
||||
self._init_default_charts()
|
||||
self._init_default_dashboards()
|
||||
|
||||
def _init_default_data_sources(self):
|
||||
"""初始化默认数据源"""
|
||||
# 传感器数据源
|
||||
sensor_source = DataSource(
|
||||
id="sensor_data",
|
||||
name="传感器数据",
|
||||
type=DataSourceType.SENSOR_DATA,
|
||||
description="所有IoT传感器实时和历史数据",
|
||||
config={
|
||||
"time_field": "timestamp",
|
||||
"value_field": "value",
|
||||
"device_field": "device_id",
|
||||
"location_field": "location",
|
||||
"type_field": "data_type"
|
||||
},
|
||||
query_template="SELECT * FROM sensor_data WHERE {filters}",
|
||||
columns=[
|
||||
{"name": "id", "type": "integer", "description": "数据记录ID"},
|
||||
{"name": "device_id", "type": "string", "description": "设备ID"},
|
||||
{"name": "data_type", "type": "string", "description": "数据类型"},
|
||||
{"name": "value", "type": "float", "description": "数值"},
|
||||
{"name": "unit", "type": "string", "description": "单位"},
|
||||
{"name": "timestamp", "type": "datetime", "description": "时间戳"},
|
||||
{"name": "location", "type": "string", "description": "位置"},
|
||||
{"name": "quality_score", "type": "float", "description": "质量评分"}
|
||||
]
|
||||
)
|
||||
self.data_sources[sensor_source.id] = sensor_source
|
||||
|
||||
# 设备状态数据源
|
||||
device_source = DataSource(
|
||||
id="device_data",
|
||||
name="设备状态",
|
||||
type=DataSourceType.DEVICE_DATA,
|
||||
description="所有设备的运行状态和配置信息",
|
||||
config={
|
||||
"status_field": "status",
|
||||
"type_field": "device_type",
|
||||
"location_field": "location"
|
||||
},
|
||||
query_template="SELECT * FROM device WHERE {filters}",
|
||||
columns=[
|
||||
{"name": "id", "type": "string", "description": "设备ID"},
|
||||
{"name": "name", "type": "string", "description": "设备名称"},
|
||||
{"name": "device_type", "type": "string", "description": "设备类型"},
|
||||
{"name": "location", "type": "string", "description": "位置"},
|
||||
{"name": "status", "type": "string", "description": "状态"},
|
||||
{"name": "install_date", "type": "datetime", "description": "安装日期"},
|
||||
{"name": "metadata", "type": "json", "description": "元数据"}
|
||||
]
|
||||
)
|
||||
self.data_sources[device_source.id] = device_source
|
||||
|
||||
# 警报数据源
|
||||
alert_source = DataSource(
|
||||
id="alert_data",
|
||||
name="警报数据",
|
||||
type=DataSourceType.ALERT_DATA,
|
||||
description="系统警报和通知记录",
|
||||
config={
|
||||
"level_field": "level",
|
||||
"type_field": "alert_type",
|
||||
"resolved_field": "resolved"
|
||||
},
|
||||
query_template="SELECT * FROM alert WHERE {filters}",
|
||||
columns=[
|
||||
{"name": "id", "type": "string", "description": "警报ID"},
|
||||
{"name": "device_id", "type": "string", "description": "设备ID"},
|
||||
{"name": "alert_type", "type": "string", "description": "警报类型"},
|
||||
{"name": "level", "type": "string", "description": "警报级别"},
|
||||
{"name": "message", "type": "string", "description": "警报信息"},
|
||||
{"name": "timestamp", "type": "datetime", "description": "发生时间"},
|
||||
{"name": "resolved", "type": "boolean", "description": "是否已解决"},
|
||||
{"name": "resolved_at", "type": "datetime", "description": "解决时间"}
|
||||
]
|
||||
)
|
||||
self.data_sources[alert_source.id] = alert_source
|
||||
|
||||
# 系统统计数据源
|
||||
stats_source = DataSource(
|
||||
id="system_stats",
|
||||
name="系统统计",
|
||||
type=DataSourceType.SYSTEM_STATS,
|
||||
description="系统运行性能和使用统计",
|
||||
config={
|
||||
"cpu_field": "cpu_usage_percent",
|
||||
"memory_field": "memory_usage_mb",
|
||||
"records_field": "total_records"
|
||||
},
|
||||
query_template="SELECT * FROM system_stats WHERE {filters}",
|
||||
columns=[
|
||||
{"name": "timestamp", "type": "datetime", "description": "统计时间"},
|
||||
{"name": "total_records", "type": "integer", "description": "总记录数"},
|
||||
{"name": "total_devices", "type": "integer", "description": "设备总数"},
|
||||
{"name": "active_connections", "type": "integer", "description": "活跃连接数"},
|
||||
{"name": "api_requests_count", "type": "integer", "description": "API请求数"},
|
||||
{"name": "alerts_count", "type": "integer", "description": "警报数量"},
|
||||
{"name": "data_quality_score", "type": "float", "description": "数据质量评分"},
|
||||
{"name": "memory_usage_mb", "type": "float", "description": "内存使用量(MB)"},
|
||||
{"name": "cpu_usage_percent", "type": "float", "description": "CPU使用率(%)"}
|
||||
]
|
||||
)
|
||||
self.data_sources[stats_source.id] = stats_source
|
||||
|
||||
def _init_default_charts(self):
|
||||
"""初始化默认图表"""
|
||||
# 流量趋势图
|
||||
flow_trend = Chart(
|
||||
id="flow_trend",
|
||||
name="流量趋势分析",
|
||||
description="显示各区域流量随时间的变化趋势",
|
||||
chart_type=ChartType.LINE,
|
||||
data_source=DataSourceType.SENSOR_DATA,
|
||||
x_axis="timestamp",
|
||||
y_axis=["value"],
|
||||
group_by=["location"],
|
||||
aggregation="avg",
|
||||
filters={"data_type": "LL"},
|
||||
options={
|
||||
"title": "各区域流量趋势",
|
||||
"yAxis": {"title": "流量 (m³/h)"},
|
||||
"xAxis": {"title": "时间"},
|
||||
"legend": {"show": True},
|
||||
"tooltip": {"trigger": "axis"}
|
||||
},
|
||||
tags=["流量", "趋势", "区域"]
|
||||
)
|
||||
self.charts[flow_trend.id] = flow_trend
|
||||
|
||||
# 设备状态分布图
|
||||
device_status = Chart(
|
||||
id="device_status_distribution",
|
||||
name="设备状态分布",
|
||||
description="显示不同状态设备的数量分布",
|
||||
chart_type=ChartType.PIE,
|
||||
data_source=DataSourceType.DEVICE_DATA,
|
||||
x_axis="status",
|
||||
y_axis=["count"],
|
||||
aggregation="count",
|
||||
options={
|
||||
"title": "设备状态分布",
|
||||
"legend": {"show": True},
|
||||
"tooltip": {"trigger": "item"}
|
||||
},
|
||||
tags=["设备", "状态", "分布"]
|
||||
)
|
||||
self.charts[device_status.id] = device_status
|
||||
|
||||
# 警报级别统计图
|
||||
alert_stats = Chart(
|
||||
id="alert_level_stats",
|
||||
name="警报级别统计",
|
||||
description="按级别统计警报数量",
|
||||
chart_type=ChartType.BAR,
|
||||
data_source=DataSourceType.ALERT_DATA,
|
||||
x_axis="level",
|
||||
y_axis=["count"],
|
||||
aggregation="count",
|
||||
filters={"resolved": False},
|
||||
options={
|
||||
"title": "未解决警报按级别统计",
|
||||
"yAxis": {"title": "数量"},
|
||||
"xAxis": {"title": "警报级别"},
|
||||
"legend": {"show": False}
|
||||
},
|
||||
tags=["警报", "级别", "统计"]
|
||||
)
|
||||
self.charts[alert_stats.id] = alert_stats
|
||||
|
||||
# 系统性能监控图
|
||||
system_performance = Chart(
|
||||
id="system_performance",
|
||||
name="系统性能监控",
|
||||
description="显示系统CPU和内存使用率趋势",
|
||||
chart_type=ChartType.LINE,
|
||||
data_source=DataSourceType.SYSTEM_STATS,
|
||||
x_axis="timestamp",
|
||||
y_axis=["cpu_usage_percent", "memory_usage_mb"],
|
||||
options={
|
||||
"title": "系统性能监控",
|
||||
"yAxis": [{"title": "CPU使用率(%)"}, {"title": "内存使用量(MB)"}],
|
||||
"xAxis": {"title": "时间"},
|
||||
"legend": {"show": True},
|
||||
"tooltip": {"trigger": "axis"}
|
||||
},
|
||||
tags=["系统", "性能", "监控"]
|
||||
)
|
||||
self.charts[system_performance.id] = system_performance
|
||||
|
||||
def _init_default_dashboards(self):
|
||||
"""初始化默认看板"""
|
||||
# 水务运营总览看板
|
||||
overview_dashboard = Dashboard(
|
||||
id="operation_overview",
|
||||
name="水务运营总览",
|
||||
description="水务系统整体运营情况综合看板",
|
||||
charts=["flow_trend", "device_status_distribution", "alert_level_stats", "system_performance"],
|
||||
layout=[
|
||||
{"i": "flow_trend", "x": 0, "y": 0, "w": 12, "h": 8},
|
||||
{"i": "device_status_distribution", "x": 12, "y": 0, "w": 6, "h": 6},
|
||||
{"i": "alert_level_stats", "x": 18, "y": 0, "w": 6, "h": 6},
|
||||
{"i": "system_performance", "x": 0, "y": 8, "w": 24, "h": 8}
|
||||
],
|
||||
is_public=True,
|
||||
tags=["运营", "总览", "综合"]
|
||||
)
|
||||
self.dashboards[overview_dashboard.id] = overview_dashboard
|
||||
|
||||
# 设备管理看板
|
||||
device_dashboard = Dashboard(
|
||||
id="device_management",
|
||||
name="设备管理看板",
|
||||
description="设备状态监控和维护管理",
|
||||
charts=["device_status_distribution"],
|
||||
layout=[
|
||||
{"i": "device_status_distribution", "x": 0, "y": 0, "w": 12, "h": 8}
|
||||
],
|
||||
tags=["设备", "管理", "监控"]
|
||||
)
|
||||
self.dashboards[device_dashboard.id] = device_dashboard
|
||||
|
||||
# 安全监控看板
|
||||
security_dashboard = Dashboard(
|
||||
id="security_monitoring",
|
||||
name="安全监控看板",
|
||||
description="系统安全和警报监控",
|
||||
charts=["alert_level_stats"],
|
||||
layout=[
|
||||
{"i": "alert_level_stats", "x": 0, "y": 0, "w": 12, "h": 8}
|
||||
],
|
||||
tags=["安全", "监控", "警报"]
|
||||
)
|
||||
self.dashboards[security_dashboard.id] = security_dashboard
|
||||
|
||||
def get_chart(self, chart_id: str) -> Optional[Chart]:
|
||||
"""获取图表"""
|
||||
return self.charts.get(chart_id)
|
||||
|
||||
def get_all_charts(self) -> List[Chart]:
|
||||
"""获取所有图表"""
|
||||
return list(self.charts.values())
|
||||
|
||||
def create_chart(self, chart_data: Dict[str, Any]) -> Chart:
|
||||
"""创建图表"""
|
||||
chart = Chart.from_dict(chart_data)
|
||||
self.charts[chart.id] = chart
|
||||
return chart
|
||||
|
||||
def update_chart(self, chart_id: str, chart_data: Dict[str, Any]) -> Optional[Chart]:
|
||||
"""更新图表"""
|
||||
if chart_id in self.charts:
|
||||
chart = Chart.from_dict(chart_data)
|
||||
chart.id = chart_id # 保持ID不变
|
||||
self.charts[chart_id] = chart
|
||||
return chart
|
||||
return None
|
||||
|
||||
def delete_chart(self, chart_id: str) -> bool:
|
||||
"""删除图表"""
|
||||
if chart_id in self.charts:
|
||||
del self.charts[chart_id]
|
||||
# 从所有看板中移除该图表
|
||||
for dashboard in self.dashboards.values():
|
||||
if chart_id in dashboard.charts:
|
||||
dashboard.charts.remove(chart_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_dashboard(self, dashboard_id: str) -> Optional[Dashboard]:
|
||||
"""获取看板"""
|
||||
return self.dashboards.get(dashboard_id)
|
||||
|
||||
def get_all_dashboards(self) -> List[Dashboard]:
|
||||
"""获取所有看板"""
|
||||
return list(self.dashboards.values())
|
||||
|
||||
def create_dashboard(self, dashboard_data: Dict[str, Any]) -> Dashboard:
|
||||
"""创建看板"""
|
||||
dashboard = Dashboard.from_dict(dashboard_data)
|
||||
self.dashboards[dashboard.id] = dashboard
|
||||
return dashboard
|
||||
|
||||
def update_dashboard(self, dashboard_id: str, dashboard_data: Dict[str, Any]) -> Optional[Dashboard]:
|
||||
"""更新看板"""
|
||||
if dashboard_id in self.dashboards:
|
||||
dashboard = Dashboard.from_dict(dashboard_data)
|
||||
dashboard.id = dashboard_id # 保持ID不变
|
||||
self.dashboards[dashboard_id] = dashboard
|
||||
return dashboard
|
||||
return None
|
||||
|
||||
def delete_dashboard(self, dashboard_id: str) -> bool:
|
||||
"""删除看板"""
|
||||
if dashboard_id in self.dashboards:
|
||||
del self.dashboards[dashboard_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_data_source(self, source_id: str) -> Optional[DataSource]:
|
||||
"""获取数据源"""
|
||||
return self.data_sources.get(source_id)
|
||||
|
||||
def get_all_data_sources(self) -> List[DataSource]:
|
||||
"""获取所有数据源"""
|
||||
return list(self.data_sources.values())
|
||||
|
||||
def get_dataset(self, dataset_id: str) -> Optional[Dataset]:
|
||||
"""获取数据集"""
|
||||
return self.datasets.get(dataset_id)
|
||||
|
||||
def get_all_datasets(self) -> List[Dataset]:
|
||||
"""获取所有数据集"""
|
||||
return list(self.datasets.values())
|
||||
|
||||
def execute_chart_data(self, chart_id: str) -> Dict[str, Any]:
|
||||
"""执行图表数据查询"""
|
||||
chart = self.get_chart(chart_id)
|
||||
if not chart:
|
||||
return {"error": "Chart not found"}
|
||||
|
||||
# 这里模拟数据查询,实际应该连接到数据库或数据源
|
||||
data = self._generate_chart_data(chart)
|
||||
|
||||
return {
|
||||
"chart_id": chart_id,
|
||||
"chart_name": chart.name,
|
||||
"data": data,
|
||||
"columns": chart.options.get("columns", []),
|
||||
"chart_type": chart.chart_type.value,
|
||||
"options": chart.options
|
||||
}
|
||||
|
||||
def _generate_chart_data(self, chart: Chart) -> List[Dict[str, Any]]:
|
||||
"""生成图表数据(模拟)"""
|
||||
# 根据图表类型和数据源生成模拟数据
|
||||
if chart.data_source == DataSourceType.SENSOR_DATA:
|
||||
return self._generate_sensor_data(chart)
|
||||
elif chart.data_source == DataSourceType.DEVICE_DATA:
|
||||
return self._generate_device_data(chart)
|
||||
elif chart.data_source == DataSourceType.ALERT_DATA:
|
||||
return self._generate_alert_data(chart)
|
||||
elif chart.data_source == DataSourceType.SYSTEM_STATS:
|
||||
return self._generate_stats_data(chart)
|
||||
else:
|
||||
return []
|
||||
|
||||
def _generate_sensor_data(self, chart: Chart) -> List[Dict[str, Any]]:
|
||||
"""生成传感器数据"""
|
||||
data = []
|
||||
|
||||
# 生成时间序列数据
|
||||
base_time = datetime.now() - timedelta(days=7)
|
||||
locations = ["A区", "B区", "C区", "D区"]
|
||||
|
||||
for i in range(24 * 7): # 7天,每小时一个点
|
||||
timestamp = base_time + timedelta(hours=i)
|
||||
|
||||
for location in locations:
|
||||
# 添加一些随机波动
|
||||
base_value = 50 if chart.filters.get("data_type") == "LL" else 1.0
|
||||
value = base_value + np.random.normal(0, 10)
|
||||
|
||||
data.append({
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"location": location,
|
||||
"value": round(value, 2),
|
||||
"device_id": f"device_{hash(location) % 10 + 1}",
|
||||
"data_type": chart.filters.get("data_type", "LL"),
|
||||
"quality_score": round(np.random.uniform(0.8, 1.0), 2)
|
||||
})
|
||||
|
||||
# 应用过滤和聚合
|
||||
if chart.group_by:
|
||||
# 简单的分组聚合
|
||||
grouped_data = {}
|
||||
for item in data:
|
||||
key = tuple(item.get(field) for field in chart.group_by)
|
||||
if key not in grouped_data:
|
||||
grouped_data[key] = []
|
||||
grouped_data[key].append(item)
|
||||
|
||||
result = []
|
||||
for key, items in grouped_data.items():
|
||||
group_data = {}
|
||||
for i, field in enumerate(chart.group_by):
|
||||
group_data[field] = key[i]
|
||||
|
||||
# 聚合计算
|
||||
values = [item["value"] for item in items]
|
||||
if chart.aggregation == "avg":
|
||||
group_data["value"] = sum(values) / len(values)
|
||||
elif chart.aggregation == "sum":
|
||||
group_data["value"] = sum(values)
|
||||
elif chart.aggregation == "max":
|
||||
group_data["value"] = max(values)
|
||||
elif chart.aggregation == "min":
|
||||
group_data["value"] = min(values)
|
||||
else:
|
||||
group_data["value"] = sum(values) / len(values)
|
||||
|
||||
result.append(group_data)
|
||||
|
||||
return result
|
||||
|
||||
return data
|
||||
|
||||
def _generate_device_data(self, chart: Chart) -> List[Dict[str, Any]]:
|
||||
"""生成设备数据"""
|
||||
devices = [
|
||||
{"id": "device_1", "name": "流量计-001", "device_type": "流量计", "location": "A区", "status": "active"},
|
||||
{"id": "device_2", "name": "压力计-001", "device_type": "压力计", "location": "A区", "status": "active"},
|
||||
{"id": "device_3", "name": "水位计-001", "device_type": "水位计", "location": "B区", "status": "maintenance"},
|
||||
{"id": "device_4", "name": "浊度计-001", "device_type": "浊度计", "location": "B区", "status": "active"},
|
||||
{"id": "device_5", "name": "pH计-001", "device_type": "pH计", "location": "C区", "status": "inactive"},
|
||||
]
|
||||
|
||||
# 按状态分组
|
||||
status_groups = {}
|
||||
for device in devices:
|
||||
status = device["status"]
|
||||
if status not in status_groups:
|
||||
status_groups[status] = []
|
||||
status_groups[status].append(device)
|
||||
|
||||
# 生成统计数据
|
||||
result = []
|
||||
for status, devices_in_status in status_groups.items():
|
||||
result.append({
|
||||
"status": status,
|
||||
"count": len(devices_in_status),
|
||||
"devices": [d["name"] for d in devices_in_status]
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def _generate_alert_data(self, chart: Chart) -> List[Dict[str, Any]]:
|
||||
"""生成警报数据"""
|
||||
alerts = [
|
||||
{"level": "info", "count": 5, "description": "信息级别警报"},
|
||||
{"level": "warning", "count": 3, "description": "警告级别警报"},
|
||||
{"level": "error", "count": 1, "description": "错误级别警报"},
|
||||
{"level": "critical", "count": 0, "description": "严重级别警报"},
|
||||
]
|
||||
|
||||
return alerts
|
||||
|
||||
def _generate_stats_data(self, chart: Chart) -> List[Dict[str, Any]]:
|
||||
"""生成系统统计数据"""
|
||||
data = []
|
||||
base_time = datetime.now() - timedelta(days=1)
|
||||
|
||||
for i in range(24): # 24小时数据
|
||||
timestamp = base_time + timedelta(hours=i)
|
||||
|
||||
data.append({
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"total_records": 1000 + np.random.randint(-100, 100),
|
||||
"total_devices": 25 + np.random.randint(-5, 5),
|
||||
"active_connections": 5 + np.random.randint(-2, 3),
|
||||
"api_requests_count": 150 + np.random.randint(-30, 30),
|
||||
"alerts_count": np.random.randint(0, 5),
|
||||
"data_quality_score": round(np.random.uniform(0.9, 1.0), 2),
|
||||
"memory_usage_mb": 100 + np.random.randint(-20, 20),
|
||||
"cpu_usage_percent": 30 + np.random.randint(-10, 10)
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
def setup_superset_integration(self, integration_data: Dict[str, Any]) -> SupersetIntegration:
|
||||
"""设置Superset集成"""
|
||||
integration = SupersetIntegration.from_dict(integration_data)
|
||||
self.superset_integration = integration
|
||||
return integration
|
||||
|
||||
def setup_metabase_integration(self, integration_data: Dict[str, Any]) -> MetabaseIntegration:
|
||||
"""设置Metabase集成"""
|
||||
integration = MetabaseIntegration.from_dict(integration_data)
|
||||
self.metabase_integration = integration
|
||||
return integration
|
||||
|
||||
def get_chart_data_api(self, chart_id: str) -> Dict[str, Any]:
|
||||
"""获取图表数据API接口"""
|
||||
return self.execute_chart_data(chart_id)
|
||||
|
||||
def get_dashboard_data_api(self, dashboard_id: str) -> Dict[str, Any]:
|
||||
"""获取看板数据API接口"""
|
||||
dashboard = self.get_dashboard(dashboard_id)
|
||||
if not dashboard:
|
||||
return {"error": "Dashboard not found"}
|
||||
|
||||
charts_data = {}
|
||||
for chart_id in dashboard.charts:
|
||||
charts_data[chart_id] = self.get_chart_data_api(chart_id)
|
||||
|
||||
return {
|
||||
"dashboard_id": dashboard_id,
|
||||
"dashboard_name": dashboard.name,
|
||||
"charts": charts_data,
|
||||
"layout": dashboard.layout
|
||||
}
|
||||
|
||||
def get_public_dashboards(self) -> List[Dashboard]:
|
||||
"""获取公开看板"""
|
||||
return [db for db in self.dashboards.values() if db.is_public]
|
||||
|
||||
def get_charts_by_tag(self, tag: str) -> List[Chart]:
|
||||
"""根据标签获取图表"""
|
||||
return [chart for chart in self.charts.values() if tag in chart.tags]
|
||||
|
||||
def get_dashboards_by_tag(self, tag: str) -> List[Dashboard]:
|
||||
"""根据标签获取看板"""
|
||||
return [dashboard for dashboard in self.dashboards.values() if tag in dashboard.tags]
|
||||
|
||||
def search_charts(self, keyword: str) -> List[Chart]:
|
||||
"""搜索图表"""
|
||||
keyword = keyword.lower()
|
||||
return [
|
||||
chart for chart in self.charts.values()
|
||||
if keyword in chart.name.lower() or keyword in chart.description.lower()
|
||||
or any(keyword in tag.lower() for tag in chart.tags)
|
||||
]
|
||||
|
||||
def search_dashboards(self, keyword: str) -> List[Dashboard]:
|
||||
"""搜索看板"""
|
||||
keyword = keyword.lower()
|
||||
return [
|
||||
dashboard for dashboard in self.dashboards.values()
|
||||
if keyword in dashboard.name.lower() or keyword in dashboard.description.lower()
|
||||
or any(keyword in tag.lower() for tag in dashboard.tags)
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
IoT Module - 物联网平台核心模块
|
||||
包含MQTT协议适配器、设备注册/发现API、统一设备模型等
|
||||
"""
|
||||
|
||||
from .device_manager import DeviceManager
|
||||
from .device_controller import DeviceController
|
||||
|
||||
__all__ = ['DeviceManager', 'DeviceController']
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+177
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
IoT模块Flask应用
|
||||
集成MQTT适配器、设备管理器、OTA管理等核心组件
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from flask import Flask
|
||||
from .device_manager import DeviceManager
|
||||
from .mqtt_adapter import MqttAdapter
|
||||
from .device_controller import DeviceController
|
||||
from .ota_manager import OtaManager
|
||||
from .ota_controller import OtaController
|
||||
from .config import MqttConfig, DatabaseConfig
|
||||
|
||||
|
||||
def create_iot_app(config=None):
|
||||
"""
|
||||
创建IoT模块Flask应用
|
||||
|
||||
Args:
|
||||
config: 配置字典
|
||||
|
||||
Returns:
|
||||
Flask: Flask应用实例
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 加载配置
|
||||
if config is None:
|
||||
config = {
|
||||
'mqtt': MqttConfig.from_env(),
|
||||
'database': DatabaseConfig.from_env()
|
||||
}
|
||||
|
||||
# 初始化组件
|
||||
device_manager = DeviceManager()
|
||||
mqtt_adapter = MqttAdapter(
|
||||
broker_host=config['mqtt'].broker_host,
|
||||
broker_port=config['mqtt'].broker_port,
|
||||
username=config['mqtt'].username,
|
||||
password=config['mqtt'].password,
|
||||
client_id=config['mqtt'].client_id
|
||||
)
|
||||
ota_manager = OtaManager()
|
||||
|
||||
# 创建控制器
|
||||
device_controller = DeviceController(device_manager, mqtt_adapter)
|
||||
ota_controller = OtaController(ota_manager)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(device_controller.get_blueprint())
|
||||
app.register_blueprint(ota_controller.get_blueprint())
|
||||
|
||||
@app.route('/api/iot/status')
|
||||
def get_status():
|
||||
"""获取IoT模块状态"""
|
||||
return {
|
||||
"mqtt": mqtt_adapter.get_connection_status(),
|
||||
"device_statistics": device_manager.get_device_statistics(),
|
||||
"ota_statistics": ota_manager.get_update_statistics(),
|
||||
"devices_count": len(device_manager.devices),
|
||||
"shadows_count": len(device_manager.shadows)
|
||||
}
|
||||
|
||||
@app.route('/api/iot/mqtt/connect', methods=['POST'])
|
||||
def connect_mqtt():
|
||||
"""连接MQTT"""
|
||||
try:
|
||||
success = mqtt_adapter.connect()
|
||||
return {
|
||||
"success": success,
|
||||
"message": "MQTT connected" if success else "MQTT connection failed"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT connection error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@app.route('/api/iot/mqtt/disconnect', methods=['POST'])
|
||||
def disconnect_mqtt():
|
||||
"""断开MQTT连接"""
|
||||
try:
|
||||
mqtt_adapter.disconnect()
|
||||
return {"success": True, "message": "MQTT disconnected"}
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT disconnection error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@app.route('/api/iot/mqtt/reconnect', methods=['POST'])
|
||||
def reconnect_mqtt():
|
||||
"""重连MQTT"""
|
||||
try:
|
||||
mqtt_adapter.disconnect()
|
||||
success = mqtt_adapter.connect()
|
||||
return {
|
||||
"success": success,
|
||||
"message": "MQTT reconnected" if success else "MQTT reconnection failed"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT reconnection error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@app.route('/api/iot/mqtt/publish', methods=['POST'])
|
||||
def publish_message():
|
||||
"""发布MQTT消息"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
topic = data.get('topic')
|
||||
payload = data.get('payload', {})
|
||||
qos = data.get('qos', 0)
|
||||
retain = data.get('retain', False)
|
||||
|
||||
success = mqtt_adapter.publish(topic, payload, qos, retain)
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Message published" if success else "Message publish failed"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT publish error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@app.route('/api/iot/initialize', methods=['POST'])
|
||||
def initialize_iot():
|
||||
"""初始化IoT模块"""
|
||||
try:
|
||||
# 连接MQTT
|
||||
mqtt_success = mqtt_adapter.connect()
|
||||
|
||||
# 订阅设备主题
|
||||
mqtt_adapter.subscribe_device_topics(device_manager)
|
||||
|
||||
# 初始化一些示例设备
|
||||
if len(device_manager.devices) == 0:
|
||||
sample_devices = [
|
||||
{
|
||||
'device_sn': 'LL-001',
|
||||
'device_type': 'flow_meter',
|
||||
'name': '流量计-001',
|
||||
'description': 'A区入口流量计',
|
||||
'area': 'A区',
|
||||
'position': '入口处',
|
||||
'manufacturer': '华为',
|
||||
'model': 'LL-100'
|
||||
},
|
||||
{
|
||||
'device_sn': 'YL-001',
|
||||
'device_type': 'pressure_meter',
|
||||
'name': '压力表-001',
|
||||
'description': 'B区主压力表',
|
||||
'area': 'B区',
|
||||
'position': '主管道',
|
||||
'manufacturer': '西门子',
|
||||
'model': 'YL-200'
|
||||
}
|
||||
]
|
||||
|
||||
for device_data in sample_devices:
|
||||
device_manager.register_device(device_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"mqtt_connected": mqtt_success,
|
||||
"devices_count": len(device_manager.devices),
|
||||
"message": "IoT module initialized successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"IoT initialization error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
IoT模块配置
|
||||
包含MQTT配置、数据库配置、设备类型映射等
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MqttConfig:
|
||||
"""MQTT配置"""
|
||||
broker_host: str = "localhost"
|
||||
broker_port: int = 1883
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
client_id: str = "water-management-system"
|
||||
keep_alive: int = 60
|
||||
clean_session: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
"""从环境变量加载配置"""
|
||||
return cls(
|
||||
broker_host=os.getenv('MQTT_BROKER_HOST', 'localhost'),
|
||||
broker_port=int(os.getenv('MQTT_BROKER_PORT', 1883)),
|
||||
username=os.getenv('MQTT_USERNAME', ''),
|
||||
password=os.getenv('MQTT_PASSWORD', ''),
|
||||
client_id=os.getenv('MQTT_CLIENT_ID', 'water-management-system'),
|
||||
keep_alive=int(os.getenv('MQTT_KEEP_ALIVE', 60)),
|
||||
clean_session=os.getenv('MQTT_CLEAN_SESSION', 'true').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
"""数据库配置"""
|
||||
host: str = "localhost"
|
||||
port: int = 3306
|
||||
database: str = "water_management"
|
||||
username: str = "root"
|
||||
password: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
"""从环境变量加载配置"""
|
||||
return cls(
|
||||
host=os.getenv('DB_HOST', 'localhost'),
|
||||
port=int(os.getenv('DB_PORT', 3306)),
|
||||
database=os.getenv('DB_NAME', 'water_management'),
|
||||
username=os.getenv('DB_USER', 'root'),
|
||||
password=os.getenv('DB_PASSWORD', '')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceTypeMapping:
|
||||
"""设备类型映射"""
|
||||
# 水利行业标准设备类型映射
|
||||
MAPPINGS = {
|
||||
# 流量计
|
||||
'LL': 'flow_meter', # 流量计
|
||||
'Q': 'flow_meter', # 流量
|
||||
'QL': 'flow_meter', # 瞬时流量
|
||||
|
||||
# 压力表
|
||||
'YL': 'pressure_meter', # 压力表
|
||||
'P': 'pressure_meter', # 压力
|
||||
'PL': 'pressure_meter', # 瞬时压力
|
||||
|
||||
# 水位计
|
||||
'SW': 'level_meter', # 水位计
|
||||
'L': 'level_meter', # 水位
|
||||
'WL': 'level_meter', # 瞬时水位
|
||||
|
||||
# 水质仪
|
||||
'ZD': 'quality_meter', # 浊度
|
||||
'PH': 'quality_meter', # pH值
|
||||
'DO': 'quality_meter', # 溶解氧
|
||||
'COD': 'quality_meter', # 化学需氧量
|
||||
'NH3': 'quality_meter', # 氨氮
|
||||
'TEMP': 'quality_meter', # 温度
|
||||
|
||||
# 阀门
|
||||
'FV': 'valve', # 阀门
|
||||
'BV': 'valve', # 球阀
|
||||
'GV': 'valve', # 闸阀
|
||||
|
||||
# 水泵
|
||||
'PUMP': 'pump', # 水泵
|
||||
'CP': 'pump', # 循环泵
|
||||
'FP': 'pump', # 给水泵
|
||||
|
||||
# 传感器
|
||||
'SENSOR': 'sensor', # 传感器
|
||||
'TS': 'sensor', # 温度传感器
|
||||
'HS': 'sensor', # 湿度传感器
|
||||
|
||||
# 摄像头
|
||||
'CAM': 'camera', # 摄像头
|
||||
'IPC': 'camera', # 网络摄像头
|
||||
|
||||
# 其他
|
||||
'OTHER': 'other' # 其他设备
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def map_device_type(cls, standard_type: str) -> str:
|
||||
"""
|
||||
将标准设备类型映射为内部类型
|
||||
|
||||
Args:
|
||||
standard_type: 标准设备类型
|
||||
|
||||
Returns:
|
||||
str: 内部设备类型
|
||||
"""
|
||||
return cls.MAPPINGS.get(standard_type.upper(), 'other')
|
||||
|
||||
@classmethod
|
||||
def get_all_standard_types(cls) -> list:
|
||||
"""获取所有标准设备类型"""
|
||||
return list(cls.MAPPINGS.keys())
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnitConversion:
|
||||
"""单位转换配置"""
|
||||
# 水利行业常用单位转换
|
||||
CONVERSIONS = {
|
||||
# 流量单位
|
||||
'm³/h': {'m³/s': 1/3600, 'L/s': 1000/3600},
|
||||
'm³/s': {'m³/h': 3600, 'L/s': 1000},
|
||||
'L/s': {'m³/h': 3600/1000, 'm³/s': 1/1000},
|
||||
|
||||
# 压力单位
|
||||
'MPa': {'kPa': 1000, 'Pa': 1000000},
|
||||
'kPa': {'MPa': 1/1000, 'Pa': 1000},
|
||||
'Pa': {'MPa': 1/1000000, 'kPa': 1/1000},
|
||||
|
||||
# 水位单位
|
||||
'm': {'cm': 100, 'mm': 1000},
|
||||
'cm': {'m': 1/100, 'mm': 10},
|
||||
'mm': {'m': 1/1000, 'cm': 1/10},
|
||||
|
||||
# 水质单位
|
||||
'NTU': {'': 1}, # 浊度无标准转换
|
||||
'pH': {'': 1}, # pH值无标准转换
|
||||
'mg/L': {'ppm': 1}, # mg/L 和 ppm 等价
|
||||
'ppm': {'mg/L': 1}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def convert_unit(cls, value: float, from_unit: str, to_unit: str) -> float:
|
||||
"""
|
||||
单位转换
|
||||
|
||||
Args:
|
||||
value: 数值
|
||||
from_unit: 源单位
|
||||
to_unit: 目标单位
|
||||
|
||||
Returns:
|
||||
float: 转换后的数值
|
||||
"""
|
||||
if from_unit == to_unit:
|
||||
return value
|
||||
|
||||
if from_unit in cls.CONVERSIONS and to_unit in cls.CONVERSIONS[from_unit]:
|
||||
factor = cls.CONVERSIONS[from_unit][to_unit]
|
||||
return value * factor
|
||||
|
||||
# 如果没有找到转换关系,尝试反向查找
|
||||
for unit, conversions in cls.CONVERSIONS.items():
|
||||
if to_unit in conversions and from_unit in conversions:
|
||||
# 相同基准单位的转换
|
||||
factor = conversions[from_unit] / conversions[to_unit]
|
||||
return value * factor
|
||||
|
||||
# 无法转换,返回原值
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def get_supported_units(cls) -> list:
|
||||
"""获取支持的所有单位"""
|
||||
units = set()
|
||||
for conversions in cls.CONVERSIONS.values():
|
||||
units.update(conversions.keys())
|
||||
return list(units)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
设备控制器
|
||||
提供设备注册/发现的REST API接口
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from flask import Blueprint, request, jsonify
|
||||
from .device_manager import DeviceManager
|
||||
from .models import DeviceType, DeviceStatus
|
||||
|
||||
|
||||
class DeviceController:
|
||||
"""设备控制器"""
|
||||
|
||||
def __init__(self, device_manager: DeviceManager):
|
||||
"""
|
||||
初始化设备控制器
|
||||
|
||||
Args:
|
||||
device_manager: 设备管理器
|
||||
"""
|
||||
self.device_manager = device_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建Blueprint
|
||||
self.blueprint = Blueprint('device', __name__, url_prefix='/api/iot/device')
|
||||
|
||||
# 注册路由
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""注册API路由"""
|
||||
|
||||
@self.blueprint.route('/', methods=['GET'])
|
||||
def list_devices():
|
||||
"""获取设备列表"""
|
||||
try:
|
||||
# 获取查询参数
|
||||
device_type_str = request.args.get('type')
|
||||
status_str = request.args.get('status')
|
||||
area = request.args.get('area')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 10))
|
||||
|
||||
# 过滤条件
|
||||
device_type = DeviceType(device_type_str) if device_type_str else None
|
||||
status = DeviceStatus(status_str) if status_str else None
|
||||
|
||||
# 获取设备列表
|
||||
devices = self.device_manager.list_devices(device_type, status, area)
|
||||
|
||||
# 分页
|
||||
total = len(devices)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_devices = devices[start:end]
|
||||
|
||||
# 转换为字典格式
|
||||
device_list = [device.to_dict() for device in paginated_devices]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": device_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error listing devices: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>', methods=['GET'])
|
||||
def get_device(device_sn):
|
||||
"""获取设备详情"""
|
||||
try:
|
||||
device = self.device_manager.get_device(device_sn)
|
||||
if not device:
|
||||
return jsonify({"success": False, "error": "Device not found"}), 404
|
||||
|
||||
# 获取设备影子
|
||||
shadow = self.device_manager.get_device_shadow(device_sn)
|
||||
device_data = device.to_dict()
|
||||
if shadow:
|
||||
device_data['shadow'] = shadow.to_dict()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": device_data
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting device {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/', methods=['POST'])
|
||||
def register_device():
|
||||
"""注册新设备"""
|
||||
try:
|
||||
device_data = request.get_json()
|
||||
|
||||
# 验证必要字段
|
||||
required_fields = ['device_sn', 'device_type', 'name']
|
||||
for field in required_fields:
|
||||
if field not in device_data:
|
||||
return jsonify({"success": False, "error": f"Missing required field: {field}"}), 400
|
||||
|
||||
# 检查设备是否已存在
|
||||
existing_device = self.device_manager.get_device(device_data['device_sn'])
|
||||
if existing_device:
|
||||
return jsonify({"success": False, "error": "Device already exists"}), 409
|
||||
|
||||
# 注册设备
|
||||
device = self.device_manager.register_device(device_data)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": device.to_dict(),
|
||||
"message": "Device registered successfully"
|
||||
}), 201
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error registering device: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>', methods=['PUT'])
|
||||
def update_device(device_sn):
|
||||
"""更新设备信息"""
|
||||
try:
|
||||
device = self.device_manager.get_device(device_sn)
|
||||
if not device:
|
||||
return jsonify({"success": False, "error": "Device not found"}), 404
|
||||
|
||||
updates = request.get_json()
|
||||
|
||||
# 更新设备
|
||||
updated_device = self.device_manager.update_device(device_sn, updates)
|
||||
if not updated_device:
|
||||
return jsonify({"success": False, "error": "Failed to update device"}), 400
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": updated_device.to_dict(),
|
||||
"message": "Device updated successfully"
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating device {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>', methods=['DELETE'])
|
||||
def delete_device(device_sn):
|
||||
"""删除设备"""
|
||||
try:
|
||||
success = self.device_manager.delete_device(device_sn)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Device not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Device deleted successfully"
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error deleting device {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>/shadow', methods=['GET'])
|
||||
def get_device_shadow(device_sn):
|
||||
"""获取设备影子"""
|
||||
try:
|
||||
shadow = self.device_manager.get_device_shadow(device_sn)
|
||||
if not shadow:
|
||||
return jsonify({"success": False, "error": "Device shadow not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": shadow.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting device shadow {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>/shadow', methods=['PUT'])
|
||||
def update_device_shadow(device_sn):
|
||||
"""更新设备影子"""
|
||||
try:
|
||||
state = request.get_json()
|
||||
|
||||
success = self.device_manager.update_device_shadow(device_sn, state)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Device not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Device shadow updated successfully"
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating device shadow {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/discover', methods=['POST'])
|
||||
def discover_devices():
|
||||
"""设备发现"""
|
||||
try:
|
||||
discovered = self.device_manager.discover_devices()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": discovered,
|
||||
"message": f"Discovered {len(discovered)} devices"
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error discovering devices: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/<device_sn>/command', methods=['POST'])
|
||||
def send_device_command(device_sn):
|
||||
"""发送设备控制命令"""
|
||||
try:
|
||||
command = request.get_json()
|
||||
|
||||
# 验证设备存在
|
||||
device = self.device_manager.get_device(device_sn)
|
||||
if not device:
|
||||
return jsonify({"success": False, "error": "Device not found"}), 404
|
||||
|
||||
# 发送命令
|
||||
success = self.mqtt_adapter.send_command(device_sn, command)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Failed to send command"}), 500
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Command sent successfully",
|
||||
"device_sn": device_sn,
|
||||
"command": command
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending command to device {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/statistics', methods=['GET'])
|
||||
def get_device_statistics():
|
||||
"""获取设备统计信息"""
|
||||
try:
|
||||
statistics = self.device_manager.get_device_statistics()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": statistics
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting device statistics: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
def get_blueprint(self):
|
||||
"""获取Blueprint"""
|
||||
return self.blueprint
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
设备管理服务
|
||||
负责设备的CRUD操作、设备影子管理、设备发现等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from .models import Device, DeviceShadow, DeviceStatus, DeviceType
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""设备管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.devices: Dict[str, Device] = {} # device_sn -> Device
|
||||
self.shadows: Dict[str, DeviceShadow] = {} # device_sn -> DeviceShadow
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def register_device(self, device_data: Dict[str, Any]) -> Device:
|
||||
"""
|
||||
注册设备
|
||||
|
||||
Args:
|
||||
device_data: 设备数据字典
|
||||
|
||||
Returns:
|
||||
Device: 注册的设备对象
|
||||
"""
|
||||
device = Device(
|
||||
device_sn=device_data['device_sn'],
|
||||
device_type=DeviceType(device_data.get('device_type', 'other')),
|
||||
name=device_data.get('name', ''),
|
||||
description=device_data.get('description', ''),
|
||||
area=device_data.get('area', ''),
|
||||
position=device_data.get('position', ''),
|
||||
geom=device_data.get('geom'),
|
||||
manufacturer=device_data.get('manufacturer', ''),
|
||||
model=device_data.get('model', ''),
|
||||
firmware_version=device_data.get('firmware_version', ''),
|
||||
hardware_version=device_data.get('hardware_version', ''),
|
||||
metadata=device_data.get('metadata', {})
|
||||
)
|
||||
|
||||
self.devices[device.device_sn] = device
|
||||
|
||||
# 创建设备影子
|
||||
shadow = DeviceShadow(device_sn=device.device_sn)
|
||||
self.shadows[device.device_sn] = shadow
|
||||
|
||||
self.logger.info(f"Device registered: {device.device_sn}")
|
||||
return device
|
||||
|
||||
def get_device(self, device_sn: str) -> Optional[Device]:
|
||||
"""
|
||||
获取设备信息
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
|
||||
Returns:
|
||||
Device: 设备对象,如果不存在返回None
|
||||
"""
|
||||
return self.devices.get(device_sn)
|
||||
|
||||
def update_device(self, device_sn: str, updates: Dict[str, Any]) -> Optional[Device]:
|
||||
"""
|
||||
更新设备信息
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
updates: 更新的字段
|
||||
|
||||
Returns:
|
||||
Device: 更新后的设备对象,如果不存在返回None
|
||||
"""
|
||||
device = self.devices.get(device_sn)
|
||||
if not device:
|
||||
return None
|
||||
|
||||
# 更新设备属性
|
||||
for key, value in updates.items():
|
||||
if hasattr(device, key):
|
||||
setattr(device, key, value)
|
||||
|
||||
device.updated_at = datetime.now()
|
||||
self.logger.info(f"Device updated: {device_sn}")
|
||||
return device
|
||||
|
||||
def delete_device(self, device_sn: str) -> bool:
|
||||
"""
|
||||
删除设备
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
if device_sn in self.devices:
|
||||
del self.devices[device_sn]
|
||||
if device_sn in self.shadows:
|
||||
del self.shadows[device_sn]
|
||||
self.logger.info(f"Device deleted: {device_sn}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_devices(self,
|
||||
device_type: Optional[DeviceType] = None,
|
||||
status: Optional[DeviceStatus] = None,
|
||||
area: Optional[str] = None) -> List[Device]:
|
||||
"""
|
||||
列出设备
|
||||
|
||||
Args:
|
||||
device_type: 设备类型过滤
|
||||
status: 设备状态过滤
|
||||
area: 区域过滤
|
||||
|
||||
Returns:
|
||||
List[Device]: 设备列表
|
||||
"""
|
||||
devices = list(self.devices.values())
|
||||
|
||||
if device_type:
|
||||
devices = [d for d in devices if d.device_type == device_type]
|
||||
|
||||
if status:
|
||||
devices = [d for d in devices if d.status == status]
|
||||
|
||||
if area:
|
||||
devices = [d for d in devices if d.area == area]
|
||||
|
||||
return devices
|
||||
|
||||
def update_device_shadow(self, device_sn: str, state: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
更新设备影子
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
state: 设备状态
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
if device_sn not in self.shadows:
|
||||
return False
|
||||
|
||||
shadow = self.shadows[device_sn]
|
||||
shadow.state.update(state)
|
||||
shadow.timestamp = datetime.now()
|
||||
self.logger.debug(f"Device shadow updated: {device_sn}")
|
||||
return True
|
||||
|
||||
def get_device_shadow(self, device_sn: str) -> Optional[DeviceShadow]:
|
||||
"""
|
||||
获取设备影子
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
|
||||
Returns:
|
||||
DeviceShadow: 设备影子对象
|
||||
"""
|
||||
return self.shadows.get(device_sn)
|
||||
|
||||
def discover_devices(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
设备发现 - 扫描网络中的设备
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 发现的设备列表
|
||||
"""
|
||||
discovered = []
|
||||
|
||||
# 模拟设备发现过程
|
||||
# 在实际实现中,这里可以包含网络扫描、协议握手等逻辑
|
||||
for device_sn, device in self.devices.items():
|
||||
if device.status == DeviceStatus.OFFLINE:
|
||||
# 模拟设备上线
|
||||
device.status = DeviceStatus.ONLINE
|
||||
device.last_seen = datetime.now()
|
||||
device.ip_address = f"192.168.1.{hash(device_sn) % 255 + 1}"
|
||||
|
||||
discovered.append({
|
||||
"device_sn": device_sn,
|
||||
"name": device.name,
|
||||
"type": device.device_type.value,
|
||||
"ip_address": device.ip_address,
|
||||
"status": device.status.value
|
||||
})
|
||||
|
||||
self.logger.info(f"Discovered {len(discovered)} devices")
|
||||
return discovered
|
||||
|
||||
def get_device_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取设备统计信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 统计信息
|
||||
"""
|
||||
total = len(self.devices)
|
||||
online = sum(1 for d in self.devices.values() if d.status == DeviceStatus.ONLINE)
|
||||
offline = total - online
|
||||
|
||||
by_type = {}
|
||||
for device in self.devices.values():
|
||||
device_type = device.device_type.value
|
||||
by_type[device_type] = by_type.get(device_type, 0) + 1
|
||||
|
||||
return {
|
||||
"total_devices": total,
|
||||
"online_devices": online,
|
||||
"offline_devices": offline,
|
||||
"devices_by_type": by_type
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
IoT 设备模型定义
|
||||
包含设备实体、设备影子、OTA升级等核心数据模型
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Any
|
||||
import uuid
|
||||
|
||||
|
||||
class DeviceStatus(Enum):
|
||||
"""设备状态枚举"""
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
MAINTENANCE = "maintenance"
|
||||
FAULT = "fault"
|
||||
|
||||
|
||||
class DeviceType(Enum):
|
||||
"""设备类型枚举"""
|
||||
FLOW_METER = "flow_meter" # 流量计
|
||||
PRESSURE_METER = "pressure_meter" # 压力表
|
||||
LEVEL_METER = "level_meter" # 水位计
|
||||
QUALITY_METER = "quality_meter" # 水质仪
|
||||
VALVE = "valve" # 阀门
|
||||
PUMP = "pump" # 水泵
|
||||
SENSOR = "sensor" # 传感器
|
||||
CAMERA = "camera" # 摄像头
|
||||
OTHER = "other" # 其他
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""设备实体模型"""
|
||||
# 必需字段(无默认值)
|
||||
device_sn: str # 设备序列号(唯一标识)
|
||||
device_type: DeviceType # 设备类型
|
||||
name: str # 设备名称
|
||||
|
||||
# 可选字段(有默认值)
|
||||
description: str = "" # 设备描述
|
||||
area: str = "" # 区域
|
||||
position: str = "" # 位置
|
||||
geom: Optional[str] = None # 地理坐标(GeoJSON格式)
|
||||
manufacturer: str = "" # 厂商
|
||||
model: str = "" # 型号
|
||||
firmware_version: str = "" # 固件版本
|
||||
hardware_version: str = "" # 硬件版本
|
||||
status: DeviceStatus = DeviceStatus.OFFLINE
|
||||
last_seen: Optional[datetime] = None
|
||||
ip_address: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
id: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"device_sn": self.device_sn,
|
||||
"device_type": self.device_type.value,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"area": self.area,
|
||||
"position": self.position,
|
||||
"geom": self.geom,
|
||||
"manufacturer": self.manufacturer,
|
||||
"model": self.model,
|
||||
"firmware_version": self.firmware_version,
|
||||
"hardware_version": self.hardware_version,
|
||||
"status": self.status.value,
|
||||
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
|
||||
"ip_address": self.ip_address,
|
||||
"port": self.port,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceShadow:
|
||||
"""设备影子模型"""
|
||||
# 必需字段
|
||||
device_sn: str # 设备序列号
|
||||
|
||||
# 可选字段
|
||||
state: Dict[str, Any] = field(default_factory=dict) # 设备状态
|
||||
desired_state: Dict[str, Any] = field(default_factory=dict) # 期望状态
|
||||
reported_state: Dict[str, Any] = field(default_factory=dict) # 报告状态
|
||||
timestamp: datetime = field(default_factory=datetime.now) # 时间戳
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"device_sn": self.device_sn,
|
||||
"state": self.state,
|
||||
"desired_state": self.desired_state,
|
||||
"reported_state": self.reported_state,
|
||||
"timestamp": self.timestamp.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OtaUpdate:
|
||||
"""OTA升级记录"""
|
||||
# 必需字段
|
||||
device_sn: str # 设备序列号
|
||||
version: str # 目标版本
|
||||
file_url: str # 固件文件URL
|
||||
file_size: int # 文件大小
|
||||
checksum: str # 文件校验和
|
||||
|
||||
# 可选字段
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
status: str = "pending" # 状态:pending/downloading/installed/failed
|
||||
progress: int = 0 # 进度百分比
|
||||
error_message: Optional[str] = None # 错误信息
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"device_sn": self.device_sn,
|
||||
"version": self.version,
|
||||
"file_url": self.file_url,
|
||||
"file_size": self.file_size,
|
||||
"checksum": self.checksum,
|
||||
"status": self.status,
|
||||
"progress": self.progress,
|
||||
"error_message": self.error_message,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MqttMessage:
|
||||
"""MQTT消息模型"""
|
||||
# 必需字段
|
||||
topic: str # 主题
|
||||
payload: Dict[str, Any] # 消息内容
|
||||
|
||||
# 可选字段
|
||||
qos: int = 0 # QoS等级
|
||||
retain: bool = False # 是否保留消息
|
||||
timestamp: datetime = field(default_factory=datetime.now) # 时间戳
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"topic": self.topic,
|
||||
"payload": self.payload,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
"timestamp": self.timestamp.isoformat()
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
MQTT 协议适配器
|
||||
负责MQTT连接管理、消息订阅/发布、消息解析等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import paho.mqtt.client as mqtt
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, Callable, List
|
||||
from .models import MqttMessage
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class MqttAdapter:
|
||||
"""MQTT适配器"""
|
||||
|
||||
def __init__(self,
|
||||
broker_host: str = "localhost",
|
||||
broker_port: int = 1883,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
client_id: str = "water-management-system"):
|
||||
"""
|
||||
初始化MQTT适配器
|
||||
|
||||
Args:
|
||||
broker_host: MQTT broker地址
|
||||
broker_port: MQTT broker端口
|
||||
username: 用户名
|
||||
password: 密码
|
||||
client_id: 客户端ID
|
||||
"""
|
||||
self.broker_host = broker_host
|
||||
self.broker_port = broker_port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.client_id = client_id
|
||||
|
||||
self.client = mqtt.Client(client_id=client_id)
|
||||
self.message_handlers: Dict[str, Callable] = {}
|
||||
self.connected = False
|
||||
self.lock = Lock()
|
||||
|
||||
# 配置MQTT客户端
|
||||
if username and password:
|
||||
self.client.username_pw_set(username, password)
|
||||
|
||||
# 设置回调函数
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
self.client.on_message = self._on_message
|
||||
self.client.on_publish = self._on_publish
|
||||
self.client.on_subscribe = self._on_subscribe
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
"""连接回调"""
|
||||
if rc == 0:
|
||||
self.connected = True
|
||||
self.logger.info(f"Connected to MQTT broker at {self.broker_host}:{self.broker_port}")
|
||||
else:
|
||||
self.logger.error(f"Failed to connect to MQTT broker, return code {rc}")
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""断开连接回调"""
|
||||
self.connected = False
|
||||
self.logger.warning(f"Disconnected from MQTT broker, return code {rc}")
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
"""消息接收回调"""
|
||||
try:
|
||||
# 解析消息
|
||||
payload = json.loads(msg.payload.decode('utf-8')) if msg.payload else {}
|
||||
|
||||
message = MqttMessage(
|
||||
topic=msg.topic,
|
||||
payload=payload,
|
||||
qos=msg.qos,
|
||||
retain=msg.retain
|
||||
)
|
||||
|
||||
self.logger.debug(f"Received message: {message.topic} - {message.payload}")
|
||||
|
||||
# 查找对应的消息处理器
|
||||
for topic_pattern, handler in self.message_handlers.items():
|
||||
if self._topic_matches(msg.topic, topic_pattern):
|
||||
try:
|
||||
handler(message)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in message handler for {msg.topic}: {e}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Failed to parse JSON message from {msg.topic}: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing message from {msg.topic}: {e}")
|
||||
|
||||
def _on_publish(self, client, userdata, mid):
|
||||
"""发布消息回调"""
|
||||
self.logger.debug(f"Message published with mid: {mid}")
|
||||
|
||||
def _on_subscribe(self, client, userdata, mid, granted_qos):
|
||||
"""订阅回调"""
|
||||
self.logger.debug(f"Subscribed with mid: {mid}, granted_qos: {granted_qos}")
|
||||
|
||||
def _topic_matches(self, topic: str, pattern: str) -> bool:
|
||||
"""检查主题是否匹配模式"""
|
||||
# 简单的通配符匹配实现
|
||||
# 支持单层通配符 + 和多层通配符 #
|
||||
pattern_parts = pattern.split('/')
|
||||
topic_parts = topic.split('/')
|
||||
|
||||
if len(pattern_parts) != len(topic_parts):
|
||||
return False
|
||||
|
||||
for p_part, t_part in zip(pattern_parts, topic_parts):
|
||||
if p_part == '+' or p_part == '#':
|
||||
continue
|
||||
if p_part != t_part:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
连接到MQTT broker
|
||||
|
||||
Returns:
|
||||
bool: 是否连接成功
|
||||
"""
|
||||
try:
|
||||
self.client.connect(self.broker_host, self.broker_port, 60)
|
||||
self.client.loop_start()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""断开MQTT连接"""
|
||||
if self.connected:
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
检查是否已连接
|
||||
|
||||
Returns:
|
||||
bool: 是否已连接
|
||||
"""
|
||||
return self.connected
|
||||
|
||||
def subscribe(self, topic: str, qos: int = 0) -> bool:
|
||||
"""
|
||||
订阅主题
|
||||
|
||||
Args:
|
||||
topic: 主题
|
||||
qos: QoS等级
|
||||
|
||||
Returns:
|
||||
bool: 是否订阅成功
|
||||
"""
|
||||
try:
|
||||
result = self.client.subscribe(topic, qos)
|
||||
if result[0] == mqtt.MQTT_ERR_SUCCESS:
|
||||
self.logger.info(f"Subscribed to topic: {topic}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to subscribe to topic: {topic}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error subscribing to topic {topic}: {e}")
|
||||
return False
|
||||
|
||||
def unsubscribe(self, topic: str) -> bool:
|
||||
"""
|
||||
取消订阅主题
|
||||
|
||||
Args:
|
||||
topic: 主题
|
||||
|
||||
Returns:
|
||||
bool: 是否取消订阅成功
|
||||
"""
|
||||
try:
|
||||
result = self.client.unsubscribe(topic)
|
||||
if result[0] == mqtt.MQTT_ERR_SUCCESS:
|
||||
self.logger.info(f"Unsubscribed from topic: {topic}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to unsubscribe from topic: {topic}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error unsubscribing from topic {topic}: {e}")
|
||||
return False
|
||||
|
||||
def publish(self, topic: str, payload: Any, qos: int = 0, retain: bool = False) -> bool:
|
||||
"""
|
||||
发布消息
|
||||
|
||||
Args:
|
||||
topic: 主题
|
||||
payload: 消息内容
|
||||
qos: QoS等级
|
||||
retain: 是否保留消息
|
||||
|
||||
Returns:
|
||||
bool: 是否发布成功
|
||||
"""
|
||||
try:
|
||||
if isinstance(payload, dict):
|
||||
payload = json.dumps(payload)
|
||||
elif not isinstance(payload, str):
|
||||
payload = str(payload)
|
||||
|
||||
result = self.client.publish(topic, payload, qos, retain)
|
||||
if result[0] == mqtt.MQTT_ERR_SUCCESS:
|
||||
self.logger.debug(f"Published to topic: {topic}")
|
||||
return True
|
||||
else:
|
||||
self.logger.error(f"Failed to publish to topic: {topic}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error publishing to topic {topic}: {e}")
|
||||
return False
|
||||
|
||||
def add_message_handler(self, topic_pattern: str, handler: Callable[[MqttMessage], None]):
|
||||
"""
|
||||
添加消息处理器
|
||||
|
||||
Args:
|
||||
topic_pattern: 主题模式(支持通配符)
|
||||
handler: 消息处理函数
|
||||
"""
|
||||
with self.lock:
|
||||
self.message_handlers[topic_pattern] = handler
|
||||
self.logger.info(f"Added message handler for pattern: {topic_pattern}")
|
||||
|
||||
def remove_message_handler(self, topic_pattern: str):
|
||||
"""
|
||||
移除消息处理器
|
||||
|
||||
Args:
|
||||
topic_pattern: 主题模式
|
||||
"""
|
||||
with self.lock:
|
||||
if topic_pattern in self.message_handlers:
|
||||
del self.message_handlers[topic_pattern]
|
||||
self.logger.info(f"Removed message handler for pattern: {topic_pattern}")
|
||||
|
||||
def subscribe_device_topics(self, device_manager):
|
||||
"""
|
||||
订阅设备相关主题
|
||||
|
||||
Args:
|
||||
device_manager: 设备管理器实例
|
||||
"""
|
||||
# 设备状态上报
|
||||
self.add_message_handler("devices/+/status", self._handle_device_status)
|
||||
|
||||
# 设备数据上报
|
||||
self.add_message_handler("devices/+/data", self._handle_device_data)
|
||||
|
||||
# 设备控制命令响应
|
||||
self.add_message_handler("devices/+/command/response", self._handle_command_response)
|
||||
|
||||
# 设备OTA状态
|
||||
self.add_message_handler("devices/+/ota/status", self._handle_ota_status)
|
||||
|
||||
def _handle_device_status(self, message: MqttMessage):
|
||||
"""处理设备状态消息"""
|
||||
topic_parts = message.topic.split('/')
|
||||
if len(topic_parts) >= 2:
|
||||
device_sn = topic_parts[1]
|
||||
status = message.payload.get('status', 'unknown')
|
||||
|
||||
# 更新设备状态
|
||||
device = device_manager.get_device(device_sn)
|
||||
if device:
|
||||
from .models import DeviceStatus
|
||||
try:
|
||||
device.status = DeviceStatus(status)
|
||||
device.last_seen = datetime.now()
|
||||
device_manager.logger.info(f"Device {device_sn} status updated to {status}")
|
||||
except ValueError:
|
||||
device_manager.logger.warning(f"Unknown status: {status}")
|
||||
|
||||
def _handle_device_data(self, message: MqttMessage):
|
||||
"""处理设备数据消息"""
|
||||
topic_parts = message.topic.split('/')
|
||||
if len(topic_parts) >= 2:
|
||||
device_sn = topic_parts[1]
|
||||
data = message.payload
|
||||
|
||||
# 更新设备影子
|
||||
device_manager.update_device_shadow(device_sn, data)
|
||||
device_manager.logger.debug(f"Device {device_sn} data updated")
|
||||
|
||||
def _handle_command_response(self, message: MqttMessage):
|
||||
"""处理命令响应消息"""
|
||||
topic_parts = message.topic.split('/')
|
||||
if len(topic_parts) >= 2:
|
||||
device_sn = topic_parts[1]
|
||||
command_id = message.payload.get('command_id')
|
||||
result = message.payload.get('result')
|
||||
|
||||
device_manager.logger.info(f"Device {device_sn} command response: {command_id} -> {result}")
|
||||
|
||||
def _handle_ota_status(self, message: MqttMessage):
|
||||
"""处理OTA状态消息"""
|
||||
topic_parts = message.topic.split('/')
|
||||
if len(topic_parts) >= 2:
|
||||
device_sn = topic_parts[1]
|
||||
status = message.payload.get('status')
|
||||
progress = message.payload.get('progress', 0)
|
||||
|
||||
device_manager.logger.info(f"Device {device_sn} OTA status: {status}, progress: {progress}%")
|
||||
|
||||
def send_command(self, device_sn: str, command: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
发送设备控制命令
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
command: 命令内容
|
||||
|
||||
Returns:
|
||||
bool: 是否发送成功
|
||||
"""
|
||||
topic = f"devices/{device_sn}/command"
|
||||
command['command_id'] = f"cmd_{datetime.now().timestamp()}"
|
||||
command['timestamp'] = datetime.now().isoformat()
|
||||
|
||||
return self.publish(topic, command, qos=1)
|
||||
|
||||
def get_connection_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取连接状态
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 连接状态信息
|
||||
"""
|
||||
return {
|
||||
"connected": self.connected,
|
||||
"broker_host": self.broker_host,
|
||||
"broker_port": self.broker_port,
|
||||
"client_id": self.client_id,
|
||||
"message_handlers_count": len(self.message_handlers)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
OTA固件升级控制器
|
||||
提供OTA升级相关的REST API接口
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from flask import Blueprint, request, jsonify
|
||||
from .ota_manager import OtaManager
|
||||
from .models import OtaUpdate
|
||||
|
||||
|
||||
class OtaController:
|
||||
"""OTA控制器"""
|
||||
|
||||
def __init__(self, ota_manager: OtaManager):
|
||||
"""
|
||||
初始化OTA控制器
|
||||
|
||||
Args:
|
||||
ota_manager: OTA管理器
|
||||
"""
|
||||
self.ota_manager = ota_manager
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建Blueprint
|
||||
self.blueprint = Blueprint('ota', __name__, url_prefix='/api/iot/ota')
|
||||
|
||||
# 注册路由
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""注册API路由"""
|
||||
|
||||
@self.blueprint.route('/updates', methods=['GET'])
|
||||
def list_updates():
|
||||
"""获取OTA更新列表"""
|
||||
try:
|
||||
# 获取查询参数
|
||||
device_sn = request.args.get('device_sn')
|
||||
status = request.args.get('status')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = int(request.args.get('per_page', 10))
|
||||
|
||||
# 过滤条件
|
||||
updates = list(self.ota_manager.updates.values())
|
||||
if device_sn:
|
||||
updates = [u for u in updates if u.device_sn == device_sn]
|
||||
if status:
|
||||
updates = [u for u in updates if u.status == status]
|
||||
|
||||
# 分页
|
||||
total = len(updates)
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_updates = updates[start:end]
|
||||
|
||||
# 转换为字典格式
|
||||
update_list = [update.to_dict() for update in paginated_updates]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": update_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error listing OTA updates: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates/<update_id>', methods=['GET'])
|
||||
def get_update(update_id):
|
||||
"""获取OTA更新详情"""
|
||||
try:
|
||||
update = self.ota_manager.get_update(update_id)
|
||||
if not update:
|
||||
return jsonify({"success": False, "error": "Update not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": update.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting OTA update {update_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates', methods=['POST'])
|
||||
def create_update():
|
||||
"""创建OTA更新任务"""
|
||||
try:
|
||||
update_data = request.get_json()
|
||||
|
||||
# 验证必要字段
|
||||
required_fields = ['device_sn', 'version', 'file_url', 'file_size', 'checksum']
|
||||
for field in required_fields:
|
||||
if field not in update_data:
|
||||
return jsonify({"success": False, "error": f"Missing required field: {field}"}), 400
|
||||
|
||||
# 创建更新
|
||||
update = self.ota_manager.create_update(
|
||||
device_sn=update_data['device_sn'],
|
||||
version=update_data['version'],
|
||||
file_url=update_data['file_url'],
|
||||
file_size=update_data['file_size'],
|
||||
checksum=update_data['checksum']
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": update.to_dict(),
|
||||
"message": "OTA update created successfully"
|
||||
}), 201
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating OTA update: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates/<update_id>/start', methods=['POST'])
|
||||
def start_update(update_id):
|
||||
"""开始OTA更新"""
|
||||
try:
|
||||
success = self.ota_manager.start_update(update_id)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Failed to start update"}), 400
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "OTA update started successfully",
|
||||
"update_id": update_id
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error starting OTA update {update_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates/<update_id>/progress', methods=['PUT'])
|
||||
def update_progress(update_id):
|
||||
"""更新OTA进度"""
|
||||
try:
|
||||
progress_data = request.get_json()
|
||||
|
||||
progress = progress_data.get('progress')
|
||||
error_message = progress_data.get('error_message')
|
||||
|
||||
if progress is None:
|
||||
return jsonify({"success": False, "error": "Progress is required"}), 400
|
||||
|
||||
success = self.ota_manager.update_progress(update_id, progress, error_message)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Update not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "OTA progress updated successfully",
|
||||
"update_id": update_id,
|
||||
"progress": progress
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error updating OTA progress {update_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates/<update_id>/cancel', methods=['POST'])
|
||||
def cancel_update(update_id):
|
||||
"""取消OTA更新"""
|
||||
try:
|
||||
success = self.ota_manager.cancel_update(update_id)
|
||||
if not success:
|
||||
return jsonify({"success": False, "error": "Failed to cancel update"}), 400
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "OTA update cancelled successfully",
|
||||
"update_id": update_id
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cancelling OTA update {update_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/updates/device/<device_sn>', methods=['GET'])
|
||||
def get_device_updates(device_sn):
|
||||
"""获取设备的OTA更新记录"""
|
||||
try:
|
||||
updates = self.ota_manager.get_updates_by_device(device_sn)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": [update.to_dict() for update in updates],
|
||||
"device_sn": device_sn
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting updates for device {device_sn}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@self.blueprint.route('/statistics', methods=['GET'])
|
||||
def get_statistics():
|
||||
"""获取OTA统计信息"""
|
||||
try:
|
||||
statistics = self.ota_manager.get_update_statistics()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": statistics
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting OTA statistics: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
def get_blueprint(self):
|
||||
"""获取Blueprint"""
|
||||
return self.blueprint
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
OTA固件升级管理器
|
||||
负责设备OTA升级流程、版本管理、升级状态跟踪等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from .models import OtaUpdate
|
||||
|
||||
|
||||
class OtaManager:
|
||||
"""OTA管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.updates: Dict[str, OtaUpdate] = {} # update_id -> OtaUpdate
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def create_update(self, device_sn: str, version: str, file_url: str,
|
||||
file_size: int, checksum: str) -> OtaUpdate:
|
||||
"""
|
||||
创建OTA升级任务
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
version: 目标版本
|
||||
file_url: 固件文件URL
|
||||
file_size: 文件大小
|
||||
checksum: 文件校验和
|
||||
|
||||
Returns:
|
||||
OtaUpdate: OTA升级对象
|
||||
"""
|
||||
update = OtaUpdate(
|
||||
device_sn=device_sn,
|
||||
version=version,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
checksum=checksum
|
||||
)
|
||||
|
||||
self.updates[update.id] = update
|
||||
self.logger.info(f"Created OTA update: {update.id} for device {device_sn}")
|
||||
|
||||
return update
|
||||
|
||||
def get_update(self, update_id: str) -> Optional[OtaUpdate]:
|
||||
"""
|
||||
获取OTA升级信息
|
||||
|
||||
Args:
|
||||
update_id: 更新ID
|
||||
|
||||
Returns:
|
||||
OtaUpdate: OTA升级对象
|
||||
"""
|
||||
return self.updates.get(update_id)
|
||||
|
||||
def get_updates_by_device(self, device_sn: str) -> List[OtaUpdate]:
|
||||
"""
|
||||
获取设备的OTA升级记录
|
||||
|
||||
Args:
|
||||
device_sn: 设备序列号
|
||||
|
||||
Returns:
|
||||
List[OtaUpdate]: OTA升级列表
|
||||
"""
|
||||
return [update for update in self.updates.values() if update.device_sn == device_sn]
|
||||
|
||||
def start_update(self, update_id: str) -> bool:
|
||||
"""
|
||||
开始OTA升级
|
||||
|
||||
Args:
|
||||
update_id: 更新ID
|
||||
|
||||
Returns:
|
||||
bool: 是否开始成功
|
||||
"""
|
||||
update = self.updates.get(update_id)
|
||||
if not update:
|
||||
return False
|
||||
|
||||
if update.status != "pending":
|
||||
self.logger.warning(f"Update {update_id} is not in pending state")
|
||||
return False
|
||||
|
||||
update.status = "downloading"
|
||||
update.started_at = datetime.now()
|
||||
self.logger.info(f"Started OTA update: {update_id}")
|
||||
|
||||
return True
|
||||
|
||||
def update_progress(self, update_id: str, progress: int, error_message: Optional[str] = None) -> bool:
|
||||
"""
|
||||
更新OTA升级进度
|
||||
|
||||
Args:
|
||||
update_id: 更新ID
|
||||
progress: 进度百分比
|
||||
error_message: 错误信息
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
update = self.updates.get(update_id)
|
||||
if not update:
|
||||
return False
|
||||
|
||||
update.progress = progress
|
||||
|
||||
if error_message:
|
||||
update.error_message = error_message
|
||||
update.status = "failed"
|
||||
self.logger.error(f"OTA update {update_id} failed: {error_message}")
|
||||
elif progress >= 100:
|
||||
update.status = "installed"
|
||||
update.completed_at = datetime.now()
|
||||
self.logger.info(f"OTA update {update_id} completed successfully")
|
||||
else:
|
||||
# 保持下载中状态
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def cancel_update(self, update_id: str) -> bool:
|
||||
"""
|
||||
取消OTA升级
|
||||
|
||||
Args:
|
||||
update_id: 更新ID
|
||||
|
||||
Returns:
|
||||
bool: 是否取消成功
|
||||
"""
|
||||
update = self.updates.get(update_id)
|
||||
if not update:
|
||||
return False
|
||||
|
||||
if update.status in ["installed", "failed"]:
|
||||
self.logger.warning(f"Cannot cancel completed update {update_id}")
|
||||
return False
|
||||
|
||||
update.status = "failed"
|
||||
update.error_message = "Cancelled by user"
|
||||
update.completed_at = datetime.now()
|
||||
|
||||
self.logger.info(f"Cancelled OTA update: {update_id}")
|
||||
return True
|
||||
|
||||
def get_update_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取OTA统计信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 统计信息
|
||||
"""
|
||||
total = len(self.updates)
|
||||
pending = sum(1 for u in self.updates.values() if u.status == "pending")
|
||||
downloading = sum(1 for u in self.updates.values() if u.status == "downloading")
|
||||
installed = sum(1 for u in self.updates.values() if u.status == "installed")
|
||||
failed = sum(1 for u in self.updates.values() if u.status == "failed")
|
||||
|
||||
return {
|
||||
"total_updates": total,
|
||||
"pending": pending,
|
||||
"downloading": downloading,
|
||||
"installed": installed,
|
||||
"failed": failed
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
数据模型定义
|
||||
定义水务管理系统的各种数据结构
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
class DataType(Enum):
|
||||
"""数据类型枚举"""
|
||||
LL = "LL" # 流量
|
||||
YL = "YL" # 压力
|
||||
SW = "SW" # 水位
|
||||
ZD = "ZD" # 浊度
|
||||
PH = "PH" # pH值
|
||||
WD = "WD" # 温度
|
||||
DD = "DD" # 电导率
|
||||
YD = "YD" # 硬度
|
||||
|
||||
class AlertLevel(Enum):
|
||||
"""警报级别枚举"""
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
CRITICAL = "critical"
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""设备模型"""
|
||||
id: str
|
||||
name: str
|
||||
device_type: str
|
||||
location: str
|
||||
description: Optional[str] = None
|
||||
install_date: Optional[datetime] = None
|
||||
status: str = "active" # active, inactive, maintenance
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class SensorData:
|
||||
"""传感器数据模型"""
|
||||
id: str
|
||||
device_id: str
|
||||
data_type: DataType
|
||||
value: float
|
||||
unit: str
|
||||
timestamp: datetime
|
||||
location: str
|
||||
quality_score: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"device_id": self.device_id,
|
||||
"data_type": self.data_type.value,
|
||||
"value": self.value,
|
||||
"unit": self.unit,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"location": self.location,
|
||||
"quality_score": self.quality_score,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SensorData':
|
||||
"""从字典创建对象"""
|
||||
return cls(
|
||||
id=data["id"],
|
||||
device_id=data["device_id"],
|
||||
data_type=DataType(data["data_type"]),
|
||||
value=float(data["value"]),
|
||||
unit=data.get("unit", ""),
|
||||
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||
location=data.get("location", ""),
|
||||
quality_score=float(data.get("quality_score", 1.0)),
|
||||
metadata=data.get("metadata", {})
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class Alert:
|
||||
"""警报模型"""
|
||||
id: str
|
||||
device_id: str
|
||||
alert_type: str
|
||||
level: AlertLevel
|
||||
message: str
|
||||
timestamp: datetime
|
||||
resolved: bool = False
|
||||
resolved_by: Optional[str] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"device_id": self.device_id,
|
||||
"alert_type": self.alert_type,
|
||||
"level": self.level.value,
|
||||
"message": self.message,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"resolved": self.resolved,
|
||||
"resolved_by": self.resolved_by,
|
||||
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class BatchImport:
|
||||
"""批量导入记录模型"""
|
||||
id: str
|
||||
batch_id: str
|
||||
data_source: str
|
||||
total_records: int
|
||||
successful_records: int
|
||||
failed_records: int
|
||||
status: str # pending, processing, completed, failed
|
||||
file_name: Optional[str] = None
|
||||
import_time: Optional[datetime] = None
|
||||
completed_time: Optional[datetime] = None
|
||||
error_messages: List[str] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"batch_id": self.batch_id,
|
||||
"data_source": self.data_source,
|
||||
"total_records": self.total_records,
|
||||
"successful_records": self.successful_records,
|
||||
"failed_records": self.failed_records,
|
||||
"status": self.status,
|
||||
"file_name": self.file_name,
|
||||
"import_time": self.import_time.isoformat() if self.import_time else None,
|
||||
"completed_time": self.completed_time.isoformat() if self.completed_time else None,
|
||||
"error_messages": self.error_messages,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class APIRequest:
|
||||
"""API请求模型"""
|
||||
id: str
|
||||
method: str
|
||||
endpoint: str
|
||||
params: Dict[str, Any]
|
||||
headers: Dict[str, Any]
|
||||
body: Optional[Any] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
response_code: Optional[int] = None
|
||||
response_time_ms: Optional[float] = None
|
||||
response_body: Optional[Any] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"method": self.method,
|
||||
"endpoint": self.endpoint,
|
||||
"params": self.params,
|
||||
"headers": self.headers,
|
||||
"body": self.body,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"response_code": self.response_code,
|
||||
"response_time_ms": self.response_time_ms,
|
||||
"response_body": self.response_body
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class WebSocketConnection:
|
||||
"""WebSocket连接模型"""
|
||||
id: str
|
||||
client_ip: str
|
||||
connected_at: datetime
|
||||
disconnected_at: Optional[datetime] = None
|
||||
subscriptions: List[str] = field(default_factory=list)
|
||||
message_count: int = 0
|
||||
last_message_at: Optional[datetime] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"client_ip": self.client_ip,
|
||||
"connected_at": self.connected_at.isoformat(),
|
||||
"disconnected_at": self.disconnected_at.isoformat() if self.disconnected_at else None,
|
||||
"subscriptions": self.subscriptions,
|
||||
"message_count": self.message_count,
|
||||
"last_message_at": self.last_message_at.isoformat() if self.last_message_at else None,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class SystemStats:
|
||||
"""系统统计模型"""
|
||||
timestamp: datetime
|
||||
total_records: int
|
||||
total_devices: int
|
||||
active_connections: int
|
||||
api_requests_count: int
|
||||
alerts_count: int
|
||||
data_quality_score: float
|
||||
memory_usage_mb: float
|
||||
cpu_usage_percent: float
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"total_records": self.total_records,
|
||||
"total_devices": self.total_devices,
|
||||
"active_connections": self.active_connections,
|
||||
"api_requests_count": self.api_requests_count,
|
||||
"alerts_count": self.alerts_count,
|
||||
"data_quality_score": self.data_quality_score,
|
||||
"memory_usage_mb": self.memory_usage_mb,
|
||||
"cpu_usage_percent": self.cpu_usage_percent
|
||||
}
|
||||
|
||||
class DataValidator:
|
||||
"""数据验证器"""
|
||||
|
||||
@staticmethod
|
||||
def validate_sensor_data(data: Dict[str, Any]) -> List[str]:
|
||||
"""验证传感器数据"""
|
||||
errors = []
|
||||
|
||||
# 必需字段检查
|
||||
required_fields = ["device_id", "data_type", "value", "location"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
errors.append(f"缺少必需字段: {field}")
|
||||
|
||||
# 数据类型验证
|
||||
if "data_type" in data:
|
||||
try:
|
||||
DataType(data["data_type"])
|
||||
except ValueError:
|
||||
errors.append(f"无效的数据类型: {data['data_type']}")
|
||||
|
||||
# 数值验证
|
||||
if "value" in data:
|
||||
try:
|
||||
value = float(data["value"])
|
||||
# 根据数据类型进行数值范围检查
|
||||
data_type = data.get("data_type")
|
||||
if data_type == "LL" and value < 0:
|
||||
errors.append("流量不能为负数")
|
||||
elif data_type == "YL" and value < 0:
|
||||
errors.append("压力不能为负数")
|
||||
elif data_type == "SW" and value < 0:
|
||||
errors.append("水位不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"无效的数值: {data['value']}")
|
||||
|
||||
# 时间戳验证
|
||||
if "timestamp" in data:
|
||||
try:
|
||||
if isinstance(data["timestamp"], str):
|
||||
datetime.fromisoformat(data["timestamp"])
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"无效的时间戳格式: {data['timestamp']}")
|
||||
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def validate_device_data(data: Dict[str, Any]) -> List[str]:
|
||||
"""验证设备数据"""
|
||||
errors = []
|
||||
|
||||
# 必需字段检查
|
||||
required_fields = ["id", "name", "device_type", "location"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
errors.append(f"缺少必需字段: {field}")
|
||||
|
||||
# 设备ID格式验证
|
||||
if "id" in data:
|
||||
device_id = data["id"]
|
||||
if not isinstance(device_id, str) or not device_id.strip():
|
||||
errors.append("设备ID不能为空")
|
||||
elif len(device_id) > 50:
|
||||
errors.append("设备ID长度不能超过50个字符")
|
||||
|
||||
# 状态验证
|
||||
if "status" in data and data["status"] not in ["active", "inactive", "maintenance"]:
|
||||
errors.append("设备状态必须是: active, inactive, maintenance")
|
||||
|
||||
return errors
|
||||
|
||||
@staticmethod
|
||||
def validate_alert_data(data: Dict[str, Any]) -> List[str]:
|
||||
"""验证警报数据"""
|
||||
errors = []
|
||||
|
||||
# 必需字段检查
|
||||
required_fields = ["device_id", "alert_type", "level", "message"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
errors.append(f"缺少必需字段: {field}")
|
||||
|
||||
# 警报级别验证
|
||||
if "level" in data:
|
||||
try:
|
||||
AlertLevel(data["level"])
|
||||
except ValueError:
|
||||
errors.append(f"无效的警报级别: {data['level']}")
|
||||
|
||||
return errors
|
||||
|
||||
# 全局验证器实例
|
||||
validator = DataValidator()
|
||||
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
数据处理工具模块
|
||||
提供数据验证、转换、格式化等工具函数
|
||||
"""
|
||||
import json
|
||||
import csv
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DataConverter:
|
||||
"""数据转换器"""
|
||||
|
||||
# 水利行业标准字段映射
|
||||
FIELD_MAPPING = {
|
||||
"流量": "LL",
|
||||
"压力": "YL",
|
||||
"水位": "SW",
|
||||
"浊度": "ZD",
|
||||
"pH值": "PH",
|
||||
"温度": "WD",
|
||||
"电导率": "DD",
|
||||
"硬度": "YD",
|
||||
# 支持常见的中文字段名
|
||||
"流量计": "LL",
|
||||
"压力表": "YL",
|
||||
"水位计": "SW",
|
||||
"浊度仪": "ZD",
|
||||
"pH计": "PH",
|
||||
"温度计": "WD",
|
||||
"电导率仪": "DD",
|
||||
"硬度计": "YD"
|
||||
}
|
||||
|
||||
# 单位转换
|
||||
UNIT_CONVERSIONS = {
|
||||
# 流量单位转换 (m³/h)
|
||||
"m³/h": 1.0,
|
||||
"L/s": 3.6, # L/s = m³/h / 1000 * 3600
|
||||
"m³/d": 1/24, # m³/d = m³/h / 24
|
||||
"L/min": 1/60, # L/min = m³/h / 1000 * 60
|
||||
|
||||
# 压力单位转换 (MPa)
|
||||
"MPa": 1.0,
|
||||
"kPa": 0.001, # kPa = MPa / 1000
|
||||
"bar": 0.1, # bar = MPa * 10
|
||||
"kgf/cm²": 0.0980665, # kgf/cm² = MPa / 0.0980665
|
||||
|
||||
# 水位单位转换 (m)
|
||||
"m": 1.0,
|
||||
"cm": 0.01, # cm = m / 100
|
||||
"mm": 0.001, # mm = m / 1000
|
||||
|
||||
# 浊度单位转换 (NTU)
|
||||
"NTU": 1.0,
|
||||
"FNU": 1.0, # FNU ≈ NTU
|
||||
|
||||
# pH值单位转换
|
||||
"pH": 1.0,
|
||||
|
||||
# 温度单位转换 (°C)
|
||||
"°C": 1.0,
|
||||
"K": 1.0, # 相对差值
|
||||
"°F": lambda x: (x - 32) / 1.8, # °F to °C
|
||||
|
||||
# 电导率单位转换 (μS/cm)
|
||||
"μS/cm": 1.0,
|
||||
"mS/cm": 1000, # mS/cm = μS/cm * 1000
|
||||
"S/m": 10000 # S/m = μS/cm * 100
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_field_name(cls, field_name: str) -> str:
|
||||
"""标准化字段名"""
|
||||
if not field_name:
|
||||
return ""
|
||||
|
||||
field_name = field_name.strip().upper()
|
||||
|
||||
# 如果已经是标准格式,直接返回
|
||||
if field_name in cls.FIELD_MAPPING.values():
|
||||
return field_name
|
||||
|
||||
# 查映射表
|
||||
if field_name in cls.FIELD_MAPPING:
|
||||
return cls.FIELD_MAPPING[field_name]
|
||||
|
||||
# 英文映射
|
||||
english_mapping = {
|
||||
"flow": "LL",
|
||||
"pressure": "YL",
|
||||
"level": "SW",
|
||||
"turbidity": "ZD",
|
||||
"ph": "PH",
|
||||
"temperature": "WD",
|
||||
"conductivity": "DD",
|
||||
"hardness": "YD"
|
||||
}
|
||||
|
||||
if field_name.lower() in english_mapping:
|
||||
return english_mapping[field_name.lower()]
|
||||
|
||||
return field_name
|
||||
|
||||
@classmethod
|
||||
def convert_unit(cls, value: float, from_unit: str, to_unit: str) -> float:
|
||||
"""单位转换"""
|
||||
if from_unit == to_unit:
|
||||
return value
|
||||
|
||||
if from_unit not in cls.UNIT_CONVERSIONS:
|
||||
raise ValueError(f"不支持的单位: {from_unit}")
|
||||
|
||||
if to_unit not in cls.UNIT_CONVERSIONS:
|
||||
raise ValueError(f"不支持的目标单位: {to_unit}")
|
||||
|
||||
from_conv = cls.UNIT_CONVERSIONS[from_unit]
|
||||
to_conv = cls.UNIT_CONVERSIONS[to_unit]
|
||||
|
||||
if callable(from_conv):
|
||||
value = from_conv(value)
|
||||
|
||||
if callable(to_conv):
|
||||
return value / to_conv
|
||||
else:
|
||||
return value * (to_conv / from_conv)
|
||||
|
||||
@classmethod
|
||||
def validate_sensor_data(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""验证传感器数据"""
|
||||
errors = []
|
||||
validated_data = {}
|
||||
|
||||
# 必需字段验证
|
||||
required_fields = ["device_id", "data_type", "value"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
errors.append(f"缺少必需字段: {field}")
|
||||
else:
|
||||
validated_data[field] = data[field]
|
||||
|
||||
# 数据类型验证和标准化
|
||||
if "data_type" in validated_data:
|
||||
original_type = validated_data["data_type"]
|
||||
validated_data["data_type"] = cls.normalize_field_name(original_type)
|
||||
|
||||
if validated_data["data_type"] != original_type:
|
||||
logger.info(f"字段名标准化: {original_type} -> {validated_data['data_type']}")
|
||||
|
||||
# 数值验证
|
||||
if "value" in validated_data:
|
||||
try:
|
||||
validated_data["value"] = float(validated_data["value"])
|
||||
# 检查数值范围
|
||||
data_type = validated_data.get("data_type", "")
|
||||
if data_type == "LL" and validated_data["value"] < 0:
|
||||
errors.append("流量不能为负数")
|
||||
elif data_type == "YL" and validated_data["value"] < 0:
|
||||
errors.append("压力不能为负数")
|
||||
elif data_type == "SW" and validated_data["value"] < 0:
|
||||
errors.append("水位不能为负数")
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"无效的数值: {validated_data['value']}")
|
||||
|
||||
# 地点验证
|
||||
if "location" not in validated_data or not validated_data["location"]:
|
||||
validated_data["location"] = "未知"
|
||||
|
||||
# 时间戳处理
|
||||
if "timestamp" in data:
|
||||
try:
|
||||
if isinstance(data["timestamp"], str):
|
||||
validated_data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
||||
else:
|
||||
validated_data["timestamp"] = data["timestamp"]
|
||||
except (ValueError, TypeError):
|
||||
validated_data["timestamp"] = datetime.now()
|
||||
else:
|
||||
validated_data["timestamp"] = datetime.now()
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"data": validated_data,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
class DataFormatter:
|
||||
"""数据格式化器"""
|
||||
|
||||
@staticmethod
|
||||
def format_sensor_data(data: Dict[str, Any], format_type: str = "json") -> str:
|
||||
"""格式化传感器数据"""
|
||||
if format_type == "json":
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
elif format_type == "csv":
|
||||
# CSV格式只包含关键字段
|
||||
csv_fields = ["device_id", "data_type", "value", "location", "timestamp"]
|
||||
csv_data = {k: data.get(k, "") for k in csv_fields}
|
||||
import io
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=csv_fields)
|
||||
writer.writeheader()
|
||||
writer.writerow(csv_data)
|
||||
return output.getvalue()
|
||||
else:
|
||||
raise ValueError(f"不支持的格式类型: {format_type}")
|
||||
|
||||
@staticmethod
|
||||
def format_statistics(stats: Dict[str, Any], format_type: str = "text") -> str:
|
||||
"""格式化统计数据"""
|
||||
if format_type == "json":
|
||||
return json.dumps(stats, ensure_ascii=False, indent=2)
|
||||
elif format_type == "text":
|
||||
lines = ["数据统计报告", "=" * 20]
|
||||
lines.append(f"总记录数: {stats.get('total_records', 0)}")
|
||||
|
||||
if "by_type" in stats:
|
||||
lines.append("\n按数据类型统计:")
|
||||
for data_type, count in stats["by_type"].items():
|
||||
lines.append(f" {data_type}: {count} 条")
|
||||
|
||||
if "by_device" in stats:
|
||||
lines.append("\n按设备统计:")
|
||||
for device_id, count in list(stats["by_device"].items())[:10]: # 只显示前10个
|
||||
lines.append(f" {device_id}: {count} 条")
|
||||
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
raise ValueError(f"不支持的格式类型: {format_type}")
|
||||
|
||||
class DataHasher:
|
||||
"""数据哈希工具"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_data_hash(data: Dict[str, Any]) -> str:
|
||||
"""计算数据哈希值"""
|
||||
# 将数据转换为字符串
|
||||
data_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
# 计算MD5哈希
|
||||
hash_md5 = hashlib.md5(data_str.encode())
|
||||
return hash_md5.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def generate_data_id(device_id: str, data_type: str, timestamp: datetime) -> str:
|
||||
"""生成数据ID"""
|
||||
# 使用设备ID、数据类型和时间戳生成唯一ID
|
||||
time_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
hash_input = f"{device_id}_{data_type}_{time_str}"
|
||||
hash_md5 = hashlib.md5(hash_input.encode())
|
||||
return f"{data_type}_{device_id}_{hash_md5.hexdigest()[:8]}"
|
||||
|
||||
class DataQualityChecker:
|
||||
"""数据质量检查器"""
|
||||
|
||||
@staticmethod
|
||||
def check_data_quality(records: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""检查数据质量"""
|
||||
quality_report = {
|
||||
"total_records": len(records),
|
||||
"valid_records": 0,
|
||||
"invalid_records": 0,
|
||||
"quality_score": 0,
|
||||
"issues": [],
|
||||
"statistics": {}
|
||||
}
|
||||
|
||||
if not records:
|
||||
quality_report["quality_score"] = 0
|
||||
return quality_report
|
||||
|
||||
valid_records = []
|
||||
|
||||
for record in records:
|
||||
issues = []
|
||||
|
||||
# 检查必需字段
|
||||
required_fields = ["device_id", "data_type", "value"]
|
||||
for field in required_fields:
|
||||
if field not in record or not record[field]:
|
||||
issues.append(f"缺少必需字段: {field}")
|
||||
|
||||
# 检查数据类型
|
||||
if "data_type" in record and record["data_type"]:
|
||||
valid_types = ["LL", "YL", "SW", "ZD", "PH", "WD", "DD", "YD"]
|
||||
if record["data_type"] not in valid_types:
|
||||
issues.append(f"无效的数据类型: {record['data_type']}")
|
||||
|
||||
# 检查数值范围
|
||||
if "value" in record and record["value"]:
|
||||
try:
|
||||
value = float(record["value"])
|
||||
data_type = record.get("data_type", "")
|
||||
|
||||
if data_type == "LL" and value < 0:
|
||||
issues.append("流量不能为负数")
|
||||
elif data_type == "YL" and value < 0:
|
||||
issues.append("压力不能为负数")
|
||||
elif data_type == "SW" and value < 0:
|
||||
issues.append("水位不能为负数")
|
||||
|
||||
# 检查异常值
|
||||
if data_type == "LL" and value > 10000:
|
||||
issues.append("流量值异常大")
|
||||
elif data_type == "YL" and value > 10:
|
||||
issues.append("压力值异常大")
|
||||
|
||||
except (ValueError, TypeError):
|
||||
issues.append("无效的数值格式")
|
||||
|
||||
if not issues:
|
||||
valid_records.append(record)
|
||||
quality_report["valid_records"] += 1
|
||||
else:
|
||||
quality_report["invalid_records"] += 1
|
||||
quality_report["issues"].extend(issues)
|
||||
|
||||
# 计算质量分数
|
||||
quality_report["quality_score"] = quality_report["valid_records"] / len(records)
|
||||
|
||||
# 统计信息
|
||||
if records:
|
||||
quality_report["statistics"] = {
|
||||
"completeness": quality_report["valid_records"] / len(records),
|
||||
"uniqueness": len(set(r.get("device_id", "") for r in valid_records)) / len(valid_records) if valid_records else 0,
|
||||
"timeliness": quality_report.calculate_timeliness(records)
|
||||
}
|
||||
|
||||
return quality_report
|
||||
|
||||
@staticmethod
|
||||
def calculate_timeliness(records: List[Dict[str, Any]]) -> float:
|
||||
"""计算数据及时性(24小时内的数据比例)"""
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
recent_count = 0
|
||||
|
||||
for record in records:
|
||||
timestamp = record.get("timestamp")
|
||||
if timestamp:
|
||||
try:
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = datetime.fromisoformat(timestamp)
|
||||
|
||||
time_diff = now - timestamp
|
||||
if time_diff <= timedelta(hours=24):
|
||||
recent_count += 1
|
||||
except:
|
||||
pass
|
||||
|
||||
return recent_count / len(records)
|
||||
|
||||
class DataExporter:
|
||||
"""数据导出工具"""
|
||||
|
||||
@staticmethod
|
||||
def export_to_csv(records: List[Dict[str, Any]], file_path: str) -> bool:
|
||||
"""导出为CSV文件"""
|
||||
try:
|
||||
if not records:
|
||||
return False
|
||||
|
||||
# 获取所有字段
|
||||
all_fields = set()
|
||||
for record in records:
|
||||
all_fields.update(record.keys())
|
||||
|
||||
# 排序字段
|
||||
field_order = ["device_id", "data_type", "value", "location", "timestamp"]
|
||||
for field in all_fields:
|
||||
if field not in field_order:
|
||||
field_order.append(field)
|
||||
|
||||
with open(file_path, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.DictWriter(csvfile, fieldnames=field_order)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导出CSV失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def export_to_json(records: List[Dict[str, Any]], file_path: str) -> bool:
|
||||
"""导出为JSON文件"""
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as jsonfile:
|
||||
json.dump(records, jsonfile, ensure_ascii=False, indent=2, default=str)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"导出JSON失败: {str(e)}")
|
||||
return False
|
||||
|
||||
# 全局工具实例
|
||||
data_converter = DataConverter()
|
||||
data_formatter = DataFormatter()
|
||||
data_hasher = DataHasher()
|
||||
quality_checker = DataQualityChecker()
|
||||
data_exporter = DataExporter()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
WebSocket 实时数据推送服务器
|
||||
支持实时数据推送、连接管理和数据广播
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
from datetime import datetime
|
||||
from typing import Set, Dict, Any
|
||||
import logging
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebSocketServer:
|
||||
"""WebSocket服务器类"""
|
||||
|
||||
def __init__(self, host: str = "0.0.0.0", port: int = 8765):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.clients: Set[websockets.WebSocketServerProtocol] = set()
|
||||
self.data_history: list = [] # 存储最近的数据用于新连接
|
||||
|
||||
async def register_client(self, websocket: websockets.WebSocketServerProtocol):
|
||||
"""注册新客户端"""
|
||||
self.clients.add(websocket)
|
||||
client_ip = websocket.remote_address[0]
|
||||
logger.info(f"新客户端连接: {client_ip}")
|
||||
|
||||
# 发送历史数据给新连接的客户端
|
||||
if self.data_history:
|
||||
await websocket.send(json.dumps({
|
||||
"type": "history",
|
||||
"data": self.data_history[-50:] # 发送最近50条数据
|
||||
}))
|
||||
|
||||
# 发送欢迎消息
|
||||
await websocket.send(json.dumps({
|
||||
"type": "welcome",
|
||||
"message": "已连接到水务管理系统实时数据服务器",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}))
|
||||
|
||||
async def unregister_client(self, websocket: websockets.WebSocketServerProtocol):
|
||||
"""注销客户端"""
|
||||
if websocket in self.clients:
|
||||
self.clients.remove(websocket)
|
||||
client_ip = websocket.remote_address[0]
|
||||
logger.info(f"客户端断开连接: {client_ip}")
|
||||
|
||||
async def broadcast_data(self, data: Dict[str, Any]):
|
||||
"""广播数据到所有连接的客户端"""
|
||||
if not self.clients:
|
||||
return
|
||||
|
||||
# 添加时间戳
|
||||
data["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
# 保存历史数据
|
||||
self.data_history.append(data)
|
||||
if len(self.data_history) > 1000: # 只保留最近1000条记录
|
||||
self.data_history.pop(0)
|
||||
|
||||
# 广播数据
|
||||
message = json.dumps(data)
|
||||
disconnected_clients = []
|
||||
|
||||
for client in self.clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
disconnected_clients.append(client)
|
||||
|
||||
# 清理已断开的连接
|
||||
for client in disconnected_clients:
|
||||
await self.unregister_client(client)
|
||||
|
||||
async def handle_client_message(self, websocket: websockets.WebSocketServerProtocol, message: str):
|
||||
"""处理客户端消息"""
|
||||
try:
|
||||
data = json.loads(message)
|
||||
|
||||
if data.get("type") == "subscribe":
|
||||
# 处理订阅请求
|
||||
subscription_type = data.get("subscription", "all")
|
||||
response = {
|
||||
"type": "subscription_ack",
|
||||
"subscription": subscription_type,
|
||||
"message": f"已订阅 {subscription_type} 类型数据"
|
||||
}
|
||||
await websocket.send(json.dumps(response))
|
||||
logger.info(f"客户端订阅了 {subscription_type} 类型数据")
|
||||
|
||||
elif data.get("type") == "ping":
|
||||
# 响应心跳检测
|
||||
response = {
|
||||
"type": "pong",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
await websocket.send(json.dumps(response))
|
||||
|
||||
else:
|
||||
logger.warning(f"未知的消息类型: {data.get('type', 'unknown')}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error("无效的JSON消息")
|
||||
except Exception as e:
|
||||
logger.error(f"处理客户端消息时出错: {str(e)}")
|
||||
|
||||
async def client_handler(self, websocket: websockets.WebSocketServerProtocol, path: str):
|
||||
"""处理客户端连接"""
|
||||
await self.register_client(websocket)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
await self.handle_client_message(websocket, message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
await self.unregister_client(websocket)
|
||||
|
||||
async def start_server(self):
|
||||
"""启动WebSocket服务器"""
|
||||
logger.info(f"启动WebSocket服务器: {self.host}:{self.port}")
|
||||
|
||||
# 创建并启动服务器
|
||||
self.server = await websockets.serve(
|
||||
self.client_handler,
|
||||
self.host,
|
||||
self.port
|
||||
)
|
||||
|
||||
logger.info("WebSocket服务器已启动")
|
||||
return self.server
|
||||
|
||||
async def send_sensor_data(self, sensor_data: Dict[str, Any]):
|
||||
"""发送传感器数据"""
|
||||
data = {
|
||||
"type": "sensor_data",
|
||||
"data_type": sensor_data.get("data_type"),
|
||||
"device_id": sensor_data.get("device_id"),
|
||||
"value": sensor_data.get("value"),
|
||||
"location": sensor_data.get("location"),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
await self.broadcast_data(data)
|
||||
|
||||
async def send_alert(self, alert_data: Dict[str, Any]):
|
||||
"""发送警报信息"""
|
||||
data = {
|
||||
"type": "alert",
|
||||
"level": alert_data.get("level", "warning"),
|
||||
"message": alert_data.get("message"),
|
||||
"device_id": alert_data.get("device_id"),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
await self.broadcast_data(data)
|
||||
|
||||
# 全局WebSocket服务器实例
|
||||
websocket_server = WebSocketServer()
|
||||
|
||||
# 示例数据生成器
|
||||
async def data_generator():
|
||||
"""模拟数据生成器"""
|
||||
import random
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(5) # 每5秒发送一次数据
|
||||
|
||||
# 模拟不同的传感器数据
|
||||
sensor_types = ["LL", "YL", "SW", "ZD"]
|
||||
sensor_type = random.choice(sensor_types)
|
||||
|
||||
# 根据传感器类型生成合理的数值范围
|
||||
if sensor_type == "LL": # 流量
|
||||
value = random.uniform(10, 100)
|
||||
elif sensor_type == "YL": # 压力
|
||||
value = random.uniform(0.1, 1.0)
|
||||
elif sensor_type == "SW": # 水位
|
||||
value = random.uniform(0, 10)
|
||||
else: # ZD 浊度
|
||||
value = random.uniform(0, 50)
|
||||
|
||||
sensor_data = {
|
||||
"data_type": sensor_type,
|
||||
"device_id": f"device_{random.randint(1, 10)}",
|
||||
"value": round(value, 2),
|
||||
"location": random.choice(["A区", "B区", "C区", "D区"])
|
||||
}
|
||||
|
||||
await websocket_server.send_sensor_data(sensor_data)
|
||||
|
||||
# 启动服务器和生成器
|
||||
async def main():
|
||||
"""主函数"""
|
||||
# 启动WebSocket服务器
|
||||
server = await websocket_server.start_server()
|
||||
|
||||
# 启动数据生成器
|
||||
generator_task = asyncio.create_task(data_generator())
|
||||
|
||||
# 保持服务器运行
|
||||
try:
|
||||
await asyncio.Future() # 永远等待
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到中断信号,正在关闭服务器...")
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
generator_task.cancel()
|
||||
await generator_task
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
IoT模块测试脚本
|
||||
用于验证MQTT适配器、设备管理器和API功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from src.iot.device_manager import DeviceManager
|
||||
from src.iot.mqtt_adapter import MqttAdapter
|
||||
from src.iot.device_controller import DeviceController
|
||||
from src.iot.models import DeviceType, DeviceStatus
|
||||
|
||||
|
||||
async def test_device_manager():
|
||||
"""测试设备管理器"""
|
||||
print("=== 测试设备管理器 ===")
|
||||
|
||||
device_manager = DeviceManager()
|
||||
|
||||
# 注册设备
|
||||
device_data = {
|
||||
'device_sn': 'LL-001',
|
||||
'device_type': 'flow_meter',
|
||||
'name': '流量计-001',
|
||||
'description': 'A区入口流量计',
|
||||
'area': 'A区',
|
||||
'position': '入口处',
|
||||
'manufacturer': '华为',
|
||||
'model': 'LL-100'
|
||||
}
|
||||
|
||||
device = device_manager.register_device(device_data)
|
||||
print(f"注册设备: {device.device_sn} - {device.name}")
|
||||
|
||||
# 获取设备
|
||||
retrieved_device = device_manager.get_device('LL-001')
|
||||
print(f"获取设备: {retrieved_device.name}")
|
||||
|
||||
# 更新设备
|
||||
updated_device = device_manager.update_device('LL-001', {'status': DeviceStatus.ONLINE})
|
||||
print(f"更新设备状态: {updated_device.status}")
|
||||
|
||||
# 列出设备
|
||||
devices = device_manager.list_devices()
|
||||
print(f"设备列表: {len(devices)}个设备")
|
||||
|
||||
# 更新设备影子
|
||||
device_manager.update_device_shadow('LL-001', {'temperature': 25.5, 'pressure': 0.8})
|
||||
shadow = device_manager.get_device_shadow('LL-001')
|
||||
print(f"设备影子: {shadow.state}")
|
||||
|
||||
# 获取统计信息
|
||||
stats = device_manager.get_device_statistics()
|
||||
print(f"设备统计: {stats}")
|
||||
|
||||
print("设备管理器测试完成\n")
|
||||
|
||||
|
||||
async def test_mqtt_adapter():
|
||||
"""测试MQTT适配器"""
|
||||
print("=== 测试MQTT适配器 ===")
|
||||
|
||||
# 创建MQTT适配器(不实际连接)
|
||||
mqtt_adapter = MqttAdapter(
|
||||
broker_host="localhost",
|
||||
broker_port=1883,
|
||||
client_id="test-client"
|
||||
)
|
||||
|
||||
# 测试消息发布
|
||||
test_payload = {"message": "Hello IoT", "timestamp": "2024-01-01T00:00:00"}
|
||||
success = mqtt_adapter.publish("test/topic", test_payload)
|
||||
print(f"消息发布测试: {'成功' if success else '失败'}")
|
||||
|
||||
# 测试连接状态
|
||||
status = mqtt_adapter.get_connection_status()
|
||||
print(f"MQTT状态: {status}")
|
||||
|
||||
print("MQTT适配器测试完成\n")
|
||||
|
||||
|
||||
async def test_device_controller():
|
||||
"""测试设备控制器"""
|
||||
print("=== 测试设备控制器 ===")
|
||||
|
||||
# 创建组件
|
||||
device_manager = DeviceManager()
|
||||
mqtt_adapter = MqttAdapter()
|
||||
device_controller = DeviceController(device_manager, mqtt_adapter)
|
||||
|
||||
# 注册一些测试设备
|
||||
test_devices = [
|
||||
{
|
||||
'device_sn': 'LL-001',
|
||||
'device_type': 'flow_meter',
|
||||
'name': '流量计-001',
|
||||
'area': 'A区',
|
||||
'manufacturer': '华为'
|
||||
},
|
||||
{
|
||||
'device_sn': 'YL-001',
|
||||
'device_type': 'pressure_meter',
|
||||
'name': '压力表-001',
|
||||
'area': 'B区',
|
||||
'manufacturer': '西门子'
|
||||
}
|
||||
]
|
||||
|
||||
for device_data in test_devices:
|
||||
device_manager.register_device(device_data)
|
||||
|
||||
# 模拟API请求
|
||||
print("测试设备注册:")
|
||||
print(f"已注册设备数量: {len(device_manager.devices)}")
|
||||
|
||||
# 模拟设备发现
|
||||
discovered = device_manager.discover_devices()
|
||||
print(f"发现设备数量: {len(discovered)}")
|
||||
|
||||
print("设备控制器测试完成\n")
|
||||
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("开始 IoT 模块测试...\n")
|
||||
|
||||
try:
|
||||
# 测试各个组件
|
||||
await test_device_manager()
|
||||
await test_mqtt_adapter()
|
||||
await test_device_controller()
|
||||
|
||||
print("✅ 所有测试完成!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
IoT模块简化测试脚本
|
||||
仅测试设备管理器功能,不依赖MQTT
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from src.iot.device_manager import DeviceManager
|
||||
from src.iot.models import DeviceType, DeviceStatus
|
||||
|
||||
|
||||
async def test_device_manager():
|
||||
"""测试设备管理器"""
|
||||
print("=== 测试设备管理器 ===")
|
||||
|
||||
device_manager = DeviceManager()
|
||||
|
||||
# 注册设备
|
||||
device_data = {
|
||||
'device_sn': 'LL-001',
|
||||
'device_type': 'flow_meter',
|
||||
'name': '流量计-001',
|
||||
'description': 'A区入口流量计',
|
||||
'area': 'A区',
|
||||
'position': '入口处',
|
||||
'manufacturer': '华为',
|
||||
'model': 'LL-100'
|
||||
}
|
||||
|
||||
device = device_manager.register_device(device_data)
|
||||
print(f"注册设备: {device.device_sn} - {device.name}")
|
||||
|
||||
# 获取设备
|
||||
retrieved_device = device_manager.get_device('LL-001')
|
||||
print(f"获取设备: {retrieved_device.name}")
|
||||
|
||||
# 更新设备
|
||||
updated_device = device_manager.update_device('LL-001', {'status': DeviceStatus.ONLINE})
|
||||
print(f"更新设备状态: {updated_device.status}")
|
||||
|
||||
# 列出设备
|
||||
devices = device_manager.list_devices()
|
||||
print(f"设备列表: {len(devices)}个设备")
|
||||
|
||||
# 更新设备影子
|
||||
device_manager.update_device_shadow('LL-001', {'temperature': 25.5, 'pressure': 0.8})
|
||||
shadow = device_manager.get_device_shadow('LL-001')
|
||||
print(f"设备影子: {shadow.state}")
|
||||
|
||||
# 获取统计信息
|
||||
stats = device_manager.get_device_statistics()
|
||||
print(f"设备统计: {stats}")
|
||||
|
||||
# 测试设备发现
|
||||
discovered = device_manager.discover_devices()
|
||||
print(f"发现设备: {len(discovered)}个设备")
|
||||
|
||||
print("设备管理器测试完成\n")
|
||||
|
||||
|
||||
async def test_device_filtering():
|
||||
"""测试设备过滤功能"""
|
||||
print("=== 测试设备过滤 ===")
|
||||
|
||||
device_manager = DeviceManager()
|
||||
|
||||
# 注册多个设备
|
||||
test_devices = [
|
||||
{'device_sn': 'LL-001', 'device_type': 'flow_meter', 'name': '流量计-001', 'area': 'A区'},
|
||||
{'device_sn': 'YL-001', 'device_type': 'pressure_meter', 'name': '压力表-001', 'area': 'A区'},
|
||||
{'device_sn': 'SW-001', 'device_type': 'level_meter', 'name': '水位计-001', 'area': 'B区'},
|
||||
{'device_sn': 'LL-002', 'device_type': 'flow_meter', 'name': '流量计-002', 'area': 'B区'},
|
||||
]
|
||||
|
||||
for device_data in test_devices:
|
||||
device_manager.register_device(device_data)
|
||||
|
||||
# 测试按类型过滤
|
||||
flow_meters = device_manager.list_devices(device_type=DeviceType.FLOW_METER)
|
||||
print(f"流量计数量: {len(flow_meters)}")
|
||||
|
||||
# 测试按区域过滤
|
||||
a_zone_devices = device_manager.list_devices(area='A区')
|
||||
print(f"A区设备数量: {len(a_zone_devices)}")
|
||||
|
||||
# 测试按状态过滤
|
||||
online_devices = device_manager.list_devices(status=DeviceStatus.ONLINE)
|
||||
print(f"在线设备数量: {len(online_devices)}")
|
||||
|
||||
print("设备过滤测试完成\n")
|
||||
|
||||
|
||||
async def test_device_shadow():
|
||||
"""测试设备影子功能"""
|
||||
print("=== 测试设备影子 ===")
|
||||
|
||||
device_manager = DeviceManager()
|
||||
|
||||
# 注册设备
|
||||
device_data = {
|
||||
'device_sn': 'LL-001',
|
||||
'device_type': 'flow_meter',
|
||||
'name': '流量计-001'
|
||||
}
|
||||
device_manager.register_device(device_data)
|
||||
|
||||
# 更新设备影子
|
||||
shadow_data = {
|
||||
'temperature': 25.5,
|
||||
'pressure': 0.8,
|
||||
'flow_rate': 100.5
|
||||
}
|
||||
|
||||
success = device_manager.update_device_shadow('LL-001', shadow_data)
|
||||
print(f"影子更新成功: {success}")
|
||||
|
||||
# 获取设备影子
|
||||
shadow = device_manager.get_device_shadow('LL-001')
|
||||
print(f"设备影子状态: {shadow.state}")
|
||||
|
||||
print("设备影子测试完成\n")
|
||||
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("开始 IoT 模块简化测试...\n")
|
||||
|
||||
try:
|
||||
# 测试各个组件
|
||||
await test_device_manager()
|
||||
await test_device_filtering()
|
||||
await test_device_shadow()
|
||||
|
||||
print("✅ 所有测试完成!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user