diff --git a/Assets/Res/language/language.json b/Assets/Res/language/language.json index 67b724f..b899c54 100644 --- a/Assets/Res/language/language.json +++ b/Assets/Res/language/language.json @@ -1658,6 +1658,11 @@ "key": "100332", "zh": "视频加载中,请稍候", "en": "Video loading, please wait" + }, + { + "key": "100333", + "zh": "设备需WiFi在线方可进行OTA升级", + "en": "OTA update requires the device to be online via WiFi" } diff --git a/Assets/Scripts/Managers/DataBase.cs b/Assets/Scripts/Managers/DataBase.cs index 2e80be4..c43e6b8 100644 --- a/Assets/Scripts/Managers/DataBase.cs +++ b/Assets/Scripts/Managers/DataBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; using UnityEngine; namespace Kill.Managers @@ -365,6 +366,41 @@ namespace Kill.Managers public string version; } + /// + /// WiFi OTA push 响应(返回 transferId) + /// + [System.Serializable] + public class OTAPushResponse + { + public int code; + public string message; + public OTATransferInfo data; + } + + /// + /// OTA 传输信息(push 返回与 SSE 进度事件共用,后端字段为下划线命名) + /// + [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 string id; diff --git a/Assets/Scripts/Network/HttpRequestManager.cs b/Assets/Scripts/Network/HttpRequestManager.cs index cf4ef49..02b244d 100644 --- a/Assets/Scripts/Network/HttpRequestManager.cs +++ b/Assets/Scripts/Network/HttpRequestManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -292,6 +293,72 @@ namespace Kill.Network #endregion + #region SSE 流式请求 + + /// + /// SSE 流式下载处理器:按事件分隔符 \n\n 解析 data: 行并回调 + /// + public class SSEDownloadHandler : DownloadHandlerScript + { + private readonly Action _onEventData; + private readonly StringBuilder _buffer = new StringBuilder(); + + public SSEDownloadHandler(Action 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; + } + } + + /// + /// 发起 SSE 流式请求(长连接)。 + /// 事件数据通过 onEventData 逐条回调;连接结束(服务端关闭)或出错后协程结束。 + /// + public IEnumerator StartSSE(string url, Dictionary queryParams, Dictionary headers, Action onEventData, Action 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 核心请求方法 /// diff --git a/Assets/Scripts/UI/Components/UpdateLoadingUI.cs b/Assets/Scripts/UI/Components/UpdateLoadingUI.cs index 1e9d444..252c65d 100644 --- a/Assets/Scripts/UI/Components/UpdateLoadingUI.cs +++ b/Assets/Scripts/UI/Components/UpdateLoadingUI.cs @@ -72,7 +72,7 @@ namespace Kill.UI.Components public void SetPrecent(float p) { float fullP=p*100; - precent.text=fullP.ToString("f1")+"%"; + precent.text=Mathf.RoundToInt(fullP).ToString()+"%"; } } } diff --git a/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs b/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs index ba405cf..54ff148 100644 --- a/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs +++ b/Assets/Scripts/UI/Pages/DeviceInfoPage/FirmwareUpdatePage.cs @@ -1,3 +1,4 @@ +using System; using System.Collections; using System.Collections.Generic; using System.IO; @@ -7,6 +8,7 @@ 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 @@ -103,9 +105,20 @@ namespace Kill.UI.Pages isUpdating = true; Screen.sleepTimeout = SleepTimeout.NeverSleep; - // WiFi 模式:直接 push OTA 指令,设备自行下载固件 - if (DataManager.Instance.hasWifi&&!DataManager.Instance.hasBluetooth) + // 仅蓝牙连接:不支持直接OTA,提示先配置WiFi + 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 { var pushRequest = new OTAPushRequest @@ -113,29 +126,45 @@ namespace Kill.UI.Pages ble_mac = DataManager.Instance.selectedDevice.ble_mac, version = otaData.version }; - var pushResponse = await NetworkCtrl.Instance.Post("/api/v1/ota/push", pushRequest); - ResponseCodeHandler.HandleResponse(pushResponse, - onSuccess: (data) => + var pushResponse = await NetworkCtrl.Instance.Post("/api/v1/ota/push", pushRequest); + if (pushResponse.IsSuccess && pushResponse.Data != null && pushResponse.Data.data != null && !string.IsNullOrEmpty(pushResponse.Data.data.transferId)) + { + 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; - }, - onError: (code, message) => + // 传输完成后等待 10s 再返回主页(设备升级后需要时间重启) + StartCoroutine(DelayedGoHome(10f)); + } + else { 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) { Debug.LogError($"WiFi OTA push 异常: {ex.Message}"); + UpdateLoadingUI.Instance.Hide(); + ToastUI.Show("100227"); + UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); } finally { isUpdating = false; } - UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); return; } @@ -186,7 +215,6 @@ namespace Kill.UI.Pages if (success) { DataManager.Instance.selectedDevice.firmware_version = otaData.version; - UpdateLoadingUI.Instance.Hide(); ToastUI.Show("100226"); // OTA 成功后等待 15s 再返回主页(设备升级后需要时间重启) StartCoroutine(DelayedGoHome(15f)); @@ -215,6 +243,51 @@ namespace Kill.UI.Pages UIManager.Instance.OpenMainPage(UIManager.PageName.homePage); } + /// + /// 订阅 WiFi OTA 传输进度(SSE),返回是否传输完成 + /// + private Task SubscribeOtaProgress(string transferId, Action onProgress) + { + var tcs = new TaskCompletionSource(); + StartCoroutine(SSEProgressCoroutine(transferId, onProgress, success => tcs.TrySetResult(success))); + return tcs.Task; + } + + /// + /// SSE 进度协程:持续接收进度事件,传输完成(status/percentage)或连接结束后结束 + /// + private IEnumerator SSEProgressCoroutine(string transferId, Action onProgress, Action onDone) + { + bool transferFinished = false; + string sseUrl = $"/api/v1/ota/transfer/{transferId}/progress"; + var queryParams = new Dictionary { { "token", DataManager.Instance.token } }; + yield return HttpRequestManager.Instance.StartSSE(sseUrl, queryParams, null, + data => + { + try + { + var info = JsonConvert.DeserializeObject(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); + } + /// /// 加载固件文件 /// diff --git a/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs b/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs index 67b1ed4..93aac5b 100644 --- a/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs +++ b/Assets/Scripts/UI/Pages/HomePage/HomePageCtrl.cs @@ -1714,7 +1714,8 @@ namespace Kill.UI.Pages while (!stopPolling && selectedDevice != null) { - await System.Threading.Tasks.Task.Delay(10000); + // 每 5s 检测一次设备 WiFi 在线状态 + await System.Threading.Tasks.Task.Delay(5000); if (stopPolling || selectedDevice == null) break;