feat: 新增WiFi OTA升级功能与相关优化

1. 新增多语言文案用于OTA升级提示
2. 优化更新进度显示为整数百分比
3. 调整设备轮询间隔为5秒
4. 新增SSE流式请求处理与OTA数据模型
5. 实现WiFi环境下基于SSE的OTA升级流程
6. 增加蓝牙环境下OTA升级的前置校验与提示
This commit is contained in:
“虞渠成” 2026-08-25 14:12:45 +08:00
parent ca042827d6
commit 8568724c3f
6 changed files with 196 additions and 14 deletions

View File

@ -1658,6 +1658,11 @@
"key": "100332", "key": "100332",
"zh": "视频加载中,请稍候", "zh": "视频加载中,请稍候",
"en": "Video loading, please wait" "en": "Video loading, please wait"
},
{
"key": "100333",
"zh": "设备需WiFi在线方可进行OTA升级",
"en": "OTA update requires the device to be online via WiFi"
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine; using UnityEngine;
namespace Kill.Managers namespace Kill.Managers
@ -365,6 +366,41 @@ namespace Kill.Managers
public string version; public string version;
} }
/// <summary>
/// WiFi OTA push 响应(返回 transferId
/// </summary>
[System.Serializable]
public class OTAPushResponse
{
public int code;
public string message;
public OTATransferInfo data;
}
/// <summary>
/// OTA 传输信息push 返回与 SSE 进度事件共用,后端字段为下划线命名)
/// </summary>
[System.Serializable]
public class OTATransferInfo
{
[JsonProperty("transfer_id")]
public string transferId;
[JsonProperty("device_id")]
public string deviceId;
public string filename;
[JsonProperty("file_size")]
public long fileSize;
public int percentage;
[JsonProperty("completed_chunks")]
public int completedChunks;
[JsonProperty("total_chunks")]
public int totalChunks;
[JsonProperty("chunk_index")]
public int chunkIndex;
public string direction;
public string status;
}
public class MessageData public class MessageData
{ {
public string id; public string id;

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -292,6 +293,72 @@ namespace Kill.Network
#endregion #endregion
#region SSE
/// <summary>
/// SSE 流式下载处理器:按事件分隔符 \n\n 解析 data: 行并回调
/// </summary>
public class SSEDownloadHandler : DownloadHandlerScript
{
private readonly Action<string> _onEventData;
private readonly StringBuilder _buffer = new StringBuilder();
public SSEDownloadHandler(Action<string> onEventData) : base(new byte[16384])
{
_onEventData = onEventData;
}
protected override bool ReceiveData(byte[] data, int dataLength)
{
if (data == null || dataLength == 0) return false;
_buffer.Append(Encoding.UTF8.GetString(data, 0, dataLength));
string content = _buffer.ToString();
int idx;
while ((idx = content.IndexOf("\n\n", StringComparison.Ordinal)) >= 0)
{
string evt = content.Substring(0, idx);
content = content.Substring(idx + 2);
foreach (string line in evt.Split('\n'))
{
string l = line.Trim();
if (l.StartsWith("data:"))
{
string payload = l.Substring(5).Trim();
if (!string.IsNullOrEmpty(payload) && payload != "[DONE]")
_onEventData?.Invoke(payload);
}
}
}
// 保留未完成的事件块,等待下次数据到达
_buffer.Clear();
_buffer.Append(content);
return true;
}
}
/// <summary>
/// 发起 SSE 流式请求(长连接)。
/// 事件数据通过 onEventData 逐条回调;连接结束(服务端关闭)或出错后协程结束。
/// </summary>
public IEnumerator StartSSE(string url, Dictionary<string, string> queryParams, Dictionary<string, string> headers, Action<string> onEventData, Action<string> onError)
{
string fullUrl = GetFullUrl(BuildUrl(url, queryParams));
var handler = new SSEDownloadHandler(onEventData);
using (UnityWebRequest request = new UnityWebRequest(fullUrl, UnityWebRequest.kHttpVerbGET, handler, null))
{
AddHeadersToRequest(request, headers);
// SSE 为长连接,大文件 OTA 传输耗时长,超时设大防止中途断开
request.timeout = 600;
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success && request.result != UnityWebRequest.Result.ProtocolError)
{
onError?.Invoke(request.error);
}
}
}
#endregion
#region #region
/// <summary> /// <summary>

View File

@ -72,7 +72,7 @@ namespace Kill.UI.Components
public void SetPrecent(float p) public void SetPrecent(float p)
{ {
float fullP=p*100; float fullP=p*100;
precent.text=fullP.ToString("f1")+"%"; precent.text=Mathf.RoundToInt(fullP).ToString()+"%";
} }
} }
} }

View File

@ -1,3 +1,4 @@
using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@ -7,6 +8,7 @@ using Kill.Core;
using Kill.Managers; using Kill.Managers;
using Kill.Network; using Kill.Network;
using Kill.UI.Components; using Kill.UI.Components;
using Newtonsoft.Json;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace Kill.UI.Pages namespace Kill.UI.Pages
@ -103,9 +105,20 @@ namespace Kill.UI.Pages
isUpdating = true; isUpdating = true;
Screen.sleepTimeout = SleepTimeout.NeverSleep; Screen.sleepTimeout = SleepTimeout.NeverSleep;
// WiFi 模式:直接 push OTA 指令,设备自行下载固件 // 仅蓝牙连接不支持直接OTA提示先配置WiFi
if (DataManager.Instance.hasWifi&&!DataManager.Instance.hasBluetooth) if (DataManager.Instance.hasBluetooth && !DataManager.Instance.hasWifi)
{ {
ToastUI.Show("100333");
isUpdating = false;
return;
}
// WiFi 模式push OTA 指令SSE 订阅传输进度
if (DataManager.Instance.hasWifi)
{
ToastUI.Show("100294");
UpdateLoadingUI.Instance.Show();
UpdateLoadingUI.Instance.SetPrecent(0);
try try
{ {
var pushRequest = new OTAPushRequest var pushRequest = new OTAPushRequest
@ -113,29 +126,45 @@ namespace Kill.UI.Pages
ble_mac = DataManager.Instance.selectedDevice.ble_mac, ble_mac = DataManager.Instance.selectedDevice.ble_mac,
version = otaData.version version = otaData.version
}; };
var pushResponse = await NetworkCtrl.Instance.Post<NoDataResponse>("/api/v1/ota/push", pushRequest); var pushResponse = await NetworkCtrl.Instance.Post<OTAPushResponse>("/api/v1/ota/push", pushRequest);
ResponseCodeHandler.HandleResponse(pushResponse, if (pushResponse.IsSuccess && pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId))
onSuccess: (data) => {
string transferId = pushResponse.Data.data.transferId;
Debug.Log($"WiFi OTA push 成功transferId: {transferId}");
// SSE 订阅传输进度,传输完成返回 true
bool success = await SubscribeOtaProgress(transferId, p => UpdateLoadingUI.Instance.SetPrecent(p / 100f));
if (success)
{ {
ToastUI.Show("100308"); ToastUI.Show("100226");
DataManager.Instance.selectedDevice.firmware_version = otaData.version; DataManager.Instance.selectedDevice.firmware_version = otaData.version;
}, // 传输完成后等待 10s 再返回主页(设备升级后需要时间重启)
onError: (code, message) => StartCoroutine(DelayedGoHome(10f));
}
else
{ {
ToastUI.Show("100227"); ToastUI.Show("100227");
Debug.LogError($"WiFi OTA push 失败: {message}"); UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
} }
); }
else
{
ToastUI.Show("100227");
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");
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
} }
finally finally
{ {
isUpdating = false; isUpdating = false;
} }
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
return; return;
} }
@ -186,7 +215,6 @@ namespace Kill.UI.Pages
if (success) if (success)
{ {
DataManager.Instance.selectedDevice.firmware_version = otaData.version; DataManager.Instance.selectedDevice.firmware_version = otaData.version;
UpdateLoadingUI.Instance.Hide();
ToastUI.Show("100226"); ToastUI.Show("100226");
// OTA 成功后等待 15s 再返回主页(设备升级后需要时间重启) // OTA 成功后等待 15s 再返回主页(设备升级后需要时间重启)
StartCoroutine(DelayedGoHome(15f)); StartCoroutine(DelayedGoHome(15f));
@ -215,6 +243,51 @@ namespace Kill.UI.Pages
UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); UIManager.Instance.OpenMainPage(UIManager.PageName.homePage);
} }
/// <summary>
/// 订阅 WiFi OTA 传输进度SSE返回是否传输完成
/// </summary>
private Task<bool> SubscribeOtaProgress(string transferId, Action<int> onProgress)
{
var tcs = new TaskCompletionSource<bool>();
StartCoroutine(SSEProgressCoroutine(transferId, onProgress, success => tcs.TrySetResult(success)));
return tcs.Task;
}
/// <summary>
/// SSE 进度协程持续接收进度事件传输完成status/percentage或连接结束后结束
/// </summary>
private IEnumerator SSEProgressCoroutine(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();
if (info.percentage >= 100 || status.Contains("completed") || status.Contains("success") || status.Contains("done"))
{
transferFinished = true;
}
}
catch (System.Exception ex)
{
Debug.LogError($"OTA 进度解析失败: {ex.Message}");
}
},
err =>
{
Debug.LogError($"OTA 进度连接错误: {err}");
});
// SSE 连接结束(服务端关闭或出错),以最后收到的进度状态判定是否完成
onDone?.Invoke(transferFinished);
}
/// <summary> /// <summary>
/// 加载固件文件 /// 加载固件文件
/// </summary> /// </summary>

View File

@ -1714,7 +1714,8 @@ namespace Kill.UI.Pages
while (!stopPolling && selectedDevice != null) while (!stopPolling && selectedDevice != null)
{ {
await System.Threading.Tasks.Task.Delay(10000); // 每 5s 检测一次设备 WiFi 在线状态
await System.Threading.Tasks.Task.Delay(5000);
if (stopPolling || selectedDevice == null) if (stopPolling || selectedDevice == null)
break; break;