3 Commits
33 changed files with 219 additions and 43 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 ? '当前设备已运行最新版本,无需更新。'
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
}) })
+5 -3
View File
@@ -20,13 +20,13 @@ import {
CHILD_NAME_RULE_MESSAGE, CHILD_NAME_RULE_MESSAGE,
createChild, createChild,
getChildren, getChildren,
sanitizeChildNameInput,
resolveChildSelection, resolveChildSelection,
setSelectedChildId as setStoredSelectedChildId, setSelectedChildId as setStoredSelectedChildId,
validateChildNameInput, validateChildNameInput,
} from '@/services/child' } from '@/services/child'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { trafficPageUrl } from '@/services/traffic-binding' import { trafficPageUrl } from '@/services/traffic-binding'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
@@ -161,6 +161,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
} }
export default function Bind() { export default function Bind() {
usePageShare()
const router = useRouter() const router = useRouter()
const isAssignMode = String(router.params?.mode || '').trim() === 'assign' const isAssignMode = String(router.params?.mode || '').trim() === 'assign'
const isCardMode = String(router.params?.mode || '').trim() === 'card' const isCardMode = String(router.params?.mode || '').trim() === 'card'
@@ -802,9 +803,10 @@ export default function Bind() {
? children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : CHILD_NAME_RULE_MESSAGE ? children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : CHILD_NAME_RULE_MESSAGE
: children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : '可不填,稍后再关联宝贝' : children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : '可不填,稍后再关联宝贝'
} }
maxlength={5} maxlength={-1}
onInput={(event) => { onInput={(event) => {
const nextChildName = sanitizeChildNameInput(event.detail.value) // Preserve pinyin composition; validate the final name on submit.
const nextChildName = event.detail.value
setNewChildName(nextChildName) setNewChildName(nextChildName)
if (nextChildName) { if (nextChildName) {
setSelectedChildId(null) setSelectedChildId(null)
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
}) })
@@ -13,6 +13,7 @@ import {
PeerKind, PeerKind,
} from '../../../services/chat' } from '../../../services/chat'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
function normalizePeerKind(value?: string): PeerKind { function normalizePeerKind(value?: string): PeerKind {
@@ -194,6 +195,7 @@ function getAudioUrlDebugInfo(rawUrl: string) {
} }
export default function ChatDetail() { export default function ChatDetail() {
usePageShare()
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const router = useRouter() const router = useRouter()
const params = router.params const params = router.params
+2 -1
View File
@@ -1,4 +1,5 @@
export default { export default {
navigationBarTitleText: '聊天记录', navigationBarTitleText: '聊天记录',
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
} }
+2
View File
@@ -9,9 +9,11 @@ import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth' import { getToken } from '@/services/auth'
import { getConversations, ChatConversation } from '../../services/chat' import { getConversations, ChatConversation } from '../../services/chat'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
export default function Chat() { export default function Chat() {
usePageShare()
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const [conversations, setConversations] = useState<ChatConversation[]>([]) const [conversations, setConversations] = useState<ChatConversation[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
}) })
+2
View File
@@ -10,6 +10,7 @@ import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child } from '@/services/child' import { Child } from '@/services/child'
import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, getDeviceVolumeCommandStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device' import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, getDeviceVolumeCommandStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
function formatTime(value?: string | null): string { function formatTime(value?: string | null): string {
@@ -119,6 +120,7 @@ function getAlarmLocationHint(alarm: DeviceAlarmItem | null): string {
} }
export default function Device() { export default function Device() {
usePageShare()
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const [binding, setBinding] = useState<Binding | null>(null) const [binding, setBinding] = useState<Binding | null>(null)
const [pendingBinding, setPendingBinding] = useState<Binding | null>(null) const [pendingBinding, setPendingBinding] = useState<Binding | null>(null)
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '家庭邀请', navigationBarTitleText: '家庭邀请',
enableShareAppMessage: true,
}) })
@@ -7,9 +7,11 @@ import { getToken } from '@/services/auth'
import { setSelectedBindingDeviceId } from '@/services/binding' import { setSelectedBindingDeviceId } from '@/services/binding'
import { setSelectedChildId } from '@/services/child' import { setSelectedChildId } from '@/services/child'
import { acceptFamilyInvitation, FamilyInvitation, getFamilyInvitation } from '@/services/family' import { acceptFamilyInvitation, FamilyInvitation, getFamilyInvitation } from '@/services/family'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
export default function FamilyInvite() { export default function FamilyInvite() {
usePageShare()
const router = useRouter() const router = useRouter()
const inviteToken = String(router.params?.token || '').trim() const inviteToken = String(router.params?.token || '').trim()
const redirectUrl = `/pages/family-invite/index?token=${inviteToken}` const redirectUrl = `/pages/family-invite/index?token=${inviteToken}`
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
}) })
+2
View File
@@ -13,6 +13,7 @@ import {
DeviceTrajectoryPoint, DeviceTrajectoryPoint,
} from '@/services/location' } from '@/services/location'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
type TrajectoryMode = 'current' | 'today' | 'recent' type TrajectoryMode = 'current' | 'today' | 'recent'
@@ -127,6 +128,7 @@ function getTrajectoryMarkerId(index: number): number {
} }
export default function Location() { export default function Location() {
usePageShare()
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null) const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '微信登录', navigationBarTitleText: '微信登录',
enableShareAppMessage: true,
}) })
+2
View File
@@ -3,6 +3,7 @@ import { View, Text, Button, Checkbox, CheckboxGroup, Image } from '@tarojs/comp
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { getToken, wechatLogin } from '@/services/auth' import { getToken, wechatLogin } from '@/services/auth'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
const LOGIN_PAGE_URL = '/pages/login/index' const LOGIN_PAGE_URL = '/pages/login/index'
@@ -39,6 +40,7 @@ function navigateAfterLogin(url: string): void {
} }
export default function Login() { export default function Login() {
usePageShare()
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [agreementAccepted, setAgreementAccepted] = useState(false) const [agreementAccepted, setAgreementAccepted] = useState(false)
@@ -0,0 +1,3 @@
export default definePageConfig({
enableShareAppMessage: true,
})
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro' import Taro, { useRouter } from '@tarojs/taro'
import { getToken } from '@/services/auth' import { getToken } from '@/services/auth'
import { resolveWechatMpNotificationLink } from '@/services/notification' import { resolveWechatMpNotificationLink } from '@/services/notification'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
function buildQuery(params: Record<string, string | number | null | undefined>): string { function buildQuery(params: Record<string, string | number | null | undefined>): string {
@@ -18,6 +19,7 @@ function buildCurrentPageUrl(token: string): string {
} }
export default function NotificationEntry() { export default function NotificationEntry() {
usePageShare()
const router = useRouter() const router = useRouter()
const token = String(router.params.token || '').trim() const token = String(router.params.token || '').trim()
const [message, setMessage] = useState('正在打开留言...') const [message, setMessage] = useState('正在打开留言...')
@@ -2,4 +2,5 @@ export default definePageConfig({
navigationBarTitleText: '定时休眠', navigationBarTitleText: '定时休眠',
navigationBarBackgroundColor: '#F5F7FA', navigationBarBackgroundColor: '#F5F7FA',
navigationBarTextStyle: 'black', navigationBarTextStyle: 'black',
enableShareAppMessage: true,
}) })
@@ -7,6 +7,7 @@ import { loadCurrentChildBindingContext, Binding } from '@/services/binding'
import { Child } from '@/services/child' import { Child } from '@/services/child'
import { DeviceStatus, getDeviceStatus, setDeviceSleepSchedule } from '@/services/device' import { DeviceStatus, getDeviceStatus, setDeviceSleepSchedule } from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
function formatSleepRange(status: DeviceStatus | null): string { function formatSleepRange(status: DeviceStatus | null): string {
@@ -28,6 +29,7 @@ function formatUpdatedAt(value?: string | null): string {
} }
export default function SleepSchedule() { export default function SleepSchedule() {
usePageShare()
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom' navigationStyle: 'custom',
enableShareAppMessage: true,
}) })
+2 -3
View File
@@ -26,7 +26,6 @@ import {
CHILD_NAME_RULE_MESSAGE, CHILD_NAME_RULE_MESSAGE,
clearSelectedChildId, clearSelectedChildId,
deleteChild, deleteChild,
sanitizeChildNameInput,
setSelectedChildId, setSelectedChildId,
updateChild, updateChild,
validateChildNameInput, validateChildNameInput,
@@ -1496,8 +1495,8 @@ export default function Sleep() {
value={childName} value={childName}
placeholder={CHILD_NAME_RULE_MESSAGE} placeholder={CHILD_NAME_RULE_MESSAGE}
focus focus
maxlength={5} maxlength={-1}
onInput={(event) => setChildName(sanitizeChildNameInput(event.detail.value))} onInput={(event) => setChildName(event.detail.value)}
/> />
</View> </View>
<View className='modal-actions'> <View className='modal-actions'>
@@ -1 +1,4 @@
export default definePageConfig({ navigationBarTitleText: '流量服务关联' }) export default definePageConfig({
navigationBarTitleText: '流量服务关联',
enableShareAppMessage: true,
})
@@ -4,11 +4,13 @@ import { useRef, useState } from 'react'
import { getToken } from '@/services/auth' import { getToken } from '@/services/auth'
import { clearSelectedBindingDeviceId } from '@/services/binding' import { clearSelectedBindingDeviceId } from '@/services/binding'
import { getPageStyle } from '@/utils/page-style' import { getPageStyle } from '@/utils/page-style'
import { usePageShare } from '@/utils/page-share'
import { TrafficOperation, getTrafficOperation, resumeTrafficOperation, import { TrafficOperation, getTrafficOperation, resumeTrafficOperation,
flushTrafficReceipt, finishTrafficUnbind, jumpTraffic } from '@/services/traffic-binding' flushTrafficReceipt, finishTrafficUnbind, jumpTraffic } from '@/services/traffic-binding'
import './index.scss' import './index.scss'
export default function TrafficBindingPage() { export default function TrafficBindingPage() {
usePageShare()
const { params } = useRouter() const { params } = useRouter()
const deviceId = String(params.device_id || '') const deviceId = String(params.device_id || '')
const action = params.action === 'unbind' ? 'unbind' : 'bind' const action = params.action === 'unbind' ? 'unbind' : 'bind'
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '服务号通知', navigationBarTitleText: '服务号通知',
enableShareAppMessage: true,
}) })
+2
View File
@@ -1,8 +1,10 @@
import { Text, View, WebView } from '@tarojs/components' import { Text, View, WebView } from '@tarojs/components'
import { useRouter } from '@tarojs/taro' import { useRouter } from '@tarojs/taro'
import { usePageShare } from '@/utils/page-share'
import './index.scss' import './index.scss'
export default function WebviewPage() { export default function WebviewPage() {
usePageShare()
const router = useRouter() const router = useRouter()
const encodedUrl = String(router.params?.url || '').trim() const encodedUrl = String(router.params?.url || '').trim()
const targetUrl = encodedUrl ? decodeURIComponent(encodedUrl) : '' const targetUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''
-4
View File
@@ -17,10 +17,6 @@ export function normalizeChildNameInput(value?: string | null): string {
return String(value || '').trim() return String(value || '').trim()
} }
export function sanitizeChildNameInput(value?: string | null): string {
return normalizeChildNameInput(value).replace(/[^\u4e00-\u9fa5]/g, '').slice(0, 5)
}
export function validateChildNameInput(value?: string | null): string { export function validateChildNameInput(value?: string | null): string {
const childName = normalizeChildNameInput(value) const childName = normalizeChildNameInput(value)
if (!childName) return '请输入宝贝姓名' if (!childName) return '请输入宝贝姓名'
+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: '暂无完整版本信息' }
} }
+40
View File
@@ -0,0 +1,40 @@
import { useRouter, useShareAppMessage } from '@tarojs/taro'
const DEFAULT_SHARE_TITLES: Record<string, string> = {
'/pages/device/index': '伴伴设备管理',
'/pages/login/index': '登录伴伴小程序',
'/pages/bind/index': '绑定伴伴设备',
'/pages/traffic-binding/index': '伴伴流量服务',
'/pages/notification/index': '查看伴伴通知',
'/pages/chat/index': '伴伴聊天记录',
'/pages/chat/detail/index': '查看伴伴聊天',
'/pages/location/index': '查看设备位置',
'/pages/sleep/index': '伴伴设备管理',
'/pages/sleep-schedule/index': '设置设备休眠时间',
'/pages/family-invite/index': '家庭共享邀请',
'/pages/webview/index': '伴伴服务通知',
}
function normalizePath(path: string): string {
if (!path) return '/pages/device/index'
return path.startsWith('/') ? path : `/${path}`
}
function buildPagePath(path: string, params: Record<string, string | undefined>): string {
const query = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
return query ? `${path}?${query}` : path
}
export function usePageShare(title?: string): void {
const router = useRouter()
const pagePath = normalizePath(router.path)
useShareAppMessage(() => ({
title: title || DEFAULT_SHARE_TITLES[pagePath] || '伴伴小程序',
path: buildPagePath(pagePath, router.params || {}),
}))
}
@@ -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(