提交 e05c975f authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim, element-locate): 设备模拟定时上报配置 + 元素定位优化

设备模拟模块:
- 新增定时上报配置功能(1秒-24小时灵活配置)
- 修复消息体格式符合 Mqtt_Send.py 真实设备格式
- 修复消息体被覆盖的关键 Bug
- 添加 topic_params 和 report_config 字段
- 优化启动设备异常处理

元素定位模块:
- 优化元素定位 API 接口
- 增强前端 API 调用逻辑

文档:
- 新增自然语言用例智能定位功能 PRD 和执行计划
- 新增设备模拟修复计划文档
- 新增设备模拟主题配置交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 4e31fa9b
# PRD — 自然语言用例智能定位功能
> **模块**: UI 自动化测试 — 智能元素定位
> **优先级**: P0
> **作者**: czj / Claude Code
> **创建日期**: 2026-08-05
> **最后修改**: 2026-08-05
---
## 一、需求背景
### 1.1 用户场景
用户在前端页面录入用例时,只需写自然语言步骤描述:
```
1. 访问登录页面
2. 输入用户名 admin@xty
3. 输入密码 Ubains@13579
4. 点击登录按钮
5. 点击功能中心
6. 点击信息发布
7. 点击新增按钮
```
系统自动访问被测系统,为每个步骤找到精确的定位元素,填充到用例中,使用例可直接执行。
### 1.2 当前问题
现有"获取定位"功能(`/api/element/locate-batch`)的工作方式:
```
自然语言步骤 → 提取页面所有元素 → Claude CLI 推断匹配 → 返回选择器
```
**问题**
| # | 问题 | 示例 |
|---|------|------|
| 1 | AI 推断选择器不准确 | 返回 `.el-tabs` 但页面实际没有这个元素 |
| 2 | 只返回一个选择器,无备选 | 主选择器失效后用例直接失败 |
| 3 | 没有真实执行验证 | 选择器看起来对但点击不到 |
| 4 | 登录流程处理不完善 | 缺少勾选协议步骤导致无法登录 |
### 1.3 已验证的方案
在 2026-08-05 的测试中,通过 Playwright 脚本自动访问被测系统,自动执行操作并提取选择器,生成"信息发布-导航验证"用例(12 步),**执行 12/12 全部通过**
验证结论:**Playwright 实际操作 + 提取选择器的方式是可靠的,不需要 AI 推断**
---
## 二、需求目标
### 2.1 核心目标
用户录入自然语言步骤后,系统自动访问被测系统,**实际执行操作**并提取精确选择器,使生成的用例可直接执行通过。
### 2.2 与现有"获取定位"的区别
| 对比项 | 现有获取定位 | 智能定位(本需求) |
|--------|------------|-------------------|
| 方式 | 提取所有元素 → AI 匹配 | 实际执行操作 → 提取选择器 |
| 准确性 | AI 推断,不稳定 | 实际验证,高准确 |
| Claude CLI | 必须依赖 | 不需要 |
| 登录处理 | 简单登录 | 完整登录(含协议勾选) |
| 选择器数量 | 每步 1 个 | 每步多个候选 |
| 验证方式 | 无 | 实际执行验证 |
### 2.3 成功指标
| 指标 | 目标值 |
|------|--------|
| 定位选择器准确率 | ≥ 95% |
| 用例首次执行通过率 | ≥ 90% |
| 是否需要 Claude CLI | 不需要 |
| 单步骤定位延迟 | ≤ 3s |
---
## 三、功能需求
### FR-01: 自然语言步骤解析
将用户输入的自然语言步骤解析为可执行的操作:
| 自然语言 | 解析结果 |
|----------|----------|
| 访问登录页面 | `navigate``url: https://192.168.5.44` |
| 输入用户名 admin@xty | `fill``selector: ?, value: admin@xty` |
| 输入密码 Ubains@13579 | `fill``selector: ?, value: Ubains@13579` |
| 点击登录按钮 | `click``selector: ?` |
| 等待页面加载 | `wait``selector: ?, timeout: 10000` |
| 点击功能中心 | `click``selector: ?` |
| 点击信息发布 | `click``selector: ?` |
**关键点**:解析后 `selector` 为空,需要通过实际访问被测系统来填充。
**解析策略**
- 关键词匹配动作类型:`输入`→fill,`点击`→click,`等待`→wait,`访问`→navigate,`选择`→select
- 提取操作值:`输入用户名 admin@xty` → value=`admin@xty`
- 提取目标元素描述:`点击登录按钮` → 目标=`登录按钮`
### FR-02: 自动访问被测系统并执行
系统使用 Playwright 自动访问被测系统,按步骤顺序执行操作,在执行过程中提取选择器。
**完整流程**
```
1. 启动 Playwright 浏览器
2. 执行登录模板(自动登录)
3. 按步骤顺序:
a. 根据自然语言描述,在当前页面查找匹配的元素
b. 实际执行操作(点击/填写/等待)
c. 提取精确选择器 + 候选选择器
d. 截图
e. 继续下一步
4. 关闭浏览器
5. 返回所有步骤的选择器
```
### FR-03: 元素查找策略
在每个步骤中,如何从自然语言描述找到页面上的目标元素:
**三级策略**(按优先级):
#### 第一级:关键词直接匹配(最快,无需 AI)
```python
def find_element_by_keyword(page, description):
"""通过关键词在页面元素中直接匹配"""
# 提取关键词
keywords = extract_keywords(description) # "登录按钮" → ["登录"]
# 遍历页面所有可交互元素
elements = page.locator('input, button, a, select, textarea, [role="button"]').all()
for el in elements:
# 匹配元素的文本内容
text = el.inner_text().strip()
if any(kw in text for kw in keywords):
return el, extract_selectors(el)
# 匹配 placeholder
placeholder = el.get_attribute('placeholder') or ''
if any(kw in placeholder for kw in keywords):
return el, extract_selectors(el)
# 匹配 aria-label
aria = el.get_attribute('aria-label') or ''
if any(kw in aria for kw in keywords):
return el, extract_selectors(el)
return None, None
```
**匹配规则**
| 自然语言描述 | 关键词提取 | 匹配目标 | 匹配方式 |
|-------------|-----------|---------|---------|
| 输入用户名 | ["用户名", "手机号"] | placeholder | 包含匹配 |
| 输入密码 | ["密码"] | type=password | 精确匹配 |
| 输入验证码 | ["验证码", "图形"] | placeholder | 包含匹配 |
| 点击登录按钮 | ["登录"] | button 文本 | 包含匹配 |
| 点击功能中心 | ["功能中心"] | 文本/id | 包含匹配 |
| 点击信息发布 | ["信息发布"] | 文本 | 包含匹配 |
| 点击新增按钮 | ["新增", "添加"] | button 文本 | 包含匹配 |
#### 第二级:语义推断匹配
当关键词无法直接匹配时,根据操作类型推断目标元素:
| 操作 | 推断规则 |
|------|---------|
| `fill` + 有 value | 找输入框(input/textarea),优先匹配 placeholder 包含步骤描述关键词的 |
| `click` + 包含"按钮" | 找 button,优先匹配文本包含关键词的 |
| `click` + 包含"菜单"/"导航" | 找菜单项/链接 |
| `wait` + 包含"页面" | 找容器元素(.container, .el-main, [class*="nav"]) |
| `click` + 包含"抽屉"/"弹窗" | 找 .el-drawer, .el-dialog |
#### 第三级:页面快照 + 元素列表回退
如果前两级都没匹配到,提取页面所有可交互元素列表,按操作类型过滤后选择最可能的元素。
### FR-04: 多候选选择器
每个步骤提取多种选择器,执行时按优先级回退:
```json
{
"order": 3,
"name": "输入用户名",
"action": "fill",
"params": {
"selector": "input[placeholder*=\"手机号\"]",
"value": "admin@xty"
},
"selectors": {
"primary": "input[placeholder*=\"手机号\"]",
"candidates": [
{"type": "css", "value": "input[placeholder*=\"手机号\"]", "confidence": 0.95},
{"type": "css", "value": "#login-username", "confidence": 0.90},
{"type": "xpath", "value": "//input[@placeholder[contains(.,'手机号')]]", "confidence": 0.80},
{"type": "text", "value": "手机号/用户名/邮箱", "confidence": 0.70}
]
},
"element_info": {
"tag": "INPUT",
"type": "text",
"placeholder": "手机号/用户名/邮箱",
"text": "",
"id": "",
"className": "el-input__inner"
}
}
```
### FR-05: 登录流程自动化
系统预置已验证的登录模板,自动处理登录(含协议勾选),用户无需手动配置登录步骤。
**预置登录模板(8 步,已验证通过)**
| 步骤 | 名称 | 操作 | 选择器 | 值 |
|------|------|------|--------|-----|
| 1 | 访问登录页面 | navigate | - | https://192.168.5.44 |
| 2 | 等待登录表单加载 | wait | `input[placeholder*="手机号"]` | timeout:10000 |
| 3 | 输入用户名 | fill | `input[placeholder*="手机号"]` | admin@xty |
| 4 | 输入密码 | fill | `input[type="password"]` | Ubains@13579 |
| 5 | 输入验证码 | fill | `input[placeholder*="图"]` | csba |
| 6 | 勾选协议复选框 | click | `.el-checkbox` | - |
| 7 | 点击登录按钮 | click | `button:has-text("登录")` | - |
| 8 | 等待登录跳转完成 | wait | `[class*='nav'], .container, .el-main` | timeout:15000 |
**功能中心导航模板(3 步,已验证通过)**
| 步骤 | 名称 | 操作 | 选择器 |
|------|------|------|--------|
| 9 | 点击功能中心图标 | click | `//*[@id="Home"]/div[1]/div[1]` |
| 10 | 等待功能抽屉打开 | wait | `.el-drawer` |
| 11 | 点击目标菜单 | click | `.el-drawer >> text="${菜单名}"` |
### FR-06: 定位结果展示与编辑
定位完成后,前端展示每个步骤的定位结果:
- ✅ 定位成功:显示主选择器 + 置信度 + 截图
- ⚠️ 定位不确定:显示候选选择器列表,用户可选择
- ❌ 定位失败:标记失败,用户可手动填写选择器
用户可编辑选择器后重新验证。
---
## 四、API 设计
### 4.1 智能定位 API
#### POST /api/element/smart-locate
**请求体**
```json
{
"steps": [
{"order": 1, "name": "访问登录页面", "action": "navigate", "params": {"url": "https://192.168.5.44"}},
{"order": 2, "name": "输入用户名", "action": "fill", "params": {"value": "admin@xty"}},
{"order": 3, "name": "输入密码", "action": "fill", "params": {"value": "Ubains@13579"}},
{"order": 4, "name": "点击登录按钮", "action": "click"},
{"order": 5, "name": "点击功能中心", "action": "click"},
{"order": 6, "name": "点击信息发布", "action": "click"},
{"order": 7, "name": "点击新增按钮", "action": "click"}
],
"auto_login": true,
"navigate_menu": "信息发布",
"page_url": "https://192.168.5.44"
}
```
**响应**
```json
{
"success": true,
"total_steps": 7,
"located_steps": 7,
"results": [
{
"order": 1,
"name": "访问登录页面",
"success": true,
"action": "navigate",
"params": {"url": "https://192.168.5.44"},
"selectors": {
"primary": null,
"candidates": []
},
"screenshot": "base64..."
},
{
"order": 2,
"name": "输入用户名",
"success": true,
"action": "fill",
"params": {
"selector": "input[placeholder*=\"手机号\"]",
"value": "admin@xty"
},
"selectors": {
"primary": "input[placeholder*=\"手机号\"]",
"candidates": [
{"type": "css", "value": "input[placeholder*=\"手机号\"]", "confidence": 0.95},
{"type": "xpath", "value": "//input[contains(@placeholder,'手机号')]", "confidence": 0.80}
]
},
"element_info": {
"tag": "INPUT",
"placeholder": "手机号/用户名/邮箱",
"type": "text"
},
"screenshot": "base64..."
}
],
"message": "成功定位 7/7 个步骤"
}
```
### 4.2 登录模板 API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/login-templates` | 获取所有登录模板 |
| POST | `/api/login-templates` | 创建登录模板 |
| PUT | `/api/login-templates/{id}` | 更新登录模板 |
| DELETE | `/api/login-templates/{id}` | 删除登录模板 |
---
## 五、核心算法
### 5.1 整体流程
```python
def smart_locate(steps, auto_login=True, navigate_menu=""):
"""智能定位主流程"""
# 1. 启动浏览器
executor = PlaywrightExecutor()
executor.start()
# 2. 自动登录(使用预置模板)
if auto_login:
executor.do_login() # 执行登录模板8步
# 3. 导航到目标菜单
if navigate_menu:
navigate_to_menu(executor, navigate_menu) # 功能中心3步
# 4. 逐步骤定位
results = []
for step in steps:
result = locate_step(executor, step)
results.append(result)
# 如果定位成功,实际执行操作(确保下一步在正确的页面状态)
if result["success"]:
execute_step(executor, result)
# 5. 关闭浏览器
executor.stop()
return results
```
### 5.2 单步骤定位
```python
def locate_step(executor, step):
"""为单个步骤定位元素"""
action = step["action"]
name = step["name"]
# navigate 类型不需要定位
if action == "navigate":
return {"success": True, "selectors": None}
# 提取关键词
keywords = extract_keywords(name)
# 第一级:关键词直接匹配
element, selectors = find_element_by_keyword(executor.page, keywords, action)
if element:
return {
"success": True,
"selectors": selectors,
"element_info": extract_element_info(element)
}
# 第二级:语义推断
element, selectors = find_element_by_semantic(executor.page, keywords, action)
if element:
return {
"success": True,
"selectors": selectors,
"element_info": extract_element_info(element)
}
# 第三级:回退
return {"success": False, "message": f"未找到匹配元素: {name}"}
```
### 5.3 关键词提取
```python
# 动作词表
ACTION_WORDS = ["点击", "输入", "选择", "等待", "访问", "勾选", "填写", "按下", "切换"]
# 元素类型词表
ELEMENT_WORDS = ["按钮", "输入框", "下拉", "菜单", "链接", "复选框", "表格", "标签", "图标"]
def extract_keywords(description):
"""从自然语言描述中提取关键词"""
# 去除动作词
text = description
for word in ACTION_WORDS + ELEMENT_WORDS:
text = text.replace(word, "")
# 分词(按空格、标点分割)
words = [w.strip() for w in re.split(r'[\s,,、]', text) if w.strip()]
return words
```
---
## 六、与现有"获取定位"的关系
### 6.1 替代关系
本功能**替代**现有的 `/api/element/locate-batch` 接口,不再需要 Claude CLI。
### 6.2 复用部分
| 组件 | 处理方式 |
|------|---------|
| `PlaywrightExecutor` | 复用,扩展 `do_login()``navigate_to_menu()` |
| `extract_interactive_elements()` | 复用,用于元素列表提取 |
| 前端配置弹窗 | 改造,去掉 Claude CLI 相关配置 |
| WebSocket 推送 | 复用,实时推送定位进度 |
### 6.3 不再需要
| 组件 | 原因 |
|------|------|
| `call_claude_batch_via_ssh()` | 不再依赖 Claude CLI |
| SSH 连接逻辑 | 不再需要连接宿主机 |
| AI 置信度判断 | 用实际执行验证代替 |
---
## 七、非功能需求
| # | 需求 | 说明 |
|---|------|------|
| 1 | 定位过程互斥 | 同一时间只能有一个定位任务运行 |
| 2 | 超时保护 | 单次定位最长 5 分钟 |
| 3 | 浏览器资源清理 | 定位完成后自动关闭浏览器 |
| 4 | 兼容现有用例格式 | 生成的步骤格式与现有用例完全兼容 |
---
## 八、约束与风险
| # | 风险 | 缓解措施 |
|---|------|----------|
| 1 | 关键词提取不准 | 多级匹配策略 + 语义推断 + 回退 |
| 2 | 微前端 shadowRoot 内元素 | 遍历 shadowRoot + micro-app 容器 |
| 3 | iframe 内元素 | 注入脚本到 iframe contentDocument |
| 4 | Windows 同步 API 约束 | 使用 sync_playwright + 线程池 |
| 5 | 页面动态加载 | 每步操作后等待 + 截图验证 |
---
*本文档由 Claude Code 于 2026-08-05 创建。*
# 执行计划 — 自然语言用例智能定位功能
> **关联 PRD**: `_PRD_自然语言用例智能定位功能.md`
> **优先级**: P0
> **作者**: czj / Claude Code
> **创建日期**: 2026-08-05
> **预计工期**: 5 天
---
## 总体规划
| Phase | 内容 | 工期 | 依赖 |
|-------|------|------|------|
| Phase 1 | 登录流程模板化 | 1 天 | 无 |
| Phase 2 | 智能定位后端核心 | 2 天 | Phase 1 |
| Phase 3 | 前端集成与测试 | 1 天 | Phase 2 |
| Phase 4 | 优化与文档更新 | 1 天 | Phase 3 |
---
## Phase 1: 登录流程模板化(1 天)
### 目标
将已验证的登录步骤固化为模板,智能定位时自动使用,用户无需手动配置登录。
### 任务清单
- [ ] 创建 `login_templates` 表(SQLite)
- [ ] 创建 `backend/app/models/login_template.py`
- [ ] 创建 `backend/app/schemas/login_template.py`
- [ ] 创建 `backend/app/routers/login_template.py`
- [ ] 创建 `backend/app/services/login_template_service.py`
- [ ] 插入默认登录模板(已验证的 8 步登录 + 3 步功能中心导航)
- [ ]`main.py` 注册路由
### 预置模板内容
**登录模板(8 步)**
| 步骤 | 名称 | 操作 | 选择器 | 值 |
|------|------|------|--------|-----|
| 1 | 访问登录页面 | navigate | - | https://192.168.5.44 |
| 2 | 等待登录表单加载 | wait | `input[placeholder*="手机号"]` | timeout:10000 |
| 3 | 输入用户名 | fill | `input[placeholder*="手机号"]` | admin@xty |
| 4 | 输入密码 | fill | `input[type="password"]` | Ubains@13579 |
| 5 | 输入验证码 | fill | `input[placeholder*="图"]` | csba |
| 6 | 勾选协议复选框 | click | `.el-checkbox` | - |
| 7 | 点击登录按钮 | click | `button:has-text("登录")` | - |
| 8 | 等待登录跳转完成 | wait | `[class*='nav'], .container, .el-main` | timeout:15000 |
**功能中心导航模板(3 步)**
| 步骤 | 名称 | 操作 | 选择器 |
|------|------|------|--------|
| 9 | 点击功能中心图标 | click | `//*[@id="Home"]/div[1]/div[1]` |
| 10 | 等待功能抽屉打开 | wait | `.el-drawer` |
| 11 | 点击目标菜单 | click | `.el-drawer >> text="${菜单名}"` |
### 验证标准
- [ ] API 可正常 CRUD 登录模板
- [ ] 默认模板存在且包含 11 个步骤
---
## Phase 2: 智能定位后端核心(2 天)
### 目标
实现 `/api/element/smart-locate` 接口,自动访问被测系统、执行操作、提取选择器。
### 任务清单
#### 2.1 关键词提取与匹配模块(0.5 天)
- [ ] 创建 `backend/app/services/keyword_matcher.py`
```python
# 关键词提取
def extract_keywords(description: str) -> list[str]
# 关键词匹配元素
def match_element_by_keywords(page, keywords: list[str], action: str) -> tuple[Element, list[dict]]
```
#### 2.2 选择器提取模块(0.5 天)
- [ ] 创建 `backend/app/services/selector_extractor.py`
```python
# 提取多候选选择器
def extract_selectors(element) -> dict
# 按优先级排序
# 1. data-testid 2. ID 3. placeholder 4. aria-label 5. 文本 6. 组合选择器 7. XPath
```
#### 2.3 智能定位服务(1 天)
- [ ] 创建 `backend/app/services/smart_locate_service.py`
```python
class SmartLocateService:
def locate_steps(self, steps: list[dict], auto_login: bool, navigate_menu: str) -> list[dict]:
"""
主流程:
1. 启动 Playwright
2. 执行登录模板
3. 导航到目标菜单
4. 逐步骤定位 + 执行
5. 返回结果
"""
def locate_single_step(self, step: dict) -> dict:
"""
单步骤定位:
1. 提取关键词
2. 关键词匹配
3. 语义推断
4. 回退策略
"""
```
#### 2.4 智能定位路由(0.5 天)
- [ ] 创建 `backend/app/routers/smart_locate.py`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/element/smart-locate` | 智能定位主接口 |
| POST | `/api/element/verify-step` | 单步骤验证(调试用) |
- [ ]`main.py` 注册路由
### 验证标准
- [ ] `/api/element/smart-locate` 可自动登录并定位元素
- [ ] 每个步骤返回多种候选选择器
- [ ] 定位的选择器执行通过率 ≥ 90%
---
## Phase 3: 前端集成与测试(1 天)
### 目标
改造前端"获取定位"功能,调用智能定位接口。
### 任务清单
- [ ] 修改 `frontend/src/api/elementLocate.ts`,增加 `smartLocate` 接口
- [ ] 修改 `frontend/src/views/Cases.vue`,"获取定位"按钮改为调用智能定位接口
- [ ] 保留现有配置弹窗(目标菜单、自动登录等)
- [ ] 实时显示定位进度(WebSocket)
- [ ] 定位结果展示:成功/不确定/失败,可编辑选择器
### 验证标准
- [ ] 点击"获取定位"后可正常调用智能定位接口
- [ ] 定位结果自动填充到步骤表格
- [ ] 可直接保存并执行用例
---
## Phase 4: 优化与文档更新(1 天)
### 任务清单
- [ ] 端到端测试:创建"会议管理"模块用例并验证执行
- [ ] 更新 `HANDOFF_UI自动化.md`
- [ ] 删除旧的 `prd-code` skill 相关文档(`_PRD_用例录制器功能.md`
- [ ] 清理临时脚本
### 验证标准
- [ ] 新建用例定位准确率 ≥ 95%
- [ ] 用例首次执行通过率 ≥ 90%
---
## 文件清单
### 新增文件
| 文件 | Phase | 说明 |
|------|-------|------|
| `backend/app/models/login_template.py` | 1 | 登录模板 ORM |
| `backend/app/schemas/login_template.py` | 1 | 登录模板 Schema |
| `backend/app/routers/login_template.py` | 1 | 登录模板路由 |
| `backend/app/services/login_template_service.py` | 1 | 登录模板服务 |
| `backend/app/services/keyword_matcher.py` | 2 | 关键词匹配模块 |
| `backend/app/services/selector_extractor.py` | 2 | 选择器提取模块 |
| `backend/app/services/smart_locate_service.py` | 2 | 智能定位服务 |
| `backend/app/routers/smart_locate.py` | 2 | 智能定位路由 |
### 修改文件
| 文件 | Phase | 说明 |
|------|-------|------|
| `backend/app/main.py` | 1,2 | 注册新路由 |
| `backend/app/database.py` | 1 | 新增 login_templates 表 |
| `backend/app/executors/playwright_executor.py` | 2 | 增加 `do_login_template()` 方法 |
| `frontend/src/api/elementLocate.ts` | 3 | 增加 smartLocate 接口 |
| `frontend/src/views/Cases.vue` | 3 | 改造获取定位功能 |
---
## 与旧方案对比
| 对比项 | 旧方案(AI 推断) | 新方案(智能定位) |
|--------|-----------------|------------------|
| 核心接口 | `/api/element/locate-batch` | `/api/element/smart-locate` |
| Claude CLI | 必须依赖 | 不需要 |
| 选择器来源 | AI 静态推断 | 实际执行验证 |
| 准确率 | 不稳定 | 高(已验证 12/12 通过) |
| 延迟 | 10-30s | 预计 5-15s |
| 成本 | Claude API 调用 | 无额外成本 |
---
*本文档由 Claude Code 于 2026-08-05 创建。*
# 执行计划 — 修复 metadata 字段错误
> **文档类型**: 执行计划文档
> **创建日期**: 2026-08-03
> **作者**: Claude Code
> **优先级**: P0(阻塞)
> **版本**: v5.60
> **状态**: ✅ 全部完成
---
## 一、执行概述
### 1.1 目标
修复创建模拟设备时的多个关联错误,恢复设备创建功能。
### 1.2 范围
- 修改 `backend/app/services/device_sim_service.py` 字段名
- 修改 `backend/app/schemas/device_sim.py` 添加验证器和缺失字段
- 数据库迁移添加 `topic_params`
- 更新 PRD 文档版本至 v5.60
### 1.3 实际工时
30 分钟
---
## 二、执行步骤
### Step 1: 修复 service 层字段名(✅ 已完成)
**文件**: `backend/app/services/device_sim_service.py:389`
```python
# 修改前
extra_attrs=data.metadata or {},
# 修改后
extra_attrs=data.extra_attrs or {},
```
---
### Step 2: 添加 field_validator(✅ 已完成)
**文件**: `backend/app/schemas/device_sim.py`
1. 添加导入:`field_validator`
2.`SimulatorResponse` 中添加 `ignore_sqlalchemy_metadata` 验证器
3. 添加 `metadata` 字段声明(`exclude=True`
---
### Step 3: 恢复缺失字段(✅ 已完成)
**文件**: `backend/app/schemas/device_sim.py`
1. `SimulatorBase` 添加 `topic_params` 字段
2. `SimulatorUpdate` 添加 `topic_params` 字段
3. 添加 `TopicTemplateResponse``TopicTemplateListResponse`
---
### Step 4: 数据库迁移(✅ 已完成)
```sql
ALTER TABLE device_simulators ADD COLUMN topic_params TEXT
```
---
### Step 5: 重启服务并验证(✅ 已完成)
- 后端服务已重启
- 创建门口屏设备:201 ✓
- 创建无纸化设备:201 ✓
- 创建中控设备:201 ✓
- 主题模板 API:200 ✓
---
### Step 6: 更新版本标记(✅ 已完成)
PRD 文档已更新为 v5.60。
---
## 三、修改文件清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/services/device_sim_service.py` | 修改 | 第 389 行:字段名修正 |
| `backend/app/schemas/device_sim.py` | 修改 | 添加验证器、缺失字段、主题模板 Schema |
| `backend/data/test_platform.db` | 修改 | 添加 `topic_params` 列 |
| `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` | 修改 | 更新版本至 v5.60 |
| `Docs/PRD/需求文档/设备模拟/_问题处理_创建模拟设备失败_metadata字段错误.md` | 新建 | 问题处理文档 |
| `Docs/PRD/需求文档/设备模拟/_执行计划_修复metadata字段错误_v5.60.md` | 新建 | 本文档 |
---
## 四、验收标准
- [x] service 层字段名修正
- [x] Schema 添加 field_validator
- [x] Schema 恢复缺失字段
- [x] 数据库迁移完成
- [x] 后端服务重启
- [x] 创建设备 API 返回 201
- [x] 不同设备类型均可创建
- [x] 主题参数配置功能正常
- [x] 无其他副作用
---
## 五、相关文档
- 问题处理文档: `_问题处理_创建模拟设备失败_metadata字段错误.md`
- PRD 文档: `_PRD_需求文档_设备模拟模块.md`
- 交接文档: `HANDOFF_设备模拟_主题配置改造.md`
---
*本文档为修复 metadata 字段错误的执行计划,版本 v5.60,全部完成。*
# HANDOFF — 设备模拟主题配置改造
> **生成时间**: 2026-08-03
> **当前分支**: `platform-auto-test`
> **状态**: ✅ 主题配置改造完成
---
## 一、本次会话完成的工作
### 1. ✅ 主题模板系统实现
**背景**:模拟器原来使用 `{prefix}/{device_type}/{device_id}/{action}` 格式拼接主题,但真实系统(192.168.5.44)使用固定主题路径,包含动态参数。
**改造内容**
#### 1.1 新增主题模板常量文件
文件:`backend/app/simulators/topic_templates.py`
定义门口屏和无纸化的真实主题模板:
```python
DEVICE_TOPIC_TEMPLATES = {
"door": [
{"key": "conference", "template": "/meeting/conference/{room_id}/", ...},
{"key": "material_client", "template": "/material/client/{device_id}/", ...},
{"key": "device_service_request", "template": "/iot/v1/device/service/request/{company_id}/{device_id}/", "direction": "subscribe"},
# ... 共 8 个模板
],
"paperless": [
{"key": "meeting_message", "template": "/meeting/message/{room_id}/", ...},
{"key": "meeting_control", "template": "/iot/v1/meeting/service/request/meetingControl/{company_id}/{mid}/", ...},
# ... 共 5 个模板
],
"central": [], # 保留旧格式
"client": [], # 保留旧格式
}
```
每个模板包含:
- `key`: 模板唯一标识
- `template`: 主题路径模板(含 `{param}` 占位符)
- `label`: 中文标签
- `params`: 需要填写的动态参数名列表
- `param_labels`: 参数名 → 中文标签映射
- `param_defaults`: 参数名 → 默认值映射
- `direction`: `publish`(上报)或 `subscribe`(订阅)
#### 1.2 工具函数
```python
# 解析主题模板,用参数值替换占位符
resolve_topic(template, params) str
# 解析设备类型的所有主题模板
resolve_all_topics(device_type, topic_params) -> Dict[str, dict]
# 获取设备类型的默认参数值
get_default_params(device_type) -> dict
```
### 2. ✅ 数据模型变更
#### 2.1 `DeviceSimulator` 新增 `topic_params` 字段
文件:`backend/app/models/device_sim.py`
```python
topic_params: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True,
comment="主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}"
)
```
#### 2.2 Schema 变更
文件:`backend/app/schemas/device_sim.py`
- `SimulatorBase` / `SimulatorCreate` / `SimulatorUpdate` 新增 `topic_params` 字段
- 新增 `TopicTemplateResponse``TopicTemplateListResponse` Schema
### 3. ✅ 新增 API 端点
文件:`backend/app/routers/device_sim.py`
```
GET /api/device-sim/topic-templates/{device_type}
→ 返回该设备类型的主题模板列表(含参数定义和默认值)
```
响应示例:
```json
{
"deviceType": "door",
"templates": [
{
"key": "conference",
"template": "/meeting/conference/{room_id}/",
"label": "会议信息",
"params": ["room_id"],
"paramLabels": {"room_id": "会议室编号"},
"paramDefaults": {"room_id": "A101"},
"direction": "publish"
}
],
"hasRealTopics": true
}
```
### 4. ✅ 模拟器改造
#### 4.1 `base_simulator.py` 改造
新增属性:
- `topic_params`: 主题动态参数
- `_resolved_topics`: 解析后的主题映射
- `_has_real_topics`: 是否有真实主题模板
新增方法:
- `_resolve_topics()`: 解析主题模板
- `get_resolved_topic(key)`: 获取解析后的主题
- `get_publish_topics()`: 获取所有上报主题
- `get_subscribe_topics()`: 获取所有订阅主题
改造方法:
- `start()`: 使用真实主题发布注册消息,自动订阅 `direction=subscribe` 的主题
- `stop()`: 使用真实主题发送离线消息,取消订阅
- `_auto_report_loop()`: 轮流使用各 publish 主题上报
#### 4.2 各子模拟器改造
- `door_simulator.py`: 构造函数新增 `topic_params``_auto_close_door`/`_simulate_reboot` 使用真实主题
- `paperless_simulator.py`: 构造函数新增 `topic_params``_simulate_file_sync` 使用真实主题
- `central_simulator.py` / `client_simulator.py`: 仅新增参数,保留旧格式
### 5. ✅ 前端改造
#### 5.1 类型定义更新
文件:`frontend/src/types/device.ts`
- `Simulator` / `SimulatorCreate` / `SimulatorUpdate` 新增 `topicParams` 字段
- 新增 `TopicTemplate``TopicTemplateListResponse` 类型
#### 5.2 API 封装
文件:`frontend/src/api/deviceSim.ts`
新增:`getTopicTemplates(deviceType: string): Promise<TopicTemplateListResponse>`
#### 5.3 DeviceList.vue 改造
文件:`frontend/src/components/device/DeviceList.vue`
- 新增设备弹窗增加"主题参数配置"区域
- 选择设备类型后自动加载主题模板
- 展示该设备类型的主题列表,每个动态参数显示输入框
- **预填默认值**(从 `paramDefaults` 获取)
- 保存时将参数值存入 `topicParams`
---
## 二、修改文件清单
### 后端
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `backend/app/simulators/topic_templates.py` | **新建** | 主题模板常量定义 + 解析工具函数 |
| `backend/app/models/device_sim.py` | 修改 | `DeviceSimulator` 新增 `topic_params` 字段 |
| `backend/app/schemas/device_sim.py` | 修改 | 新增 `topic_params` 字段和 `TopicTemplate` Schema |
| `backend/app/routers/device_sim.py` | 修改 | 新增 `GET /topic-templates/{device_type}` 端点 |
| `backend/app/services/device_sim_service.py` | 修改 | `create_simulator`/`start_simulator` 适配 `topic_params` |
| `backend/app/simulators/__init__.py` | 修改 | `create_simulator()` 新增 `topic_params` 参数 |
| `backend/app/simulators/base_simulator.py` | 修改 | 新增主题解析逻辑,改造 `start/stop/_auto_report_loop` |
| `backend/app/simulators/door_simulator.py` | 修改 | 构造函数 + `_auto_close_door` + `_simulate_reboot` |
| `backend/app/simulators/paperless_simulator.py` | 修改 | 构造函数 + `_simulate_file_sync` |
| `backend/app/simulators/central_simulator.py` | 修改 | 构造函数新增参数(保留旧格式) |
| `backend/app/simulators/client_simulator.py` | 修改 | 构造函数新增参数(保留旧格式) |
### 前端
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `frontend/src/types/device.ts` | 修改 | 新增 `topicParams` 字段和 `TopicTemplate` 类型 |
| `frontend/src/api/deviceSim.ts` | 修改 | 新增 `getTopicTemplates()` API |
| `frontend/src/components/device/DeviceList.vue` | 修改 | 新增设备弹窗增加主题参数配置区域 |
---
## 三、核心机制说明
### 3.1 主题解析流程
```
用户创建设备 → 前端获取主题模板 → 用户填写动态参数 → 保存到 topic_params
模拟器启动 → _resolve_topics() → 用 topic_params 替换模板中的 {param}
resolved_topics = {
"conference": "/meeting/conference/A101/",
"device_service_request": "/iot/v1/device/service/request/001/DS001/",
...
}
```
### 3.2 自动订阅机制
模拟器 `start()` 时:
1. 获取 `direction=subscribe` 的主题列表
2. 调用 `mqtt.subscribe()` 订阅这些主题
3. 平台下发指令时,`_on_command()` 收到并处理
### 3.3 轮流上报机制
`_auto_report_loop()` 中:
- 如果有真实主题模板,轮流使用各 `publish` 主题上报
- 每次上报使用不同主题,确保所有主题都有数据
### 3.4 向后兼容
- `topic_params``nullable`,旧设备无此字段时模拟器使用旧格式
- 中控/集控客户端暂无真实主题模板,继续使用 `{prefix}/{type}/{id}/{action}` 格式
---
## 四、真实主题列表
### 门口屏(8 个)
| Key | 模板 | 方向 | 参数 |
|-----|------|------|------|
| conference | `/meeting/conference/{room_id}/` | publish | room_id |
| material_client | `/material/client/{device_id}/` | publish | device_id |
| conference_service_request | `/iot/v1/conference/service/request/{company_id}/{room_id}/` | publish | company_id, room_id |
| face_extract | `/doorscreen/v5/{device_id}/face/extractFeature/imageBase64/list/up` | publish | device_id |
| radar_event | `/iot/v1/device/event/request/millimeterhaveRadar/{room_id}/` | publish | room_id |
| meeting_message | `/meeting/message/{room_id}/` | publish | room_id |
| reboot_response | `rebootResponseTopic` | publish | 无 |
| device_service_request | `/iot/v1/device/service/request/{company_id}/{device_id}/` | subscribe | company_id, device_id |
### 无纸化(5 个)
| Key | 模板 | 方向 | 参数 |
|-----|------|------|------|
| meeting_message | `/meeting/message/{room_id}/` | publish | room_id |
| meeting_control | `/iot/v1/meeting/service/request/meetingControl/{company_id}/{mid}/` | publish | company_id, mid |
| android_panel | `/androidPanel/` | publish | 无 |
| meeting_service_request | `/iot/v1/meeting/service/request/{room_id}/` | subscribe | room_id |
| paperless_service | `/message/paperLessService/callService` | publish | 无 |
---
## 五、默认参数值
| 参数 | 默认值 | 说明 |
|------|--------|------|
| room_id | A101 | 会议室编号 |
| device_id | DS001 | 设备ID |
| company_id | 001 | 公司编号 |
| mid | M001 | 会议ID |
---
## 六、待提交的代码修改
所有改动都未提交,建议使用 `/GitCommit` skill 提交:
```bash
git add -A
git commit -m "feat(device-sim): 主题配置改造 - 支持真实系统主题模板
- 新增 topic_templates.py 定义门口屏/无纸化真实主题模板
- DeviceSimulator 模型新增 topic_params JSON 字段
- 新增 GET /api/device-sim/topic-templates/{device_type} API
- 模拟器支持真实主题:模板 + 参数 → 实际主题
- 前端新增设备弹窗增加主题参数配置区域
- 自动订阅响应主题,轮流使用各 publish 主题上报
- 向后兼容:旧设备无 topic_params 时使用旧格式
Co-Authored-By: Claude <noreply@anthropic.com>"
```
---
## 七、验证步骤
### 7.1 后端验证
```bash
cd backend
uvicorn app.main:app --reload --port 8001
```
访问 http://localhost:8001/docs 确认新 API:
- `GET /api/device-sim/topic-templates/{device_type}`
### 7.2 前端验证
```bash
cd frontend
npm run dev
```
访问 http://localhost:3000/device-sim/door:
1. 点击"新增设备"
2. 确认显示"主题参数配置"区域
3. 确认动态参数输入框预填了默认值
### 7.3 功能验证
1. 创建门口屏设备,填写参数(如会议室编号=A202)
2. 启动模拟设备
3. 检查上报记录,确认主题为 `/meeting/conference/A202/` 格式
---
## 八、后续优化建议
### P1 - 后续迭代
| 任务 | 说明 |
|------|------|
| 中控真实主题 | 需要提供中控设备的真实主题列表 |
| 集控客户端真实主题 | 需要提供集控客户端的真实主题列表 |
| 主题参数校验 | 前端校验参数格式(如会议室编号不能为空) |
| 批量创建 | 支持批量创建同类型设备,自动递增参数值 |
### P2 - 体验优化
| 任务 | 说明 |
|------|------|
| 主题预览 | 前端实时预览解析后的主题路径 |
| 参数模板 | 保存常用的参数组合为模板,快速复用 |
| 设备克隆 | 克隆设备时复制 topic_params |
---
## 九、相关文档索引
| 文档 | 路径 |
|------|------|
| 设备模拟 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求文档_设备模拟模块.md` |
| 设备模拟执行计划 | `Docs/PRD/需求文档/设备模拟/_执行计划_设备模拟模块.md` |
| 多主题配置优化 PRD | `Docs/PRD/需求文档/设备模拟/_PRD_需求优化_环境配置多主题与设备接收记录.md` |
| 上次交接文档 | `HANDOFF_设备模拟.md` |
| 项目总交接 | `HANDOFF.md` |
---
*本文档由 Claude Code 生成,记录设备模拟主题配置改造进度,供下次会话快速恢复上下文。*
\ No newline at end of file
...@@ -52,6 +52,7 @@ class EnvConfig(Base): ...@@ -52,6 +52,7 @@ class EnvConfig(Base):
topics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="多主题前缀映射,如 {'door': 'device/door', 'paperless': 'device/paperless'}") topics: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="多主题前缀映射,如 {'door': 'device/door', 'paperless': 'device/paperless'}")
use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS") use_tls: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否启用 TLS")
client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀") client_id_prefix: Mapped[str] = mapped_column(String(100), default="sim", comment="客户端 ID 前缀")
default_topic_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="默认主题参数,含 company_id/room_ids/app_token_prefix/app_token_start/app_token_end/conference_id/conference_name")
status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态") status: Mapped[str] = mapped_column(String(20), default="disconnected", comment="连接状态")
last_connected_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后连接时间") last_connected_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后连接时间")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
...@@ -90,6 +91,8 @@ class DeviceSimulator(Base): ...@@ -90,6 +91,8 @@ class DeviceSimulator(Base):
status: Mapped[str] = mapped_column(String(20), default="stopped", comment="运行状态") status: Mapped[str] = mapped_column(String(20), default="stopped", comment="运行状态")
auto_reconnect: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否自动重连") auto_reconnect: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否自动重连")
extra_attrs: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="设备自定义属性") extra_attrs: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="设备自定义属性")
topic_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}")
report_config: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="上报配置,含 interval(秒)/enabled(开关)")
last_reported_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后上报时间") last_reported_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="最后上报时间")
total_reports: Mapped[int] = mapped_column(Integer, default=0, comment="累计上报次数") total_reports: Mapped[int] = mapped_column(Integer, default=0, comment="累计上报次数")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
......
...@@ -64,6 +64,8 @@ class BatchLocateRequest(BaseModel): ...@@ -64,6 +64,8 @@ class BatchLocateRequest(BaseModel):
wait_for_selector: str = "" # 等待特定元素出现(CSS选择器) wait_for_selector: str = "" # 等待特定元素出现(CSS选择器)
retry_on_empty: bool = True # 元素为空时是否重试 retry_on_empty: bool = True # 元素为空时是否重试
max_retries: int = 2 # 最大重试次数 max_retries: int = 2 # 最大重试次数
# === 新增参数(导航功能)===
navigate_menu: str = "" # 目标菜单名称(如"会议管理"、"信息管理")
class StepLocateResult(BaseModel): class StepLocateResult(BaseModel):
...@@ -252,8 +254,17 @@ def extract_keywords(text: str) -> list: ...@@ -252,8 +254,17 @@ def extract_keywords(text: str) -> list:
return [kw for kw in keywords if kw not in seen and not seen.add(kw)] return [kw for kw in keywords if kw not in seen and not seen.add(kw)]
def build_locator_candidates(step_description: str, elements: list, page_title: str = "", page_url: str = "") -> list: def build_locator_candidates(step_description: str, elements: list, page_title: str = "", page_url: str = "", skip_claude: bool = False) -> list:
"""关键词算法匹配元素,返回候选定位器列表(按置信度降序)""" """
关键词算法匹配元素,返回候选定位器列表(按置信度降序)
Args:
step_description: 步骤描述
elements: 页面元素列表
page_title: 页面标题
page_url: 页面 URL
skip_claude: 是否跳过 Claude CLI 调用(批量定位时已在外面批量调用)
"""
candidates = [] candidates = []
desc_lower = step_description.lower() desc_lower = step_description.lower()
is_input_action = any(kw in desc_lower for kw in ['输入', '填写', '键入', '写入', '填入']) is_input_action = any(kw in desc_lower for kw in ['输入', '填写', '键入', '写入', '填入'])
...@@ -296,8 +307,11 @@ def build_locator_candidates(step_description: str, elements: list, page_title: ...@@ -296,8 +307,11 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
candidates.sort(key=lambda x: x['confidence'], reverse=True) candidates.sort(key=lambda x: x['confidence'], reverse=True)
# 始终调用宿主机 Claude CLI 进行精确匹配 # 如果 skip_claude=True,跳过 Claude CLI 调用(批量定位时已在外面批量调用)
# 容器内已登录并提取了元素,直接传给 Claude CLI 做匹配 if skip_claude:
return candidates[:5]
# 单个定位时调用 Claude CLI 进行精确匹配
logger.info("调用宿主机 Claude CLI 进行精确匹配...") logger.info("调用宿主机 Claude CLI 进行精确匹配...")
claude_result = call_claude_code_via_ssh(step_description, page_url, elements=elements, page_title=page_title) claude_result = call_claude_code_via_ssh(step_description, page_url, elements=elements, page_title=page_title)
if claude_result and claude_result.get('locator_type') and claude_result.get('locator_value'): if claude_result and claude_result.get('locator_type') and claude_result.get('locator_value'):
...@@ -330,10 +344,121 @@ def build_locator_candidates(step_description: str, elements: list, page_title: ...@@ -330,10 +344,121 @@ def build_locator_candidates(step_description: str, elements: list, page_title:
return candidates[:5] return candidates[:5]
def call_claude_batch_via_ssh(step_descriptions: list, page_url: str, elements: list, page_title: str = "") -> dict:
"""
批量调用 Claude CLI 一次性匹配多个步骤(优化性能,减少 SSH 调用次数)
Args:
step_descriptions: 步骤描述列表 [{"order": 1, "description": "输入用户名"}, ...]
page_url: 页面 URL
elements: 页面元素列表
page_title: 页面标题
Returns:
dict: {order: {"locator_type": ..., "locator_value": ..., "confidence": ..., "reason": ...}}
"""
HOST = "192.168.5.60"
HOST_USER = "ubains"
SSH_PASSWORD = "Ubains@123"
if not elements:
logger.warning("没有元素列表可传给 Claude CLI")
return {}
if not step_descriptions:
return {}
# 构造批量匹配的 prompt
element_summary = json.dumps(elements[:80], ensure_ascii=False, indent=2) # 增加到 80 个元素
steps_json = json.dumps(step_descriptions, ensure_ascii=False, indent=2)
prompt = f"""你是一个UI自动化测试的定位助手。根据多个步骤描述和页面元素列表,批量匹配所有步骤对应的元素。
页面标题:{page_title}
页面URL:{page_url}
步骤列表(JSON格式):
{steps_json}
可交互元素列表(JSON格式):
{element_summary}
请为每个步骤找到最匹配的元素,返回JSON格式结果:
{{
"results": [
{{
"order": 步骤序号,
"locator_type": "css 或 xpath 或 text 或 id",
"locator_value": "定位值",
"confidence": 0.0-1.0(小数表示置信度),
"reason": "简要说明为什么匹配这个元素"
}},
...
]
}}
注意:
1. 必须为所有步骤返回结果,即使置信度为 0
2. 如果找不到匹配元素,locator_type 和 locator_value 设为空字符串,confidence 设为 0
3. 优先使用 CSS 选择器,语义化定位器优先(如 [placeholder*='xxx'])"""
try:
import paramiko
import base64
# 将 prompt 编码为 base64 避免特殊字符问题
prompt_b64 = base64.b64encode(prompt.encode('utf-8')).decode('ascii')
# 通过 SSH 调用 Claude CLI(增加超时时间到 120 秒)
cmd = f'echo {prompt_b64} | base64 -d | claude -p --print 2>/dev/null'
logger.info(f"通过 SSH 调用宿主机 Claude CLI 进行批量元素匹配({len(step_descriptions)} 个步骤)...")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOST, username=HOST_USER, password=SSH_PASSWORD, timeout=10)
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=120)
output = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
ssh.close()
if output:
# 提取 JSON 结果
json_start = output.find('{')
json_end = output.rfind('}') + 1
if json_start >= 0 and json_end > json_start:
parsed = json.loads(output[json_start:json_end])
results = parsed.get('results', [])
# 转换为以 order 为 key 的字典
result_map = {}
for r in results:
order = r.get('order', 0)
result_map[order] = {
'locator_type': r.get('locator_type', ''),
'locator_value': r.get('locator_value', ''),
'confidence': r.get('confidence', 0),
'reason': r.get('reason', 'Claude Code 批量匹配')
}
logger.info(f"Claude CLI 批量匹配完成: {len(result_map)}/{len(step_descriptions)} 个步骤")
return result_map
else:
logger.warning(f"Claude CLI 输出中未找到 JSON: {output[:300]}")
else:
logger.warning(f"Claude CLI 无输出, stderr: {err[:200]}")
return {}
except Exception as e:
logger.warning(f"Claude CLI 批量调用异常: {e}")
return {}
def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str = "", def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str = "",
elements: list = None, page_title: str = "") -> dict: elements: list = None, page_title: str = "") -> dict:
""" """
通过 SSH 调用宿主机上的 Claude CLI 进行元素匹配 通过 SSH 调用宿主机上的 Claude CLI 进行元素匹配(单个步骤)
容器内已通过 Playwright 登录并提取了页面元素, 容器内已通过 Playwright 登录并提取了页面元素,
这里只把元素列表传给宿主机的 Claude CLI 做精确匹配, 这里只把元素列表传给宿主机的 Claude CLI 做精确匹配,
...@@ -423,12 +548,259 @@ def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str ...@@ -423,12 +548,259 @@ def call_claude_code_via_ssh(step_description: str, page_url: str, expected: str
return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": f"Claude调用异常: {str(e)}"} return {"locator_type": "", "locator_value": "", "confidence": 0, "reason": f"Claude调用异常: {str(e)}"}
def _navigate_to_menu(page, menu_name: str) -> bool:
"""
在已登录的页面上导航到指定菜单
被测系统导航方式:
1. 首页菜单卡片(.block 元素,包含分类和子菜单)
2. 直接通过 URL 路由导航(#hash 路由)
3. 功能中心抽屉(.home_nav_left 打开,再点击菜单项)
4. 通用 text 选择器
Args:
page: Playwright Page 对象
menu_name: 目标菜单名称(如"会议管理"、"信息管理")
Returns:
bool: 是否成功导航
"""
if not menu_name:
return True
logger.info(f"尝试导航到菜单: {menu_name}")
# 首页菜单卡片 URL 映射(基于实际探索,2026-08-04)
# 探索结果:22/24 成功,2 个超时(RSE相关)
MENU_ROUTES = {
# 会议管理相关
'新建会议': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FCreateMeeting',
'会议模板': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FMeetingtemplate',
'个人日程': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FtodayMeeting',
'会议室列表': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FMeetingRoomList',
'会议列表': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll',
# 数据统计
'预定数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FStatisticsModule',
'使用数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FUsedataModule',
'管理看板': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FManagePanel',
'故障数据': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FErrorModule',
# 运维管理
'运维数据': '/#/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
'运维统计': '/#/backend/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
'巡检报表': '/#/backend/monitor?monitor=%2Fmonitor%2F%23%2Flogin',
# 统计报表
'会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'通知统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'会议服务统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
'会议室统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
# 第三方集成
'Welink会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FOnlineMeetingStatistics%3FonlineType%25M22',
'历史记录-WeLink': '/#/backend/meetingV2?meetingV2=%2FmeetingV2%2F%23%2Fmeeting%2Frecord%3FonlineType%25M2Welink',
'SMC3.0会议统计': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FOnlineMeetingStatistics%3FonlineType%25M21',
'历史记录-SMC3.0': '/#/backend/meetingV2?meetingV2=%2FmeetingV2%2F%23%2Fmeeting%2Frecord%3FonlineType%25M2SMC3',
# 概览
'会议概览': '/#/platform?platform=%2Fplatform%2F%23%2FHome',
'会议室概览': '/#/platform?platform=%2Fplatform%2F%23%2FHome',
# 一级菜单别名映射(兼容旧代码)
'会议管理': '/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll',
'信息管理': '/#/backend/backstage?backstage=%2Fbackstage%2F%23%2FBackend%2FStatistics%2FMeetingStatistics',
}
# 方式0: 通过 URL 路由直接导航(最可靠)
if menu_name in MENU_ROUTES:
try:
base_url = 'https://192.168.5.44'
target_url = base_url + MENU_ROUTES[menu_name]
logger.info(f"通过 URL 路由导航: {target_url}")
page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(5000) # 等待微前端子应用加载
# 验证页面是否加载成功
current_url = page.url
logger.info(f"导航后 URL: {current_url}")
return True
except Exception as e:
logger.warning(f"URL 路由导航失败: {e}")
# 方式1: 点击首页菜单卡片(.block 元素)
try:
blocks = page.query_selector_all('.block')
logger.info(f"找到 {len(blocks)} 个菜单卡片")
for block in blocks:
p_elem = block.query_selector('p')
if p_elem:
text = (p_elem.inner_text() or '').strip()
if menu_name in text or text in menu_name:
block.click()
logger.info(f"通过首页菜单卡片导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"首页菜单卡片导航失败: {e}")
# 方式2: 点击左侧导航菜单(el-menu)
try:
menu_items = page.query_selector_all('.el-menu-item')
for item in menu_items:
text = (item.inner_text() or '').strip()
if menu_name in text or text in menu_name:
item.click()
logger.info(f"通过左侧菜单导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"左侧菜单导航失败: {e}")
# 方式3: 点击子菜单标题展开
try:
sub_menus = page.query_selector_all('.el-sub-menu__title')
for sub in sub_menus:
text = (sub.inner_text() or '').strip()
if menu_name in text or text in menu_name:
sub.click()
page.wait_for_timeout(1000)
menu_items = page.query_selector_all('.el-menu-item')
for item in menu_items:
item_text = (item.inner_text() or '').strip()
if menu_name in item_text or item_text in menu_name:
item.click()
logger.info(f"通过子菜单导航成功: {item_text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"子菜单导航失败: {e}")
# 方式4: 功能中心抽屉
try:
nav_btn = page.query_selector('.home_nav_left')
if nav_btn and nav_btn.is_visible():
nav_btn.click()
page.wait_for_timeout(2000)
drawer_items = page.query_selector_all('.el-drawer .menu-item, .el-drawer .block, .el-drawer p')
for item in drawer_items:
text = (item.inner_text() or '').strip()
if menu_name in text or text in menu_name:
item.click()
logger.info(f"通过功能中心导航成功: {text}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"功能中心导航失败: {e}")
# 方式5: 使用 Playwright 的 text 选择器点击
try:
page.click(f'text="{menu_name}"', timeout=5000)
logger.info(f"通过 text 选择器导航成功: {menu_name}")
page.wait_for_timeout(3000)
return True
except Exception as e:
logger.warning(f"text 选择器导航失败: {e}")
logger.warning(f"所有导航方式均失败,无法导航到: {menu_name}")
return False
def _infer_menu_from_steps(steps: list) -> str:
"""
从用例步骤描述中推断目标菜单名称
根据步骤描述中的关键词匹配菜单名称。
如果多个步骤指向不同菜单,返回第一个非导航步骤的菜单。
Args:
steps: 用例步骤列表
Returns:
str: 推断的菜单名称,空字符串表示无法推断
"""
# 菜单关键词映射(基于实际首页菜单,2026-08-04)
MENU_KEYWORDS = {
# 会议管理
'新建会议': ['新建会议', '创建会议', '快速预约', '预约会议'],
'会议模板': ['会议模板', '模板', '快速创建'],
'个人日程': ['个人日程', '我的会议', '今日会议'],
'会议室列表': ['会议室', '会议室列表', '房间列表'],
'会议列表': ['会议列表', '所有会议', '会议记录'],
# 数据统计
'预定数据': ['预定', '预定数据', '预订统计'],
'使用数据': ['使用数据', '使用统计', '使用情况'],
'管理看板': ['管理看板', '看板', '数据看板'],
'故障数据': ['故障', '故障数据', '故障统计'],
# 运维管理
'运维数据': ['运维数据', '运维情况'],
'运维统计': ['运维统计', '运维报表'],
'巡检报表': ['巡检', '巡检报表', '巡检记录'],
# 统计报表
'会议统计': ['会议统计', '会议报表'],
'通知统计': ['通知统计', '通知报表'],
'会议服务统计': ['会议服务', '服务统计'],
'会议室统计': ['会议室统计', '会议室报表'],
# 第三方集成
'Welink会议统计': ['Welink', 'WeLink', 'welink'],
'历史记录-WeLink': ['WeLink历史', 'WeLink记录'],
'SMC3.0会议统计': ['SMC', 'SMC3.0', 'smc'],
'历史记录-SMC3.0': ['SMC历史', 'SMC记录'],
'RSE会议统计': ['RSE', 'rse'],
'历史记录-RSE': ['RSE历史', 'RSE记录'],
# 概览
'会议概览': ['会议概览', '概览'],
'会议室概览': ['会议室概览'],
# 一级菜单别名(兼容旧代码)
'会议管理': ['会议', '预约', '会议室'],
'信息管理': ['信息', '通知', '消息', '公告'],
}
# 收集所有步骤描述中的关键词
all_keywords = []
for step in steps:
desc = step.get('name', '') or step.get('description', '')
if not desc.strip():
continue
# 跳过导航和等待步骤
action = step.get('action', '')
if action in ['navigate', 'wait']:
continue
all_keywords.append(desc)
# 匹配菜单
menu_scores = {}
for menu_name, keywords in MENU_KEYWORDS.items():
score = 0
for desc in all_keywords:
for kw in keywords:
if kw in desc:
score += 1
break # 每个步骤只计一次
if score > 0:
menu_scores[menu_name] = score
if not menu_scores:
return ""
# 返回得分最高的菜单
best_menu = max(menu_scores, key=menu_scores.get)
logger.info(f"根据步骤推断目标菜单: {best_menu} (得分: {menu_scores[best_menu]})")
return best_menu
def _do_locate_with_executor(executor, page_url: str, auto_login: bool, def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
page_load_timeout: int = 10000, page_load_timeout: int = 10000,
extra_wait_time: int = 5000, extra_wait_time: int = 5000,
wait_for_selector: str = "", wait_for_selector: str = "",
retry_on_empty: bool = True, retry_on_empty: bool = True,
max_retries: int = 2) -> dict: max_retries: int = 2,
navigate_menu: str = "") -> dict:
""" """
使用已有的 PlaywrightExecutor 执行定位准备工作 使用已有的 PlaywrightExecutor 执行定位准备工作
...@@ -447,6 +819,13 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool, ...@@ -447,6 +819,13 @@ def _do_locate_with_executor(executor, page_url: str, auto_login: bool,
return {'success': False, 'elements': [], 'page_title': '', 'current_url': '', return {'success': False, 'elements': [], 'page_title': '', 'current_url': '',
'message': '自动登录失败', 'elements_extracted': 0, 'retries': 0, 'page_load_time': 0} 'message': '自动登录失败', 'elements_extracted': 0, 'retries': 0, 'page_load_time': 0}
# 导航到目标菜单(如果指定)
if navigate_menu and executor._is_logged_in:
logger.info(f"导航到目标菜单: {navigate_menu}")
nav_success = _navigate_to_menu(executor._page, navigate_menu)
if not nav_success:
logger.warning(f"导航到 {navigate_menu} 失败,将使用当前页面的元素")
# 访问目标页面 # 访问目标页面
logger.info(f"访问页面: {page_url}") logger.info(f"访问页面: {page_url}")
current_url = executor._page.url current_url = executor._page.url
...@@ -615,6 +994,13 @@ async def locate_batch(request: BatchLocateRequest): ...@@ -615,6 +994,13 @@ async def locate_batch(request: BatchLocateRequest):
logger.info(f"用例有 {len(steps)} 个步骤") logger.info(f"用例有 {len(steps)} 个步骤")
# 智能推断目标菜单(如果未指定)
navigate_menu = request.navigate_menu
if not navigate_menu and request.auto_login:
navigate_menu = _infer_menu_from_steps(steps)
if navigate_menu:
logger.info(f"智能推断目标菜单: {navigate_menu}")
# 在线程中执行 Playwright # 在线程中执行 Playwright
result_container = { result_container = {
'success': False, 'case_id': request.case_id, 'success': False, 'case_id': request.case_id,
...@@ -642,7 +1028,8 @@ async def locate_batch(request: BatchLocateRequest): ...@@ -642,7 +1028,8 @@ async def locate_batch(request: BatchLocateRequest):
extra_wait_time=request.extra_wait_time, extra_wait_time=request.extra_wait_time,
wait_for_selector=request.wait_for_selector, wait_for_selector=request.wait_for_selector,
retry_on_empty=request.retry_on_empty, retry_on_empty=request.retry_on_empty,
max_retries=request.max_retries max_retries=request.max_retries,
navigate_menu=navigate_menu
) )
if not prep['success']: if not prep['success']:
result_container['message'] = prep['message'] result_container['message'] = prep['message']
...@@ -655,19 +1042,22 @@ async def locate_batch(request: BatchLocateRequest): ...@@ -655,19 +1042,22 @@ async def locate_batch(request: BatchLocateRequest):
result_container['page_load_time'] = prep['page_load_time'] result_container['page_load_time'] = prep['page_load_time']
located_count = 0 located_count = 0
# 为每个步骤匹配定位器 # === 性能优化:批量调用 Claude CLI(一次性匹配所有步骤)===
for step in updated_steps: # 1. 收集需要定位的步骤
steps_to_locate = []
step_index_map = {} # order -> 索引
for idx, step in enumerate(updated_steps):
step_desc = step.get('name', '') or step.get('description', '') step_desc = step.get('name', '') or step.get('description', '')
step_order = step.get('order', 0) step_order = step.get('order', 0)
action = step.get('action', '')
# 跳过空描述和导航步骤
if not step_desc.strip(): if not step_desc.strip():
result_container['results'].append(StepLocateResult( result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=False, message="步骤描述为空" order=step_order, description=step_desc, success=False, message="步骤描述为空"
)) ))
continue continue
# 跳过 navigate 类型的步骤(不需要定位器)
action = step.get('action', '')
if action == 'navigate': if action == 'navigate':
result_container['results'].append(StepLocateResult( result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=True, order=step_order, description=step_desc, success=True,
...@@ -676,34 +1066,71 @@ async def locate_batch(request: BatchLocateRequest): ...@@ -676,34 +1066,71 @@ async def locate_batch(request: BatchLocateRequest):
located_count += 1 located_count += 1
continue continue
# 匹配定位器 steps_to_locate.append({'order': step_order, 'description': step_desc})
candidates = build_locator_candidates(step_desc, elements, page_title, request.page_url) step_index_map[step_order] = idx
if candidates:
best = candidates[0]
# 更新步骤的定位信息(使用驼峰命名匹配前端 IntentStepEditor)
step['locatorType'] = best['locator_type']
step['locatorValue'] = best['locator_value']
# 兼容蛇形命名(如果其他地方需要)
step['locator_type'] = best['locator_type']
step['locator_value'] = best['locator_value']
# 同步到 params.selector
if best['locator_value'] and 'params' in step:
step['params']['selector'] = best['locator_value']
elif best['locator_value']:
step['params'] = {'selector': best['locator_value']}
result_container['results'].append(StepLocateResult( # 2. 批量调用 Claude CLI 一次性匹配(核心优化)
order=step_order, description=step_desc, success=True, logger.info(f"批量调用 Claude CLI 匹配 {len(steps_to_locate)} 个步骤...")
locator_type=best['locator_type'], locator_value=best['locator_value'], claude_results = {}
confidence=best['confidence'], message=f"置信度 {best['confidence']:.0%}" if steps_to_locate:
)) try:
located_count += 1 claude_results = call_claude_batch_via_ssh(
step_descriptions=steps_to_locate,
page_url=request.page_url,
elements=elements,
page_title=page_title
)
except Exception as e:
logger.warning(f"Claude 批量匹配失败,将使用关键词算法: {e}")
# 3. 为每个步骤匹配定位器
for step_info in steps_to_locate:
step_order = step_info['order']
step_desc = step_info['description']
step_idx = step_index_map[step_order]
step = updated_steps[step_idx]
# 优先使用 Claude 批量匹配结果
claude_result = claude_results.get(step_order, {})
if claude_result and claude_result.get('locator_value'):
# 使用 Claude 结果
locator_type = claude_result['locator_type']
locator_value = claude_result['locator_value']
confidence = claude_result.get('confidence', 0.8)
message = f"Claude 批量匹配 (置信度 {confidence:.0%})"
else: else:
result_container['results'].append(StepLocateResult( # 回退到关键词算法(skip_claude=True 避免重复调用)
order=step_order, description=step_desc, success=False, candidates = build_locator_candidates(step_desc, elements, page_title, request.page_url, skip_claude=True)
message="未找到匹配元素" if candidates:
)) best = candidates[0]
locator_type = best['locator_type']
locator_value = best['locator_value']
confidence = best['confidence']
message = f"关键词匹配 (置信度 {confidence:.0%})"
else:
# 无匹配
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=False,
message="未找到匹配元素"
))
continue
# 更新步骤的定位信息
step['locatorType'] = locator_type
step['locatorValue'] = locator_value
step['locator_type'] = locator_type
step['locator_value'] = locator_value
if locator_value and 'params' in step:
step['params']['selector'] = locator_value
elif locator_value:
step['params'] = {'selector': locator_value}
result_container['results'].append(StepLocateResult(
order=step_order, description=step_desc, success=True,
locator_type=locator_type, locator_value=locator_value,
confidence=confidence, message=message
))
located_count += 1
result_container['located_steps'] = located_count result_container['located_steps'] = located_count
result_container['success'] = True result_container['success'] = True
...@@ -721,7 +1148,7 @@ async def locate_batch(request: BatchLocateRequest): ...@@ -721,7 +1148,7 @@ async def locate_batch(request: BatchLocateRequest):
worker_thread = threading.Thread(target=_batch_worker, daemon=True) worker_thread = threading.Thread(target=_batch_worker, daemon=True)
worker_thread.start() worker_thread.start()
worker_thread.join(timeout=300) # 5 minutes for batch locate (each step needs SSH + Claude CLI) worker_thread.join(timeout=600) # 10 minutes for batch locate (login + menu navigation + element extraction + each step needs SSH + Claude CLI)
if worker_thread.is_alive(): if worker_thread.is_alive():
return BatchLocateResponse(success=False, case_id=request.case_id, total_steps=len(steps), located_steps=0, results=[], message="批量定位超时") return BatchLocateResponse(success=False, case_id=request.case_id, total_steps=len(steps), located_steps=0, results=[], message="批量定位超时")
......
...@@ -34,6 +34,7 @@ class EnvConfigBase(BaseModel): ...@@ -34,6 +34,7 @@ class EnvConfigBase(BaseModel):
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射,key为设备类型,value为主题前缀") topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射,key为设备类型,value为主题前缀")
use_tls: bool = Field(default=False, description="是否启用 TLS") use_tls: bool = Field(default=False, description="是否启用 TLS")
client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀") client_id_prefix: str = Field(default="sim", max_length=100, description="客户端 ID 前缀")
default_topic_params: Optional[dict] = Field(default=None, description="默认主题参数,如 {'company_id': '001', 'room_ids': ['A101', 'A202']}")
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
...@@ -58,6 +59,7 @@ class EnvConfigUpdate(BaseModel): ...@@ -58,6 +59,7 @@ class EnvConfigUpdate(BaseModel):
topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射") topics: Optional[Dict[str, str]] = Field(default=None, description="多主题前缀映射")
use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS") use_tls: Optional[bool] = Field(default=None, description="是否启用 TLS")
client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀") client_id_prefix: Optional[str] = Field(default=None, max_length=100, description="客户端 ID 前缀")
default_topic_params: Optional[dict] = Field(default=None, description="默认主题参数")
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
...@@ -100,6 +102,21 @@ class SimulatorBase(BaseModel): ...@@ -100,6 +102,21 @@ class SimulatorBase(BaseModel):
auto_reconnect: bool = Field(default=True, description="是否自动重连") auto_reconnect: bool = Field(default=True, description="是否自动重连")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性") extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数") topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数")
report_config: Optional[dict] = Field(default=None, description="上报配置,含 interval(秒)/enabled(开关)")
@field_validator('report_config')
@classmethod
def validate_report_config(cls, v):
"""验证上报配置字段"""
if v is None:
return None
if 'interval' in v:
if not isinstance(v['interval'], int) or v['interval'] < 1:
raise ValueError('interval must be a positive integer (>= 1)')
if 'enabled' in v:
if not isinstance(v['enabled'], bool):
raise ValueError('enabled must be a boolean')
return v
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
...@@ -119,6 +136,21 @@ class SimulatorUpdate(BaseModel): ...@@ -119,6 +136,21 @@ class SimulatorUpdate(BaseModel):
auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连") auto_reconnect: Optional[bool] = Field(default=None, description="是否自动重连")
extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性") extra_attrs: Optional[dict] = Field(default=None, description="设备自定义属性")
topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数") topic_params: Optional[Dict[str, str]] = Field(default=None, description="主题动态参数")
report_config: Optional[dict] = Field(default=None, description="上报配置,含 interval(秒)/enabled(开关)")
@field_validator('report_config')
@classmethod
def validate_report_config(cls, v):
"""验证上报配置字段"""
if v is None:
return None
if 'interval' in v:
if not isinstance(v['interval'], int) or v['interval'] < 1:
raise ValueError('interval must be a positive integer (>= 1)')
if 'enabled' in v:
if not isinstance(v['enabled'], bool):
raise ValueError('enabled must be a boolean')
return v
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
......
...@@ -388,6 +388,7 @@ class DeviceSimService: ...@@ -388,6 +388,7 @@ class DeviceSimService:
auto_reconnect=data.auto_reconnect, auto_reconnect=data.auto_reconnect,
extra_attrs=data.extra_attrs or {}, extra_attrs=data.extra_attrs or {},
topic_params=data.topic_params or {}, topic_params=data.topic_params or {},
report_config=data.report_config,
status="stopped", status="stopped",
) )
...@@ -507,6 +508,7 @@ class DeviceSimService: ...@@ -507,6 +508,7 @@ class DeviceSimService:
device_id=simulator.device_id, device_id=simulator.device_id,
env_config_id=config.id, env_config_id=config.id,
mqtt_manager=mqtt_manager, mqtt_manager=mqtt_manager,
report_config=simulator.report_config,
topics=topics, topics=topics,
topic_params=simulator.topic_params, topic_params=simulator.topic_params,
) )
...@@ -529,7 +531,11 @@ class DeviceSimService: ...@@ -529,7 +531,11 @@ class DeviceSimService:
_running_simulators[device_id] = sim _running_simulators[device_id] = sim
simulator.status = "running" simulator.status = "running"
simulator.updated_at = datetime.now() simulator.updated_at = datetime.now()
await self.db.flush() try:
await self.db.flush()
except Exception as e:
logger.warning(f"更新设备状态失败(模拟器已启动): {device_id}, error={e}")
# 不抛出异常,模拟器已在运行
return success return success
......
...@@ -37,8 +37,9 @@ DEFAULT_INTERVAL_MAP = { ...@@ -37,8 +37,9 @@ DEFAULT_INTERVAL_MAP = {
def create_simulator(device_type: str, device_id: str, def create_simulator(device_type: str, device_id: str,
env_config_id: str, mqtt_manager: MqttManager, env_config_id: str, mqtt_manager: MqttManager,
report_interval: Optional[int] = None, report_config: Optional[dict] = None,
topics: Optional[dict] = None) -> Optional[BaseSimulator]: topics: Optional[dict] = None,
topic_params: Optional[dict] = None) -> Optional[BaseSimulator]:
""" """
模拟器工厂方法 模拟器工厂方法
...@@ -49,8 +50,9 @@ def create_simulator(device_type: str, device_id: str, ...@@ -49,8 +50,9 @@ def create_simulator(device_type: str, device_id: str,
device_id: 设备 ID device_id: 设备 ID
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器实例 mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒),None 使用默认值 report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀 topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
topic_params: 主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}
Returns: Returns:
BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None BaseSimulator: 模拟器实例,如果设备类型不支持则返回 None
...@@ -59,15 +61,18 @@ def create_simulator(device_type: str, device_id: str, ...@@ -59,15 +61,18 @@ def create_simulator(device_type: str, device_id: str,
if not simulator_class: if not simulator_class:
return None return None
if report_interval is None: # 如果未提供 report_config,使用设备类型默认值
report_interval = DEFAULT_INTERVAL_MAP.get(device_type, 30) if report_config is None:
default_interval = DEFAULT_INTERVAL_MAP.get(device_type, 30)
report_config = {"interval": default_interval, "enabled": True}
return simulator_class( return simulator_class(
device_id=device_id, device_id=device_id,
env_config_id=env_config_id, env_config_id=env_config_id,
mqtt_manager=mqtt_manager, mqtt_manager=mqtt_manager,
report_interval=report_interval, report_config=report_config,
topics=topics or {} topics=topics or {},
topic_params=topic_params,
) )
......
...@@ -15,7 +15,7 @@ import threading ...@@ -15,7 +15,7 @@ import threading
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, List
from app.services.mqtt_manager import MqttManager from app.services.mqtt_manager import MqttManager
...@@ -36,14 +36,15 @@ class BaseSimulator(ABC): ...@@ -36,14 +36,15 @@ class BaseSimulator(ABC):
mqtt (MqttManager): MQTT 连接管理器 mqtt (MqttManager): MQTT 连接管理器
_running (bool): 是否正在运行 _running (bool): 是否正在运行
_thread (threading.Thread): 定时上报线程 _thread (threading.Thread): 定时上报线程
_report_interval (int): 上报间隔(秒) _report_config (dict): 上报配置,含 interval(秒)/enabled(开关)
_on_report_callback (callable): 上报回调(用于记录日志) _on_report_callback (callable): 上报回调(用于记录日志)
""" """
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
device_type: str, mqtt_manager: MqttManager, device_type: str, mqtt_manager: MqttManager,
report_interval: int = 30, report_config: Optional[dict] = None,
topics: Optional[dict] = None): topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
""" """
初始化模拟设备 初始化模拟设备
...@@ -52,8 +53,9 @@ class BaseSimulator(ABC): ...@@ -52,8 +53,9 @@ class BaseSimulator(ABC):
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
device_type: 设备类型 device_type: 设备类型
mqtt_manager: MQTT 管理器实例 mqtt_manager: MQTT 管理器实例
report_interval: 上报间隔(秒) report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射,key 为设备类型,value 为主题前缀 topics: 多主题前缀映射,key 为设备类型,value 为主题前缀
topic_params: 主题动态参数,如 {'room_id': 'A101', 'company_id': '001'}
""" """
self.device_id = device_id self.device_id = device_id
self.env_config_id = env_config_id self.env_config_id = env_config_id
...@@ -61,10 +63,18 @@ class BaseSimulator(ABC): ...@@ -61,10 +63,18 @@ class BaseSimulator(ABC):
self.mqtt = mqtt_manager self.mqtt = mqtt_manager
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._report_interval = report_interval self._report_config = report_config or {}
self._report_interval = self._report_config.get('interval', 30)
self._report_enabled = self._report_config.get('enabled', True)
self._on_report_callback = None self._on_report_callback = None
self._lock = threading.Lock() self._lock = threading.Lock()
self.topics = topics or {} self.topics = topics or {}
self.topic_params = topic_params or {}
# 解析真实主题模板
self._resolved_topics: dict = {}
self._has_real_topics: bool = False
self._resolve_topics()
def get_topic_prefix(self) -> str: def get_topic_prefix(self) -> str:
""" """
...@@ -79,6 +89,72 @@ class BaseSimulator(ABC): ...@@ -79,6 +89,72 @@ class BaseSimulator(ABC):
return self.topics[self.device_type] return self.topics[self.device_type]
return f"device/{self.device_type}" return f"device/{self.device_type}"
def _resolve_topics(self) -> None:
"""
解析主题模板,用 topic_params 替换占位符生成实际主题
如果设备类型有真实主题模板(门口屏/无纸化),则使用真实主题;
否则标记为无真实主题,使用旧格式 fallback。
"""
from app.simulators.topic_templates import (
has_real_topics,
resolve_all_topics,
)
self._has_real_topics = has_real_topics(self.device_type)
if self._has_real_topics:
# 确保topic_params中包含device_id(用于主题模板中的{device_id}占位符)
params_with_device_id = dict(self.topic_params)
if 'device_id' not in params_with_device_id:
params_with_device_id['device_id'] = self.device_id
self._resolved_topics = resolve_all_topics(self.device_type, params_with_device_id)
logger.info(f"设备 {self.device_id} 解析主题: {self._resolved_topics}")
def get_resolved_topic(self, template_key: str) -> Optional[str]:
"""
获取解析后的主题
Args:
template_key: 模板 key,如 "conference", "meeting_message"
Returns:
str: 解析后的实际主题,如不存在返回 None
"""
if template_key in self._resolved_topics:
return self._resolved_topics[template_key]["topic"]
return None
def get_publish_topics(self) -> List[dict]:
"""
获取所有上报主题
Returns:
List[dict]: 上报主题列表,每项包含 key, topic, label
"""
if not self._has_real_topics:
return []
return [
{"key": k, "topic": v["topic"], "label": v["label"]}
for k, v in self._resolved_topics.items()
if v["direction"] == "publish"
]
def get_subscribe_topics(self) -> List[dict]:
"""
获取所有订阅主题
Returns:
List[dict]: 订阅主题列表,每项包含 key, topic, label
"""
if not self._has_real_topics:
return []
return [
{"key": k, "topic": v["topic"], "label": v["label"]}
for k, v in self._resolved_topics.items()
if v["direction"] == "subscribe"
]
@abstractmethod @abstractmethod
def build_register_payload(self) -> dict: def build_register_payload(self) -> dict:
""" """
...@@ -99,6 +175,126 @@ class BaseSimulator(ABC): ...@@ -99,6 +175,126 @@ class BaseSimulator(ABC):
""" """
pass pass
def build_reboot_response_payload(self) -> dict:
"""
构建 rebootResponseTopic 消息体
包含设备所有固定/模拟字段,符合 Mqtt_Send.py 的消息格式。
Returns:
dict: reboot 响应消息内容
"""
import random
import json
from datetime import datetime
# 从 topic_params 获取动态参数
company_id = self.topic_params.get("company_id", "001")
room_id = self.topic_params.get("room_id", "A101")
app_token = self.topic_params.get("app_token", "AND-518-0001")
conference_id = self.topic_params.get("conference_id", "")
conference_name = self.topic_params.get("conference_name", "")
# 内存和存储
total_mem = 1957
free_mem = random.randint(600, 900)
internal_total = 11075297280
internal_free = random.randint(9700000000, 11000000000)
# 系统时间
sys_date_obj = datetime.now()
sys_date = sys_date_obj.strftime("%a %b %d %H:%M:%S GMT+08:00 %Y")
sys_date_str = sys_date_obj.strftime("%Y-%m-%d %H:%M:%S")
# 运行时长(模拟)
seed = hash(self.device_id) % 86400
hours = seed // 3600
minutes = (seed % 3600) // 60
seconds = seed % 60
sys_elapsed_realtime = f"{hours}:{minutes:02d}:{seconds:02d}"
# 构建 buildInfo
build_info = {
"appToken": app_token,
"companyNumber": company_id,
"cnum": room_id,
"conferenceName": conference_name,
"conferenceId": int(conference_id) if conference_id else 0,
"defaultQrCodeUrl": None,
"aliasName": conference_name,
"serverBaseUrl": "https://192.168.9.86/exapi/",
"localBindTime": sys_date_str,
"generalField": json.dumps({
"floorPlanPath": "",
"conferencePhone": "",
"chooseTimeType": 1,
"meetingTopicSwitch": "1",
"meetingContentSwitch": "1",
"meetingReverseTypeSwitch": "1",
"seatArrangeSwitch": "0",
"meetingVoteSwitch": "1",
"jumpToPaperless": 2,
"meetingPublishScreenSwitch": "1",
"isLeaderJoin": False,
"approvalList": []
})
}
# 构建 result 对象
result_data = {
"result": {
"buildInfo": build_info,
"faceVersion": "4.2.12021020201.1",
"wgtVersion": "0.2.134",
"deviceModel": "Pad10-Pro",
"abiList": ["arm64-v8a", "armeabi-v7a", "armeabi"],
"androidId": self.device_id,
"appName": "UBAINS",
"appPackageName": "com.ubains.local.gviewer",
"appVersion": 351,
"appVersionName": "1.3.351",
"battery": round(random.uniform(0.3, 1.0), 1),
"bluetoothEnabled": False,
"camerasCount": 1,
"charging": True,
"density": 1,
"freeAndTotalMemory": f"{free_mem}M/{total_mem}M",
"internalAvailableSize": internal_free,
"internalTotalSize": internal_total,
"ipAddress": ".".join(str(random.randint(1, 255)) for _ in range(4)),
"macAddress": f"{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}:{random.randint(0,255):02X}",
"networkType": "NETWORK_WIFI",
"processCpuUsage": f"{random.randint(10, 50)}.{random.randint(0, 99):02d}%",
"resolution": "1920x1200",
"romName": "yuxian",
"rooted": True,
"sdkVersionCode": 30,
"sdkVersionName": "11",
"sysDate": sys_date,
"sysDateStr": sys_date_str,
"sysElapsedRealtime": sys_elapsed_realtime,
"sysLanguage": "en",
"sysSupportedSensors": ["Accelerometer sensor"],
"buildConfig": {
"FLAVOR_APP_TYPE": "rksystem",
"BUILD_TYPE": "release",
"FLAVOR_FEATURE": "doorscreen",
"DEBUG": False,
"FLAVOR": "rksystemDoorscreen",
},
"faceBuildInfo": "arcfacepro4x",
"authCode": app_token,
"conferenceName": conference_name
}
}
# 外层消息结构
return {
"method": "/system/readSystemInfo",
"clientId": self.device_id,
"result": json.dumps(result_data, ensure_ascii=False)
}
@abstractmethod @abstractmethod
def _on_command(self, topic: str, payload: dict) -> None: def _on_command(self, topic: str, payload: dict) -> None:
""" """
...@@ -155,6 +351,9 @@ class BaseSimulator(ABC): ...@@ -155,6 +351,9 @@ class BaseSimulator(ABC):
2. 订阅平台下发指令主题 2. 订阅平台下发指令主题
3. 开启定时上报线程 3. 开启定时上报线程
如果设备类型有真实主题模板(门口屏/无纸化),使用真实主题;
否则使用旧格式 {prefix}/{device_type}/{device_id}/{action}。
Returns: Returns:
bool: 是否启动成功 bool: 是否启动成功
""" """
...@@ -165,20 +364,63 @@ class BaseSimulator(ABC): ...@@ -165,20 +364,63 @@ class BaseSimulator(ABC):
try: try:
# 1. 发送注册消息 # 1. 发送注册消息
register_topic = f"{self.get_topic_prefix()}/{self.device_id}/register" if self._has_real_topics:
register_payload = self.build_register_payload() # 使用真实主题:注册消息发送到第一个 publish 主题
publish_topics = self.get_publish_topics()
if publish_topics:
register_topic_info = publish_topics[0]
register_topic = register_topic_info["topic"]
topic_key = register_topic_info.get("key", "")
# 根据主题 key 构建不同的注册消息体
if topic_key == "reboot_response":
register_payload = self.build_reboot_response_payload()
elif topic_key == "android_broadcast":
# 安卓广播主题:心跳上报
register_payload = {
"type": "heartbeat",
"clientId": self.device_id,
"appId": "com.ubains.local.gviewer",
"deviceId": self.device_id,
}
else:
register_payload = self.build_register_payload()
else:
register_topic = self.get_topic_prefix()
register_payload = self.build_register_payload()
else:
# 旧格式
register_topic = f"{self.get_topic_prefix()}/{self.device_id}/register"
register_payload = self.build_register_payload()
reg_success = self.mqtt.publish(self.env_config_id, register_topic, register_payload) reg_success = self.mqtt.publish(self.env_config_id, register_topic, register_payload)
self._notify_report(register_topic, register_payload, self._notify_report(register_topic, register_payload,
status="success" if reg_success else "failed") status="success" if reg_success else "failed")
# 2. 订阅平台下发指令主题 # 2. 订阅平台下发指令主题
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command" if self._has_real_topics:
sub_success = self.mqtt.subscribe(self.env_config_id, command_topic, self._on_command) # 使用真实主题:订阅所有 subscribe 方向的主题
if not sub_success: for sub_info in self.get_subscribe_topics():
logger.warning(f"订阅指令主题失败: {command_topic}") sub_success = self.mqtt.subscribe(self.env_config_id, sub_info["topic"], self._on_command)
if not sub_success:
logger.warning(f"订阅指令主题失败: {sub_info['topic']}")
else:
logger.info(f"订阅指令主题成功: {sub_info['topic']} ({sub_info['label']})")
else:
# 旧格式
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
sub_success = self.mqtt.subscribe(self.env_config_id, command_topic, self._on_command)
if not sub_success:
logger.warning(f"订阅指令主题失败: {command_topic}")
# 3. 发送初始状态 # 3. 发送初始状态
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" if self._has_real_topics:
# 使用真实主题:状态消息发送到第一个 publish 主题
if publish_topics:
status_topic = publish_topics[0]["topic"]
else:
status_topic = self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
status_payload = self.build_status_payload() status_payload = self.build_status_payload()
status_payload["status"] = "online" status_payload["status"] = "online"
self.mqtt.publish(self.env_config_id, status_topic, status_payload) self.mqtt.publish(self.env_config_id, status_topic, status_payload)
...@@ -207,6 +449,7 @@ class BaseSimulator(ABC): ...@@ -207,6 +449,7 @@ class BaseSimulator(ABC):
流程: 流程:
1. 发送离线状态 1. 发送离线状态
2. 停止定时上报线程 2. 停止定时上报线程
3. 取消订阅指令主题
Returns: Returns:
bool: 是否停止成功 bool: 是否停止成功
...@@ -219,14 +462,23 @@ class BaseSimulator(ABC): ...@@ -219,14 +462,23 @@ class BaseSimulator(ABC):
self._running = False self._running = False
# 发送离线状态 # 发送离线状态
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" if self._has_real_topics:
publish_topics = self.get_publish_topics()
offline_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
offline_payload = {"status": "offline", "device_id": self.device_id} offline_payload = {"status": "offline", "device_id": self.device_id}
self.mqtt.publish(self.env_config_id, offline_topic, offline_payload) self.mqtt.publish(self.env_config_id, offline_topic, offline_payload)
self._notify_report(offline_topic, offline_payload, status="success") self._notify_report(offline_topic, offline_payload, status="success")
# 取消订阅指令主题 # 取消订阅指令主题
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command" if self._has_real_topics:
self.mqtt.unsubscribe(self.env_config_id, command_topic) for sub_info in self.get_subscribe_topics():
self.mqtt.unsubscribe(self.env_config_id, sub_info["topic"])
else:
command_topic = f"{self.get_topic_prefix()}/{self.device_id}/command"
self.mqtt.unsubscribe(self.env_config_id, command_topic)
logger.info(f"模拟设备停止成功: {self.device_id}") logger.info(f"模拟设备停止成功: {self.device_id}")
return True return True
...@@ -269,13 +521,51 @@ class BaseSimulator(ABC): ...@@ -269,13 +521,51 @@ class BaseSimulator(ABC):
定时上报循环 定时上报循环
在独立线程中运行,定时上报设备状态。 在独立线程中运行,定时上报设备状态。
如果有真实主题模板,轮流使用各 publish 主题上报;
否则使用旧格式。
""" """
# 检查是否启用定时上报
if not self._report_enabled:
logger.info(f"定时上报已禁用: {self.device_id}")
return
logger.info(f"定时上报线程启动: {self.device_id}, 间隔={self._report_interval}s") logger.info(f"定时上报线程启动: {self.device_id}, 间隔={self._report_interval}s")
# 轮流使用各 publish 主题的索引
publish_index = 0
while self._running: while self._running:
try: try:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" status_payload = None
status_payload = self.build_status_payload() if self._has_real_topics:
# 使用真实主题:轮流使用各 publish 主题上报
publish_topics = self.get_publish_topics()
if publish_topics:
topic_info = publish_topics[publish_index % len(publish_topics)]
status_topic = topic_info["topic"]
topic_key = topic_info.get("key", "")
publish_index += 1
# 根据主题 key 构建不同的消息体
if topic_key == "reboot_response":
status_payload = self.build_reboot_response_payload()
elif topic_key == "android_broadcast":
# 安卓广播主题:心跳上报
status_payload = {
"type": "heartbeat",
"clientId": self.device_id,
"appId": "com.ubains.local.gviewer",
"deviceId": self.device_id,
}
else:
status_payload = self.build_status_payload()
else:
status_topic = self.get_topic_prefix()
status_payload = self.build_status_payload()
else:
# 旧格式
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
status_payload = self.build_status_payload()
success = self.mqtt.publish(self.env_config_id, status_topic, status_payload) success = self.mqtt.publish(self.env_config_id, status_topic, status_payload)
self._notify_report(status_topic, status_payload, self._notify_report(status_topic, status_payload,
status="success" if success else "failed") status="success" if success else "failed")
......
...@@ -97,10 +97,11 @@ class CentralSimulator(BaseSimulator): ...@@ -97,10 +97,11 @@ class CentralSimulator(BaseSimulator):
} }
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None): topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
super().__init__(device_id, env_config_id, "central", mqtt_manager, super().__init__(device_id, env_config_id, "central", mqtt_manager,
report_interval, topics) report_config, topics, topic_params)
self._sub_devices = {} self._sub_devices = {}
self._current_scene = "power_off" self._current_scene = "power_off"
self._init_sub_devices() self._init_sub_devices()
......
...@@ -42,8 +42,9 @@ class ClientSimulator(BaseSimulator): ...@@ -42,8 +42,9 @@ class ClientSimulator(BaseSimulator):
_VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"] _VERSIONS = ["V1.2.0", "V1.3.0", "V1.4.0", "V2.0.0", "V2.1.0"]
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 5, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None): topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
""" """
初始化集控客户端模拟器 初始化集控客户端模拟器
...@@ -51,11 +52,12 @@ class ClientSimulator(BaseSimulator): ...@@ -51,11 +52,12 @@ class ClientSimulator(BaseSimulator):
device_id: 设备 ID device_id: 设备 ID
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器 mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒),集控客户端心跳频率更高 report_config: 上报配置,如 {'interval': 5, 'enabled': True}
topics: 多主题前缀映射 topics: 多主题前缀映射
topic_params: 主题动态参数
""" """
super().__init__(device_id, env_config_id, "client", mqtt_manager, super().__init__(device_id, env_config_id, "client", mqtt_manager,
report_interval, topics) report_config, topics, topic_params)
self._version = random.choice(self._VERSIONS) self._version = random.choice(self._VERSIONS)
self._heartbeat_count = 0 self._heartbeat_count = 0
self._log_level = "info" self._log_level = "info"
......
...@@ -38,8 +38,9 @@ class DoorSimulator(BaseSimulator): ...@@ -38,8 +38,9 @@ class DoorSimulator(BaseSimulator):
""" """
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None): topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
""" """
初始化门口屏模拟器 初始化门口屏模拟器
...@@ -47,11 +48,12 @@ class DoorSimulator(BaseSimulator): ...@@ -47,11 +48,12 @@ class DoorSimulator(BaseSimulator):
device_id: 设备 ID device_id: 设备 ID
env_config_id: 环境配置 ID env_config_id: 环境配置 ID
mqtt_manager: MQTT 管理器 mqtt_manager: MQTT 管理器
report_interval: 上报间隔(秒) report_config: 上报配置,如 {'interval': 30, 'enabled': True}
topics: 多主题前缀映射 topics: 多主题前缀映射
topic_params: 主题动态参数
""" """
super().__init__(device_id, env_config_id, "door", mqtt_manager, super().__init__(device_id, env_config_id, "door", mqtt_manager,
report_interval, topics) report_config, topics, topic_params)
self._door_status = "closed" self._door_status = "closed"
self._battery_level = random.randint(60, 100) self._battery_level = random.randint(60, 100)
self._call_active = False self._call_active = False
...@@ -156,7 +158,11 @@ class DoorSimulator(BaseSimulator): ...@@ -156,7 +158,11 @@ class DoorSimulator(BaseSimulator):
def _auto_close_door(self) -> None: def _auto_close_door(self) -> None:
"""模拟自动关门""" """模拟自动关门"""
self._door_status = "closed" self._door_status = "closed"
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" if self._has_real_topics:
publish_topics = self.get_publish_topics()
status_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, status_topic, { self.mqtt.publish(self.env_config_id, status_topic, {
"device_id": self.device_id, "device_id": self.device_id,
"status": "online", "status": "online",
...@@ -168,7 +174,11 @@ class DoorSimulator(BaseSimulator): ...@@ -168,7 +174,11 @@ class DoorSimulator(BaseSimulator):
"""模拟设备重启""" """模拟设备重启"""
import threading as t import threading as t
# 发送离线 # 发送离线
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" if self._has_real_topics:
publish_topics = self.get_publish_topics()
offline_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
offline_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, offline_topic, { self.mqtt.publish(self.env_config_id, offline_topic, {
"device_id": self.device_id, "status": "offline", "reason": "reboot" "device_id": self.device_id, "status": "offline", "reason": "reboot"
}) })
...@@ -176,7 +186,11 @@ class DoorSimulator(BaseSimulator): ...@@ -176,7 +186,11 @@ class DoorSimulator(BaseSimulator):
def _recover(): def _recover():
time.sleep(5) time.sleep(5)
if self._running: if self._running:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status" if self._has_real_topics:
publish_topics = self.get_publish_topics()
status_topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
status_topic = f"{self.get_topic_prefix()}/{self.device_id}/status"
self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload()) self.mqtt.publish(self.env_config_id, status_topic, self.build_status_payload())
t.Thread(target=_recover, daemon=True).start() t.Thread(target=_recover, daemon=True).start()
......
...@@ -52,10 +52,11 @@ class PaperlessSimulator(BaseSimulator): ...@@ -52,10 +52,11 @@ class PaperlessSimulator(BaseSimulator):
] ]
def __init__(self, device_id: str, env_config_id: str, def __init__(self, device_id: str, env_config_id: str,
mqtt_manager: MqttManager, report_interval: int = 30, mqtt_manager: MqttManager, report_config: Optional[dict] = None,
topics: Optional[dict] = None): topics: Optional[dict] = None,
topic_params: Optional[dict] = None):
super().__init__(device_id, env_config_id, "paperless", mqtt_manager, super().__init__(device_id, env_config_id, "paperless", mqtt_manager,
report_interval, topics) report_config, topics, topic_params)
self._meeting_status = "idle" self._meeting_status = "idle"
self._signed_in = False self._signed_in = False
self._file_sync_progress = 0 self._file_sync_progress = 0
...@@ -187,8 +188,15 @@ class PaperlessSimulator(BaseSimulator): ...@@ -187,8 +188,15 @@ class PaperlessSimulator(BaseSimulator):
time.sleep(1) time.sleep(1)
self._file_sync_progress = i * 10 self._file_sync_progress = i * 10
# 上报同步进度 # 上报同步进度
sync_topic = f"{self.get_topic_prefix()}/{self.device_id}/file_sync" if self._has_real_topics:
self.mqtt.publish(self.env_config_id, sync_topic, { # 使用真实主题,找 meeting_message 或第一个 publish 主题
topic = self.get_resolved_topic("meeting_message")
if not topic:
publish_topics = self.get_publish_topics()
topic = publish_topics[0]["topic"] if publish_topics else self.get_topic_prefix()
else:
topic = f"{self.get_topic_prefix()}/{self.device_id}/file_sync"
self.mqtt.publish(self.env_config_id, topic, {
"device_id": self.device_id, "device_id": self.device_id,
"progress": self._file_sync_progress, "progress": self._file_sync_progress,
"file_name": f"meeting_materials_{time.strftime('%Y%m%d')}.pdf", "file_name": f"meeting_materials_{time.strftime('%Y%m%d')}.pdf",
......
...@@ -74,6 +74,8 @@ export interface BatchLocateRequest { ...@@ -74,6 +74,8 @@ export interface BatchLocateRequest {
retry_on_empty?: boolean retry_on_empty?: boolean
/** 最大重试次数(默认 2) */ /** 最大重试次数(默认 2) */
max_retries?: number max_retries?: number
/** 目标菜单名称(如"会议管理"、"信息管理",支持智能推断) */
navigate_menu?: string
} }
/** /**
......
...@@ -51,6 +51,14 @@ ...@@ -51,6 +51,14 @@
> >
{{ row.status === 'running' ? '停止' : '启动' }} {{ row.status === 'running' ? '停止' : '启动' }}
</el-button> </el-button>
<el-button
link
type="primary"
size="small"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button <el-button
link link
type="primary" type="primary"
...@@ -81,7 +89,7 @@ ...@@ -81,7 +89,7 @@
</div> </div>
<!-- 新增设备弹窗 --> <!-- 新增设备弹窗 -->
<el-dialog v-model="createDialogVisible" title="新增模拟设备" width="500px"> <el-dialog v-model="createDialogVisible" title="新增模拟设备" width="600px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" label-position="top"> <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px" label-position="top">
<el-form-item label="关联环境" prop="envConfigId"> <el-form-item label="关联环境" prop="envConfigId">
<el-select v-model="createForm.envConfigId" placeholder="选择环境配置" style="width: 100%"> <el-select v-model="createForm.envConfigId" placeholder="选择环境配置" style="width: 100%">
...@@ -94,9 +102,77 @@ ...@@ -94,9 +102,77 @@
<el-form-item label="设备 ID" prop="deviceId"> <el-form-item label="设备 ID" prop="deviceId">
<el-input v-model="createForm.deviceId" :placeholder="`如:${deviceType}_001`" /> <el-input v-model="createForm.deviceId" :placeholder="`如:${deviceType}_001`" />
</el-form-item> </el-form-item>
<!-- 门口屏授权码选择 -->
<el-form-item v-if="hasRealTopics && deviceType === 'door' && appTokenOptions.length > 0" label="门口屏授权码">
<el-select v-model="createForm.topicParams!.app_token" placeholder="选择授权码" style="width: 100%" size="small">
<el-option v-for="token in appTokenOptions" :key="token" :label="token" :value="token" />
</el-select>
</el-form-item>
<el-form-item label="自动重连"> <el-form-item label="自动重连">
<el-switch v-model="createForm.autoReconnect" /> <el-switch v-model="createForm.autoReconnect" />
</el-form-item> </el-form-item>
<!-- 上报配置区域 -->
<el-divider content-position="left">上报配置</el-divider>
<el-form-item label="启用定时上报">
<el-switch v-model="createForm.reportConfig!.enabled" @change="onReportEnabledChange" />
</el-form-item>
<el-form-item label="上报间隔(秒)" v-if="createForm.reportConfig?.enabled">
<el-input-number
v-model="createForm.reportConfig!.interval"
:min="1"
:max="86400"
:step="1"
placeholder="请输入上报间隔"
style="width: 200px"
/>
<span class="form-hint" style="margin-left: 10px; color: #909399; font-size: 12px;">
范围: 1秒 ~ 24小时(86400秒)
</span>
</el-form-item>
<!-- 主题参数配置(仅门口屏/无纸化显示)- 已隐藏 -->
<!-- <el-divider v-if="hasRealTopics" content-position="left">主题参数配置</el-divider>
<div v-if="hasRealTopics && topicTemplates.length > 0" class="topic-params-section">
<div v-for="tmpl in topicTemplates" :key="tmpl.key" class="topic-template-item">
<div class="topic-template-header">
<el-tag :type="tmpl.direction === 'publish' ? 'primary' : 'success'" size="small" class="direction-tag">
{{ tmpl.direction === 'publish' ? '上报' : '订阅' }}
</el-tag>
<span class="topic-label">{{ tmpl.label }}</span>
<span class="topic-template-path">{{ tmpl.template }}</span>
</div>
<div v-if="tmpl.params.length > 0" class="topic-param-inputs">
<el-row :gutter="12">
<el-col v-for="paramKey in tmpl.params" :key="paramKey" :span="12">
<el-form-item :label="tmpl.paramLabels[paramKey] || paramKey" class="compact-form-item">
<el-input
v-if="paramKey === 'company_id'"
v-model="(createForm.topicParams as any)[paramKey]"
size="small"
disabled
/>
<el-select
v-else-if="paramKey === 'room_id' && defaultTopicParams?.room_ids?.length"
v-model="(createForm.topicParams as any)[paramKey]"
size="small"
style="width: 100%"
placeholder="选择会议室"
>
<el-option v-for="room in defaultTopicParams.room_ids" :key="room" :label="room" :value="room" />
</el-select>
<el-input
v-else
v-model="(createForm.topicParams as any)[paramKey]"
:placeholder="tmpl.paramDefaults[paramKey] || `请输入${tmpl.paramLabels[paramKey] || paramKey}`"
size="small"
/>
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</div> -->
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createDialogVisible = false">取消</el-button> <el-button @click="createDialogVisible = false">取消</el-button>
...@@ -124,6 +200,22 @@ ...@@ -124,6 +200,22 @@
<el-button type="primary" :loading="reporting" @click="handleManualReport">发送</el-button> <el-button type="primary" :loading="reporting" @click="handleManualReport">发送</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 编辑设备弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑设备" width="500px">
<el-form ref="editFormRef" :model="editForm" label-width="100px" label-position="top">
<el-form-item label="设备名称">
<el-input v-model="editForm.deviceName" />
</el-form-item>
<el-form-item label="自动重连">
<el-switch v-model="editForm.autoReconnect" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editing" @click="handleEdit">保存</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
...@@ -134,13 +226,15 @@ import { Plus } from '@element-plus/icons-vue' ...@@ -134,13 +226,15 @@ import { Plus } from '@element-plus/icons-vue'
import { import {
listSimulators, listSimulators,
createSimulator, createSimulator,
updateSimulator,
deleteSimulator, deleteSimulator,
startSimulator, startSimulator,
stopSimulator, stopSimulator,
manualReport, manualReport,
listEnvConfigs, listEnvConfigs,
getTopicTemplates,
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType } from '@/types/device' import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device' import { DEVICE_TYPE_LABELS } from '@/types/device'
const props = defineProps<{ const props = defineProps<{
...@@ -176,6 +270,11 @@ const createForm = ref<SimulatorCreate>({ ...@@ -176,6 +270,11 @@ const createForm = ref<SimulatorCreate>({
deviceName: '', deviceName: '',
deviceId: '', deviceId: '',
autoReconnect: true, autoReconnect: true,
topicParams: {},
reportConfig: {
interval: 30,
enabled: true,
},
}) })
const createRules = { const createRules = {
envConfigId: [{ required: true, message: '请选择环境配置', trigger: 'change' }], envConfigId: [{ required: true, message: '请选择环境配置', trigger: 'change' }],
...@@ -183,12 +282,52 @@ const createRules = { ...@@ -183,12 +282,52 @@ const createRules = {
deviceId: [{ required: true, message: '请输入设备 ID', trigger: 'blur' }], deviceId: [{ required: true, message: '请输入设备 ID', trigger: 'blur' }],
} }
// 主题模板
const topicTemplates = ref<TopicTemplate[]>([])
const hasRealTopics = ref(false)
const loadingTemplates = ref(false)
// 环境配置的默认主题参数
const defaultTopicParams = ref<{
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null>(null)
// 授权码选项列表
const appTokenOptions = computed(() => {
if (!defaultTopicParams.value?.app_token_prefix) return []
const prefix = defaultTopicParams.value.app_token_prefix
const start = defaultTopicParams.value.app_token_start ?? 1
const end = defaultTopicParams.value.app_token_end ?? 10
const options: string[] = []
for (let i = start; i <= end; i++) {
options.push(`${prefix}-${String(i).padStart(4, '0')}`)
}
return options
})
// 手动上报弹窗 // 手动上报弹窗
const reportDialogVisible = ref(false) const reportDialogVisible = ref(false)
const reporting = ref(false) const reporting = ref(false)
const reportFormRef = ref() const reportFormRef = ref()
const reportForm = ref({ topic: '', payloadStr: '{}', deviceId: '' }) const reportForm = ref({ topic: '', payloadStr: '{}', deviceId: '' })
// 编辑设备弹窗
const editDialogVisible = ref(false)
const editing = ref(false)
const editFormRef = ref()
const editForm = ref({
id: '',
deviceName: '',
autoReconnect: true,
topicParams: {} as Record<string, string>,
})
/** 加载设备列表 */ /** 加载设备列表 */
async function loadDevices() { async function loadDevices() {
loading.value = true loading.value = true
...@@ -219,16 +358,88 @@ async function loadEnvConfigs() { ...@@ -219,16 +358,88 @@ async function loadEnvConfigs() {
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
} }
/** 加载主题模板 */
async function loadTopicTemplates() {
if (!props.deviceType) return
loadingTemplates.value = true
try {
const res = await getTopicTemplates(props.deviceType)
topicTemplates.value = res.templates
hasRealTopics.value = res.hasRealTopics
} catch (_) {
topicTemplates.value = []
hasRealTopics.value = false
} finally {
loadingTemplates.value = false
}
}
/** 打开新增弹窗 */ /** 打开新增弹窗 */
function openCreateDialog() { async function openCreateDialog() {
createForm.value = { createForm.value = {
envConfigId: filterEnvConfigId.value || '', envConfigId: filterEnvConfigId.value || '',
deviceType: props.deviceType, deviceType: props.deviceType,
deviceName: '', deviceName: '',
deviceId: '', deviceId: '',
autoReconnect: true, autoReconnect: true,
topicParams: {},
reportConfig: {
interval: 30,
enabled: true,
},
} }
defaultTopicParams.value = null
createDialogVisible.value = true createDialogVisible.value = true
// 加载主题模板
await loadTopicTemplates()
// 如果有预选环境,加载其默认参数
if (createForm.value.envConfigId) {
await loadDefaultTopicParams(createForm.value.envConfigId)
}
}
/** 加载环境配置的默认主题参数 */
async function loadDefaultTopicParams(envConfigId: string) {
const env = envConfigs.value.find(e => e.id === envConfigId)
if (env?.defaultTopicParams) {
defaultTopicParams.value = env.defaultTopicParams
// 预填默认参数值
if (hasRealTopics.value) {
const defaults: Record<string, string> = {}
// 使用环境配置的 company_id
if (env.defaultTopicParams.company_id) {
defaults.company_id = env.defaultTopicParams.company_id
}
// 使用环境配置的第一个 room_id
if (env.defaultTopicParams.room_ids?.length) {
defaults.room_id = env.defaultTopicParams.room_ids[0]
}
// 其他参数使用模板默认值
for (const tmpl of topicTemplates.value) {
for (const paramKey of tmpl.params) {
if (!defaults[paramKey] && tmpl.paramDefaults[paramKey]) {
defaults[paramKey] = tmpl.paramDefaults[paramKey]
}
}
}
createForm.value.topicParams = defaults
}
}
}
/** 上报启用状态变化 */
function onReportEnabledChange(enabled: boolean) {
if (enabled && !createForm.value.reportConfig?.interval) {
// 启用时确保 interval 有默认值
createForm.value.reportConfig = {
...createForm.value.reportConfig,
interval: 30,
enabled: true,
}
}
} }
/** 创建设备 */ /** 创建设备 */
...@@ -282,6 +493,36 @@ async function handleDelete(row: Simulator) { ...@@ -282,6 +493,36 @@ async function handleDelete(row: Simulator) {
} }
} }
/** 打开编辑弹窗 */
function openEditDialog(row: Simulator) {
editForm.value = {
id: row.id,
deviceName: row.deviceName,
autoReconnect: row.autoReconnect,
topicParams: {},
}
editDialogVisible.value = true
}
/** 保存编辑 */
async function handleEdit() {
editing.value = true
try {
await updateSimulator(editForm.value.id, {
deviceName: editForm.value.deviceName,
autoReconnect: editForm.value.autoReconnect,
})
ElMessage.success('设备已更新')
editDialogVisible.value = false
await loadDevices()
emit('device-changed')
} catch (e: any) {
ElMessage.error('更新失败: ' + (e.message || ''))
} finally {
editing.value = false
}
}
/** 打开手动上报弹窗 */ /** 打开手动上报弹窗 */
function openManualReport(row: Simulator) { function openManualReport(row: Simulator) {
reportForm.value = { topic: 'status', payloadStr: '{}', deviceId: row.id } reportForm.value = { topic: 'status', payloadStr: '{}', deviceId: row.id }
...@@ -342,4 +583,61 @@ onMounted(() => { ...@@ -342,4 +583,61 @@ onMounted(() => {
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 16px;
} }
.topic-params-section {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
margin-bottom: 12px;
}
.topic-template-item {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.topic-template-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.topic-template-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.direction-tag {
flex-shrink: 0;
}
.topic-label {
font-weight: 500;
color: #303133;
}
.topic-template-path {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #909399;
margin-left: auto;
}
.topic-param-inputs {
padding-left: 8px;
}
.compact-form-item {
margin-bottom: 8px;
}
.compact-form-item :deep(.el-form-item__label) {
font-size: 13px;
padding-bottom: 2px;
}
</style> </style>
\ No newline at end of file
...@@ -36,6 +36,15 @@ export interface EnvConfig { ...@@ -36,6 +36,15 @@ export interface EnvConfig {
topics: Record<string, string> | null topics: Record<string, string> | null
useTls: boolean useTls: boolean
clientIdPrefix: string clientIdPrefix: string
defaultTopicParams: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
status: string status: string
lastConnectedAt: string | null lastConnectedAt: string | null
createdAt: string createdAt: string
...@@ -53,6 +62,15 @@ export interface EnvConfigCreate { ...@@ -53,6 +62,15 @@ export interface EnvConfigCreate {
topics?: Record<string, string> | null topics?: Record<string, string> | null
useTls?: boolean useTls?: boolean
clientIdPrefix?: string clientIdPrefix?: string
defaultTopicParams?: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
} }
/** 更新环境配置请求 */ /** 更新环境配置请求 */
...@@ -66,6 +84,15 @@ export interface EnvConfigUpdate { ...@@ -66,6 +84,15 @@ export interface EnvConfigUpdate {
topics?: Record<string, string> | null topics?: Record<string, string> | null
useTls?: boolean useTls?: boolean
clientIdPrefix?: string clientIdPrefix?: string
defaultTopicParams?: {
company_id?: string
room_ids?: string[]
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
conference_id?: string
conference_name?: string
} | null
} }
/** 模拟设备 */ /** 模拟设备 */
...@@ -79,6 +106,10 @@ export interface Simulator { ...@@ -79,6 +106,10 @@ export interface Simulator {
autoReconnect: boolean autoReconnect: boolean
extraAttrs: Record<string, any> | null extraAttrs: Record<string, any> | null
topicParams: Record<string, string> | null topicParams: Record<string, string> | null
reportConfig?: {
interval: number // 上报间隔(秒)
enabled?: boolean // 是否启用定时上报
}
lastReportedAt: string | null lastReportedAt: string | null
totalReports: number totalReports: number
createdAt: string createdAt: string
...@@ -94,6 +125,10 @@ export interface SimulatorCreate { ...@@ -94,6 +125,10 @@ export interface SimulatorCreate {
autoReconnect?: boolean autoReconnect?: boolean
extraAttrs?: Record<string, any> | null extraAttrs?: Record<string, any> | null
topicParams?: Record<string, string> | null topicParams?: Record<string, string> | null
reportConfig?: {
interval: number
enabled?: boolean
}
} }
/** 更新模拟设备请求 */ /** 更新模拟设备请求 */
...@@ -102,6 +137,10 @@ export interface SimulatorUpdate { ...@@ -102,6 +137,10 @@ export interface SimulatorUpdate {
autoReconnect?: boolean autoReconnect?: boolean
extraAttrs?: Record<string, any> | null extraAttrs?: Record<string, any> | null
topicParams?: Record<string, string> | null topicParams?: Record<string, string> | null
reportConfig?: {
interval: number
enabled?: boolean
}
} }
/** 上报记录 */ /** 上报记录 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论