3 Commits
33 changed files with 219 additions and 43 deletions
@@ -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 ? '当前设备已运行最新版本,无需更新。'
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+5 -3
View File
@@ -20,13 +20,13 @@ import {
CHILD_NAME_RULE_MESSAGE,
createChild,
getChildren,
sanitizeChildNameInput,
resolveChildSelection,
setSelectedChildId as setStoredSelectedChildId,
validateChildNameInput,
} from '@/services/child'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { trafficPageUrl } from '@/services/traffic-binding'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
@@ -161,6 +161,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
}
export default function Bind() {
usePageShare()
const router = useRouter()
const isAssignMode = String(router.params?.mode || '').trim() === 'assign'
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位简体中文姓名(暂不支持数字和英文)' : '可不填,稍后再关联宝贝'
}
maxlength={5}
maxlength={-1}
onInput={(event) => {
const nextChildName = sanitizeChildNameInput(event.detail.value)
// Preserve pinyin composition; validate the final name on submit.
const nextChildName = event.detail.value
setNewChildName(nextChildName)
if (nextChildName) {
setSelectedChildId(null)
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
navigationStyle: 'custom',
enableShareAppMessage: true,
})
@@ -13,6 +13,7 @@ import {
PeerKind,
} from '../../../services/chat'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
function normalizePeerKind(value?: string): PeerKind {
@@ -194,6 +195,7 @@ function getAudioUrlDebugInfo(rawUrl: string) {
}
export default function ChatDetail() {
usePageShare()
const systemBanner = useSystemBanner()
const router = useRouter()
const params = router.params
+2 -1
View File
@@ -1,4 +1,5 @@
export default {
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 { getConversations, ChatConversation } from '../../services/chat'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
export default function Chat() {
usePageShare()
const systemBanner = useSystemBanner()
const [conversations, setConversations] = useState<ChatConversation[]>([])
const [loading, setLoading] = useState(true)
+2 -1
View File
@@ -1,3 +1,4 @@
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 { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, getDeviceVolumeCommandStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
function formatTime(value?: string | null): string {
@@ -119,6 +120,7 @@ function getAlarmLocationHint(alarm: DeviceAlarmItem | null): string {
}
export default function Device() {
usePageShare()
const systemBanner = useSystemBanner()
const [binding, setBinding] = useState<Binding | null>(null)
const [pendingBinding, setPendingBinding] = useState<Binding | null>(null)
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '家庭邀请',
enableShareAppMessage: true,
})
@@ -7,9 +7,11 @@ import { getToken } from '@/services/auth'
import { setSelectedBindingDeviceId } from '@/services/binding'
import { setSelectedChildId } from '@/services/child'
import { acceptFamilyInvitation, FamilyInvitation, getFamilyInvitation } from '@/services/family'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
export default function FamilyInvite() {
usePageShare()
const router = useRouter()
const inviteToken = String(router.params?.token || '').trim()
const redirectUrl = `/pages/family-invite/index?token=${inviteToken}`
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+2
View File
@@ -13,6 +13,7 @@ import {
DeviceTrajectoryPoint,
} from '@/services/location'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
type TrajectoryMode = 'current' | 'today' | 'recent'
@@ -127,6 +128,7 @@ function getTrajectoryMarkerId(index: number): number {
}
export default function Location() {
usePageShare()
const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true)
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
@@ -1,3 +1,4 @@
export default definePageConfig({
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 Taro from '@tarojs/taro'
import { getToken, wechatLogin } from '@/services/auth'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
const LOGIN_PAGE_URL = '/pages/login/index'
@@ -39,6 +40,7 @@ function navigateAfterLogin(url: string): void {
}
export default function Login() {
usePageShare()
const [submitting, setSubmitting] = 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 { getToken } from '@/services/auth'
import { resolveWechatMpNotificationLink } from '@/services/notification'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
function buildQuery(params: Record<string, string | number | null | undefined>): string {
@@ -18,6 +19,7 @@ function buildCurrentPageUrl(token: string): string {
}
export default function NotificationEntry() {
usePageShare()
const router = useRouter()
const token = String(router.params.token || '').trim()
const [message, setMessage] = useState('正在打开留言...')
@@ -2,4 +2,5 @@ export default definePageConfig({
navigationBarTitleText: '定时休眠',
navigationBarBackgroundColor: '#F5F7FA',
navigationBarTextStyle: 'black',
enableShareAppMessage: true,
})
@@ -7,6 +7,7 @@ import { loadCurrentChildBindingContext, Binding } from '@/services/binding'
import { Child } from '@/services/child'
import { DeviceStatus, getDeviceStatus, setDeviceSleepSchedule } from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
function formatSleepRange(status: DeviceStatus | null): string {
@@ -28,6 +29,7 @@ function formatUpdatedAt(value?: string | null): string {
}
export default function SleepSchedule() {
usePageShare()
const systemBanner = useSystemBanner()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
+2 -1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom'
navigationStyle: 'custom',
enableShareAppMessage: true,
})
+2 -3
View File
@@ -26,7 +26,6 @@ import {
CHILD_NAME_RULE_MESSAGE,
clearSelectedChildId,
deleteChild,
sanitizeChildNameInput,
setSelectedChildId,
updateChild,
validateChildNameInput,
@@ -1496,8 +1495,8 @@ export default function Sleep() {
value={childName}
placeholder={CHILD_NAME_RULE_MESSAGE}
focus
maxlength={5}
onInput={(event) => setChildName(sanitizeChildNameInput(event.detail.value))}
maxlength={-1}
onInput={(event) => setChildName(event.detail.value)}
/>
</View>
<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 { clearSelectedBindingDeviceId } from '@/services/binding'
import { getPageStyle } from '@/utils/page-style'
import { usePageShare } from '@/utils/page-share'
import { TrafficOperation, getTrafficOperation, resumeTrafficOperation,
flushTrafficReceipt, finishTrafficUnbind, jumpTraffic } from '@/services/traffic-binding'
import './index.scss'
export default function TrafficBindingPage() {
usePageShare()
const { params } = useRouter()
const deviceId = String(params.device_id || '')
const action = params.action === 'unbind' ? 'unbind' : 'bind'
@@ -1,3 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '服务号通知',
enableShareAppMessage: true,
})
+2
View File
@@ -1,8 +1,10 @@
import { Text, View, WebView } from '@tarojs/components'
import { useRouter } from '@tarojs/taro'
import { usePageShare } from '@/utils/page-share'
import './index.scss'
export default function WebviewPage() {
usePageShare()
const router = useRouter()
const encodedUrl = String(router.params?.url || '').trim()
const targetUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''
-4
View File
@@ -17,10 +17,6 @@ export function normalizeChildNameInput(value?: string | null): string {
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 {
const childName = normalizeChildNameInput(value)
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))
}
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: '暂无完整版本信息' }
}
+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.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)
})
+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. 管理固件配置信息(管理员接口)
**获取固件配置**
@@ -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 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}")
+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(
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(