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

feat(device-sim): 无纸化设备消息体修复 + 环境配置支持无纸化授权码

- 无纸化模拟器新增 build_reboot_response_payload() 和 build_heartbeat_payload()

- deviceModel: 门口屏 YT-10, 无纸化 Pad-10Pro

- 授权码逻辑: 无纸化使用 paperless_token_prefix 或 AND->ANP 替换

- 主题模板: 无纸化新增 rebootResponseTopic 和 /uams/android/broadcast

- 环境配置页面新增无纸化授权码配置区域
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 e05c975f
......@@ -246,7 +246,7 @@ class BaseSimulator(ABC):
"buildInfo": build_info,
"faceVersion": "4.2.12021020201.1",
"wgtVersion": "0.2.134",
"deviceModel": "Pad10-Pro",
"deviceModel": "YT-10",
"abiList": ["arm64-v8a", "armeabi-v7a", "armeabi"],
"androidId": self.device_id,
"appName": "UBAINS",
......@@ -550,6 +550,10 @@ class BaseSimulator(ABC):
status_payload = self.build_reboot_response_payload()
elif topic_key == "android_broadcast":
# 安卓广播主题:心跳上报
# 优先使用子类的 build_heartbeat_payload 方法
if hasattr(self, 'build_heartbeat_payload'):
status_payload = self.build_heartbeat_payload()
else:
status_payload = {
"type": "heartbeat",
"clientId": self.device_id,
......
......@@ -6,13 +6,14 @@
作者:czj
创建日期:2026-07-29
最后修改:2026-07-29
最后修改:2026-08-05
"""
import json
import logging
import random
import time
from datetime import datetime
from typing import Optional
from app.simulators.base_simulator import BaseSimulator
......@@ -25,8 +26,8 @@ class PaperlessSimulator(BaseSimulator):
"""
无纸化会议设备模拟器
模拟无纸化会议终端,支持:
- 设备注册与状态上报
模拟无纸化会议终端(华为平板 BAH3-W59),支持:
- 设备注册与状态上报(rebootResponseTopic + /uams/android/broadcast)
- 参会人员签到/签退
- 会议文件同步状态
- 投屏开始/结束/异常
......@@ -63,6 +64,131 @@ class PaperlessSimulator(BaseSimulator):
self._screen_sharing = False
self._current_agenda = ""
def build_reboot_response_payload(self) -> dict:
"""
构建无纸化设备的 rebootResponseTopic 消息体
无纸化设备与门口屏消息体有差异:
- appName: "无纸化2.0"
- deviceModel: "BAH3-W59"(华为平板)
- wgtVersion: "0.0.31"
- buildInfo: 空字符串
- faceVersion: 空字符串
- authCode: 格式如 "ANP-P7J-0020"
- conferenceName: 会议室名称
Returns:
dict: reboot 响应消息内容
"""
# 从 topic_params 获取动态参数(与门口屏一致,仅前缀 AND→ANP)
conference_name = self.topic_params.get("conference_name", "测试会议室1")
# 无纸化授权码:优先使用 paperless_token_prefix,否则将门口屏的 AND 前缀替换为 ANP
paperless_prefix = self.topic_params.get("paperless_token_prefix", "")
if paperless_prefix:
# 使用环境配置中的无纸化前缀 + 序号
token_start = int(self.topic_params.get("paperless_token_start", 1))
token_seq = hash(self.device_id) % 10000 + token_start
auth_code = f"{paperless_prefix}-{token_seq:04d}"
else:
# 回退:将门口屏的 AND 前缀替换为 ANP
app_token = self.topic_params.get("app_token", "AND-518-0001")
auth_code = app_token.replace("AND-", "ANP-", 1)
# 内存和存储(华为平板 BAH3-W59 配置)
total_mem = 3781
free_mem = random.randint(1000, 2500)
internal_total = 53768880128 # 约 50GB
internal_free = random.randint(20000000000, 40000000000)
# 系统时间
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) % 200000 # 最大约 55 小时
hours = seed // 3600
minutes = (seed % 3600) // 60
seconds = seed % 60
sys_elapsed_realtime = f"{hours}:{minutes:02d}:{seconds:02d}"
# 传感器列表(华为平板传感器)
sensors = [
"accelerometer-kx021",
"mag-mmc5603",
"orientation",
"als-tcs3707",
"gravity",
"linear Acceleration",
"rotation Vector",
"HALL sensor",
"uncalibrated Magnetic Field",
"significant Motion",
"geomagnetic Rotation Vector",
"phonecall sensor"
]
# 构建 result 对象(无纸化设备特有格式)
result_data = {
"result": {
"buildInfo": "", # 无纸化设备此字段为空
"faceVersion": "", # 无纸化设备此字段为空
"wgtVersion": "0.0.31",
"deviceModel": "Pad-10Pro",
"abiList": ["arm64-v8a", "armeabi-v7a", "armeabi"],
"androidId": self.device_id,
"appName": "无纸化2.0",
"appPackageName": "com.ubains.local.gviewer",
"appVersion": 414,
"appVersionName": "1.3.414",
"battery": round(random.uniform(0.5, 1.0), 2),
"bluetoothEnabled": False,
"camerasCount": 2,
"charging": random.choice([True, False]),
"density": 2,
"freeAndTotalMemory": f"{free_mem}M/{total_mem}M",
"internalAvailableSize": internal_free,
"internalTotalSize": internal_total,
"ipAddress": f"192.168.{random.randint(1, 254)}.{random.randint(1, 254)}",
"macAddress": ":".join([f"{random.randint(0, 255):02X}" for _ in range(6)]),
"networkType": "NETWORK_WIFI",
"processCpuUsage": "ERR", # 真实设备返回 ERR
"resolution": "2000x1200",
"romName": "huawei",
"rooted": False,
"sdkVersionCode": 29,
"sdkVersionName": "10",
"sysDate": sys_date,
"sysDateStr": sys_date_str,
"sysElapsedRealtime": sys_elapsed_realtime,
"sysLanguage": "zh",
"sysSupportedSensors": sensors,
"authCode": auth_code,
"conferenceName": conference_name
}
}
# 外层消息结构
return {
"method": "/system/readSystemInfo",
"clientId": self.device_id,
"result": json.dumps(result_data, ensure_ascii=False)
}
def build_heartbeat_payload(self) -> dict:
"""
构建 /uams/android/broadcast 心跳消息体
Returns:
dict: 心跳消息内容
"""
return {
"type": "heartbeat",
"clientId": self.device_id,
"appId": "com.ubains.local.gviewer",
"deviceId": self.device_id
}
def build_register_payload(self) -> dict:
"""构建设备注册消息"""
return {
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:topic_templates.py
模块描述:设备类型 MQTT 主题模板定义
每种设备类型定义一组固定主题模板,包含动态参数占位符。
模拟器启动时用 topic_params 替换占位符,生成实际主题。
作者:czj
创建日期:2026-08-03
最后修改:2026-08-03
"""
from typing import Dict, List
# 主题模板结构说明:
# - key: 模板唯一标识
# - template: 主题路径模板(含 {param} 占位符)
# - label: 中文标签
# - params: 需要填写的动态参数名列表
# - param_labels: 参数名 -> 中文标签映射
# - param_defaults: 参数名 -> 默认值映射
# - direction: publish(上报)/ subscribe(接收/订阅)
DEVICE_TOPIC_TEMPLATES: Dict[str, List[dict]] = {
"door": [
# ========== 上报主题(publish)==========
{
"key": "reboot_response",
"template": "rebootResponseTopic",
"label": "重启响应",
"params": [],
"param_labels": {},
"param_defaults": {},
"direction": "publish",
},
{
"key": "android_broadcast",
"template": "/uams/android/broadcast",
"label": "安卓广播",
"params": ["device_id"],
"param_labels": {"device_id": "设备ID"},
"param_defaults": {"device_id": "DS001"},
"direction": "publish",
},
# ========== 订阅主题(subscribe)==========
{
"key": "conference",
"template": "/meeting/conference/{room_id}/",
"label": "会议信息",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
"direction": "subscribe",
},
{
"key": "material_client",
"template": "/material/client/{device_id}/",
"label": "物料客户端",
"params": ["device_id"],
"param_labels": {"device_id": "设备ID"},
"param_defaults": {"device_id": "DS001"},
"direction": "subscribe",
},
{
"key": "conference_service_request",
"template": "/iot/v1/conference/service/request/{company_id}/{room_id}/",
"label": "会议服务请求",
"params": ["company_id", "room_id"],
"param_labels": {"company_id": "公司编号", "room_id": "会议室编号"},
"param_defaults": {"company_id": "001", "room_id": "A101"},
"direction": "subscribe",
},
{
"key": "face_extract",
"template": "/doorscreen/v5/{device_id}/face/extractFeature/imageBase64/list/up",
"label": "人脸特征提取",
"params": ["device_id"],
"param_labels": {"device_id": "设备ID"},
"param_defaults": {"device_id": "DS001"},
"direction": "subscribe",
},
{
"key": "radar_event",
"template": "/iot/v1/device/event/request/millimeterhaveRadar/{room_id}/",
"label": "毫米波雷达事件",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
"direction": "subscribe",
},
{
"key": "meeting_message",
"template": "/meeting/message/{room_id}/",
"label": "会议消息",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
"direction": "subscribe",
},
{
"key": "device_service_request",
"template": "/iot/v1/device/service/request/{company_id}/{device_id}/",
"label": "设备服务请求",
"params": ["company_id", "device_id"],
"param_labels": {"company_id": "公司编号", "device_id": "设备ID"},
"param_defaults": {"company_id": "001", "device_id": "DS001"},
"direction": "subscribe",
},
],
"paperless": [
# ========== 上报主题(publish)==========
{
"key": "reboot_response",
"template": "rebootResponseTopic",
"label": "重启响应",
"params": [],
"param_labels": {},
"param_defaults": {},
"direction": "publish",
},
{
"key": "android_broadcast",
"template": "/uams/android/broadcast",
"label": "安卓广播",
"params": ["device_id"],
"param_labels": {"device_id": "设备ID"},
"param_defaults": {"device_id": "PS001"},
"direction": "publish",
},
{
"key": "meeting_message",
"template": "/meeting/message/{room_id}/",
"label": "会议消息",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
"direction": "publish",
},
{
"key": "meeting_control",
"template": "/iot/v1/meeting/service/request/meetingControl/{company_id}/{mid}/",
"label": "会议控制",
"params": ["company_id", "mid"],
"param_labels": {"company_id": "公司编号", "mid": "会议ID"},
"param_defaults": {"company_id": "001", "mid": "M001"},
"direction": "publish",
},
{
"key": "android_panel",
"template": "/androidPanel/",
"label": "安卓面板",
"params": [],
"param_labels": {},
"param_defaults": {},
"direction": "publish",
},
{
"key": "paperless_service",
"template": "/message/paperLessService/callService",
"label": "无纸化服务调用",
"params": [],
"param_labels": {},
"param_defaults": {},
"direction": "publish",
},
# ========== 订阅主题(subscribe)==========
{
"key": "meeting_service_request",
"template": "/iot/v1/meeting/service/request/{room_id}/",
"label": "会议服务请求",
"params": ["room_id"],
"param_labels": {"room_id": "会议室编号"},
"param_defaults": {"room_id": "A101"},
"direction": "subscribe",
},
],
"central": [], # 暂无真实主题,保留旧格式
"client": [], # 暂无真实主题,保留旧格式
}
def get_topic_templates(device_type: str) -> List[dict]:
"""
获取指定设备类型的主题模板列表
Args:
device_type: 设备类型 (door/paperless/central/client)
Returns:
List[dict]: 主题模板列表
"""
return DEVICE_TOPIC_TEMPLATES.get(device_type, [])
def has_real_topics(device_type: str) -> bool:
"""
判断设备类型是否有真实主题模板
Args:
device_type: 设备类型
Returns:
bool: 是否有真实主题模板
"""
return bool(DEVICE_TOPIC_TEMPLATES.get(device_type, []))
def resolve_topic(template: str, params: dict) -> str:
"""
解析主题模板,用参数值替换占位符
Args:
template: 主题模板,如 "/meeting/conference/{room_id}/"
params: 参数值映射,如 {"room_id": "A101"}
Returns:
str: 解析后的实际主题,如 "/meeting/conference/A101/"
"""
result = template
for key, value in params.items():
result = result.replace(f"{{{key}}}", str(value))
return result
def resolve_all_topics(device_type: str, topic_params: dict) -> Dict[str, dict]:
"""
解析设备类型的所有主题模板
Args:
device_type: 设备类型
topic_params: 参数值映射
Returns:
Dict[str, dict]: 解析后的主题映射,key 为模板 key,value 包含:
- topic: 解析后的实际主题
- direction: 方向
- label: 中文标签
- template: 原始模板
"""
templates = get_topic_templates(device_type)
resolved = {}
for tmpl in templates:
resolved[tmpl["key"]] = {
"topic": resolve_topic(tmpl["template"], topic_params or {}),
"direction": tmpl["direction"],
"label": tmpl["label"],
"template": tmpl["template"],
}
return resolved
def get_default_params(device_type: str) -> dict:
"""
获取设备类型的默认参数值
Args:
device_type: 设备类型
Returns:
dict: 默认参数值映射
"""
templates = get_topic_templates(device_type)
defaults = {}
for tmpl in templates:
for param_key, default_val in tmpl.get("param_defaults", {}).items():
if param_key not in defaults:
defaults[param_key] = default_val
return defaults
......@@ -42,6 +42,9 @@ export interface EnvConfig {
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
paperless_token_prefix?: string
paperless_token_start?: number
paperless_token_end?: number
conference_id?: string
conference_name?: string
} | null
......@@ -68,6 +71,9 @@ export interface EnvConfigCreate {
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
paperless_token_prefix?: string
paperless_token_start?: number
paperless_token_end?: number
conference_id?: string
conference_name?: string
} | null
......@@ -90,6 +96,9 @@ export interface EnvConfigUpdate {
app_token_prefix?: string
app_token_start?: number
app_token_end?: number
paperless_token_prefix?: string
paperless_token_start?: number
paperless_token_end?: number
conference_id?: string
conference_name?: string
} | null
......
......@@ -48,7 +48,7 @@
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="500px">
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑环境配置' : '新增环境配置'" width="750px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="top">
<el-form-item label="配置名称" prop="name">
<el-input v-model="form.name" placeholder="如:测试环境" />
......@@ -89,6 +89,75 @@
</el-form-item>
</el-col>
</el-row>
<!-- 默认主题参数配置 -->
<el-divider content-position="left">默认主题参数</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="公司编号">
<el-input v-model="form.defaultTopicParams!.company_id" placeholder="如:001" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室名称">
<el-input v-model="form.defaultTopicParams!.conference_name" placeholder="如:测试会议室1" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室ID">
<el-input v-model="form.defaultTopicParams!.conference_id" placeholder="如:16" size="small" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="会议室编号">
<div class="room-ids-inline">
<template v-for="(_room, index) in form.defaultTopicParams!.room_ids" :key="index">
<el-input v-model="form.defaultTopicParams!.room_ids![index]" :placeholder="`C${index + 1}`" size="small" style="width: 100px" />
<el-button v-if="(form.defaultTopicParams!.room_ids?.length ?? 0) > 1" type="danger" link size="small" @click="removeRoomId(index)" :icon="Delete" />
</template>
<el-button size="small" @click="addRoomId" :icon="Plus" circle />
</div>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">门口屏授权码配置</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="授权码前缀">
<el-input v-model="form.defaultTopicParams!.app_token_prefix" placeholder="如:AND-518" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="起始编号">
<el-input-number v-model="form.defaultTopicParams!.app_token_start" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束编号">
<el-input-number v-model="form.defaultTopicParams!.app_token_end" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">无纸化授权码配置</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="授权码前缀">
<el-input v-model="form.defaultTopicParams!.paperless_token_prefix" placeholder="如:ANP-518" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="起始编号">
<el-input-number v-model="form.defaultTopicParams!.paperless_token_start" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束编号">
<el-input-number v-model="form.defaultTopicParams!.paperless_token_end" :min="1" :max="9999" style="width: 100%" size="small" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
......@@ -101,7 +170,7 @@
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { Plus, Delete } from '@element-plus/icons-vue'
import {
listEnvConfigs,
createEnvConfig,
......@@ -127,6 +196,18 @@ const defaultForm = (): EnvConfigCreate => ({
password: 'mqtt@webpassw0RD',
useTls: false,
clientIdPrefix: 'sim',
defaultTopicParams: {
company_id: '',
room_ids: [''],
app_token_prefix: '',
app_token_start: 1,
app_token_end: 10,
paperless_token_prefix: '',
paperless_token_start: 1,
paperless_token_end: 10,
conference_id: '',
conference_name: '',
},
})
const form = reactive<EnvConfigCreate>(defaultForm())
......@@ -170,10 +251,46 @@ function openEditDialog(row: EnvConfig) {
password: '',
useTls: row.useTls,
clientIdPrefix: row.clientIdPrefix,
defaultTopicParams: row.defaultTopicParams ? {
company_id: row.defaultTopicParams.company_id || '',
room_ids: row.defaultTopicParams.room_ids?.length ? [...row.defaultTopicParams.room_ids] : [''],
app_token_prefix: row.defaultTopicParams.app_token_prefix || '',
app_token_start: row.defaultTopicParams.app_token_start ?? 1,
app_token_end: row.defaultTopicParams.app_token_end ?? 10,
paperless_token_prefix: row.defaultTopicParams.paperless_token_prefix || '',
paperless_token_start: row.defaultTopicParams.paperless_token_start ?? 1,
paperless_token_end: row.defaultTopicParams.paperless_token_end ?? 10,
conference_id: row.defaultTopicParams.conference_id || '',
conference_name: row.defaultTopicParams.conference_name || '',
} : {
company_id: '',
room_ids: [''],
app_token_prefix: '',
app_token_start: 1,
app_token_end: 10,
paperless_token_prefix: '',
paperless_token_start: 1,
paperless_token_end: 10,
conference_id: '',
conference_name: '',
},
})
dialogVisible.value = true
}
/** 添加会议室编号 */
function addRoomId() {
if (!form.defaultTopicParams!.room_ids) {
form.defaultTopicParams!.room_ids = ['']
}
form.defaultTopicParams!.room_ids.push('')
}
/** 删除会议室编号 */
function removeRoomId(index: number) {
form.defaultTopicParams!.room_ids?.splice(index, 1)
}
/** 保存配置 */
async function handleSave() {
const valid = await formRef.value?.validate().catch(() => false)
......@@ -262,4 +379,11 @@ onMounted(() => {
color: #303133;
margin: 0;
}
.room-ids-inline {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
</style>
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论