feat: 新增OTA升级相关功能与设备列表缓存优化

1.  新增设备信息页OTA升级进度UI与本地持久化传输状态
2.  实现WiFi模式OTA非全屏进度订阅与重启等待逻辑
3.  新增设备列表本地缓存与加载、清空功能
4.  优化蓝牙管理:移除自动弹窗,改为提示用户手动开启蓝牙
5.  新增网络状态监听与设备在线状态刷新逻辑
6.  新增多语言文案与蓝牙未开启提示资源
7.  优化网络请求处理,新增静默错误提示参数
This commit is contained in:
“虞渠成” 2026-08-27 13:11:55 +08:00
parent 77b29b4f15
commit a95b257b0d
9 changed files with 1090 additions and 76 deletions

View File

@ -1134,6 +1134,16 @@
"zh": "固件传输失败,请重试", "zh": "固件传输失败,请重试",
"en": "Firmware transfer failed. Please try again." "en": "Firmware transfer failed. Please try again."
}, },
{
"key": "ota_update_failed",
"zh": "固件更新失败,请重试",
"en": "Firmware update failed. Please try again."
},
{
"key": "ota_waiting_reboot",
"zh": "设备更新中...",
"en": "Device updating..."
},
{ {
"key": "100228", "key": "100228",
"zh": "温度错误:", "zh": "温度错误:",
@ -1663,6 +1673,11 @@
"key": "100333", "key": "100333",
"zh": "设备需WiFi在线方可进行OTA升级", "zh": "设备需WiFi在线方可进行OTA升级",
"en": "OTA update requires the device to be online via WiFi" "en": "OTA update requires the device to be online via WiFi"
},
{
"key": "100334",
"zh": "蓝牙未开启,如需使用请打开",
"en": "Bluetooth is off. Turn it on if needed."
} }

View File

@ -1,5 +1,139 @@
%YAML 1.1 %YAML 1.1
%TAG !u! tag:unity3d.com,2011: %TAG !u! tag:unity3d.com,2011:
--- !u!1 &241336363346333283
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6901814223954429598}
- component: {fileID: 7794302058416943133}
- component: {fileID: 469724078145029111}
- component: {fileID: 5745045663283300389}
m_Layer: 5
m_Name: "\u66F4\u65B0\u8FDB\u5EA6"
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &6901814223954429598
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 241336363346333283}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1.0003841, y: 1.0003841, z: 1.0003841}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1682688447280925076}
- {fileID: 1221440869875413360}
m_Father: {fileID: 6542542256051934179}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 0}
m_AnchoredPosition: {x: 0, y: 120}
m_SizeDelta: {x: -60, y: 90}
m_Pivot: {x: 0.5, y: 0}
--- !u!222 &7794302058416943133
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 241336363346333283}
m_CullTransparentMesh: 1
--- !u!114 &469724078145029111
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 241336363346333283}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.09019608, g: 0.09019608, b: 0.09019608, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: e70cab5c4dbadd748a0e5e007f58bf9d, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 2
--- !u!114 &5745045663283300389
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 241336363346333283}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 0
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 469724078145029111}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 0}
m_TargetAssemblyTypeName: Kill.UI.Pages.DeviceInfoPage, Assembly-CSharp
m_MethodName: RestoreSettings0
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!1 &312154182751522083 --- !u!1 &312154182751522083
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -34,6 +168,7 @@ RectTransform:
- {fileID: 2568352390314877935} - {fileID: 2568352390314877935}
- {fileID: 3959655657766269314} - {fileID: 3959655657766269314}
- {fileID: 5168603418929363938} - {fileID: 5168603418929363938}
- {fileID: 6901814223954429598}
m_Father: {fileID: 0} m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0} m_AnchorMin: {x: 0, y: 0}
@ -97,6 +232,9 @@ MonoBehaviour:
changeLogText: {fileID: 1957090092938833670} changeLogText: {fileID: 1957090092938833670}
backButton: {fileID: 5686861562038373004} backButton: {fileID: 5686861562038373004}
updateButton: {fileID: 2226008222436017730} updateButton: {fileID: 2226008222436017730}
progressPanel: {fileID: 241336363346333283}
progressBar: {fileID: 4875656352718239638}
progressText: {fileID: 1165127137834906993}
--- !u!1 &414212200923698105 --- !u!1 &414212200923698105
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -852,6 +990,174 @@ MonoBehaviour:
m_FillOrigin: 0 m_FillOrigin: 0
m_UseSpriteMesh: 0 m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1 m_PixelsPerUnitMultiplier: 1
--- !u!1 &3751683427085812987
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1221440869875413360}
- component: {fileID: 6517004505051842296}
- component: {fileID: 1165127137834906993}
- component: {fileID: 2493490058903432914}
m_Layer: 5
m_Name: "\u6570\u503C"
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1221440869875413360
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3751683427085812987}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6901814223954429598}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6517004505051842296
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3751683427085812987}
m_CullTransparentMesh: 1
--- !u!114 &1165127137834906993
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3751683427085812987}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: f4c31d68e7cd3454685bb4c132a52f58, type: 3}
m_FontSize: 30
m_FontStyle: 1
m_BestFit: 0
m_MinSize: 3
m_MaxSize: 40
m_Alignment: 4
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: 0%
--- !u!114 &2493490058903432914
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3751683427085812987}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 89f1d49b50a31514787b4e268b1c73bb, type: 3}
m_Name:
m_EditorClassIdentifier:
textKey: 100221
--- !u!1 &4223866644516014858
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1682688447280925076}
- component: {fileID: 8395495456873232610}
- component: {fileID: 4875656352718239638}
m_Layer: 5
m_Name: "\u6761"
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1682688447280925076
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4223866644516014858}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6901814223954429598}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8395495456873232610
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4223866644516014858}
m_CullTransparentMesh: 1
--- !u!114 &4875656352718239638
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4223866644516014858}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: bc956526bf19a8e4da8da24025fe8240, type: 3}
m_Type: 3
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 0
m_FillAmount: 0
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &4561661931995402753 --- !u!1 &4561661931995402753
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,127 @@
fileFormatVersion: 2
guid: bc956526bf19a8e4da8da24025fe8240
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -243,16 +243,15 @@ namespace Kill.Bluetooth
if (!isEnabled) if (!isEnabled)
{ {
Log("蓝牙未开启,使用 Intent 请求用户启用..."); // 不再弹出系统蓝牙开启弹窗(弹窗会打断 App 生命周期,导致返回后页面卡住不刷新),
// 仅提示用户手动去系统设置中开启蓝牙
using (AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) Log("蓝牙未开启,仅提示用户手动开启蓝牙");
using (AndroidJavaObject currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) ToastUI.Show("100334");
{ _enableBtCoroutine = null;
AndroidJavaObject intent = new AndroidJavaObject("android.content.Intent", "android.bluetooth.adapter.action.REQUEST_ENABLE"); _pendingStartScan = false;
currentActivity.Call("startActivityForResult", intent, 1); _pendingConnectAddress = null;
} OnError?.Invoke("蓝牙未开启");
OnInitialized?.Invoke(false);
_enableBtCoroutine = StartCoroutine(WaitForBluetoothEnabled(onBluetoothReady));
} }
else else
{ {
@ -350,20 +349,23 @@ namespace Kill.Bluetooth
Log("iOS 蓝牙已开启"); Log("iOS 蓝牙已开启");
onBluetoothReady?.Invoke(); onBluetoothReady?.Invoke();
} }
// 4 = PoweredOff (蓝牙关闭) — iOS 系统会自动弹出蓝牙开启对话框,无需手动跳转设置 // 4 = PoweredOff (蓝牙关闭) — 仅提示用户手动开启,不等待系统弹窗
else if (state == 4) else if (state == 4)
{ {
Log("iOS 蓝牙未开启,等待系统弹出蓝牙开启提示..."); Log("iOS 蓝牙未开启,仅提示用户手动开启蓝牙");
// 不调用 ShowIOSTurnOnBluetoothPrompt(),让 iOS 原生弹窗处理 ToastUI.Show("100334");
// 等待蓝牙状态变化(检测焦点变化 + 轮询蓝牙状态) _enableBtCoroutine = null;
_enableBtCoroutine = StartCoroutine(WaitForIOSBluetoothEnabled(onBluetoothReady)); OnError?.Invoke("蓝牙未开启");
OnInitialized?.Invoke(false);
} }
// 3 = Unauthorized (未授权) — 需要跳转到应用设置页面让用户开启蓝牙权限 // 3 = Unauthorized (未授权) — 仅提示用户去系统设置中开启蓝牙权限,不自动跳转
else if (state == 3) else if (state == 3)
{ {
Log("iOS 蓝牙未授权,跳转到应用设置页..."); Log("iOS 蓝牙未授权,仅提示用户去系统设置中开启蓝牙权限");
ShowIOSTurnOnBluetoothPrompt(); ToastUI.Show("100334");
_enableBtCoroutine = StartCoroutine(WaitForIOSBluetoothEnabled(onBluetoothReady)); _enableBtCoroutine = null;
OnError?.Invoke("蓝牙未授权");
OnInitialized?.Invoke(false);
} }
// 2 = Unsupported (不支持蓝牙) // 2 = Unsupported (不支持蓝牙)
else if (state == 2) else if (state == 2)

View File

@ -53,6 +53,79 @@ namespace Kill.Managers
{ {
selectedDeviceMac=PlayerPrefs.GetString("selectedDeviceMac", ""); selectedDeviceMac=PlayerPrefs.GetString("selectedDeviceMac", "");
} }
#region 使
private const string OWNED_DEVICES_KEY = "ownedDevicesCache";
private const string SHARED_DEVICES_KEY = "sharedDevicesCache";
/// <summary>
/// 将当前 OwnedDevices/SharedDevices 持久化到本地(按当前登录用户隔离)
/// </summary>
public void SaveDeviceLists()
{
try
{
string userId = userInfo?.id ?? "";
string ownedJson = JsonUtility.ToJson(OwnedDevices != null ? new DeviceInfoListWrapper(OwnedDevices) : new DeviceInfoListWrapper());
string sharedJson = JsonUtility.ToJson(SharedDevices != null ? new DeviceInfoListWrapper(SharedDevices) : new DeviceInfoListWrapper());
PlayerPrefs.SetString(OWNED_DEVICES_KEY + "_" + userId, ownedJson);
PlayerPrefs.SetString(SHARED_DEVICES_KEY + "_" + userId, sharedJson);
PlayerPrefs.Save();
}
catch (Exception ex)
{
Debug.LogError($"[DataManager] 保存设备列表缓存失败: {ex.Message}");
}
}
/// <summary>
/// 从本地恢复 OwnedDevices/SharedDevices返回是否成功恢复
/// </summary>
public bool LoadDeviceLists()
{
try
{
string userId = userInfo?.id ?? "";
string ownedJson = PlayerPrefs.GetString(OWNED_DEVICES_KEY + "_" + userId, "");
string sharedJson = PlayerPrefs.GetString(SHARED_DEVICES_KEY + "_" + userId, "");
OwnedDevices = string.IsNullOrEmpty(ownedJson) ? new List<DeviceInfo>() : JsonUtility.FromJson<DeviceInfoListWrapper>(ownedJson)?.ToList() ?? new List<DeviceInfo>();
SharedDevices = string.IsNullOrEmpty(sharedJson) ? new List<DeviceInfo>() : JsonUtility.FromJson<DeviceInfoListWrapper>(sharedJson)?.ToList() ?? new List<DeviceInfo>();
return OwnedDevices.Count > 0 || SharedDevices.Count > 0;
}
catch (Exception ex)
{
Debug.LogError($"[DataManager] 读取设备列表缓存失败: {ex.Message}");
OwnedDevices = new List<DeviceInfo>();
SharedDevices = new List<DeviceInfo>();
return false;
}
}
/// <summary>
/// 清空本地缓存的设备列表(登出时调用)
/// </summary>
public void ClearDeviceLists()
{
string userId = userInfo?.id ?? "";
PlayerPrefs.DeleteKey(OWNED_DEVICES_KEY + "_" + userId);
PlayerPrefs.DeleteKey(SHARED_DEVICES_KEY + "_" + userId);
PlayerPrefs.Save();
OwnedDevices = new List<DeviceInfo>();
SharedDevices = new List<DeviceInfo>();
}
[Serializable]
private class DeviceInfoListWrapper
{
public List<DeviceInfo> items = new List<DeviceInfo>();
public DeviceInfoListWrapper() { }
public DeviceInfoListWrapper(List<DeviceInfo> source)
{
if (source != null) items.AddRange(source);
}
public List<DeviceInfo> ToList() => new List<DeviceInfo>(items);
}
#endregion
public void InitUser() public void InitUser()
{ {
token = PlayerPrefs.GetString("token", ""); token = PlayerPrefs.GetString("token", "");
@ -124,7 +197,8 @@ namespace Kill.Managers
onError: (code, msg) => onError: (code, msg) =>
{ {
Debug.LogError($"[DataManager] 获取设备配置失败: {msg}"); Debug.LogError($"[DataManager] 获取设备配置失败: {msg}");
} },
silentErrorTip: true
); );
} }
@ -164,7 +238,8 @@ namespace Kill.Managers
onError: (code, msg) => onError: (code, msg) =>
{ {
Debug.LogError($"[DataManager] 修改设备配置失败: {msg}"); Debug.LogError($"[DataManager] 修改设备配置失败: {msg}");
} },
silentErrorTip: true
); );
return success; return success;

View File

@ -93,21 +93,23 @@ namespace Kill.Network
/// <param name="code">输出状态码</param> /// <param name="code">输出状态码</param>
/// <param name="message">输出消息</param> /// <param name="message">输出消息</param>
/// <returns>是否已全局处理</returns> /// <returns>是否已全局处理</returns>
public static bool CheckGlobalHandle<T>(HttpResponse<T> response, out int code, out string message) public static bool CheckGlobalHandle<T>(HttpResponse<T> response, out int code, out string message, bool silentErrorTip = false)
{ {
code = -1; code = -1;
message = null; message = null;
// 空响应(网络异常) // 空响应(网络异常)
if (response == null) if (response == null)
{ {
ShowErrorTip("100036"); message = "网络异常";
if (!silentErrorTip) ShowErrorTip("100036");
return true; return true;
} }
// HTTP错误网络层错误 // HTTP错误网络层错误
if (!response.IsSuccess) if (!response.IsSuccess)
{ {
ShowErrorTip("100036"); message = string.IsNullOrEmpty(response.ErrorMessage) ? "网络异常" : response.ErrorMessage;
if (!silentErrorTip) ShowErrorTip("100036");
return true; return true;
} }
@ -132,13 +134,19 @@ namespace Kill.Network
/// <param name="response">HTTP响应</param> /// <param name="response">HTTP响应</param>
/// <param name="onSuccess">成功回调code=200</param> /// <param name="onSuccess">成功回调code=200</param>
/// <param name="onError">其他错误回调(可选,不传则自动提示)</param> /// <param name="onError">其他错误回调(可选,不传则自动提示)</param>
public static void HandleResponse<T>(HttpResponse<T> response, Action<T> onSuccess, Action<int, string> onError = null) /// <param name="silentErrorTip">true 时静默处理网络错误不弹100036仅触发 onError</param>
public static void HandleResponse<T>(HttpResponse<T> response, Action<T> onSuccess, Action<int, string> onError = null, bool silentErrorTip = false)
{ {
// 全局处理网络错误、601等 // 全局处理网络错误、601等
int code; int code;
string message; string message;
if (CheckGlobalHandle(response, out code, out message)) if (CheckGlobalHandle(response, out code, out message, silentErrorTip))
{ {
// silentErrorTip=true 时若调用方提供了 onError仍触发以便业务继续
if (silentErrorTip && onError != null)
{
onError.Invoke(code, message ?? "网络异常");
}
return; // 已被全局处理 return; // 已被全局处理
} }

View File

@ -21,7 +21,22 @@ namespace Kill.UI.Pages
public Text changeLogText; public Text changeLogText;
public Button backButton; public Button backButton;
public Button updateButton; public Button updateButton;
// WiFi OTA 页内进度条(非全屏,不阻塞操作)
public GameObject progressPanel;
public Image progressBar; // Fill Method = Horizontal, Fill Origin = Left
public Text progressText;
OTAData otaData; OTAData otaData;
// 当前设备持久化的 OTA transferIdWiFi 模式),空表示无进行中的传输
private const string OTA_TRANSFER_ID_KEY = "otaTransferId";
private const string OTA_TRANSFER_VER_KEY = "otaTransferVer";
private const string OTA_TRANSFER_STATUS_KEY = "otaTransferStatus";
private const string OTA_TRANSFER_DEADLINE_KEY = "otaTransferDeadline"; // 等设备重启的绝对截止时间戳Unix 秒)
// 等设备重启的总时长(秒);持久化截止时间,避免退出/重进页面时计时重置
private const int OTA_WAIT_DEADLINE_SECONDS = 30;
// status: "pushing"=传输中, "waiting_reboot"=传输完成等待设备重启/重连
private string currentTransferId;
private string currentTransferStatus;
private bool isUpdating; // OTA升级进行中(防连点重入)
void Start() void Start()
{ {
UIManager.Instance.RegisterBackAction(Back); UIManager.Instance.RegisterBackAction(Back);
@ -30,6 +45,7 @@ namespace Kill.UI.Pages
updateButton.onClick.RemoveAllListeners(); updateButton.onClick.RemoveAllListeners();
updateButton.onClick.AddListener(OnUpdateButtonClick); updateButton.onClick.AddListener(OnUpdateButtonClick);
updateButton.gameObject.SetActive(false); updateButton.gameObject.SetActive(false);
if (progressPanel != null) progressPanel.SetActive(false);
GetOtaInfo(); GetOtaInfo();
} }
@ -90,6 +106,9 @@ namespace Kill.UI.Pages
versionText = latestTip.transform.Find("value").GetComponent<Text>(); versionText = latestTip.transform.Find("value").GetComponent<Text>();
versionText.text = otaData.version; versionText.text = otaData.version;
} }
// 进入页面:若本地有未结束的 transferId则恢复订阅进度非全屏不阻塞
TryResumeWifiOta();
} }
public void Back() public void Back()
{ {
@ -97,7 +116,6 @@ namespace Kill.UI.Pages
Destroy(gameObject); Destroy(gameObject);
Screen.sleepTimeout = SleepTimeout.SystemSetting; Screen.sleepTimeout = SleepTimeout.SystemSetting;
} }
private bool isUpdating; // OTA升级进行中(防连点重入)
public async void OnUpdateButtonClick() public async void OnUpdateButtonClick()
{ {
// 防连点升级流程进行中忽略重复触发避免并发启动两个OTA // 防连点升级流程进行中忽略重复触发避免并发启动两个OTA
@ -113,12 +131,9 @@ namespace Kill.UI.Pages
return; return;
} }
// WiFi 模式push OTA 指令SSE 订阅传输进度 // WiFi 模式push OTA 指令SSE 订阅传输进度(非全屏,不阻塞操作,可关闭页面后下次进入继续订阅)
if (DataManager.Instance.hasWifi) if (DataManager.Instance.hasWifi)
{ {
ToastUI.Show("100294");
UpdateLoadingUI.Instance.Show();
UpdateLoadingUI.Instance.SetPrecent(0);
try try
{ {
var pushRequest = new OTAPushRequest var pushRequest = new OTAPushRequest
@ -127,39 +142,38 @@ namespace Kill.UI.Pages
version = otaData.version version = otaData.version
}; };
var pushResponse = await NetworkCtrl.Instance.Post<OTAPushResponse>("/api/v1/ota/push", pushRequest); var pushResponse = await NetworkCtrl.Instance.Post<OTAPushResponse>("/api/v1/ota/push", pushRequest);
if (pushResponse.IsSuccess && pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId)) // 提取 transferId优先从 data.transferId否则在 code=608已有OTA进行中时从 message 解析 "transferId=xxx"
string transferId = null;
if (pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId))
{
transferId = pushResponse.Data.data.transferId;
}
else if (pushResponse.Data != null && pushResponse.Data.code == 608 && !string.IsNullOrEmpty(pushResponse.Data.message))
{
var match = System.Text.RegularExpressions.Regex.Match(pushResponse.Data.message, @"transferId\s*=\s*([A-Za-z0-9_\-]+)");
if (match.Success)
{
transferId = match.Groups[1].Value;
Debug.Log($"WiFi OTA push 返回 code=608已有OTA进行中从message提取 transferId: {transferId}");
}
}
if (!string.IsNullOrEmpty(transferId))
{ {
string transferId = pushResponse.Data.data.transferId;
Debug.Log($"WiFi OTA push 成功transferId: {transferId}"); Debug.Log($"WiFi OTA push 成功transferId: {transferId}");
// SSE 订阅传输进度,传输完成返回 true // 持久化 transferId订阅后台进行不阻塞页面操作
bool success = await SubscribeOtaProgress(transferId, p => UpdateLoadingUI.Instance.SetPrecent(p / 100f)); SaveTransferId(transferId, otaData.version, "pushing");
if (success) StartWifiOtaSubscription(transferId);
{
ToastUI.Show("100226");
DataManager.Instance.selectedDevice.firmware_version = otaData.version;
// 传输完成后等待 10s 再返回主页(设备升级后需要时间重启)
StartCoroutine(DelayedGoHome(10f));
}
else
{
ToastUI.Show("100227");
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
}
} }
else else
{ {
ToastUI.Show("100227"); ToastUI.Show("100227");
Debug.LogError($"WiFi OTA push 失败: {pushResponse.ErrorMessage ?? pushResponse.RawResponse}"); Debug.LogError($"WiFi OTA push 失败: {pushResponse.ErrorMessage ?? pushResponse.RawResponse}");
UpdateLoadingUI.Instance.Hide();
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
} }
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
Debug.LogError($"WiFi OTA push 异常: {ex.Message}"); Debug.LogError($"WiFi OTA push 异常: {ex.Message}");
UpdateLoadingUI.Instance.Hide();
ToastUI.Show("100227"); ToastUI.Show("100227");
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
} }
finally finally
{ {
@ -201,17 +215,13 @@ namespace Kill.UI.Pages
UpdateLoadingUI.Instance.Show(); UpdateLoadingUI.Instance.Show();
uint firmwareVersion = ParseFirmwareVersion(otaData.version); uint firmwareVersion = ParseFirmwareVersion(otaData.version);
OTAManager.Instance.OnTransferProgress += OnOTATransferProgress; OTAManager.Instance.OnTransferProgress += OnOTATransferProgress;
// OTA 传输期间清空返回事件,防止用户触发返回中断升级;传输结束恢复 // OTA 传输期间进度显示清空返回事件返回键走两次返回退出App防止误返回中断升级成败都会返回主页
var otaBackAction = UIManager.Instance.GetBackAction();
UIManager.Instance.ClearBackAction(); UIManager.Instance.ClearBackAction();
StartCoroutine(OTAManager.Instance.PerformFullUpgrade(_selectedFirmwareData, firmwareVersion, (success) => StartCoroutine(OTAManager.Instance.PerformFullUpgrade(_selectedFirmwareData, firmwareVersion, (success) =>
{ {
Loom.QueueOnMainThread(() => Loom.QueueOnMainThread(() =>
{ {
isUpdating = false; isUpdating = false;
// 恢复返回事件
if (otaBackAction != null)
UIManager.Instance.RegisterBackAction(otaBackAction);
if (success) if (success)
{ {
DataManager.Instance.selectedDevice.firmware_version = otaData.version; DataManager.Instance.selectedDevice.firmware_version = otaData.version;
@ -244,19 +254,318 @@ namespace Kill.UI.Pages
} }
/// <summary> /// <summary>
/// 订阅 WiFi OTA 传输进度SSE返回是否传输完成 /// 订阅 WiFi OTA 传输进度SSE返回是否传输完成仅供兼容旧调用BLE 仍走全屏 OTAManager
/// </summary> /// </summary>
private Task<bool> SubscribeOtaProgress(string transferId, Action<int> onProgress) private Task<bool> SubscribeOtaProgress(string transferId, Action<int> onProgress)
{ {
var tcs = new TaskCompletionSource<bool>(); var tcs = new TaskCompletionSource<bool>();
StartCoroutine(SSEProgressCoroutine(transferId, onProgress, success => tcs.TrySetResult(success))); StartCoroutine(SSELegacyCoroutine(transferId, onProgress, success => tcs.TrySetResult(success)));
return tcs.Task; return tcs.Task;
} }
private IEnumerator SSELegacyCoroutine(string transferId, Action<int> onProgress, Action<bool> onDone)
{
bool finished = false;
yield return SubscribeOtaProgressCoroutine(transferId, onProgress, done => finished = done);
onDone?.Invoke(finished);
}
/// <summary> /// <summary>
/// SSE 进度协程持续接收进度事件传输完成status/percentage或连接结束后结束 /// 保存当前设备的 OTA transferId与设备mac绑定切换设备不影响
/// </summary> /// </summary>
private IEnumerator SSEProgressCoroutine(string transferId, Action<int> onProgress, Action<bool> onDone) private void SaveTransferId(string transferId, string version, string status)
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
PlayerPrefs.SetString(GetTransferIdKey(mac), transferId);
PlayerPrefs.SetString(GetTransferVerKey(mac), version ?? "");
PlayerPrefs.SetString(GetTransferStatusKey(mac), status ?? "");
PlayerPrefs.Save();
currentTransferId = transferId;
currentTransferStatus = status;
}
private void UpdateTransferStatus(string status)
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
PlayerPrefs.SetString(GetTransferStatusKey(mac), status ?? "");
PlayerPrefs.Save();
currentTransferStatus = status;
}
/// <summary>
/// 设置等待设备重启的绝对截止时间戳Unix 秒)。退出页面再回来仍按此截止时间判定,不会重置。
/// </summary>
private void SetWaitDeadline()
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
long deadline = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + OTA_WAIT_DEADLINE_SECONDS;
PlayerPrefs.SetString(GetTransferDeadlineKey(mac), deadline.ToString());
PlayerPrefs.Save();
}
private long LoadWaitDeadline()
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
string v = PlayerPrefs.GetString(GetTransferDeadlineKey(mac), "");
if (long.TryParse(v, out long d)) return d;
return 0;
}
/// <summary>
/// 读取当前设备的 OTA transferId空表示无
/// </summary>
private string LoadTransferId()
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
return PlayerPrefs.GetString(GetTransferIdKey(mac), "");
}
/// <summary>
/// 清除当前设备的 OTA transferId
/// </summary>
private void ClearTransferId()
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
PlayerPrefs.DeleteKey(GetTransferIdKey(mac));
PlayerPrefs.DeleteKey(GetTransferVerKey(mac));
PlayerPrefs.DeleteKey(GetTransferStatusKey(mac));
PlayerPrefs.DeleteKey(GetTransferDeadlineKey(mac));
PlayerPrefs.Save();
currentTransferId = null;
currentTransferStatus = null;
}
private string GetTransferIdKey(string mac) => OTA_TRANSFER_ID_KEY + "_" + mac;
private string GetTransferVerKey(string mac) => OTA_TRANSFER_VER_KEY + "_" + mac;
private string GetTransferStatusKey(string mac) => OTA_TRANSFER_STATUS_KEY + "_" + mac;
private string GetTransferDeadlineKey(string mac) => OTA_TRANSFER_DEADLINE_KEY + "_" + mac;
/// <summary>
/// 启动 WiFi OTA 订阅:仅更新页内小进度条,不阻塞页面,可关闭/重入
/// </summary>
private void StartWifiOtaSubscription(string transferId)
{
ShowProgressPanel(0);
updateButton.gameObject.SetActive(false); // 升级中禁用更新按钮
StartCoroutine(SubscribeAndHandle(transferId));
}
/// <summary>
/// 订阅协程:完成/失败/连接结束统一处理(不强制回主页)
/// </summary>
private IEnumerator SubscribeAndHandle(string transferId)
{
bool finished = false;
// SSE 回调本身在主线程,直接同步赋值即可(不再绕 Loom否则 yield 之后判断时 finished 仍是旧值)
yield return SubscribeOtaProgressCoroutine(transferId,
p => ShowProgressPanel(p),
done => finished = done);
// 协程结束:根据是否完成做后续
if (finished)
{
ShowProgressPanel(100);
// 传输完成 ≠ 设备升级完成:等待设备重启/重连,并以新版本号上线后再清理 transferId、刷新 UI
UpdateTransferStatus("waiting_reboot");
// 仅在尚未设置过截止时间时才设置(避免重复订阅重置倒计时)
if (LoadWaitDeadline() <= 0) SetWaitDeadline();
StartCoroutine(WaitDeviceRebootAndFinish(transferId));
}
else
{
// 中途失败/中断
ToastUI.Show("100227");
ClearTransferId();
HideProgressPanel();
updateButton.gameObject.SetActive(true);
}
}
/// <summary>
/// 传输完成后等待设备重启+重连:轮询设备列表直到当前设备以新版本号上线,否则按持久化的绝对截止时间超时退出。
/// 期间保持 transferId 持久化、保留页内进度条显示「升级中」文案,重新进入页面也能继续等待(截止时间不会重置)。
/// </summary>
private IEnumerator WaitDeviceRebootAndFinish(string transferId)
{
const float pollInterval = 3f; // 轮询间隔
long deadline = LoadWaitDeadline();
// 兜底:若没设置过截止时间,立即设置一个
if (deadline <= 0)
{
SetWaitDeadline();
deadline = LoadWaitDeadline();
}
// 已超过绝对截止时间:直接走超时分支并清掉 transferId避免每次进入都重复提示
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() >= deadline)
{
ClearTransferId();
HideProgressPanel();
updateButton.gameObject.SetActive(true);
ToastUI.Show("ota_update_failed");
yield break;
}
ShowProgressPanelWaitingForDevice();
ToastUI.Show("100226"); // 传输完成提示
string targetVersion = otaData != null ? otaData.version : "";
bool rebooted = false;
while (DateTimeOffset.UtcNow.ToUnixTimeSeconds() < deadline)
{
yield return new WaitForSeconds(pollInterval);
if (gameObject == null) yield break; // 页面已销毁
// 仅当持久化的还是同一个 transferId 时才继续等待(用户重新点击/切换设备会清掉)
if (LoadTransferId() != transferId) yield break;
string nowVersion = null;
yield return FetchDeviceFirmwareVersion(r => nowVersion = r);
if (!string.IsNullOrEmpty(nowVersion) && IsVersionGE(nowVersion, targetVersion))
{
rebooted = true;
break;
}
// 期间截止时间可能被重置(用户重新点击升级等),以最新值为准
long currentDeadline = LoadWaitDeadline();
if (currentDeadline > 0 && currentDeadline != deadline) deadline = currentDeadline;
}
if (gameObject == null) yield break;
if (LoadTransferId() != transferId) yield break;
if (rebooted)
{
// 设备已用新版本上线,更新本地版本并清理状态
if (DataManager.Instance.selectedDevice != null && !string.IsNullOrEmpty(targetVersion))
{
DataManager.Instance.selectedDevice.firmware_version = targetVersion;
}
ClearTransferId();
HideProgressPanel();
InitOtaUI(); // 重新判断:已是最新 / 又有新版本
}
else
{
// 超时:升级失败,提示并清理 transferId避免下次进入页面再触发
ToastUI.Show("ota_update_failed");
ClearTransferId();
HideProgressPanel();
updateButton.gameObject.SetActive(true);
}
}
/// <summary>
/// 拉取当前设备的最新 firmware_version仅赋值给回调返回空表示失败/离线)
/// </summary>
private IEnumerator FetchDeviceFirmwareVersion(Action<string> onResult)
{
string result = null;
var task = FetchDeviceFirmwareVersionAsync(v => result = v);
while (!task.IsCompleted) yield return null;
onResult?.Invoke(result);
}
private async Task FetchDeviceFirmwareVersionAsync(Action<string> onResult)
{
try
{
if (DataManager.Instance.userInfo == null || string.IsNullOrEmpty(DataManager.Instance.userInfo.id))
{
onResult?.Invoke(null);
return;
}
string url = $"/api/v1/device/user/list?user_id={DataManager.Instance.userInfo.id}";
var response = await NetworkCtrl.Instance.Get<DeviceListResponse>(url);
if (response == null || !response.IsSuccess || response.Data == null || response.Data.data == null)
{
onResult?.Invoke(null);
return;
}
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
var list = response.Data.data.owned_devices;
if (list != null)
{
foreach (var d in list)
{
if (d != null && d.ble_mac == mac)
{
onResult?.Invoke(d.firmware_version);
return;
}
}
}
var shared = response.Data.data.shared_devices;
if (shared != null)
{
foreach (var d in shared)
{
if (d != null && d.ble_mac == mac)
{
onResult?.Invoke(d.firmware_version);
return;
}
}
}
onResult?.Invoke(null);
}
catch (System.Exception ex)
{
Debug.LogError($"查询设备版本失败: {ex.Message}");
onResult?.Invoke(null);
}
}
/// <summary>
/// 比较版本号a >= b 返回 true按 "." 分段整数比较)
/// </summary>
private bool IsVersionGE(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
string[] ap = a.Split('.');
string[] bp = b.Split('.');
int len = Mathf.Max(ap.Length, bp.Length);
for (int i = 0; i < len; i++)
{
int av = 0, bv = 0;
if (i < ap.Length) int.TryParse(ap[i], out av);
if (i < bp.Length) int.TryParse(bp[i], out bv);
if (av > bv) return true;
if (av < bv) return false;
}
return true;
}
/// <summary>
/// 进度条切换到「升级中...」状态fill 保持满,文字提示)
/// </summary>
private void ShowProgressPanelWaitingForDevice()
{
if (progressPanel != null) progressPanel.SetActive(true);
if (progressBar != null) progressBar.fillAmount = 1f;
if (progressText != null && LanguageManager.Instance != null)
progressText.text = LanguageManager.Instance.GetLanguage("ota_waiting_reboot");
}
/// <summary>
/// 进入页面时:若本地存在 transferId则恢复订阅并显示进度条
/// </summary>
private void TryResumeWifiOta()
{
string mac = DataManager.Instance.selectedDevice?.ble_mac ?? "";
string savedId = LoadTransferId();
string savedStatus = PlayerPrefs.GetString(GetTransferStatusKey(mac), "");
if (string.IsNullOrEmpty(savedId)) return;
currentTransferId = savedId;
currentTransferStatus = savedStatus;
updateButton.gameObject.SetActive(false);
// 已经传输完成等待设备重启:直接走轮询分支,不重新订阅 SSE
if (savedStatus == "waiting_reboot")
{
ShowProgressPanelWaitingForDevice();
StartCoroutine(WaitDeviceRebootAndFinish(savedId));
return;
}
ShowProgressPanel(0);
StartCoroutine(SubscribeAndHandle(savedId));
}
/// <summary>
/// 仅订阅进度协程,传出是否完成(不弹出后续处理)
/// </summary>
private IEnumerator SubscribeOtaProgressCoroutine(string transferId, Action<int> onProgress, Action<bool> onDone)
{ {
bool transferFinished = false; bool transferFinished = false;
string sseUrl = $"/api/v1/ota/transfer/{transferId}/progress"; string sseUrl = $"/api/v1/ota/transfer/{transferId}/progress";
@ -270,7 +579,8 @@ namespace Kill.UI.Pages
if (info == null) return; if (info == null) return;
onProgress?.Invoke(info.percentage); onProgress?.Invoke(info.percentage);
string status = (info.status ?? "").ToLower(); string status = (info.status ?? "").ToLower();
if (info.percentage >= 100 || status.Contains("completed") || status.Contains("success") || status.Contains("done")) Debug.Log($"OTA 进度: {status}, {info.percentage}%");
if (info.percentage >= 100 || status.Contains("completed"))
{ {
transferFinished = true; transferFinished = true;
} }
@ -284,10 +594,29 @@ namespace Kill.UI.Pages
{ {
Debug.LogError($"OTA 进度连接错误: {err}"); Debug.LogError($"OTA 进度连接错误: {err}");
}); });
// SSE 连接结束(服务端关闭或出错),以最后收到的进度状态判定是否完成
onDone?.Invoke(transferFinished); onDone?.Invoke(transferFinished);
} }
/// <summary>
/// 显示页内进度条并更新百分比
/// </summary>
private void ShowProgressPanel(int percent)
{
if (progressPanel != null) progressPanel.SetActive(true);
if (progressBar != null) progressBar.fillAmount = Mathf.Clamp01(percent / 100f);
if (progressText != null) progressText.text = $"{percent}%";
}
/// <summary>
/// 隐藏页内进度条
/// </summary>
private void HideProgressPanel()
{
if (progressBar != null) progressBar.fillAmount = 0f;
if (progressText != null) progressText.text = "0%";
if (progressPanel != null) progressPanel.SetActive(false);
}
/// <summary> /// <summary>
/// 加载固件文件 /// 加载固件文件
/// </summary> /// </summary>

View File

@ -79,7 +79,7 @@ namespace Kill.UI.Pages
// 订阅蓝牙事件 // 订阅蓝牙事件
SubscribeBluetoothEvents(); SubscribeBluetoothEvents();
// 获取设备列表 // 获取设备列表(失败时使用上一次缓存的列表,继续后续流程)
await FetchDeviceList(); await FetchDeviceList();
Init(); Init();
} }
@ -129,6 +129,13 @@ namespace Kill.UI.Pages
BluetoothManager.Instance.OnStartScan+=deviceState.OnStartScan; BluetoothManager.Instance.OnStartScan+=deviceState.OnStartScan;
} }
// 网络状态:断网立刻把 WiFi 设备标离线,联网后异步重新确认
if (NetworkStateManager.Instance != null)
{
NetworkStateManager.Instance.OnNetworkDisconnected += OnNetworkDisconnectedHandler;
NetworkStateManager.Instance.OnNetworkConnected += OnNetworkConnectedHandler;
}
if (BLECommunicationManager.Instance != null) if (BLECommunicationManager.Instance != null)
{ {
// 订阅用户登录结果事件 // 订阅用户登录结果事件
@ -159,6 +166,12 @@ namespace Kill.UI.Pages
BluetoothManager.Instance.OnStartScan-=deviceState.OnStartScan; BluetoothManager.Instance.OnStartScan-=deviceState.OnStartScan;
} }
if (NetworkStateManager.Instance != null)
{
NetworkStateManager.Instance.OnNetworkDisconnected -= OnNetworkDisconnectedHandler;
NetworkStateManager.Instance.OnNetworkConnected -= OnNetworkConnectedHandler;
}
if (BLECommunicationManager.Instance != null) if (BLECommunicationManager.Instance != null)
{ {
// 取消订阅用户登录结果事件 // 取消订阅用户登录结果事件
@ -460,6 +473,33 @@ namespace Kill.UI.Pages
#endregion #endregion
#region
/// <summary>
/// 网络断开:立刻把 WiFi 设备标离线并刷新 UI
/// </summary>
private void OnNetworkDisconnectedHandler(object sender, EventArgs e)
{
Debug.Log("[HomePageCtrl] 网络断开立即刷新设备WiFi为未连接");
hasWifi = false;
DataManager.Instance.hasWifi = false;
pendingUIUpdates.Add(() =>
{
deviceState?.UpdateDeviceState(hasBluetooth, hasWifi);
});
}
/// <summary>
/// 网络恢复:异步重新查询设备在线状态(局域网通但设备可能下线)
/// </summary>
private void OnNetworkConnectedHandler(object sender, EventArgs e)
{
Debug.Log("[HomePageCtrl] 网络恢复,重新查询设备在线状态");
RefreshWifiOnlineStatusAsync();
}
#endregion
#region #region
// 当前工作模式 // 当前工作模式
@ -823,7 +863,7 @@ namespace Kill.UI.Pages
public async Task Init() public async Task Init()
{ {
LoadingUI.Show(); LoadingUI.Show();
if (DataManager.Instance.userInfo.device_count == 0) if (DataManager.Instance.userInfo.device_count == 0 || (OwnedDevices.Count == 0 && SharedDevices.Count == 0))
{ {
noDevicePage.SetActive(true); noDevicePage.SetActive(true);
normalPage.SetActive(false); normalPage.SetActive(false);
@ -880,12 +920,15 @@ namespace Kill.UI.Pages
} }
selectDeviceButton.GetComponentInChildren<Text>().text = selectedDevice.device_name; selectDeviceButton.GetComponentInChildren<Text>().text = selectedDevice.device_name;
selectDeviceButton.gameObject.SetActive(true); selectDeviceButton.gameObject.SetActive(true);
hasWifi=await GetDeviceOnlineStatus(); // 蓝牙:直接触发连接,状态以连接回调为准(不阻塞)
DataManager.Instance.hasWifi=hasWifi;
hasBluetooth = BluetoothManager.Instance.ChangeAimMac(selectedDevice.ble_mac); hasBluetooth = BluetoothManager.Instance.ChangeAimMac(selectedDevice.ble_mac);
DataManager.Instance.hasBluetooth=hasBluetooth; DataManager.Instance.hasBluetooth=hasBluetooth;
// WiFi不阻塞主线异步短超时查询进入页面先按未知/未连接显示,断网时立刻离线,命中在线再补做初始化
hasWifi = false;
DataManager.Instance.hasWifi = false;
RefreshWifiOnlineStatusAsync();
// 先初始化所有 UI 为未连接状态 // 先初始化所有 UI 为未连接状态
ResetAllDeviceUI(); ResetAllDeviceUI();
@ -969,8 +1012,6 @@ namespace Kill.UI.Pages
/// </summary> /// </summary>
public async Task FetchDeviceList() public async Task FetchDeviceList()
{ {
LoadingUI.Show();
try try
{ {
Debug.Log("[HomePageCtrl] 开始获取设备列表..."); Debug.Log("[HomePageCtrl] 开始获取设备列表...");
@ -980,15 +1021,20 @@ namespace Kill.UI.Pages
if (string.IsNullOrEmpty(userId)) if (string.IsNullOrEmpty(userId))
{ {
Debug.LogError("[HomePageCtrl] 获取设备列表失败: 用户未登录"); Debug.LogError("[HomePageCtrl] 获取设备列表失败: 用户未登录");
LoadingUI.Hide();
return; return;
} }
// 断网/接口失败兜底:先尝试从 PlayerPrefs 读取上次缓存(同步赋值给当前页字段,供随后 Init 渲染)
if (OwnedDevices.Count == 0 && SharedDevices.Count == 0 && DataManager.Instance.LoadDeviceLists())
{
OwnedDevices = new List<Kill.Managers.DeviceInfo>(DataManager.Instance.OwnedDevices);
SharedDevices = new List<Kill.Managers.DeviceInfo>(DataManager.Instance.SharedDevices);
Debug.Log($"[HomePageCtrl] 使用本地缓存设备列表: 自有={OwnedDevices.Count}, 共享={SharedDevices.Count}");
}
string url = $"/api/v1/device/user/list?user_id={userId}"; string url = $"/api/v1/device/user/list?user_id={userId}";
var response = await NetworkCtrl.Instance.Get<DeviceListResponse>(url); var response = await NetworkCtrl.Instance.Get<DeviceListResponse>(url);
LoadingUI.Hide();
ResponseCodeHandler.HandleResponse(response, ResponseCodeHandler.HandleResponse(response,
onSuccess: (data) => onSuccess: (data) =>
{ {
@ -1016,17 +1062,48 @@ namespace Kill.UI.Pages
Debug.Log($"[HomePageCtrl] 获取设备列表成功: 自有设备={OwnedDevices.Count}, 共享设备={SharedDevices.Count}"); Debug.Log($"[HomePageCtrl] 获取设备列表成功: 自有设备={OwnedDevices.Count}, 共享设备={SharedDevices.Count}");
DataManager.Instance.userInfo.device_count = OwnedDevices.Count + SharedDevices.Count; DataManager.Instance.userInfo.device_count = OwnedDevices.Count + SharedDevices.Count;
// 同步到 DataManager 并持久化到本地(断网时使用)
DataManager.Instance.OwnedDevices = new List<Kill.Managers.DeviceInfo>(OwnedDevices);
DataManager.Instance.SharedDevices = new List<Kill.Managers.DeviceInfo>(SharedDevices);
DataManager.Instance.SaveDeviceLists();
// 设备列表更新后,刷新主页选中设备与 UI不阻塞初次进入
Loom.QueueOnMainThread(() => RefreshAfterDeviceListUpdated());
}, },
onError: null onError: null
); );
} }
catch (Exception ex) catch (Exception ex)
{ {
LoadingUI.Hide();
Debug.LogError($"[HomePageCtrl] 获取设备列表异常: {ex.Message}"); Debug.LogError($"[HomePageCtrl] 获取设备列表异常: {ex.Message}");
} }
} }
/// <summary>
/// 设备列表更新后:重新解析 selectedDevice 并刷新主页相关 UI不重复 LoadingUI
/// </summary>
private void RefreshAfterDeviceListUpdated()
{
if (DataManager.Instance == null) return;
// 若初次进入时无设备列表而走了「无设备」分支,列表就绪后再切回正常页
if (OwnedDevices.Count > 0 || SharedDevices.Count > 0)
{
noDevicePage.SetActive(false);
normalPage.SetActive(true);
}
// 重新定位 selectedDevice
if (DataManager.Instance.selectedDevice == null && (OwnedDevices.Count > 0 || SharedDevices.Count > 0))
{
selectedDevice = OwnedDevices.Count > 0 ? OwnedDevices[0] : SharedDevices[0];
DataManager.Instance.selectedDeviceMac = selectedDevice.ble_mac;
DataManager.Instance.SavaSelectedDeviceMac(selectedDevice.ble_mac);
DataManager.Instance.selectedDevice = selectedDevice;
selectDeviceButton.GetComponentInChildren<Text>().text = selectedDevice.device_name;
selectDeviceButton.gameObject.SetActive(true);
}
DataManager.Instance.OwnedDevices = OwnedDevices;
DataManager.Instance.SharedDevices = SharedDevices;
}
void Update() void Update()
{ {
// 执行所有挂起的UI更新 // 执行所有挂起的UI更新
@ -1683,7 +1760,8 @@ namespace Kill.UI.Pages
var response = await NetworkCtrl.Instance.Get<DeviceOnlineResponse>( var response = await NetworkCtrl.Instance.Get<DeviceOnlineResponse>(
"/api/v1/device/online", "/api/v1/device/online",
new Dictionary<string, string> { { "mac", selectedDevice.ble_mac } } new Dictionary<string, string> { { "mac", selectedDevice.ble_mac } },
new HttpRequestConfig { Timeout = 5, MaxRetries = 1, RetryDelayMs = 500, UseExponentialBackoff = false }
); );
ResponseCodeHandler.HandleResponse(response, ResponseCodeHandler.HandleResponse(response,
@ -1697,12 +1775,78 @@ namespace Kill.UI.Pages
{ {
Debug.LogError($"[HomePageCtrl] 查询设备在线状态失败: {msg}"); Debug.LogError($"[HomePageCtrl] 查询设备在线状态失败: {msg}");
tcs.TrySetResult(false); tcs.TrySetResult(false);
} },
silentErrorTip: true
); );
// 兜底silentErrorTip=true 时 CheckGlobalHandle 会直接 return导致 onError 永远不被调,
// 这里加超时与兜底,避免 tcs 永久 hang否则后台轮询将停在这一帧
if (!tcs.Task.IsCompleted)
{
Debug.LogWarning("[HomePageCtrl] 设备在线状态查询未返回结果,兜底设为未连接");
tcs.TrySetResult(false);
}
return await tcs.Task; return await tcs.Task;
} }
/// <summary>
/// 非阻塞刷新 WiFi 在线状态:失败立即标记未连接并刷新 UI后台轮询会持续覆盖
/// 若从离线变为在线且蓝牙未连接,补做 WiFi 分支的设备状态初始化(拉取 config/schedule
/// </summary>
public async void RefreshWifiOnlineStatusAsync()
{
if (selectedDevice == null || string.IsNullOrEmpty(selectedDevice.ble_mac)) return;
bool wasOnline = hasWifi;
bool online;
try
{
online = await GetDeviceOnlineStatus();
}
catch (System.Exception ex)
{
Debug.LogError($"[HomePageCtrl] 刷新WiFi在线状态异常: {ex.Message}");
online = false;
}
if (this == null || selectedDevice == null) return;
// 失败online=false一律立即刷为未连接避免 UI 残留已连接态
hasWifi = online;
DataManager.Instance.hasWifi = online;
pendingUIUpdates.Add(() =>
{
deviceState?.UpdateDeviceState(hasBluetooth, hasWifi);
});
if (!online)
{
Debug.Log("[HomePageCtrl] WiFi在线状态获取失败已将设备刷新为未连接");
return;
}
// 由离线变为在线且蓝牙未连接:补做 WiFi 分支的设备状态初始化
if (!wasOnline && !hasBluetooth)
{
Debug.Log("[HomePageCtrl] WiFi 由离线变为在线,补做设备状态初始化");
_ = InitWifiOnlyDeviceStateAsync();
}
}
/// <summary>
/// WiFi-only 分支的设备状态初始化(拉取配置/定时任务),与 Init() 中对应分支保持一致
/// </summary>
private async Task InitWifiOnlyDeviceStateAsync()
{
if (selectedDevice == null) return;
deviceCtrl?.InitDeviceControl(false);
await DataManager.Instance.GetDeviceConfig(selectedDevice.ble_mac);
await DataManager.Instance.GetScheduleTasks(selectedDevice.ble_mac);
// 期间蓝牙可能已连接,避免用 WiFi 数据覆盖 BLE 数据
if (!hasBluetooth)
{
scheduleSettingButton.interactable = true;
moreSettingButton.interactable = true;
InitDeviceUIFromConfig();
}
}
/// <summary> /// <summary>
/// 启动设备在线状态轮询始终运行后台检测WiFi状态变化 /// 启动设备在线状态轮询始终运行后台检测WiFi状态变化
/// </summary> /// </summary>
@ -1739,6 +1883,14 @@ namespace Kill.UI.Pages
deviceState?.UpdateDeviceState(hasBluetooth, hasWifi); deviceState?.UpdateDeviceState(hasBluetooth, hasWifi);
}); });
} }
else
{
// 服务器超时/网络异常:无论初始 wasOnline 状态,都强制刷新 UI 一次以确保图标同步
pendingUIUpdates.Add(() =>
{
deviceState?.UpdateDeviceState(hasBluetooth, hasWifi);
});
}
} }
else if (online && !wasOnline && !hasBluetooth) else if (online && !wasOnline && !hasBluetooth)
{ {