安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
1576 lines
56 KiB
Plaintext
1576 lines
56 KiB
Plaintext
<template>
|
||
<view class="page">
|
||
<!-- 设备信息 -->
|
||
<view class="card">
|
||
<view class="card-title">
|
||
<text class="dev-name">{{ info.title.length > 0 ? info.title : info.sn }}</text>
|
||
<text class="badge" :class="info.online ? 'badge-on' : 'badge-off'">{{ info.online ? '在线' : '离线' }}</text>
|
||
</view>
|
||
<view class="meta">{{ info.dev_type }}<text v-if="info.model.length > 0"> · {{ info.model }}</text><text v-if="info.ver.length > 0"> · v{{ info.ver }}</text></view>
|
||
<view class="meta-sn">uuid: {{ info.uuid }}</view>
|
||
<view class="status" :class="connState === 2 ? 'status-ok' : (connState === 3 ? 'status-err' : 'status-idle')">
|
||
{{ connText }}
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 订阅被拒提示:连上了却收不到数据时直接给结论,避免靠猜 -->
|
||
<view class="warn" v-if="aclNotice.length > 0">{{ aclNotice }}</view>
|
||
|
||
<!-- MQTT 连接 -->
|
||
<view class="card">
|
||
<view class="card-title">实时连接(WebSocket)</view>
|
||
<view class="row">
|
||
<text class="label">WS地址</text>
|
||
<input class="input" v-model="wsUrl" placeholder="ws://192.168.1.10:8083" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">账号</text>
|
||
<input class="input" v-model="mqttUser" placeholder="留空则自动使用登录账号" />
|
||
</view>
|
||
<view class="row">
|
||
<button class="btn btn-primary flex1" @click="onToggleConn">{{ connState === 2 ? '断开' : '连接' }}</button>
|
||
<button class="btn btn-gray flex1" @click="onRefresh">拉取状态</button>
|
||
</view>
|
||
<view class="tips">
|
||
连接使用一次性凭证(ticket):每次连接/重连前先调 mqttTicket 换取 60s 短时密码,不直接使用会员明文密码。
|
||
服务端对 /devices/... 做归属校验:只能连接已绑定到本账号、或被共享给自己的设备(与「启用ACL」开关无关)。
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 当前值——骨架(位置/别名/阈值)来自服务端配置,数值来自 state 报文。
|
||
标题/单位/小数位等品类差异全部由 spec(dev_type → DevSpec)驱动,页面里不再写品类字面量 -->
|
||
<view class="card">
|
||
<view class="card-title">
|
||
<text>{{ spec.label }}</text>
|
||
<text class="badge" :class="cfgInfo.applied ? 'badge-on' : 'badge-off'">配置 v{{ cfgInfo.cfg_ver }}{{ cfgInfo.applied ? ' 已生效' : ' 待设备生效' }}</text>
|
||
</view>
|
||
<view v-if="positions.length === 0" class="tips">
|
||
暂无位置配置。请先在后台「设备管理 → 传感器」添加位置与阈值(配置真源在服务端,App/设备共用同一份),
|
||
或等待设备首次上报后自动生成默认配置。
|
||
</view>
|
||
<view class="pos" v-for="(p, idx) in positions" :key="p.i">
|
||
<view class="pos-head">
|
||
<text class="pos-name">{{ p.alias }}</text>
|
||
<text class="pos-temp" :class="p.alert ? 'pos-alert' : ''">{{ p.tempText }}</text>
|
||
</view>
|
||
<view class="pos-meta">{{ p.pinText }}<text v-if="spec.diffOn">{{ spec.diffLabel }} {{ p.diffText }} · </text>限值 {{ p.rangeText }}</view>
|
||
<view class="pos-meta" v-if="spec.probeOn">探头 {{ p.probeText }}</view>
|
||
<view class="pos-warn" v-if="p.warnText.length > 0">{{ p.warnText }}</view>
|
||
<view class="pos-meta">更新于 {{ p.timeText }}<text v-if="p.ivSec > 0">(该值有效至 +{{ p.ivSec }}s)</text></view>
|
||
<view class="row" style="margin-top:12rpx;">
|
||
<button class="btn btn-sm" @click="onEditCfg(p.i)">编辑</button>
|
||
<button class="btn btn-sm btn-outline" @click="onDelCfg(p.i)">移除</button>
|
||
</view>
|
||
</view>
|
||
<view class="row" style="margin-top:16rpx;">
|
||
<button class="btn btn-primary flex1" @click="onAddCfg">新增位置</button>
|
||
<button class="btn btn-gray flex1" @click="loadConfig">重新拉取配置</button>
|
||
</view>
|
||
<view class="tips">
|
||
配置真源在服务端:这里改完会 retained 下发到设备 /config,设备回 /ack 后标记「已生效」。
|
||
上限/下限填 0 表示该方向不告警<text v-if="spec.diffOn">;温差阈值 0 表示不启用温差告警</text>。
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 配置编辑弹层 -->
|
||
<view class="mask" v-if="efShow">
|
||
<view class="dialog">
|
||
<view class="dialog-title">位置配置(位置 {{ efIndex + 1 }})</view>
|
||
<view class="row">
|
||
<text class="label">名称</text>
|
||
<input class="input" v-model="efAlias" placeholder="如 A 路 / 进水口" />
|
||
</view>
|
||
<view class="row" v-if="spec.probeOn">
|
||
<text class="label">探头数</text>
|
||
<input class="input" type="number" v-model="efProbes" placeholder="1~3" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">{{ spec.highField }}</text>
|
||
<input class="input" type="number" v-model="efUpper" placeholder="0=不启用" />
|
||
</view>
|
||
<view class="row">
|
||
<text class="label">{{ spec.lowField }}</text>
|
||
<input class="input" type="number" v-model="efLower" placeholder="0=不启用" />
|
||
</view>
|
||
<view class="row" v-if="spec.diffOn">
|
||
<text class="label">{{ spec.gapField }}</text>
|
||
<input class="input" type="number" v-model="efGap" placeholder="0=不启用" />
|
||
</view>
|
||
<view class="tips" v-if="spec.diffOn">{{ spec.diffTip }}</view>
|
||
<view class="row dialog-btns">
|
||
<button class="btn btn-gray flex1" @click="closeEdit">取消</button>
|
||
<button class="btn btn-primary flex1" @click="onSaveCfg">保存并下发</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 设备状态 -->
|
||
<view class="card">
|
||
<view class="card-title">原始报文(订阅 topics.state)</view>
|
||
<view class="state-box">{{ statusJson.length > 0 ? statusJson : '暂无状态,设备未上报或未连接' }}</view>
|
||
</view>
|
||
|
||
<!-- 控制 -->
|
||
<view class="card">
|
||
<view class="card-title">控制(发布 topics.cmd)</view>
|
||
<view class="row">
|
||
<button class="btn btn-primary flex1" @click="onCmd('{"on":true}')">开</button>
|
||
<button class="btn btn-danger flex1" @click="onCmd('{"on":false}')">关</button>
|
||
<button class="btn btn-warm flex1" @click="onCmd('{"query":1}')">查询</button>
|
||
</view>
|
||
<view class="row cmd-row">
|
||
<input class="input flex1" v-model="cmdInput" placeholder='自定义命令 JSON,如 {"temp":26,"on":true}' />
|
||
<button class="btn btn-sm" @click="onCmd(cmdInput)">发送</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 日志 -->
|
||
<view class="card" v-if="logs.length > 0">
|
||
<view class="card-title">日志</view>
|
||
<view class="log-box">
|
||
<text class="log-line" v-for="(l, i) in logs" :key="i">{{ l }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||
import { MqttClient } from '../mqtt-debug/MqttClient.uts'
|
||
import { devSpecOf, SPEC_TEMP } from '../common/DevTypeSpec.uts'
|
||
|
||
interface DeviceInfo {
|
||
id: number
|
||
sn: string
|
||
mac: string
|
||
uuid: string
|
||
dev_type: string
|
||
ver: string
|
||
title: string
|
||
model: string
|
||
online: boolean
|
||
topics: UTSJSONObject | null
|
||
}
|
||
|
||
// 模块级连接句柄:避免被 uni-app x 响应式代理包裹,保证类内部字节缓冲安全
|
||
let mqttClient: MqttClient | null = null
|
||
|
||
// 历史落库去重键:retained 报文在重连时会被重投一次,不去重会写重复点
|
||
let lastHistKey = ''
|
||
|
||
// ─── 响应式状态 ───────────────────────────────────
|
||
const apiBase = ref('http://192.168.1.10')
|
||
const accessToken = ref('')
|
||
const info = ref<DeviceInfo>({ id: 0, sn: '', mac: '', uuid: '', dev_type: '', ver: '', title: '', model: '', online: false, topics: null })
|
||
|
||
// 面板规格:由 dev_type 决定(标题/单位/小数位/阈值文案),见 ../common/DevTypeSpec.uts
|
||
// 初值取温度 —— 当前在产型号只有温度,这样首屏不会先闪一下别的文案,
|
||
// dev_type 到手后由 setDevType() 立即切成对应规格。
|
||
const spec = ref(SPEC_TEMP)
|
||
|
||
const wsUrl = ref('ws://192.168.1.10:8083')
|
||
const mqttUser = ref('')
|
||
const connState = ref(0) // 0未连接 1连接中 2已连接 3失败
|
||
const connText = ref('未连接')
|
||
const statusJson = ref('')
|
||
const cmdInput = ref('')
|
||
const closedByUser = ref(false) // 用户手动断开,停止自动重连
|
||
const reconnectCount = ref(0)
|
||
const reconnectTimer = ref(0)
|
||
const logs = ref<string[]>([])
|
||
// 订阅被服务端拒绝(SUBACK=0x80)时的可见提示:归属校验/ACL 未放行都走这里
|
||
const aclNotice = ref('')
|
||
let subDeniedCount = 0
|
||
|
||
// ─── 传感器配置 / 位置视图 ─────────────────────────
|
||
// 关键设计:配置(位置数/别名/阈值)来自服务端,state 报文只带数值。
|
||
// 这样设备离线时也能先画出骨架(值显示 --),不会开天窗。
|
||
interface CfgInfo {
|
||
cfg_ver: number
|
||
sync_ver: number
|
||
applied: boolean
|
||
}
|
||
|
||
interface PosView {
|
||
i: number
|
||
alias: string
|
||
probes: number
|
||
upper: number
|
||
lower: number
|
||
gap: number
|
||
enabled: number
|
||
// 以下为「已格式化」字段:uvue 模板对复杂表达式支持有限,统一在逻辑层算好
|
||
tempText: string
|
||
diffText: string
|
||
pinText: string // 形如 "GPIO4 · ";空串 = 还没拿到引脚(设备未上线/老固件)
|
||
rangeText: string
|
||
probeText: string
|
||
warnText: string
|
||
timeText: string
|
||
ivSec: number
|
||
alert: boolean
|
||
probeCount: number
|
||
}
|
||
|
||
const positions = ref<PosView[]>([])
|
||
const cfgInfo = ref<CfgInfo>({ cfg_ver: 0, sync_ver: 0, applied: false })
|
||
|
||
// 位置序号 → GPIO 引脚。两个来源,谁先到用谁(都是"板子上的事实",不会冲突):
|
||
// ① deviceConfig 接口的 pins —— v1.7.0 起设备不再把 pin 塞进每条 state,
|
||
// 改成上线时在 /status 报一次 {"online":1,"pins":[3,4,10]},服务端缓存后随接口下发;
|
||
// ② state 报文里的 pin —— ≤1.6.2 老固件仍逐条带,保底不能删。
|
||
// loadConfig 重建骨架后靠它把 GPIO 提示补回来,否则"改一次配置就有一阵子看不到引脚"。
|
||
// 定长 3(= MAX_POS),避免下标越界;0 = 未知。
|
||
const lastPins: number[] = [0, 0, 0]
|
||
|
||
// 骨架来源标记:设备列表接口已内联配置(sensors)时置 true
|
||
// ⇒ onPullState() 不再单独请求 deviceConfig(少一次往返,首屏骨架更快出)
|
||
// 旧服务端不返回 sensors、或该设备从未配过(数组为空)时保持 false,自动回退到 deviceConfig
|
||
let skeletonReady = false
|
||
|
||
function pinTextOf(i: number): string {
|
||
const pin = (i >= 0 && i < lastPins.length) ? lastPins[i] : 0
|
||
return pin > 0 ? 'GPIO' + pin.toString() + ' · ' : ''
|
||
}
|
||
|
||
/**
|
||
* 写入 GPIO 表(下标 = 位置序号),有变化时顺带刷新已渲染位置的提示文案
|
||
*
|
||
* ⚠️ 只做"补",不做"清":拿不到引脚时保持原样(宁可显示上一次的,也不要闪成空白)。
|
||
* @return 是否发生变化
|
||
*/
|
||
function applyPins(pins: Array<any>): boolean {
|
||
let changed = false
|
||
for (let k = 0; k < pins.length && k < lastPins.length; k++) {
|
||
const pv = pins[k] as number
|
||
if (pv > 0 && lastPins[k] !== pv) {
|
||
lastPins[k] = pv
|
||
changed = true
|
||
}
|
||
}
|
||
if (changed) {
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
positions.value[k].pinText = pinTextOf(positions.value[k].i)
|
||
}
|
||
}
|
||
return changed
|
||
}
|
||
|
||
// 编辑表单用独立 ref:模板 v-model 绑基本类型最稳(uvue 对 ref 对象属性 v-model 支持有限)
|
||
const efShow = ref(false)
|
||
const efIndex = ref(0)
|
||
const efAlias = ref('')
|
||
const efProbes = ref('1')
|
||
const efUpper = ref('0')
|
||
const efLower = ref('0')
|
||
const efGap = ref('0')
|
||
|
||
// ─── 生命周期 ───────────────────────────────────
|
||
onLoad((options) => {
|
||
// 从添加设备页带参进入:?uuid=xx&title=xx&dev_type=xx&model=xx&ver=xx&sn=xx
|
||
const o = (options as UTSJSONObject | null)
|
||
if (o != null) {
|
||
const uuid = (o['uuid'] as string) || ''
|
||
if (uuid.length > 0) {
|
||
info.value.uuid = uuid
|
||
info.value.title = (o['title'] as string) || ''
|
||
setDevType((o['dev_type'] as string) || '')
|
||
info.value.model = (o['model'] as string) || ''
|
||
info.value.ver = (o['ver'] as string) || ''
|
||
info.value.sn = (o['sn'] as string) || ''
|
||
info.value.online = (o['online'] as string) === '1'
|
||
}
|
||
}
|
||
const savedBase = uni.getStorageSync('mqtt_apiBase') as string | null
|
||
if (savedBase != null && (savedBase as string).length > 0) {
|
||
apiBase.value = savedBase as string
|
||
// 由 API 地址推导 WS 地址(http→ws,端口 8083)
|
||
wsUrl.value = apiBase.value.replace(/^http/, 'ws') + ':8083'
|
||
}
|
||
const savedToken = uni.getStorageSync('mqtt_token') as string | null
|
||
if (savedToken != null && (savedToken as string).length > 0) {
|
||
accessToken.value = savedToken as string
|
||
}
|
||
onLoadDevices()
|
||
})
|
||
|
||
onUnload(() => {
|
||
onDisconnect()
|
||
})
|
||
|
||
// ─── 日志与请求 ───────────────────────────────────
|
||
function addLog(line: string) {
|
||
logs.value.push('[' + new Date().toLocaleTimeString() + '] ' + line)
|
||
if (logs.value.length > 50) {
|
||
logs.value.shift()
|
||
}
|
||
}
|
||
|
||
function httpGet(path: string, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||
const header: UTSJSONObject = {}
|
||
if (accessToken.value.length > 0) {
|
||
header['Authorization'] = 'Bearer ' + accessToken.value
|
||
}
|
||
uni.request<UTSJSONObject>({
|
||
url: apiBase.value + path,
|
||
method: 'GET',
|
||
header: header,
|
||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||
const resp = res.data
|
||
const code = resp['code'] as number
|
||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||
if (code === 0) {
|
||
onOk(resp)
|
||
} else {
|
||
onFail(msg.length > 0 ? msg : '请求失败 code=' + code)
|
||
}
|
||
},
|
||
fail: (err: RequestFail) => {
|
||
onFail(err.errMsg)
|
||
}
|
||
})
|
||
}
|
||
|
||
function httpPost(path: string, body: UTSJSONObject, onOk: (resp: UTSJSONObject) => void, onFail: (msg: string) => void) {
|
||
const header: UTSJSONObject = {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
if (accessToken.value.length > 0) {
|
||
header['Authorization'] = 'Bearer ' + accessToken.value
|
||
}
|
||
uni.request<UTSJSONObject>({
|
||
url: apiBase.value + path,
|
||
method: 'POST',
|
||
data: body,
|
||
header: header,
|
||
success: (res: RequestSuccess<UTSJSONObject>) => {
|
||
const resp = res.data
|
||
const code = resp['code'] as number
|
||
const msg = (resp['message'] as string) || (resp['msg'] as string) || ''
|
||
if (code === 0) {
|
||
onOk(resp)
|
||
} else {
|
||
onFail(msg.length > 0 ? msg : '请求失败 code=' + code)
|
||
}
|
||
},
|
||
fail: (err: RequestFail) => {
|
||
onFail(err.errMsg)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── 设备数据 ───────────────────────────────────
|
||
function buildTopics(uuid: string): UTSJSONObject {
|
||
// 与服务端 DeviceService::deviceTopics 保持一致(兜底拼装)
|
||
const t: UTSJSONObject = {
|
||
'state': '/devices/' + uuid + '/state',
|
||
'status': '/devices/' + uuid + '/status',
|
||
'config': '/devices/' + uuid + '/config',
|
||
'ack': '/devices/' + uuid + '/ack',
|
||
'cmd': '/devices/' + uuid + '/cmd',
|
||
'ota': '/devices/' + uuid + '/ota',
|
||
'ota_report': '/devices/' + uuid + '/ota/report',
|
||
'event': '/devices/' + uuid + '/event'
|
||
}
|
||
return t
|
||
}
|
||
|
||
function topic(kind: string): string {
|
||
if (info.value.topics != null) {
|
||
const t = info.value.topics[kind] as string | null
|
||
if (t != null && (t as string).length > 0) {
|
||
return t as string
|
||
}
|
||
}
|
||
return (buildTopics(info.value.uuid)[kind] as string) || ''
|
||
}
|
||
|
||
/**
|
||
* 记录设备类型并同步面板规格 —— 这是「按 dev_type 分发面板」的唯一落点
|
||
*
|
||
* 若将来要做「一品类一页面」的真·页面级分发,钩子也在这里:
|
||
* if (spec.value.key === 'switch') { uni.redirectTo({ url: '../device-switch/index?uuid=' + info.value.uuid }); return }
|
||
* 但同构品类(温度/湿度/液位/气压)不必如此,理由见 DevTypeSpec.uts 顶部说明。
|
||
*/
|
||
function setDevType(t: string) {
|
||
info.value.dev_type = t
|
||
spec.value = devSpecOf(t)
|
||
}
|
||
|
||
function onLoadDevices() {
|
||
if (info.value.uuid.length === 0) {
|
||
// 未指定设备:拉列表选第一台
|
||
httpGet('/iot/api/client/myDevices', (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
const list = (data != null ? (data['list'] as Array<UTSJSONObject>) : null) || []
|
||
if (list.length > 0) {
|
||
const it = list[0]
|
||
info.value.id = (it['id'] as number) || 0
|
||
info.value.sn = (it['sn'] as string) || ''
|
||
info.value.mac = (it['mac'] as string) || ''
|
||
info.value.uuid = (it['uuid'] as string) || ''
|
||
setDevType((it['dev_type'] as string) || '')
|
||
info.value.ver = (it['ver'] as string) || ''
|
||
info.value.title = (it['title'] as string) || ''
|
||
info.value.model = (it['model'] as string) || ''
|
||
info.value.online = (it['online'] as boolean) || false
|
||
info.value.topics = it['topics'] as UTSJSONObject | null
|
||
// 列表已内联配置 ⇒ 立刻建骨架(值随后由影子/报文填),首屏不必再等一次请求
|
||
skeletonReady = applyListConfig(it)
|
||
}
|
||
onPullState()
|
||
}, (msg: string) => {
|
||
addLog('拉取设备失败:' + msg)
|
||
})
|
||
} else {
|
||
// 已带 uuid:从列表补全 topics
|
||
httpGet('/iot/api/client/myDevices', (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
const list = (data != null ? (data['list'] as Array<UTSJSONObject>) : null) || []
|
||
for (let i = 0; i < list.length; i++) {
|
||
const it = list[i]
|
||
if ((it['uuid'] as string) === info.value.uuid) {
|
||
info.value.online = (it['online'] as boolean) || false
|
||
setDevType((it['dev_type'] as string) || '')
|
||
info.value.ver = (it['ver'] as string) || ''
|
||
info.value.topics = it['topics'] as UTSJSONObject | null
|
||
// 同分支 1:列表内联的配置直接建骨架
|
||
skeletonReady = applyListConfig(it)
|
||
break
|
||
}
|
||
}
|
||
onPullState()
|
||
}, (msg: string) => {
|
||
addLog('拉取设备失败:' + msg)
|
||
})
|
||
}
|
||
}
|
||
|
||
// ─── MQTT 连接(一次性凭证模式) ───────────────────
|
||
function onToggleConn() {
|
||
if (connState.value === 2) {
|
||
onDisconnect()
|
||
return
|
||
}
|
||
if (info.value.uuid.length === 0) {
|
||
addLog('没有设备可连接')
|
||
return
|
||
}
|
||
// 一次性凭证模式:连接/重连前先 HTTP 换取 ticket,再以 username=会员账号、password=ticket 连接
|
||
doConnect()
|
||
}
|
||
|
||
// 每次连接/重连都重新换取 60s 有效的一次性凭证,不直接使用会员明文密码
|
||
function doConnect() {
|
||
closedByUser.value = false
|
||
connState.value = 1
|
||
connText.value = '换取凭证中...'
|
||
addLog('换取 MQTT 一次性凭证 ...')
|
||
const emptyBody: UTSJSONObject = {}
|
||
httpPost('/iot/api/client/mqttTicket', emptyBody, (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
const ticket = (data != null ? (data['ticket'] as string) : null) || ''
|
||
const account = (data != null ? (data['account'] as string) : null) || ''
|
||
if (ticket.length === 0) {
|
||
connState.value = 3
|
||
connText.value = '凭证换取失败'
|
||
addLog('换取凭证失败:ticket 为空(请检查登录态)')
|
||
return
|
||
}
|
||
if (mqttUser.value.length === 0 && account.length > 0) {
|
||
mqttUser.value = account
|
||
}
|
||
const user = mqttUser.value.length > 0 ? mqttUser.value : account
|
||
connText.value = '连接中...'
|
||
addLog('连接 ' + wsUrl.value + '(username=' + user + ')...')
|
||
const client = new MqttClient({
|
||
url: wsUrl.value,
|
||
clientId: 'app-panel-' + Date.now().toString(),
|
||
username: user,
|
||
password: ticket,
|
||
keepalive: 10,
|
||
autoReconnect: false // 自动重连由本页接管(重连前重新换取凭证)
|
||
})
|
||
client.onConnAck = (code: number) => {
|
||
if (code === 0) {
|
||
connState.value = 2
|
||
connText.value = '已连接,正在订阅设备主题'
|
||
reconnectCount.value = 0
|
||
aclNotice.value = ''
|
||
subDeniedCount = 0
|
||
addLog('连接成功,订阅设备主题')
|
||
client.subscribe(topic('state'), 0)
|
||
client.subscribe(topic('status'), 0) // 在线态(LWT 保留消息);低频上报后不能靠 state 判离线
|
||
client.subscribe(topic('event'), 0)
|
||
client.subscribe(topic('ota_report'), 0)
|
||
} else {
|
||
connState.value = 3
|
||
connText.value = '连接被拒绝(code=' + code + ')'
|
||
addLog('CONNACK 拒绝 code=' + code + '(检查账号密码是否有效、ticket 是否过期、账号是否被禁用)')
|
||
client.disconnect()
|
||
}
|
||
}
|
||
client.onMessage = (topicName: string, payload: string) => {
|
||
if (topicName === topic('state')) {
|
||
statusJson.value = payload
|
||
applyState(payload)
|
||
} else if (topicName === topic('status')) {
|
||
applyStatus(payload)
|
||
} else if (topicName === topic('event')) {
|
||
addLog('event: ' + payload)
|
||
} else if (topicName === topic('ota_report')) {
|
||
addLog('ota_report: ' + payload)
|
||
}
|
||
}
|
||
client.onClose = (code: number) => {
|
||
connState.value = 0
|
||
connText.value = '连接已断开'
|
||
addLog('连接关闭 code=' + code)
|
||
scheduleReconnect()
|
||
}
|
||
client.onError = (msg: string) => {
|
||
connState.value = 3
|
||
connText.value = '连接错误'
|
||
addLog('错误:' + msg)
|
||
}
|
||
// SUBACK 结果:≥128 表示服务端拒绝该主题(3.1.1 为 0x80,MQTT5 为 0x87 等)
|
||
client.onSubAck = (topicName: string, code: number) => {
|
||
if (code >= 128) {
|
||
subDeniedCount = subDeniedCount + 1
|
||
connText.value = '已连接(' + subDeniedCount.toString() + ' 个主题被拒绝)'
|
||
addLog('订阅被拒绝:' + topicName)
|
||
aclNotice.value = '订阅被拒绝(' + topicName + '):当前账号无权访问该设备。常见原因:设备未绑定到该账号、共享已被撤销、设备已解绑;匿名连接不享有设备权限。'
|
||
} else {
|
||
addLog('订阅成功:' + topicName + ' (QoS ' + code.toString() + ')')
|
||
}
|
||
}
|
||
client.connect()
|
||
mqttClient = client
|
||
}, (msg: string) => {
|
||
connState.value = 3
|
||
connText.value = '凭证换取失败'
|
||
addLog('换取凭证失败:' + msg)
|
||
})
|
||
}
|
||
|
||
// 断线自动重连:重连前重新换取凭证,最多 5 次、间隔 2s
|
||
function scheduleReconnect() {
|
||
if (closedByUser.value || reconnectCount.value >= 5) {
|
||
return
|
||
}
|
||
reconnectCount.value++
|
||
if (reconnectTimer.value > 0) {
|
||
clearTimeout(reconnectTimer.value)
|
||
}
|
||
reconnectTimer.value = setTimeout(() => {
|
||
reconnectTimer.value = 0
|
||
doConnect()
|
||
}, 2000)
|
||
}
|
||
|
||
function onDisconnect() {
|
||
closedByUser.value = true
|
||
if (reconnectTimer.value > 0) {
|
||
clearTimeout(reconnectTimer.value)
|
||
reconnectTimer.value = 0
|
||
}
|
||
reconnectCount.value = 0
|
||
if (mqttClient != null) {
|
||
mqttClient.disconnect()
|
||
mqttClient = null
|
||
}
|
||
connState.value = 0
|
||
connText.value = '未连接'
|
||
}
|
||
|
||
// ─── 控制与状态 ───────────────────────────────────
|
||
function onCmd(payload: string) {
|
||
const cmd = payload.trim()
|
||
if (cmd.length === 0) {
|
||
addLog('命令为空')
|
||
return
|
||
}
|
||
const cmdTopic = topic('cmd')
|
||
if (mqttClient != null && mqttClient.isConnected) {
|
||
// 优先走 MQTT 直发
|
||
mqttClient.publish(cmdTopic, cmd, 0, false)
|
||
addLog('cmd -> ' + cmdTopic + ' : ' + cmd)
|
||
} else {
|
||
// 未连接时走 HTTP sendCommand 兜底
|
||
const body: UTSJSONObject = {
|
||
'uuid': info.value.uuid,
|
||
'payload': cmd
|
||
}
|
||
httpPost('/iot/api/client/sendCommand', body, (resp: UTSJSONObject) => {
|
||
addLog('HTTP 下发成功:' + JSON.stringify(resp['data']))
|
||
}, (msg: string) => {
|
||
addLog('HTTP 下发失败:' + msg)
|
||
})
|
||
}
|
||
}
|
||
|
||
function onPullState() {
|
||
if (info.value.uuid.length === 0) {
|
||
return
|
||
}
|
||
// 骨架:列表接口已内联配置(DeviceService::attachSensorConfig)时无需再拉
|
||
// 旧服务端不返回 sensors / 该设备没配过 ⇒ applyListConfig() 返回 false ⇒ 这里自动回退
|
||
if (!skeletonReady) {
|
||
loadConfig()
|
||
}
|
||
loadShadow()
|
||
}
|
||
|
||
/** 手动「拉取状态」:配置与影子都强制重拉(后台刚改过配置时用;onPullState 会按需跳过配置) */
|
||
function onRefresh() {
|
||
loadConfig()
|
||
loadShadow()
|
||
}
|
||
|
||
/** 拉影子报文(服务端缓存的最新一条 state)+ 在线态 —— 骨架之外的「值」都从这里来 */
|
||
function loadShadow() {
|
||
httpGet('/iot/api/client/deviceState?uuid=' + info.value.uuid, (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
if (data != null) {
|
||
const shadow = data['shadow'] as string | null
|
||
if (shadow != null && (shadow as string).length > 0) {
|
||
statusJson.value = shadow as string
|
||
applyState(shadow as string) // 影子格式与 state 报文一致 → 同一套解析
|
||
}
|
||
const online = data['online'] as boolean | null
|
||
if (online != null) {
|
||
info.value.online = online as boolean
|
||
}
|
||
}
|
||
}, (msg: string) => {
|
||
addLog('拉取状态失败:' + msg)
|
||
// HTTP 侧的权限拒绝(无权查看该设备)与 MQTT 订阅被拒同因,一并给提示
|
||
if (msg.indexOf('无权') >= 0) {
|
||
aclNotice.value = '无权访问该设备:' + msg + ' 请确认设备已绑定到当前账号,或已被设备主人共享给自己。'
|
||
}
|
||
})
|
||
}
|
||
|
||
// ─── 配置(服务端是真源)+ 报文解析 + 落库 ─────────
|
||
function pad2(n: number): string {
|
||
return n < 10 ? '0' + n.toString() : n.toString()
|
||
}
|
||
|
||
/**
|
||
* 用一份「位置配置数组」重建骨架(positions)
|
||
*
|
||
* 两个来源共用这里(字段完全一致,都是 SensorConfigService::format 的出口):
|
||
* ① 设备列表接口内联的 sensors —— 进页面就能画骨架(离线/首屏友好,见 applyListConfig)
|
||
* ② deviceConfig 接口的 sensors —— 兜底与手动刷新
|
||
*
|
||
* @return 是否建出骨架(有位置才算;空数组 = 服务端没配过,调用方自行决定是否回退)
|
||
*/
|
||
function applyConfigSensors(arr: Array<UTSJSONObject>, cfgVer: number, syncVer: number, applied: boolean): boolean {
|
||
cfgInfo.value.cfg_ver = cfgVer
|
||
cfgInfo.value.sync_ver = syncVer
|
||
cfgInfo.value.applied = applied
|
||
const list: PosView[] = []
|
||
for (let k = 0; k < arr.length; k++) {
|
||
const s = arr[k]
|
||
/* 位置序号:服务端统一 `index`(2026-09-15);`i` 是旧键,继续兜底。
|
||
⚠️ 取不到时会静默变 0 ⇒ 多个位置挤在同一序号:影子数据全填到位置 0,
|
||
编辑任意位置还会把别的行一起改掉,保存后服务端按序号去重 ⇒ 3 个位置并成 1 个。 */
|
||
const ri = s['index']
|
||
const i = ri != null ? (ri as number) : ((s['i'] as number) || 0)
|
||
const probes = (s['probes'] as number) || 1
|
||
const upper = (s['upper'] as number) || 0
|
||
const lower = (s['lower'] as number) || 0
|
||
const gap = (s['gap'] as number) || 0
|
||
const enabled = (s['enabled'] as number) || 0
|
||
list.push({
|
||
i: i,
|
||
alias: (s['alias'] as string) || '',
|
||
probes: probes,
|
||
upper: upper,
|
||
lower: lower,
|
||
gap: gap,
|
||
enabled: enabled,
|
||
tempText: '--',
|
||
diffText: '--',
|
||
pinText: '',
|
||
rangeText: lower.toString() + '~' + upper.toString() + spec.value.unit + (gap > 0 && spec.value.diffOn ? '(' + spec.value.diffLabel + '≥' + gap.toString() + spec.value.unit + '告警)' : '') + (enabled === 0 ? '(已停用)' : ''),
|
||
probeText: '等待上报',
|
||
warnText: '',
|
||
timeText: '—',
|
||
ivSec: 0,
|
||
alert: false,
|
||
probeCount: 0
|
||
})
|
||
}
|
||
positions.value = list
|
||
// 补回 GPIO 提示(来源见 lastPins 注释;applyPins 只补不清,拿不到就留上次的)
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
positions.value[k].pinText = pinTextOf(positions.value[k].i)
|
||
}
|
||
// 骨架就绪后,把手上已有的影子立刻填回去(列表先到、报文后到时同理)
|
||
if (statusJson.value.length > 0) {
|
||
applyState(statusJson.value)
|
||
}
|
||
return arr.length > 0
|
||
}
|
||
|
||
/**
|
||
* 用「设备列表项」内联的配置建骨架
|
||
*
|
||
* 目的:进页面一次请求(myDevices)就能出骨架,不必等第二次往返 ⇒ 初始化简单、骨架屏好做。
|
||
* 服务端:DeviceService::attachSensorConfig() 在 visibleDevices() 里内联
|
||
* sensors / cfg_ver / sync_ver / applied。
|
||
*
|
||
* ⚠️ 列表里的 pins 是 iot_device 的 **CSV 字符串**("3,4,10"),与 deviceConfig 下发的 pins(数组)
|
||
* 不同型,故这里刻意不解析:GPIO 提示由 /status 保留消息(连上即到)或 deviceConfig 补回,
|
||
* applyPins 只补不清,不会把已有提示闪成空白。
|
||
*
|
||
* @return 是否已建出骨架(false = 列表没带配置(旧服务端/从未配置)⇒ 调用方回退 deviceConfig)
|
||
*/
|
||
function applyListConfig(it: UTSJSONObject): boolean {
|
||
const arr = (it['sensors'] as Array<UTSJSONObject>) || []
|
||
if (arr.length === 0) {
|
||
return false
|
||
}
|
||
const ok = applyConfigSensors(arr, (it['cfg_ver'] as number) || 0, (it['sync_ver'] as number) || 0, (it['applied'] as boolean) || false)
|
||
addLog('设备列表已带配置:' + arr.length.toString() + ' 个位置(v' + cfgInfo.value.cfg_ver.toString() + ',' + (cfgInfo.value.applied ? '设备已生效' : '设备待生效') + ')')
|
||
return ok
|
||
}
|
||
|
||
/** 拉取传感器配置:位置列表/别名/阈值。App 与设备读的是同一份(服务端 DB) */
|
||
function loadConfig() {
|
||
if (info.value.uuid.length === 0) {
|
||
return
|
||
}
|
||
httpGet('/iot/api/client/deviceConfig?uuid=' + info.value.uuid, (resp: UTSJSONObject) => {
|
||
const data = resp['data'] as UTSJSONObject | null
|
||
if (data == null) {
|
||
return
|
||
}
|
||
const arr = (data['sensors'] as Array<UTSJSONObject>) || []
|
||
applyConfigSensors(arr, (data['cfg_ver'] as number) || 0, (data['sync_ver'] as number) || 0, (data['applied'] as boolean) || false)
|
||
// 接口下发的总线 GPIO 表(v1.7.0+;老固件/老库没有则为空,靠 state 里的 pin 保底)
|
||
const cfgPins = (data['pins'] as Array<any>) || []
|
||
if (cfgPins.length > 0) {
|
||
applyPins(cfgPins)
|
||
}
|
||
addLog('配置已加载:' + arr.length.toString() + ' 个位置(v' + cfgInfo.value.cfg_ver.toString() + ',' + (cfgInfo.value.applied ? '设备已生效' : '设备待生效') + ')')
|
||
}, (msg: string) => {
|
||
addLog('加载配置失败:' + msg)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 在线态(/status 保留消息)
|
||
* ≤1.6.2 {"online":0/1}
|
||
* v1.7.0 {"online":1,"pins":[3,4,10]} —— 上线时顺带报一次总线 GPIO 表
|
||
* (pin 是编译期常量,不必每条 state 都带;见 lastPins 注释)
|
||
*/
|
||
function applyStatus(payload: string) {
|
||
// JSON.parseObject 非法 JSON 返回 null(不抛异常),因此用 null 判定即可
|
||
const o = JSON.parseObject(payload)
|
||
if (o == null) {
|
||
addLog('status 解析失败:' + payload)
|
||
return
|
||
}
|
||
const on = (o['online'] as number) || 0
|
||
info.value.online = on === 1
|
||
/* 顺手收下引脚表:设备一上线就能显示 GPIO,不必等下一次 deviceConfig 往返 */
|
||
const pins = (o['pins'] as Array<any>) || []
|
||
if (pins.length > 0) {
|
||
applyPins(pins)
|
||
}
|
||
addLog('在线态:' + (on === 1 ? '在线' : '离线'))
|
||
}
|
||
|
||
/** 归一化后的探头槽位:两版报文都收敛成这个形状,下游代码只认它 */
|
||
interface ProbeSlot {
|
||
id: string // ROM 短码;'' = 未识别身份
|
||
temp: number | null // null = 该槽无有效读数(不是 0℃,也不是"没插"的同义词)
|
||
}
|
||
|
||
/**
|
||
* 把各版报文的探头数据归一化成 [{id, temp}](版式判定只看键在不在,绝不比版本号)
|
||
* v1.7.0 pr[[id,temp]] —— 二元组(键名 probes → pr),其余同 v1.6.0
|
||
* v1.6.0 probes[[id,temp]] —— 二元组,下标即槽位;id 与读数解耦,temp=null 时 id 仍可能有值,
|
||
* 据此可区分「没插器件」与「插了但读数失败」
|
||
* v1.5.0 probes[{id,temp}] —— 同上,只是写成了对象
|
||
* ≤1.4.0 vs[]/ok[]/ids[] —— 三个等长数组按位对应;ok[j]=false 时 vs[j] 是占位 0、
|
||
* ids[j] 一律空串(旧版把身份也一起抹了,故区分不出上述两种)
|
||
* ⚠️ 判「有没有值」一律用 "!= null",不要用 length/真值判断:0.00℃ 是 DS18B20 的合法读数。
|
||
*/
|
||
function normalizeProbes(o: UTSJSONObject, isNew: boolean, isV16: boolean): ProbeSlot[] {
|
||
const out: ProbeSlot[] = []
|
||
if (isNew) {
|
||
// 键名:pr(v1.7.0)优先、probes(≤1.6.2)回落 —— 同构,只是键名压短
|
||
let ps = (o['pr'] as Array<any>) || []
|
||
if (ps.length === 0) {
|
||
ps = (o['probes'] as Array<any>) || []
|
||
}
|
||
for (let j = 0; j < ps.length; j++) {
|
||
const p = ps[j]
|
||
let pid = ''
|
||
let pt: number | null = null
|
||
if (isV16) {
|
||
const tup = p as Array<any>
|
||
if (tup != null && tup.length > 0 && tup[0] != null) {
|
||
pid = tup[0] as string
|
||
}
|
||
if (tup != null && tup.length > 1 && tup[1] != null) {
|
||
pt = tup[1] as number
|
||
}
|
||
} else {
|
||
const po = p as UTSJSONObject
|
||
if (po != null) {
|
||
const rawId = po['id']
|
||
if (rawId != null) {
|
||
pid = rawId as string
|
||
}
|
||
const rawT = po['temp']
|
||
if (rawT != null) {
|
||
pt = rawT as number
|
||
}
|
||
}
|
||
}
|
||
out.push({ id: pid, temp: pt })
|
||
}
|
||
return out
|
||
}
|
||
const vs = (o['vs'] as Array<number>) || []
|
||
const ok = (o['ok'] as Array<boolean>) || []
|
||
const ids = (o['ids'] as Array<string>) || []
|
||
for (let j = 0; j < vs.length; j++) {
|
||
const jok = j < ok.length ? ok[j] : false
|
||
const jid = j < ids.length ? (ids[j] as string) : ''
|
||
out.push({ id: jid, temp: jok ? vs[j] : null })
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 归一化后的位置:三种报文版式都收敛成这个形状,applyState 只认它 */
|
||
interface PosState {
|
||
i: number // 位置序号(身份,= 服务端 iot_sensor_cfg.pos_index)
|
||
pin: number // 该总线 GPIO;0 = 未知(v1.7.0+ 不再随 state 上报;≤1.6.2 老固件仍带)
|
||
v: boolean // 本位置有没有有效读数
|
||
t: number // 位置数值(= 该位置的读数:温度/湿度/液位…,v=false 时无意义)
|
||
df: number // 位置内极差
|
||
slots: ProbeSlot[] // 下标 == 总线槽位
|
||
}
|
||
|
||
/**
|
||
* 把各版 state 报文的位置数组归一化成 PosState[]
|
||
*
|
||
* v1.7.0 pos[下标] = {t, df, pr[[id,temp]]}
|
||
* ↑ 与 v1.6.0 同构,只是键名压到 2 字母(positions→pos、probes→pr),
|
||
* 并去掉了两个冗余键:顶层 uuid、每位置的 pin(后者改由 /status 报一次)
|
||
* v1.6.0 positions[下标] = {pin, t, df, probes[[id,temp]]}
|
||
* ↑ 位置化数组:**下标就是位置序号**,未启用的位置是 JSON null
|
||
* v1.5.0 positions[] = {index, pin, temp, temp_diff, probes[{id,temp}]}
|
||
* ≤1.4.0 data[] = {i, t, df, v, vs[], ok[], ids[]}
|
||
*
|
||
* 版式判定一律用「键在不在」,不看固件版本号:版本号可能忘了升,键不会撒谎。
|
||
* v1.5 / v1.6 / v1.7 走同一套「位置化数组」分支,靠 index 区分 v1.5 —— v1.5 每个位置都写
|
||
* index(int,永不为 null),v1.6 起这个键彻底删掉(身份改由数组下标承载)。
|
||
*/
|
||
function normalizePositions(obj: UTSJSONObject): PosState[] {
|
||
const out: PosState[] = []
|
||
// 数组键名:pos(v1.7.0)优先、positions(≤1.6.2)回落;data(≤1.4.0)为旧式
|
||
const rawPos = obj['pos']
|
||
const rawOld = obj['positions']
|
||
const hasNew = rawPos != null || rawOld != null
|
||
let raw: Array<any> = []
|
||
if (hasNew) {
|
||
raw = (rawPos != null ? (rawPos as Array<any>) : (rawOld as Array<any>)) || []
|
||
} else {
|
||
raw = (obj['data'] as Array<any>) || []
|
||
}
|
||
|
||
let v16 = false
|
||
if (hasNew) {
|
||
v16 = true
|
||
for (let k = 0; k < raw.length; k++) {
|
||
const e = raw[k]
|
||
if (e == null) {
|
||
continue // v1.6 的位置占位
|
||
}
|
||
const eo = e as UTSJSONObject
|
||
if (eo['index'] != null || eo['temp'] != null || eo['temp_diff'] != null) {
|
||
v16 = false // 出现 v1.5 专有键 → 整条按 v1.5 解
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let k = 0; k < raw.length; k++) {
|
||
const e = raw[k]
|
||
if (e == null) {
|
||
continue // 位置化数组的 null 占位 = 该位置未启用
|
||
}
|
||
const o = e as UTSJSONObject
|
||
let i = k
|
||
if (!hasNew) {
|
||
i = (o['i'] as number) || 0
|
||
} else if (!v16) {
|
||
const ri = o['index']
|
||
if (ri == null) {
|
||
continue
|
||
}
|
||
i = ri as number
|
||
}
|
||
let v = false
|
||
let t = 0
|
||
let df = 0
|
||
if (hasNew && v16) {
|
||
const rt = o['t']
|
||
if (rt != null) {
|
||
v = true
|
||
t = rt as number
|
||
df = (o['df'] as number) || 0
|
||
}
|
||
} else if (hasNew) {
|
||
const rt = o['temp']
|
||
if (rt != null) {
|
||
v = true
|
||
t = rt as number
|
||
df = (o['temp_diff'] as number) || 0
|
||
}
|
||
} else {
|
||
v = (o['v'] as boolean) || false
|
||
t = (o['t'] as number) || 0
|
||
df = (o['df'] as number) || 0
|
||
}
|
||
out.push({
|
||
i: i,
|
||
pin: (o['pin'] as number) || 0,
|
||
v: v,
|
||
t: t,
|
||
df: df,
|
||
slots: normalizeProbes(o, hasNew, v16)
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* 解析 state 报文并填值(只填值,不改骨架)
|
||
*
|
||
* v1.7.0(当前版式 —— 键名压到 2 字母 + 去掉两处冗余,结构同 v1.6.0):
|
||
* {ts, ver, rssi, iv, cv,
|
||
* pos:[{t, df, pr:[["A3F2", 25.94]]}, null]}
|
||
* - pos ⚠️ 数组下标就是位置序号(已没有 index),null = 该位置未启用
|
||
* - t / df 位置温度 / 位置内极差;null = 本位置无有效读数(不是 0℃)
|
||
* - pr[] 下标 == 总线槽位;元素是二元组 [身份, 温度],温度 null = 该槽读数失败
|
||
* - iv 本条数据覆盖的秒数(距设备上次上报)→ 前向填充用(旧名 interval)
|
||
* - cv 设备已生效的配置版本(旧名 cfg_ver)
|
||
* - ts NTP Unix 秒(0=未对时 → 用手机接收时间兜底)
|
||
* - 无 uuid 谁发的看主题(/devices/{uuid}/state)—— 本页本来就知道 uuid
|
||
* - 无 pin v1.7.0 起改为上线时在 /status 报一次,服务端缓存 + deviceConfig 下发
|
||
* (≤1.6.2 老固件仍会逐条带 pin,所以下面照旧解析,只是可能缺省为 0)
|
||
*
|
||
* v1.6.0(可读版式,已烧录的设备仍要能用):
|
||
* {uuid, …, interval, cfg_ver, positions:[{pin, t, df, probes:[["A3F2", 25.94]]}, null]}
|
||
* v1.5.0:{…, interval, positions:[{index, pin, temp, temp_diff, probes:[{id, temp}]}]}
|
||
* ≤1.4.0:{…, iv, data:[{i, t, df, v, vs[], ok[], ids[]}]}
|
||
*
|
||
* 各版全部在 normalizePositions() 里归一化,本函数只按归一化后的形状取值 ——
|
||
* 设备不必为 App 升级,App 也不必跟着设备版本走。
|
||
* ⚠️ 顶层 iv / cv 一律「新键 ?? 旧键」链式兜底,不要再按版式分支 ——
|
||
* 旧写法 (hasNew ? interval : iv) 在 v1.7.0 下会取到 0(键名换了、位置化数组还在)。
|
||
*/
|
||
function applyState(payload: string) {
|
||
const obj = JSON.parseObject(payload)
|
||
if (obj == null) {
|
||
addLog('state 解析失败:' + payload)
|
||
return
|
||
}
|
||
const list = normalizePositions(obj)
|
||
const ts = (obj['ts'] as number) || 0
|
||
const iv = (obj['iv'] as number) || ((obj['interval'] as number) || 0)
|
||
const cfgVer = (obj['cv'] as number) || ((obj['cfg_ver'] as number) || 0)
|
||
const nowMs = Date.now()
|
||
|
||
// 设备用的配置版本比本地旧 → 说明后台改过而设备还没收到,主动刷一次配置
|
||
if (cfgVer > 0 && cfgVer !== cfgInfo.value.cfg_ver) {
|
||
addLog('设备配置版本 v' + cfgVer.toString() + ' 与本地 v' + cfgInfo.value.cfg_ver.toString() + ' 不一致,重新拉取')
|
||
loadConfig()
|
||
}
|
||
|
||
for (let k = 0; k < list.length; k++) {
|
||
const ps = list[k]
|
||
const i = ps.i
|
||
// 引脚:纯展示属性,与位置身份(i)解耦。
|
||
// ⚠️ v1.7.0 起报文不再带 pin —— 这是 ≤1.6.2 老固件的保底路径;
|
||
// v1.7.0 设备的引脚来自 /status 上线报文或 deviceConfig 的 pins(见 applyPins)。
|
||
const pin = ps.pin
|
||
if (pin > 0 && i >= 0 && i < lastPins.length) {
|
||
lastPins[i] = pin
|
||
}
|
||
const v = ps.v
|
||
const t = ps.t
|
||
const df = ps.df
|
||
// 槽位:v1.6/v1.5 = 总线枚举到的探头数(尾部空槽已截),≤1.4.0 恒为 PROBE_MAX
|
||
const slots = ps.slots
|
||
|
||
// 逐探头明细:无读数的槽显示 --(绝不显示占位 0,否则会误读成"0℃")
|
||
// 带身份显示「A3F2 25.6」——仅靠下标无法分辨是哪颗,换探头后就追踪不到了
|
||
const parts: string[] = []
|
||
let okCount = 0
|
||
let noIdCount = 0
|
||
for (let j = 0; j < slots.length; j++) {
|
||
const st = slots[j].temp
|
||
// 身份未识别时退化成槽位号(P1/P2…),至少还能定位是哪一路;
|
||
// 真原因(没枚举到器件 / 该片 ROM 序列号全 0 的克隆片)看固件串口日志
|
||
const hasId = slots[j].id.length > 0
|
||
const tag = hasId ? slots[j].id + ' ' : ('P' + (j + 1).toString() + ' ')
|
||
if (!hasId) {
|
||
noIdCount++
|
||
}
|
||
if (st != null) {
|
||
parts.push(tag + st.toFixed(1))
|
||
okCount++
|
||
} else {
|
||
parts.push(tag + '--')
|
||
}
|
||
}
|
||
const joined = parts.join(' / ')
|
||
|
||
// 定位到骨架;若设备报了配置外的新位置(如默认配置尚未同步),就地补一个
|
||
let idx = -1
|
||
for (let n = 0; n < positions.value.length; n++) {
|
||
if (positions.value[n].i === i) {
|
||
idx = n
|
||
break
|
||
}
|
||
}
|
||
if (idx < 0) {
|
||
positions.value.push({
|
||
i: i, alias: '位置' + (i + 1).toString(), probes: slots.length,
|
||
upper: 0, lower: 0, gap: 0, enabled: 1,
|
||
tempText: '--', diffText: '--', pinText: '', rangeText: '配置待同步', probeText: '等待上报',
|
||
warnText: '该位置尚未在服务端配置(请后台「传感器」补充)', timeText: '—',
|
||
ivSec: 0, alert: false, probeCount: okCount
|
||
})
|
||
idx = positions.value.length - 1
|
||
}
|
||
|
||
const p = positions.value[idx]
|
||
// 「当前是否越限」是状态,不是「告警事件」:阈值来自服务端真源(与 loadConfig 拉取的
|
||
// 同一份 upper/lower/gap,见 iot_sensor_cfg),所以本地算与云端算结果必然一致,
|
||
// 不存在规则漂移;保留本地算是为了零延迟(等云端推送会让高亮慢半拍)。
|
||
// 「告警事件」(何时发生过、设备有没有漏报)由服务端 iot_alert 负责(含云端复核),
|
||
// App 只消费不判定。
|
||
const isAlert = v && ((p.upper > 0 && t > p.upper) || (t < p.lower && p.lower !== 0) || (p.gap > 0 && df >= p.gap))
|
||
// 时间轴:优先设备 NTP 绝对时间(App 历史库靠它),未对时则用手机接收时间
|
||
const useTs = ts > 0 ? ts : Math.floor(nowMs / 1000)
|
||
const d = new Date(useTs * 1000)
|
||
const timeText = pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds())
|
||
|
||
positions.value[idx].tempText = v ? t.toFixed(spec.value.decimals) + spec.value.unit : '--(无有效读数)'
|
||
positions.value[idx].diffText = v ? df.toFixed(spec.value.decimals) + spec.value.unit : '--'
|
||
positions.value[idx].pinText = pinTextOf(i)
|
||
positions.value[idx].probeText = (joined.length > 0 ? joined : '无') + '(' + okCount.toString() + '/' + slots.length.toString() + ' 有效)'
|
||
positions.value[idx].timeText = timeText
|
||
positions.value[idx].ivSec = iv
|
||
positions.value[idx].alert = isAlert
|
||
positions.value[idx].probeCount = okCount
|
||
// 槽位数 = 总线实际枚举到的探头数。v1.5.0 起不再恒等于 PROBE_MAX(尾部空槽已截),
|
||
// 所以这个比对现在才真正有用:「后台配了 N 路、总线只认到 M 颗」能直接看出来。
|
||
// probeOn=false 的品类(湿度/液位/气压)逐路独立传感器、不走 OneWire 枚举,
|
||
// 不报 pr[] 是正常的 ⇒ 绝不能拿它比对,否则会误报「配置 1 路、只认到 0 颗」。
|
||
positions.value[idx].warnText = (spec.value.probeOn && slots.length !== p.probes && p.probes > 0)
|
||
? '⚠ 配置 ' + p.probes.toString() + ' 路探头,总线只认到 ' + slots.length.toString() + ' 颗'
|
||
: ''
|
||
if (v && p.upper > 0 && t > p.upper) {
|
||
positions.value[idx].warnText = '⚠ ' + spec.value.highText + '(上限 ' + p.upper.toString() + spec.value.unit + ')'
|
||
}
|
||
// 坏点定位:把越限探头按身份列出(形如「A3F2 95.60℃」),
|
||
// 仅有下标时无法跨时间判断到底是哪一颗探头出了问题
|
||
const overs: string[] = []
|
||
for (let j = 0; j < slots.length; j++) {
|
||
const st = slots[j].temp
|
||
if (st == null) {
|
||
continue
|
||
}
|
||
const tag = slots[j].id.length > 0 ? slots[j].id + ' ' : ''
|
||
if ((p.upper > 0 && st > p.upper) || (p.lower !== 0 && st < p.lower)) {
|
||
overs.push(tag + st.toFixed(spec.value.decimals) + spec.value.unit)
|
||
}
|
||
}
|
||
if (overs.length > 0) {
|
||
positions.value[idx].warnText = '⚠ 探头越限:' + overs.join('、')
|
||
}
|
||
|
||
saveHistory(useTs, i, t, df, okCount, slots.length, v)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 本地兜底落库
|
||
*
|
||
* 服务端已把同一份上报解析为结构化时序(iot_sensor_point,经
|
||
* /iot/api/client/sensorSeries 查询):画曲线应优先用服务端数据 —— 换手机不丢、
|
||
* 多端一致、可跨设备统计。这里保留本地分片,仅作「断网 / 未登录」时的降级来源。
|
||
* 分片策略:按天一个 key(iot_hist_{uuid}_{YYYYMMDD}),避免单 key 反复读写整张大表。
|
||
*/
|
||
function saveHistory(ts: number, i: number, t: number, df: number, okCount: number, total: number, valid: boolean) {
|
||
if (info.value.uuid.length === 0 || ts <= 0) {
|
||
return
|
||
}
|
||
// retained 报文在重连/重新订阅时会被重投,同一条数据不能写两次
|
||
const hkey = ts.toString() + '-' + i.toString()
|
||
if (hkey === lastHistKey) {
|
||
return
|
||
}
|
||
lastHistKey = hkey
|
||
const d = new Date(ts * 1000)
|
||
const dayKey = 'iot_hist_' + info.value.uuid + '_' + d.getFullYear().toString() + pad2(d.getMonth() + 1) + pad2(d.getDate())
|
||
let lines: string[] = []
|
||
const old = uni.getStorageSync(dayKey) as string | null
|
||
if (old != null && (old as string).length > 0) {
|
||
const parsed = (old as string).split('\n')
|
||
if (parsed.length > 0) {
|
||
lines = parsed
|
||
}
|
||
}
|
||
// 行格式:ts,i,t,df,okCount,total,valid —— 便于事后直接画曲线
|
||
lines.push(ts.toString() + ',' + i.toString() + ',' + t.toFixed(2) + ',' + df.toFixed(2) + ',' + okCount.toString() + ',' + total.toString() + ',' + (valid ? '1' : '0'))
|
||
uni.setStorageSync(dayKey, lines.join('\n'))
|
||
if (lines.length <= 1) {
|
||
cleanupHistory()
|
||
}
|
||
}
|
||
|
||
/** 只保留最近 7 天分片(防止 App 存储无限膨胀) */
|
||
function cleanupHistory() {
|
||
try {
|
||
const infoS = uni.getStorageInfoSync()
|
||
const keys = infoS.keys
|
||
const prefix = 'iot_hist_' + info.value.uuid + '_'
|
||
const keep = 7
|
||
const mine: string[] = []
|
||
for (let k = 0; k < keys.length; k++) {
|
||
const kk = keys[k]
|
||
if (kk.indexOf(prefix) === 0) {
|
||
mine.push(kk)
|
||
}
|
||
}
|
||
mine.sort()
|
||
while (mine.length > keep) {
|
||
const del = mine.shift()
|
||
if (del != null) {
|
||
uni.removeStorageSync(del)
|
||
addLog('清理历史分片:' + del)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
addLog('历史清理失败')
|
||
}
|
||
}
|
||
|
||
// ─── 配置编辑(改的是服务端真源,保存即 retained 下发设备) ───
|
||
// 保存为「全量覆盖」语义:提交时必须把未编辑的位置原样带回,否则会被删掉。
|
||
const CFG_MAX_POS = 3
|
||
const CFG_MAX_PROBE = 3
|
||
|
||
function openEdit(i: number, alias: string, probes: number, upper: number, lower: number, gap: number) {
|
||
efIndex.value = i
|
||
efAlias.value = alias
|
||
efProbes.value = probes.toString()
|
||
efUpper.value = upper.toString()
|
||
efLower.value = lower.toString()
|
||
efGap.value = gap.toString()
|
||
efShow.value = true
|
||
}
|
||
|
||
function onEditCfg(i: number) {
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
const p = positions.value[k]
|
||
if (p.i === i) {
|
||
openEdit(p.i, p.alias, p.probes, p.upper, p.lower, p.gap)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 新增一个位置:i 取未占用的小序号,先只填进本地列表,保存时才写服务端 */
|
||
function onAddCfg() {
|
||
if (positions.value.length >= CFG_MAX_POS) {
|
||
addLog('最多 ' + CFG_MAX_POS.toString() + ' 个位置')
|
||
return
|
||
}
|
||
let next = -1
|
||
for (let cand = 0; cand < CFG_MAX_POS; cand++) {
|
||
let used = false
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
if (positions.value[k].i === cand) {
|
||
used = true
|
||
break
|
||
}
|
||
}
|
||
if (!used) {
|
||
next = cand
|
||
break
|
||
}
|
||
}
|
||
if (next < 0) {
|
||
addLog('位置序号已用尽')
|
||
return
|
||
}
|
||
positions.value.push({
|
||
i: next, alias: '位置' + (next + 1).toString(), probes: 1,
|
||
upper: 0, lower: 0, gap: 0, enabled: 1,
|
||
tempText: '--', diffText: '--', pinText: '', rangeText: '待保存', probeText: '等待上报',
|
||
warnText: '', timeText: '—', ivSec: 0, alert: false, probeCount: 0
|
||
})
|
||
openEdit(next, '位置' + (next + 1).toString(), 1, 0, 0, 0)
|
||
}
|
||
|
||
/** 删除本地列表里的位置(保存时一并覆盖到服务端) */
|
||
function onDelCfg(i: number) {
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
if (positions.value[k].i === i) {
|
||
positions.value.splice(k, 1)
|
||
addLog('已移除位置 ' + (i + 1).toString() + '(点“保存配置”后生效)')
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeEdit() {
|
||
efShow.value = false
|
||
}
|
||
|
||
function onSaveCfg() {
|
||
if (info.value.uuid.length === 0) {
|
||
return
|
||
}
|
||
const alias = efAlias.value.trim()
|
||
const probes = parseInt(efProbes.value)
|
||
const upper = parseFloat(efUpper.value)
|
||
const lower = parseFloat(efLower.value)
|
||
const gap = parseFloat(efGap.value)
|
||
if (alias.length === 0) {
|
||
addLog('位置名称不能为空')
|
||
return
|
||
}
|
||
if (isNaN(probes) || probes < 1 || probes > CFG_MAX_PROBE) {
|
||
addLog('探头数须为 1~' + CFG_MAX_PROBE.toString())
|
||
return
|
||
}
|
||
if (isNaN(upper) || isNaN(lower) || isNaN(gap) || gap < 0) {
|
||
addLog('上限/下限' + (spec.value.diffOn ? '/温差阈值' : '') + '须为数字,且阈值不小于 0')
|
||
return
|
||
}
|
||
if (upper !== 0 && lower !== 0 && lower >= upper) {
|
||
addLog('下限须小于上限(任一为 0 表示不启用该方向告警)')
|
||
return
|
||
}
|
||
if (positions.value.length === 0) {
|
||
addLog('至少保留 1 个位置')
|
||
return
|
||
}
|
||
// 全量提交(后端是覆盖语义):未编辑的位置原样带回去
|
||
const list: UTSJSONObject[] = []
|
||
for (let k = 0; k < positions.value.length; k++) {
|
||
const p = positions.value[k]
|
||
const isCur = p.i === efIndex.value
|
||
const o: UTSJSONObject = {}
|
||
o['index'] = p.i // 位置序号统一 `index`(服务端同时兼容旧键 i,但新代码只发 index)
|
||
o['alias'] = isCur ? alias : p.alias
|
||
o['probes'] = isCur ? probes : p.probes
|
||
o['upper'] = isCur ? upper : p.upper
|
||
o['lower'] = isCur ? lower : p.lower
|
||
o['gap'] = isCur ? gap : p.gap
|
||
o['enabled'] = p.enabled // 停用状态原样保留(App 面板不做启停开关)
|
||
list.push(o)
|
||
}
|
||
const body: UTSJSONObject = {
|
||
'uuid': info.value.uuid,
|
||
'sensors': JSON.stringify(list)
|
||
}
|
||
addLog('保存配置 ...')
|
||
httpPost('/iot/api/client/saveDeviceConfig', body, (resp: UTSJSONObject) => {
|
||
addLog('配置已保存并下发(设备离线则上线自动生效)')
|
||
closeEdit()
|
||
loadConfig()
|
||
}, (msg: string) => {
|
||
addLog('保存配置失败:' + msg)
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style>
|
||
.page {
|
||
padding: 24rpx;
|
||
background-color: #f5f6fa;
|
||
min-height: 100vh;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.warn {
|
||
background-color: #fef3c7;
|
||
border-left: 8rpx solid #f59e0b;
|
||
border-radius: 12rpx;
|
||
padding: 18rpx 20rpx;
|
||
font-size: 22rpx;
|
||
color: #92400e;
|
||
line-height: 34rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.card {
|
||
background-color: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 24rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333333;
|
||
margin-bottom: 20rpx;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.dev-name {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #1e293b;
|
||
flex: 1;
|
||
}
|
||
|
||
.badge {
|
||
font-size: 20rpx;
|
||
padding: 4rpx 16rpx;
|
||
border-radius: 20rpx;
|
||
margin-left: 8rpx;
|
||
}
|
||
|
||
.badge-on {
|
||
background-color: #dcfce7;
|
||
color: #16a34a;
|
||
}
|
||
|
||
.badge-off {
|
||
background-color: #f1f5f9;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.meta {
|
||
font-size: 24rpx;
|
||
color: #475569;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.meta-sn {
|
||
font-size: 20rpx;
|
||
color: #94a3b8;
|
||
margin-top: 6rpx;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.status {
|
||
font-size: 24rpx;
|
||
margin-top: 12rpx;
|
||
}
|
||
|
||
.status-ok {
|
||
color: #16a34a;
|
||
}
|
||
|
||
.status-err {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.status-idle {
|
||
color: #999999;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.label {
|
||
width: 130rpx;
|
||
font-size: 26rpx;
|
||
color: #666666;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.input {
|
||
flex: 1;
|
||
height: 72rpx;
|
||
background-color: #f5f6fa;
|
||
border-radius: 10rpx;
|
||
padding: 0 20rpx;
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
}
|
||
|
||
.flex1 {
|
||
flex: 1;
|
||
}
|
||
|
||
.btn {
|
||
height: 76rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
color: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.btn-primary {
|
||
background-color: #3b82f6;
|
||
}
|
||
|
||
.btn-danger {
|
||
background-color: #ef4444;
|
||
}
|
||
|
||
.btn-warm {
|
||
background-color: #d97706;
|
||
}
|
||
|
||
.btn-gray {
|
||
background-color: #64748b;
|
||
}
|
||
|
||
.btn-sm {
|
||
height: 72rpx;
|
||
width: 140rpx;
|
||
font-size: 26rpx;
|
||
margin-left: 16rpx;
|
||
background-color: #3b82f6;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-outline {
|
||
background-color: #f1f5f9;
|
||
color: #64748b;
|
||
}
|
||
|
||
/* ─── 位置卡片(通用:温度/湿度/液位/气压共用) ─── */
|
||
.pos {
|
||
background-color: #f8fafc;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.pos-head {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.pos-name {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.pos-temp {
|
||
font-size: 40rpx;
|
||
font-weight: bold;
|
||
color: #0f172a;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.pos-alert {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.pos-meta {
|
||
font-size: 22rpx;
|
||
color: #64748b;
|
||
margin-top: 8rpx;
|
||
line-height: 32rpx;
|
||
}
|
||
|
||
.pos-warn {
|
||
font-size: 22rpx;
|
||
color: #d97706;
|
||
margin-top: 8rpx;
|
||
line-height: 32rpx;
|
||
}
|
||
|
||
/* ─── 配置编辑弹层 ─── */
|
||
.mask {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(15, 23, 42, 0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 99;
|
||
}
|
||
|
||
.dialog {
|
||
width: 630rpx;
|
||
background-color: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 28rpx;
|
||
}
|
||
|
||
.dialog-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #1e293b;
|
||
margin-bottom: 24rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.dialog-btns {
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.cmd-row {
|
||
margin-top: 20rpx;
|
||
}
|
||
|
||
.state-box {
|
||
background-color: #f1f5f9;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx;
|
||
font-size: 24rpx;
|
||
color: #0f172a;
|
||
font-family: monospace;
|
||
word-break: break-all;
|
||
min-height: 80rpx;
|
||
}
|
||
|
||
.tips {
|
||
font-size: 22rpx;
|
||
color: #999999;
|
||
line-height: 34rpx;
|
||
margin-top: 4rpx;
|
||
}
|
||
|
||
.log-box {
|
||
background-color: #0f172a;
|
||
border-radius: 12rpx;
|
||
padding: 16rpx;
|
||
}
|
||
|
||
.log-line {
|
||
display: block;
|
||
font-size: 20rpx;
|
||
color: #a5f3fc;
|
||
font-family: monospace;
|
||
line-height: 30rpx;
|
||
word-break: break-all;
|
||
}
|
||
</style>
|