feat(ota): support JSON progress updates

This commit is contained in:
stu2not
2026-10-13 18:12:33 +08:00
parent b6906bc8b6
commit 7d05e78a64
7 changed files with 125 additions and 22 deletions
@@ -1,17 +1,20 @@
import type { DeviceFirmwareStatus } from '@/services/device' import type { DeviceFirmwareStatus } from '@/services/device'
import { isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress } from '../../utils/firmware-status' import { isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress, normalizeFirmwareStatus } from '../../utils/firmware-status'
export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) { export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) {
const state = String(status?.update_status || '').trim().toLowerCase() const normalizedStatus = normalizeFirmwareStatus(status)
const state = String(normalizedStatus?.update_status || '').trim().toLowerCase()
const complete = state === 'success' || state === 'completed' const complete = state === 'success' || state === 'completed'
|| (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(status)) || (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(normalizedStatus))
const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(status) const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(normalizedStatus)
const failed = state === 'failed' && !current const failed = state === 'failed' && !current
const value = Number(status?.progress) const value = Number(normalizedStatus?.progress)
const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0 const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const hasProgress = complete || progress > 0 const hasProgress = complete || progress > 0
const target = status?.target_version // MQTT 003 carries target_version; latest_version is display-only fallback
const version = current ? status?.current_version : target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version // for older status rows and must not be used for completion detection.
const target = normalizedStatus?.target_version
const version = current ? normalizedStatus?.current_version : target && !['unknown', 'updating'].includes(target) ? target : normalizedStatus?.latest_version
const title = complete ? '固件更新完成' : current ? '当前已是最新版本' : failed ? '固件更新失败' : '固件更新中' const title = complete ? '固件更新完成' : current ? '当前已是最新版本' : failed ? '固件更新失败' : '固件更新中'
const message = complete ? '设备已完成更新,可以继续使用。' const message = complete ? '设备已完成更新,可以继续使用。'
: current ? '当前设备已运行最新版本,无需更新。' : current ? '当前设备已运行最新版本,无需更新。'
+21 -6
View File
@@ -8,6 +8,20 @@ export function isFirmwareUpdateInProgress(status?: string | null): boolean {
return ['sent', 'accepted', 'updating'].includes(normalizeFirmwareUpdateStatus(status)) return ['sent', 'accepted', 'updating'].includes(normalizeFirmwareUpdateStatus(status))
} }
export function normalizeFirmwareStatus(status: DeviceFirmwareStatus | null): DeviceFirmwareStatus | null {
if (!status) return null
const numericProgress = Number(status.progress)
const progress = Number.isFinite(numericProgress)
? Math.max(0, Math.min(100, numericProgress))
: 0
return {
...status,
progress,
}
}
function firmwareVersionParts(value?: string | null): number[] | null { function firmwareVersionParts(value?: string | null): number[] | null {
const normalized = String(value || '').trim().toLowerCase() const normalized = String(value || '').trim().toLowerCase()
if (!normalized || ['unknown', 'updating', '0.0.0', '--'].includes(normalized)) return null if (!normalized || ['unknown', 'updating', '0.0.0', '--'].includes(normalized)) return null
@@ -36,15 +50,16 @@ export function isFirmwareCurrent(status: DeviceFirmwareStatus | null): boolean
} }
export function firmwareSummaryView(status: DeviceFirmwareStatus | null) { export function firmwareSummaryView(status: DeviceFirmwareStatus | null) {
if (!status) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' } const normalizedStatus = normalizeFirmwareStatus(status)
if (isFirmwareUpdateInProgress(status.update_status) && !isFirmwareTargetReached(status)) { if (!normalizedStatus) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' }
if (isFirmwareUpdateInProgress(normalizedStatus.update_status) && !isFirmwareTargetReached(normalizedStatus)) {
return { action: '查看进度', subtitle: '设备正在更新,请稍候' } return { action: '查看进度', subtitle: '设备正在更新,请稍候' }
} }
if (isFirmwareCurrent(status)) return { action: '已最新', subtitle: '当前已是最新版本' } if (isFirmwareCurrent(normalizedStatus)) return { action: '已最新', subtitle: '当前已是最新版本' }
if (normalizeFirmwareUpdateStatus(status.update_status) === 'failed') { if (normalizeFirmwareUpdateStatus(normalizedStatus.update_status) === 'failed') {
return { action: status.update_available ? '更新' : '版本待确认', subtitle: status.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' } return { action: normalizedStatus.update_available ? '更新' : '版本待确认', subtitle: normalizedStatus.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' }
} }
return status.update_available return normalizedStatus.update_available
? { action: '更新', subtitle: '发现可更新版本' } ? { action: '更新', subtitle: '发现可更新版本' }
: { action: '版本待确认', subtitle: '暂无完整版本信息' } : { action: '版本待确认', subtitle: '暂无完整版本信息' }
} }
@@ -32,6 +32,22 @@ test('received progress is used directly, including fractional values', () => {
assert.equal(view.percent, 65) assert.equal(view.percent, 65)
assert.equal(view.complete, false) assert.equal(view.complete, false)
}) })
test('MQTT 003 target_version is shown while progress is updating', () => {
const view = firmwareProgressView({
...status('updating', 30),
latest_version: '2.0.4',
target_version: '2.0.4',
})
assert.equal(view.version, '2.0.4')
assert.equal(view.progress, 30)
assert.equal(view.percent, 30)
assert.match(view.message, /保持设备电量充足/)
})
test('active status without target_version falls back to latest_version', () => {
const view = firmwareProgressView(status('updating', 30))
assert.equal(view.version, '1.8.2')
assert.equal(view.progress, 30)
})
test('nearly complete progress never rounds up to 100', () => { test('nearly complete progress never rounds up to 100', () => {
assert.equal(firmwareProgressView(status('updating', 99.9)).percent, 99) assert.equal(firmwareProgressView(status('updating', 99.9)).percent, 99)
}) })
+15
View File
@@ -127,6 +127,21 @@ Headers:
} }
``` ```
设备通过 MQTT `msg_id=003` 上报实时进度时,使用 JSON `data.target_version` 和 `data.progress`,例如:
```json
{
"msg_id": "003",
"status": "updating",
"data": {
"target_version": "2.0.4",
"progress": 30
}
}
```
服务器保存该进度,小程序继续通过本状态接口轮询显示。`progress=100` 只表示设备报告写入进度完成,设备重启后上报的实际版本仍是最终完成依据。
### 4. 管理固件配置信息(管理员接口) ### 4. 管理固件配置信息(管理员接口)
**获取固件配置** **获取固件配置**
@@ -0,0 +1,31 @@
# OTA `003` 实时进度协议
设备通过 `device/{device_id}/response` 上报 OTA 状态。服务器下发 OTA 命令仍使用 `msg_id=003`,设备回执使用相同的 `msg_id`。
## 进行中回执
```json
{
"msg_id": "003",
"status": "updating",
"data": {
"target_version": "2.0.4",
"progress": 30
}
}
```
`progress` 表示设备当前已完成的百分比,范围为 `0` 到 `100`。设备应在状态变化时上报,并避免高频重复发送。`target_version` 应与服务器下发任务的目标版本一致。
## 状态约定
| 状态 | 必要字段 | 服务器行为 |
| --- | --- | --- |
| `accepted` | `current_version`、`target_version` | 记录设备已接受任务,进度默认为 `0` |
| `updating` | `target_version`、`progress` | 保存最新进度并供小程序查询 |
| `success` | `new_version` 或 `target_version` | 记录完成,进度置为 `100`,随后以设备重启后的版本上报作为最终确认 |
| `failed` | `target_version`、可选 `progress` | 保存失败状态和最后有效进度 |
服务器兼容旧设备上报的 `version`、`new_version` 字段,并优先使用 `target_version` 作为本次任务目标。非法进度值不会覆盖已保存的有效进度。
小程序通过设备固件状态接口轮询 `update_status`、`progress` 和 `target_version`,显示最近一次服务器收到的设备进度。进度达到 `100` 仍显示为等待设备确认,直到设备重启后上报目标版本。
+25 -8
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import math
import time import time
from datetime import datetime from datetime import datetime
from typing import Awaitable, Callable, Dict, Optional from typing import Awaitable, Callable, Dict, Optional
@@ -311,26 +312,39 @@ class TalkingQMQTTService:
async def _handle_ota_response(self, device_id: str, payload: dict): async def _handle_ota_response(self, device_id: str, payload: dict):
status = payload.get("status") status = payload.get("status")
data = payload.get("data", {}) data = payload.get("data") or {}
if not isinstance(data, dict):
logger.warning(device_id, "", f"[OTA] invalid response data: {payload}")
return
target_version = data.get("target_version") or data.get("version") or data.get("new_version")
progress = data.get("progress") progress = data.get("progress")
try: try:
progress_value = float(progress) if progress is not None else None parsed_progress = float(progress) if progress is not None else None
progress_value = (
parsed_progress
if parsed_progress is not None and math.isfinite(parsed_progress) and 0 <= parsed_progress <= 100
else None
)
except (TypeError, ValueError): except (TypeError, ValueError):
progress_value = None progress_value = None
if progress is not None and progress_value is None:
logger.warning(device_id, "", f"[OTA] invalid progress value: {progress!r}")
if status == "accepted": if status == "accepted":
current = data.get("current_version") current = data.get("current_version")
target = data.get("target_version") logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target_version}")
logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}")
await self._schedule_persistence( await self._schedule_persistence(
device_id, device_id,
"ota", "ota",
device_firmware_update_manager.update_firmware_update( device_firmware_update_manager.update_firmware_update(
device_id=device_id, device_id=device_id,
firmware_version=data.get("target_version") or data.get("version") or data.get("new_version") or "unknown", firmware_version=target_version or "unknown",
update_status="accepted", update_status="accepted",
progress=progress_value if progress_value is not None else 0.0, progress=progress_value if progress_value is not None else 0.0,
target_version=target_version,
), ),
) )
elif status == "updating": elif status == "updating":
@@ -339,13 +353,14 @@ class TalkingQMQTTService:
"ota", "ota",
device_firmware_update_manager.update_firmware_update( device_firmware_update_manager.update_firmware_update(
device_id=device_id, device_id=device_id,
firmware_version=data.get("version") or data.get("new_version") or "updating", firmware_version=target_version or "updating",
update_status="updating", update_status="updating",
progress=progress_value, progress=progress_value,
target_version=target_version,
), ),
) )
elif status == "success": elif status == "success":
new_version = data.get("new_version") or data.get("version") new_version = data.get("new_version") or data.get("version") or data.get("target_version")
logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}") logger.info(device_id, "", f"[OTA] 设备 {device_id} 升级完成: {new_version}")
await self._schedule_persistence( await self._schedule_persistence(
@@ -367,6 +382,7 @@ class TalkingQMQTTService:
firmware_version=new_version or "unknown", firmware_version=new_version or "unknown",
update_status="success", update_status="success",
progress=100.0, progress=100.0,
target_version=target_version,
), ),
) )
elif status == "failed": elif status == "failed":
@@ -375,9 +391,10 @@ class TalkingQMQTTService:
"ota", "ota",
device_firmware_update_manager.update_firmware_update( device_firmware_update_manager.update_firmware_update(
device_id=device_id, device_id=device_id,
firmware_version=data.get("version") or data.get("new_version") or "unknown", firmware_version=target_version or "unknown",
update_status="failed", update_status="failed",
progress=progress_value, progress=progress_value,
target_version=target_version,
), ),
) )
logger.warning(device_id, "", f"[OTA] command failed: {payload}") logger.warning(device_id, "", f"[OTA] command failed: {payload}")
+7 -1
View File
@@ -146,9 +146,15 @@ async def test_simulated_device_force_ota_dispatches_once_and_completes(monkeypa
await mqtt_service._handle_ota_response( await mqtt_service._handle_ota_response(
device_id, device_id,
{"status": "updating", "data": {"version": "1.8.5", "progress": 65}}, {
"msg_id": "003",
"status": "updating",
"data": {"target_version": "1.8.5", "progress": 65},
},
) )
assert record["update_status"] == "updating" assert record["update_status"] == "updating"
assert record["firmware_version"] == "1.8.5"
assert record["target_version"] == "1.8.5"
assert record["progress"] == 65.0 assert record["progress"] == 65.0
await mqtt_service._handle_ota_response( await mqtt_service._handle_ota_response(