diff --git a/banban-mini/src/components/firmware-progress/view-model.ts b/banban-mini/src/components/firmware-progress/view-model.ts index 0fe9efe..8eac6e0 100644 --- a/banban-mini/src/components/firmware-progress/view-model.ts +++ b/banban-mini/src/components/firmware-progress/view-model.ts @@ -1,17 +1,20 @@ 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) { - 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' - || (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(status)) - const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(status) + || (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(normalizedStatus)) + const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(normalizedStatus) 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 hasProgress = complete || progress > 0 - const target = status?.target_version - const version = current ? status?.current_version : target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version + // MQTT 003 carries target_version; latest_version is display-only fallback + // 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 message = complete ? '设备已完成更新,可以继续使用。' : current ? '当前设备已运行最新版本,无需更新。' diff --git a/banban-mini/src/utils/firmware-status.ts b/banban-mini/src/utils/firmware-status.ts index ffd4a88..04010bf 100644 --- a/banban-mini/src/utils/firmware-status.ts +++ b/banban-mini/src/utils/firmware-status.ts @@ -8,6 +8,20 @@ export function isFirmwareUpdateInProgress(status?: string | null): boolean { 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 { const normalized = String(value || '').trim().toLowerCase() 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) { - if (!status) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' } - if (isFirmwareUpdateInProgress(status.update_status) && !isFirmwareTargetReached(status)) { + const normalizedStatus = normalizeFirmwareStatus(status) + if (!normalizedStatus) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' } + if (isFirmwareUpdateInProgress(normalizedStatus.update_status) && !isFirmwareTargetReached(normalizedStatus)) { return { action: '查看进度', subtitle: '设备正在更新,请稍候' } } - if (isFirmwareCurrent(status)) return { action: '已最新', subtitle: '当前已是最新版本' } - if (normalizeFirmwareUpdateStatus(status.update_status) === 'failed') { - return { action: status.update_available ? '更新' : '版本待确认', subtitle: status.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' } + if (isFirmwareCurrent(normalizedStatus)) return { action: '已最新', subtitle: '当前已是最新版本' } + if (normalizeFirmwareUpdateStatus(normalizedStatus.update_status) === 'failed') { + return { action: normalizedStatus.update_available ? '更新' : '版本待确认', subtitle: normalizedStatus.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' } } - return status.update_available + return normalizedStatus.update_available ? { action: '更新', subtitle: '发现可更新版本' } : { action: '版本待确认', subtitle: '暂无完整版本信息' } } diff --git a/banban-mini/tests/firmware-progress.test.cjs b/banban-mini/tests/firmware-progress.test.cjs index e118b34..1905e35 100644 --- a/banban-mini/tests/firmware-progress.test.cjs +++ b/banban-mini/tests/firmware-progress.test.cjs @@ -32,6 +32,22 @@ test('received progress is used directly, including fractional values', () => { assert.equal(view.percent, 65) 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', () => { assert.equal(firmwareProgressView(status('updating', 99.9)).percent, 99) }) diff --git a/talkingq-url/docs/ota_flow.md b/talkingq-url/docs/ota_flow.md index 6690641..171ca2d 100644 --- a/talkingq-url/docs/ota_flow.md +++ b/talkingq-url/docs/ota_flow.md @@ -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. 管理固件配置信息(管理员接口) **获取固件配置** diff --git a/talkingq-url/docs/ota_progress_protocol.md b/talkingq-url/docs/ota_progress_protocol.md new file mode 100644 index 0000000..493fcc4 --- /dev/null +++ b/talkingq-url/docs/ota_progress_protocol.md @@ -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` 仍显示为等待设备确认,直到设备重启后上报目标版本。 diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index ae73214..c1db3b2 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -1,5 +1,6 @@ import asyncio import json +import math import time from datetime import datetime from typing import Awaitable, Callable, Dict, Optional @@ -311,26 +312,39 @@ class TalkingQMQTTService: async def _handle_ota_response(self, device_id: str, payload: dict): 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") 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): 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": current = data.get("current_version") - target = data.get("target_version") - logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target}") + logger.info(device_id, "", f"[OTA] 设备 {device_id} 已接受升级: {current} -> {target_version}") await self._schedule_persistence( device_id, "ota", device_firmware_update_manager.update_firmware_update( 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", progress=progress_value if progress_value is not None else 0.0, + target_version=target_version, ), ) elif status == "updating": @@ -339,13 +353,14 @@ class TalkingQMQTTService: "ota", device_firmware_update_manager.update_firmware_update( 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", progress=progress_value, + target_version=target_version, ), ) 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}") await self._schedule_persistence( @@ -367,6 +382,7 @@ class TalkingQMQTTService: firmware_version=new_version or "unknown", update_status="success", progress=100.0, + target_version=target_version, ), ) elif status == "failed": @@ -375,9 +391,10 @@ class TalkingQMQTTService: "ota", device_firmware_update_manager.update_firmware_update( 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", progress=progress_value, + target_version=target_version, ), ) logger.warning(device_id, "", f"[OTA] command failed: {payload}") diff --git a/talkingq-url/tests/test_forced_ota.py b/talkingq-url/tests/test_forced_ota.py index f2a80c9..ec2283e 100644 --- a/talkingq-url/tests/test_forced_ota.py +++ b/talkingq-url/tests/test_forced_ota.py @@ -146,9 +146,15 @@ async def test_simulated_device_force_ota_dispatches_once_and_completes(monkeypa await mqtt_service._handle_ota_response( 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["firmware_version"] == "1.8.5" + assert record["target_version"] == "1.8.5" assert record["progress"] == 65.0 await mqtt_service._handle_ota_response(