diff --git a/banban-mini/src/components/firmware-progress/view-model.ts b/banban-mini/src/components/firmware-progress/view-model.ts index 576bb05..0fe9efe 100644 --- a/banban-mini/src/components/firmware-progress/view-model.ts +++ b/banban-mini/src/components/firmware-progress/view-model.ts @@ -1,9 +1,10 @@ import type { DeviceFirmwareStatus } from '@/services/device' -import { isFirmwareCurrent, isFirmwareUpdateInProgress } from '../../utils/firmware-status' +import { isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress } from '../../utils/firmware-status' export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) { const state = String(status?.update_status || '').trim().toLowerCase() const complete = state === 'success' || state === 'completed' + || (isFirmwareUpdateInProgress(state) && isFirmwareTargetReached(status)) const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(status) const failed = state === 'failed' && !current const value = Number(status?.progress) diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index 342dde0..da01770 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -1,7 +1,7 @@ import FirmwareProgress from '@/components/firmware-progress' import PageTitle from '@/components/page-title' import { getParticipantArtwork, getRoleArtwork } from '@/utils/artwork' -import { firmwareSummaryView, isFirmwareCurrent, isFirmwareUpdateInProgress, normalizeFirmwareUpdateStatus } from '@/utils/firmware-status' +import { firmwareSummaryView, isFirmwareCurrent, isFirmwareTargetReached, isFirmwareUpdateInProgress, normalizeFirmwareUpdateStatus } from '@/utils/firmware-status' import { getPageStyle } from '@/utils/page-style' import { setActiveTabRoute } from '@/utils/tab-bar' import { View, Text, Image, Input, Button } from '@tarojs/components' @@ -248,7 +248,7 @@ export default function Sleep() { return } - if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status)) { + if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status) && !isFirmwareTargetReached(nextFirmwareStatus)) { startFirmwareStatusPolling(normalizedDeviceId) } else { stopFirmwareStatusPolling() @@ -272,14 +272,14 @@ export default function Sleep() { setFirmwareStatus(nextFirmwareStatus) setFirmwareRefreshError(false) - if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status)) { + if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status) && !isFirmwareTargetReached(nextFirmwareStatus)) { scheduleFirmwareStatusPoll(normalizedDeviceId) return } stopFirmwareStatusPolling() const nextStatus = normalizeFirmwareUpdateStatus(nextFirmwareStatus?.update_status) - if (isFirmwareUpdateSuccessful(nextStatus)) { + if (isFirmwareUpdateSuccessful(nextStatus) || isFirmwareTargetReached(nextFirmwareStatus)) { Taro.showToast({ title: '系统更新完成', icon: 'success' }) } else if (isFirmwareCurrent(nextFirmwareStatus)) { Taro.showToast({ title: '当前已是最新版本', icon: 'none' }) @@ -319,7 +319,9 @@ export default function Sleep() { if (generation !== firmwareLoadGenerationRef.current) return setFirmwareStatus(nextFirmwareStatus) setFirmwareRefreshError(false) - if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status)) setShowFirmwareProgress(true) + if (isFirmwareUpdateInProgress(nextFirmwareStatus?.update_status) && !isFirmwareTargetReached(nextFirmwareStatus)) { + setShowFirmwareProgress(true) + } syncFirmwareStatusPolling(normalizedDeviceId, nextFirmwareStatus) } catch (error: any) { if (generation !== firmwareLoadGenerationRef.current) return @@ -1060,6 +1062,7 @@ export default function Sleep() { isFamilyOwner ) const isFirmwareUpdatingByStatus = isFirmwareUpdateInProgress(firmwareStatus?.update_status) + && !isFirmwareTargetReached(firmwareStatus) const firmwareSummary = firmwareSummaryView(firmwareStatus) const canUpdateFirmware = Boolean( binding?.device_id && diff --git a/banban-mini/src/utils/firmware-status.ts b/banban-mini/src/utils/firmware-status.ts index 0a88d3c..ffd4a88 100644 --- a/banban-mini/src/utils/firmware-status.ts +++ b/banban-mini/src/utils/firmware-status.ts @@ -8,6 +8,28 @@ export function isFirmwareUpdateInProgress(status?: string | null): boolean { return ['sent', 'accepted', 'updating'].includes(normalizeFirmwareUpdateStatus(status)) } +function firmwareVersionParts(value?: string | null): number[] | null { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized || ['unknown', 'updating', '0.0.0', '--'].includes(normalized)) return null + const parts = normalized.split('.') + if (parts.some((part) => !/^\d+$/.test(part))) return null + return parts.map((part) => Number(part)) +} + +export function isFirmwareTargetReached(status: DeviceFirmwareStatus | null): boolean { + if (!status) return false + const current = firmwareVersionParts(status.current_version) + const target = firmwareVersionParts(status.target_version) + if (!current || !target) return false + const length = Math.max(current.length, target.length) + for (let index = 0; index < length; index += 1) { + const currentPart = current[index] || 0 + const targetPart = target[index] || 0 + if (currentPart !== targetPart) return currentPart > targetPart + } + return true +} + export function isFirmwareCurrent(status: DeviceFirmwareStatus | null): boolean { const knownVersion = (value?: string | null) => Boolean(value?.trim() && !['unknown', 'updating', '0.0.0', '--'].includes(value.trim().toLowerCase())) return Boolean(status && !status.update_available && knownVersion(status.current_version) && knownVersion(status.latest_version)) @@ -15,7 +37,9 @@ export function isFirmwareCurrent(status: DeviceFirmwareStatus | null): boolean export function firmwareSummaryView(status: DeviceFirmwareStatus | null) { if (!status) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' } - if (isFirmwareUpdateInProgress(status.update_status)) return { action: '查看进度', subtitle: '设备正在更新,请稍候' } + if (isFirmwareUpdateInProgress(status.update_status) && !isFirmwareTargetReached(status)) { + return { action: '查看进度', subtitle: '设备正在更新,请稍候' } + } if (isFirmwareCurrent(status)) return { action: '已最新', subtitle: '当前已是最新版本' } if (normalizeFirmwareUpdateStatus(status.update_status) === 'failed') { return { action: status.update_available ? '更新' : '版本待确认', subtitle: status.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' } diff --git a/banban-mini/tests/firmware-progress.test.cjs b/banban-mini/tests/firmware-progress.test.cjs index 91b7ca9..e118b34 100644 --- a/banban-mini/tests/firmware-progress.test.cjs +++ b/banban-mini/tests/firmware-progress.test.cjs @@ -99,6 +99,39 @@ test('a failed update with a newer version still offers a retry', () => { assert.equal(firmwareProgressView(pending).failed, true) }) +test('an active update whose target version is already installed is settled', () => { + const settled = { + ...currentFirmware, + current_version: '1.8.3', + latest_version: '1.8.4', + target_version: '1.8.3', + update_available: true, + can_update: true, + update_status: 'accepted', + progress: 0, + } + assert.deepEqual(firmwareSummaryView(settled), { action: '更新', subtitle: '发现可更新版本' }) + const view = firmwareProgressView(settled) + assert.equal(view.complete, true) + assert.equal(view.progress, 100) + assert.match(view.message, /已完成更新/) +}) + +test('an active update below its target stays in progress even at 100 percent', () => { + const pending = { + ...currentFirmware, + current_version: '1.8.2', + target_version: '1.8.3', + latest_version: '1.8.4', + update_available: true, + can_update: true, + update_status: 'updating', + progress: 100, + } + assert.deepEqual(firmwareSummaryView(pending), { action: '查看进度', subtitle: '设备正在更新,请稍候' }) + assert.equal(firmwareProgressView(pending).complete, false) +}) + test('missing or placeholder versions never claim the device is up to date', () => { const cases = [null, ...['', 'unknown', '0.0.0', '--', 'updating'].flatMap(value => [ { ...currentFirmware, current_version: value }, { ...currentFirmware, latest_version: value }, diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index a1fe7cc..5d777b2 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -308,7 +308,7 @@ class DeviceService(DatabaseServiceBase): latest_parts.extend([0] * (max_len - len(latest_parts))) return latest_parts > current_parts - def _firmware_sent_timed_out(self, updated_at: Any) -> bool: + def _firmware_update_timed_out(self, updated_at: Any) -> bool: timeout_seconds = max(0, int(settings.ota_sent_timeout_seconds or 0)) if timeout_seconds <= 0 or not updated_at: return False @@ -331,6 +331,16 @@ class DeviceService(DatabaseServiceBase): now = datetime.now(timezone.utc) if updated_at.tzinfo else datetime.now() return (now - updated_at).total_seconds() >= timeout_seconds + def _firmware_update_reached_target(self, current_version: str | None, target_version: str | None) -> bool: + current = (current_version or "").strip().lower() + target = (target_version or "").strip().lower() + placeholders = {"", "unknown", "updating", "0.0.0", "--"} + if current in placeholders or target in placeholders: + return False + + # Treat an equal or newer reported version as completion of this OTA attempt. + return not self._compare_versions(current, target) + async def get_firmware_status( self, *, @@ -348,9 +358,29 @@ class DeviceService(DatabaseServiceBase): update_record = await device_firmware_update_manager.get_firmware_update_dict(device_id) update_status = update_record.get("update_status") if update_record else "idle" progress = update_record.get("progress") if update_record else 0.0 - target_version = update_record.get("firmware_version") if update_record else None + target_version = ( + (update_record.get("target_version") or update_record.get("firmware_version")) + if update_record else None + ) updated_at = update_record.get("updated_at") if update_record else None - if str(update_status or "").strip().lower() == "sent" and self._firmware_sent_timed_out(updated_at): + normalized_update_status = str(update_status or "").strip().lower() + active_update_statuses = {"sent", "accepted", "updating"} + if normalized_update_status in active_update_statuses and self._firmware_update_reached_target( + current_version, + target_version, + ): + update_status = "success" + progress = 100.0 + await device_firmware_update_manager.update_firmware_update( + device_id=device_id, + firmware_version=target_version or latest_version or current_version or "unknown", + update_status="success", + progress=100.0, + ) + update_record = await device_firmware_update_manager.get_firmware_update_dict(device_id) + if update_record: + updated_at = update_record.get("updated_at") or updated_at + elif normalized_update_status in active_update_statuses and self._firmware_update_timed_out(updated_at): update_status = "failed" progress = 0.0 await device_firmware_update_manager.update_firmware_update( diff --git a/talkingq-url/tests/test_ota_channel_selection.py b/talkingq-url/tests/test_ota_channel_selection.py index 10be94f..a409a01 100644 --- a/talkingq-url/tests/test_ota_channel_selection.py +++ b/talkingq-url/tests/test_ota_channel_selection.py @@ -1,3 +1,5 @@ +from datetime import datetime, timedelta + import pytest from fastapi import HTTPException @@ -78,6 +80,143 @@ async def test_firmware_status_unknown_channel_cannot_update(monkeypatch): assert result["can_update"] is False +@pytest.mark.asyncio +async def test_firmware_status_settles_active_update_when_target_is_installed(monkeypatch): + service = DeviceService() + calls = [] + update_record = { + "device_id": "TalkingQ_ZHWK00003111", + "firmware_version": "1.8.3", + "target_version": "1.8.3", + "firmware_url": "https://example.com/gps-1.8.3.bin", + "firmware_id": 12, + "update_status": "accepted", + "progress": 0.0, + "updated_at": datetime.now(), + } + + async def fake_get_device_status(**kwargs): + del kwargs + return { + "device_id": "TalkingQ_ZHWK00003111", + "version": "1.8.3", + "device_type": 2, + "ota_channel": "gps", + } + + async def fake_select_latest_for_device(status_row): + del status_row + return OtaFirmwareSelection( + channel_key="gps", + version="1.8.4", + firmware_url="https://example.com/gps-1.8.4.bin", + firmware_id=13, + ) + + async def fake_get_firmware_update_dict(device_id): + assert device_id == "TalkingQ_ZHWK00003111" + return update_record + + async def fake_update_firmware_update(**kwargs): + calls.append(kwargs) + return True + + monkeypatch.setattr(service, "get_device_status", fake_get_device_status) + monkeypatch.setattr("banban.service.device.ota_firmware_service.select_latest_for_device", fake_select_latest_for_device) + monkeypatch.setattr("banban.service.device.device_firmware_update_manager.get_firmware_update_dict", fake_get_firmware_update_dict) + monkeypatch.setattr("banban.service.device.device_firmware_update_manager.update_firmware_update", fake_update_firmware_update) + + result = await service.get_firmware_status(device_id="TalkingQ_ZHWK00003111", user_id=1) + + assert result["update_status"] == "success" + assert result["progress"] == 100.0 + assert result["update_available"] is True + assert calls == [{ + "device_id": "TalkingQ_ZHWK00003111", + "firmware_version": "1.8.3", + "update_status": "success", + "progress": 100.0, + }] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("active_status", ["sent", "accepted", "updating"]) +async def test_firmware_status_times_out_stale_active_update(monkeypatch, active_status): + service = DeviceService() + calls = [] + update_record = { + "device_id": "TalkingQ_ZHWK00003111", + "firmware_version": "1.8.3", + "target_version": "1.8.3", + "firmware_url": "https://example.com/gps-1.8.3.bin", + "firmware_id": 12, + "update_status": active_status, + "progress": 42.0, + "updated_at": datetime.now() - timedelta(seconds=301), + } + + async def fake_get_device_status(**kwargs): + del kwargs + return { + "device_id": "TalkingQ_ZHWK00003111", + "version": "1.8.2", + "device_type": 2, + "ota_channel": "gps", + } + + async def fake_select_latest_for_device(status_row): + del status_row + return OtaFirmwareSelection( + channel_key="gps", + version="1.8.4", + firmware_url="https://example.com/gps-1.8.4.bin", + firmware_id=13, + ) + + async def fake_get_firmware_update_dict(device_id): + assert device_id == "TalkingQ_ZHWK00003111" + return update_record + + async def fake_update_firmware_update(**kwargs): + calls.append(kwargs) + return True + + monkeypatch.setattr(service, "get_device_status", fake_get_device_status) + monkeypatch.setattr("banban.service.device.ota_firmware_service.select_latest_for_device", fake_select_latest_for_device) + monkeypatch.setattr("banban.service.device.device_firmware_update_manager.get_firmware_update_dict", fake_get_firmware_update_dict) + monkeypatch.setattr("banban.service.device.device_firmware_update_manager.update_firmware_update", fake_update_firmware_update) + + result = await service.get_firmware_status(device_id="TalkingQ_ZHWK00003111", user_id=1) + + assert result["update_status"] == "failed" + assert result["progress"] == 0.0 + assert result["can_update"] is True + assert calls == [{ + "device_id": "TalkingQ_ZHWK00003111", + "firmware_version": "1.8.3", + "update_status": "failed", + "progress": 0.0, + }] + + +def test_firmware_update_not_complete_until_target_version_is_reported(): + service = DeviceService() + + assert not service._firmware_update_reached_target("1.8.2", "1.8.3") + assert not service._firmware_update_reached_target("unknown", "1.8.3") + assert not service._firmware_update_reached_target("1.8.4", "unknown") + assert service._firmware_update_reached_target("1.8.3", "1.8.3") + assert service._firmware_update_reached_target("1.8.4", "1.8.3") + + +def test_firmware_update_timeout_does_not_fail_recent_progress(monkeypatch): + service = DeviceService() + monkeypatch.setattr("banban.service.device.settings.ota_sent_timeout_seconds", 300) + + assert not service._firmware_update_timed_out(datetime.now()) + assert service._firmware_update_timed_out(datetime.now() - timedelta(seconds=301)) + + @pytest.mark.asyncio async def test_start_firmware_update_records_channel_and_keeps_mqtt_payload(monkeypatch): service = DeviceService()