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

686 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Kill.Bluetooth;
using Kill.Core;
using Kill.Managers;
using Kill.Network;
using Kill.UI.Components;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.UI;
namespace Kill.UI.Pages
{
public class FirmwareUpdatePage : MonoBehaviour
{
public GameObject latestTip;
public GameObject getNewVersionTip;
public Text versionText;
public Text changeLogText;
public Button backButton;
public Button updateButton;
// WiFi OTA 页内进度条(非全屏,不阻塞操作)
public GameObject progressPanel;
public Image progressBar; // Fill Method = Horizontal, Fill Origin = Left
public Text progressText;
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()
{
UIManager.Instance.RegisterBackAction(Back);
backButton.onClick.RemoveAllListeners();
backButton.onClick.AddListener(Back);
updateButton.onClick.RemoveAllListeners();
updateButton.onClick.AddListener(OnUpdateButtonClick);
updateButton.gameObject.SetActive(false);
if (progressPanel != null) progressPanel.SetActive(false);
GetOtaInfo();
}
public async void GetOtaInfo()
{
LoadingUI.Show();
var response = await NetworkCtrl.Instance.Get<OTAResponse>("/api/v1/ota/latest");
LoadingUI.Hide();
ResponseCodeHandler.HandleResponse(response,
onSuccess: (data) =>
{
otaData = data.data;
Debug.Log($"加载OTA信息成功版本: {otaData.version}");
InitOtaUI();
},
onError:(code,message)=>
{
Debug.LogError($"加载OTA信息失败: {message}");
}
);
}
public void InitOtaUI()
{
string nowVersion = DataManager.Instance.selectedDevice.firmware_version;
string[] versionParts = otaData.version.Split('.');
string[] nowVersionParts = nowVersion.Split('.');
int nowversionNum = 0;
int versionNum = 0;
int index=1;
for(int i=nowVersionParts.Length-1;i>=0;i--)
{
nowversionNum += int.Parse(nowVersionParts[i]) * 10*index++;
}
index = 1;
for (int i = versionParts.Length - 1; i >= 0; i--)
{
versionNum += int.Parse(versionParts[i]) * 10 * index++;
}
Debug.Log($"当前版本: {nowVersion} ({nowversionNum}), 最新版本: {otaData.version} ({versionNum})");
bool hasNewVersion = versionNum > nowversionNum;
if(hasNewVersion)
{
latestTip.SetActive(false);
getNewVersionTip.SetActive(true);
changeLogText.gameObject.SetActive(true);
versionText=getNewVersionTip.transform.Find("value").GetComponent<Text>();
versionText.text = otaData.version;
changeLogText.text = otaData.release_notes;
updateButton.gameObject.SetActive(true);
}
else
{
latestTip.SetActive(true);
getNewVersionTip.SetActive(false);
updateButton.gameObject.SetActive(false);
changeLogText.gameObject.SetActive(false);
versionText = latestTip.transform.Find("value").GetComponent<Text>();
versionText.text = otaData.version;
}
// 进入页面:若本地有未结束的 transferId则恢复订阅进度非全屏不阻塞
TryResumeWifiOta();
}
public void Back()
{
UIManager.Instance.RegisterBackAction(GetComponentInParent<DeviceInfoPage>().Back);
Destroy(gameObject);
Screen.sleepTimeout = SleepTimeout.SystemSetting;
}
public async void OnUpdateButtonClick()
{
// 防连点升级流程进行中忽略重复触发避免并发启动两个OTA
if (isUpdating) return;
isUpdating = true;
Screen.sleepTimeout = SleepTimeout.NeverSleep;
// 仅蓝牙连接不支持直接OTA提示先配置WiFi
if (DataManager.Instance.hasBluetooth && !DataManager.Instance.hasWifi)
{
ToastUI.Show("100333");
isUpdating = false;
return;
}
// WiFi 模式push OTA 指令SSE 订阅传输进度(非全屏,不阻塞操作,可关闭页面后下次进入继续订阅)
if (DataManager.Instance.hasWifi)
{
try
{
var pushRequest = new OTAPushRequest
{
ble_mac = DataManager.Instance.selectedDevice.ble_mac,
version = otaData.version
};
var pushResponse = await NetworkCtrl.Instance.Post<OTAPushResponse>("/api/v1/ota/push", pushRequest);
// 提取 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))
{
Debug.Log($"WiFi OTA push 成功transferId: {transferId}");
// 持久化 transferId订阅后台进行不阻塞页面操作
SaveTransferId(transferId, otaData.version, "pushing");
StartWifiOtaSubscription(transferId);
}
else
{
ToastUI.Show("100227");
Debug.LogError($"WiFi OTA push 失败: {pushResponse.ErrorMessage ?? pushResponse.RawResponse}");
}
}
catch (System.Exception ex)
{
Debug.LogError($"WiFi OTA push 异常: {ex.Message}");
ToastUI.Show("100227");
}
finally
{
isUpdating = false;
}
return;
}
// BLE 模式:下载固件 → 校验 → 蓝牙传输
ToastUI.Show("100294");
UpdateLoadingUI.Instance.Show();
string savePath = Application.persistentDataPath + "/ota.bin";
var downloadResult = await NetworkCtrl.Instance.DownloadFile(otaData.download_url, savePath, UpdateLoadingUI.Instance.GetDownloadProcess);
UpdateLoadingUI.Instance.Hide();
if (!downloadResult.IsSuccess)
{
ToastUI.Show("100224");
isUpdating = false;
return;
}
string md5 = NetworkCtrl.Instance.CalculateMD5(savePath);
if (md5 != otaData.md5)
{
ToastUI.Show("100223");
Debug.LogError("下载的文件MD5校验失败");
File.Delete(savePath);
isUpdating = false;
return;
}
LoadFirmwareFile(savePath);
if (_selectedFirmwareData == null)
{
isUpdating = false;
return;
}
ToastUI.Show("100225");
UpdateLoadingUI.Instance.SetPrecent(0);
UpdateLoadingUI.Instance.Show();
uint firmwareVersion = ParseFirmwareVersion(otaData.version);
OTAManager.Instance.OnTransferProgress += OnOTATransferProgress;
// OTA 传输期间进度显示清空返回事件返回键走两次返回退出App防止误返回中断升级成败都会返回主页
UIManager.Instance.ClearBackAction();
StartCoroutine(OTAManager.Instance.PerformFullUpgrade(_selectedFirmwareData, firmwareVersion, (success) =>
{
Loom.QueueOnMainThread(() =>
{
isUpdating = false;
if (success)
{
DataManager.Instance.selectedDevice.firmware_version = otaData.version;
ToastUI.Show("100226");
// OTA 成功后等待 15s 再返回主页(设备升级后需要时间重启)
StartCoroutine(DelayedGoHome(15f));
}
else
{
ToastUI.Show("100227");
UpdateLoadingUI.Instance.Hide();
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
}
});
}));
}
// 选中的固件文件数据
private byte[] _selectedFirmwareData;
private string _selectedFirmwarePath;
/// <summary>
/// 延迟指定时间后返回主页
/// </summary>
private IEnumerator DelayedGoHome(float delay)
{
yield return new WaitForSeconds(delay);
UpdateLoadingUI.Instance.Hide();
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
}
/// <summary>
/// 订阅 WiFi OTA 传输进度SSE返回是否传输完成仅供兼容旧调用BLE 仍走全屏 OTAManager
/// </summary>
private Task<bool> SubscribeOtaProgress(string transferId, Action<int> onProgress)
{
var tcs = new TaskCompletionSource<bool>();
StartCoroutine(SSELegacyCoroutine(transferId, onProgress, success => tcs.TrySetResult(success)));
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>
/// 保存当前设备的 OTA transferId与设备mac绑定切换设备不影响
/// </summary>
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;
string sseUrl = $"/api/v1/ota/transfer/{transferId}/progress";
var queryParams = new Dictionary<string, string> { { "token", DataManager.Instance.token } };
yield return HttpRequestManager.Instance.StartSSE(sseUrl, queryParams, null,
data =>
{
try
{
var info = JsonConvert.DeserializeObject<OTATransferInfo>(data);
if (info == null) return;
onProgress?.Invoke(info.percentage);
string status = (info.status ?? "").ToLower();
Debug.Log($"OTA 进度: {status}, {info.percentage}%");
if (info.percentage >= 100 || status.Contains("completed"))
{
transferFinished = true;
}
}
catch (System.Exception ex)
{
Debug.LogError($"OTA 进度解析失败: {ex.Message}");
}
},
err =>
{
Debug.LogError($"OTA 进度连接错误: {err}");
});
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>
private void LoadFirmwareFile(string filePath)
{
try
{
// 获取文件大小
FileInfo fileInfo = new FileInfo(filePath);
long fileSize = fileInfo.Length;
// 读取文件
_selectedFirmwareData = File.ReadAllBytes(filePath);
_selectedFirmwarePath = filePath;
string fileName = Path.GetFileName(filePath);
}
catch (System.Exception)
{
ToastUI.Show("100224");
LoadingUI.Hide();
}
}
/// <summary>
/// 解析固件版本号
/// </summary>
private uint ParseFirmwareVersion(string version)
{
try
{
string[] parts = version.Split('.');
if (parts.Length >= 3 &&
uint.TryParse(parts[0], out uint v0) &&
uint.TryParse(parts[1], out uint v1) &&
uint.TryParse(parts[2], out uint v2))
{
// 版本号格式:主版本.次版本.修订号(可含第4段)
// 按每段2位十进制压缩与设备端编码一致如 00.00.01.05 -> 105
uint v3 = 0;
if (parts.Length >= 4)
uint.TryParse(parts[3], out v3);
return v0 * 1000000 + v1 * 10000 + v2 * 100 + v3;
}
}
catch { }
// 默认返回版本号 1
return 1;
}
/// <summary>
/// OTA传输进度回调
/// </summary>
private void OnOTATransferProgress(int current, int total, int retryCount, int maxRetries)
{
UpdateLoadingUI.Instance.SetPrecent((float)current/ total);
}
}
}