Compare commits

..

5 Commits

Author SHA1 Message Date
“虞渠成”
8074ba71c0 更新readme 2026-09-07 09:17:25 +08:00
“虞渠成”
ae7a68aad1 docs: 重写README补充架构说明与开发备忘
- 项目概况/技术栈/启动流程/目录结构/核心模块(蓝牙/网络/数据/UI/热更)/构建发布备忘
2026-09-07 09:13:21 +08:00
“虞渠成”
20bec0f7e3 chore: 移除无引用的FireBaseCtrl并加固LoadRes的Editor宏保护
- FireBaseCtrl为旧版Firebase Auth实现,类名/GUID/内部类型均无引用,安全清理
- LoadRes顶部using UnityEditor包入#if UNITY_EDITOR,消除真机编译隐患
- 关闭测试服务器切换后门(enableServerBackdoor=0)
2026-09-07 09:12:23 +08:00
“虞渠成”
99ffc19f60 fix(bluetooth,ui): 修复蓝牙关闭后的断开链路与响应数据空引用
- 关蓝牙时跳过0x70协议告别帧与扫描(BluetoothLeScanner为null会抛NPE中断主页Init)
- 初始化前检查蓝牙开关静默跳过,错误回调不再误报初始化失败,断开清理不在蓝牙关闭时自动重扫
- 错误/超时响应统一携带空Data,修复超时回调BitConverter.ToString(null)崩溃
- 主页Init增加异常兜底:保证Loading收尾并记录完整异常
2026-09-07 09:12:14 +08:00
“虞渠成”
320ece473f feat(bluetooth,ui): 添加毫米波雷达状态支持并优化设备状态处理
1. 新增MillimeterWaveSetting设备状态字段与更新方法
2. 修复HomePageCtrl中设备配置空引用风险
3. 重构BLE通讯状态更新逻辑,统一MAC地址获取
4. 补充各类BLE通知的数据长度校验
5. 新增设备锁状态参数变更通知处理
6. 优化日志描述与协程管理逻辑
2026-09-04 10:59:43 +08:00
12 changed files with 348 additions and 349 deletions

1
.gitignore vendored
View File

@ -78,3 +78,4 @@ ProjectSettings/ProjectVersion.txt
Assets/Resources/BillingMode.json Assets/Resources/BillingMode.json
Assets/Resources/BillingMode.json.meta Assets/Resources/BillingMode.json.meta
*_mapping.txt *_mapping.txt
.dbg/

View File

@ -1,6 +1,5 @@
{ {
"dotnet.defaultSolution": "Kill.sln", "dotnet.defaultSolution": "Kill.sln",
"dotnet.dotnetPath": "C:/Program Files/dotnet/dotnet.exe",
"dotrush.roslyn.dotnetSdkDirectory": "C:/Program Files/dotnet/sdk/8.0.113", "dotrush.roslyn.dotnetSdkDirectory": "C:/Program Files/dotnet/sdk/8.0.113",
"dotrush.msbuildProperties": { "dotrush.msbuildProperties": {
"DefineConstants": "UNITY_EDITOR" "DefineConstants": "UNITY_EDITOR"

View File

@ -128,7 +128,7 @@ namespace Kill.Bluetooth
/// <summary> /// <summary>
/// 更新当前设备状态,并仅在值发生变化时广播状态事件。 /// 更新当前设备状态,并仅在值发生变化时广播状态事件。
/// </summary> /// </summary>
private bool UpdateDeviceState(string deviceMac, BLEDeviceStateField field, Action<BLEDeviceState> update) private bool UpdateDeviceState(string deviceMac, BLEDeviceStateField field, Func<BLEDeviceState, bool> update)
{ {
if (update == null) if (update == null)
{ {
@ -162,10 +162,9 @@ namespace Kill.Bluetooth
return changed; return changed;
} }
private bool UpdateStateFromResponse(BLEResponse response, BLEDeviceStateField field, Action<BLEDeviceState> update) private bool UpdateStateFromResponse(BLEResponse response, BLEDeviceStateField field, Func<BLEDeviceState, bool> update)
{ {
return response != null && response.IsSuccess && return response.IsSuccess && UpdateDeviceState(GetStateMac(), field, update);
UpdateDeviceState(GetCurrentDeviceMac(), field, update);
} }
private static string NormalizeDeviceMac(string deviceMac) private static string NormalizeDeviceMac(string deviceMac)
@ -295,11 +294,13 @@ namespace Kill.Bluetooth
{ {
Log($"蓝牙已断开: {address}"); Log($"蓝牙已断开: {address}");
// 使用断开参数作为设备状态键;部分平台会在清空地址前触发事件。 // 断开参数通常是 BLE 地址,优先使用已记录的真实 MAC 作为状态键。
string stateMac = NormalizeDeviceMac(address); string stateMac = string.IsNullOrEmpty(_lastStateMac)
? NormalizeDeviceMac(address)
: _lastStateMac;
if (string.IsNullOrEmpty(stateMac)) if (string.IsNullOrEmpty(stateMac))
{ {
stateMac = string.IsNullOrEmpty(_lastStateMac) ? GetCurrentDeviceMac() : _lastStateMac; stateMac = GetCurrentDeviceMac();
} }
_lastStateMac = string.IsNullOrEmpty(stateMac) ? _lastStateMac : stateMac; _lastStateMac = string.IsNullOrEmpty(stateMac) ? _lastStateMac : stateMac;
UpdateDeviceState( UpdateDeviceState(
@ -881,6 +882,15 @@ namespace Kill.Bluetooth
Log($"定时任务设置成功: {tasks[0]}"); Log($"定时任务设置成功: {tasks[0]}");
else else
Log($"定时任务批量设置成功,共 {tasks.Length} 条任务"); Log($"定时任务批量设置成功,共 {tasks.Length} 条任务");
ScheduleTask[] normalizedTasks = new ScheduleTask[tasks.Length];
for (int i = 0; i < tasks.Length; i++)
{
normalizedTasks[i] = tasks[i];
normalizedTasks[i].TaskId = (byte)i;
}
UpdateStateFromResponse(response, BLEDeviceStateField.ScheduleTasks,
state => state.SetScheduleTasks(normalizedTasks));
} }
else else
{ {
@ -1601,6 +1611,8 @@ namespace Kill.Bluetooth
SendFrame(frame, (response) => SendFrame(frame, (response) =>
{ {
var setting = MillimeterWaveSetting.FromBytes(response.Data); var setting = MillimeterWaveSetting.FromBytes(response.Data);
UpdateStateFromResponse(response, BLEDeviceStateField.MillimeterWave,
state => state.SetMillimeterWave(setting));
callback?.Invoke(setting); callback?.Invoke(setting);
OnMillimeterWaveSettingReceived?.Invoke(setting); OnMillimeterWaveSettingReceived?.Invoke(setting);
Log($"读取毫米波雷达设置: {setting}"); Log($"读取毫米波雷达设置: {setting}");
@ -1630,9 +1642,15 @@ namespace Kill.Bluetooth
{ {
bool success = response.IsSuccess; bool success = response.IsSuccess;
if (success) if (success)
Log($"毫米波雷达设置成功: {setting}"); {
Log($"毫米波设置成功: {setting}");
UpdateStateFromResponse(response, BLEDeviceStateField.MillimeterWave,
state => state.SetMillimeterWave(setting));
}
else else
LogError($"毫米波雷达设置失败, 状态码={response.Status:X2}"); {
LogError($"毫米波设置失败, 状态码={response.Status:X2}");
}
callback?.Invoke(success); callback?.Invoke(success);
}); });
} }
@ -2431,7 +2449,7 @@ namespace Kill.Bluetooth
if (!CheckConnection()) if (!CheckConnection())
{ {
LogError($"[BLE-DEBUG] 蓝牙未就绪,无法发送指令: {description}"); LogError($"[BLE-DEBUG] 蓝牙未就绪,无法发送指令: {description}");
callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR }); callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR, Data = new byte[0] });
return; return;
} }
@ -2447,7 +2465,7 @@ namespace Kill.Bluetooth
if (!CheckConnection()) if (!CheckConnection())
{ {
LogError("[BLE-DEBUG] CheckConnection failed, invoking callback with error"); LogError("[BLE-DEBUG] CheckConnection failed, invoking callback with error");
callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR }); callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR, Data = new byte[0] });
return; return;
} }
@ -2498,7 +2516,7 @@ namespace Kill.Bluetooth
{ {
LogError($"发送帧异常: {ex.Message}"); LogError($"发送帧异常: {ex.Message}");
IsWaitingResponse = false; IsWaitingResponse = false;
callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR }); callback?.Invoke(new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR, Data = new byte[0] });
} }
} }
@ -2612,7 +2630,7 @@ namespace Kill.Bluetooth
// 通知帧优先处理,并且绝对不影响命令队列(IsWaitingResponse / _pendingCallback / _responseTimer) // 通知帧优先处理,并且绝对不影响命令队列(IsWaitingResponse / _pendingCallback / _responseTimer)
// 设备主动通知是异步旁路通道,与当前等待响应的命令是两条独立链路 // 设备主动通知是异步旁路通道,与当前等待响应的命令是两条独立链路
if (frame.ReadWrite == BLEConstants.RW_NOTIFY || frame.Command == BLEConstants.NOTIFY_COMMAND_CODE) if (frame.ReadWrite == BLEConstants.RW_NOTIFY && frame.Command == BLEConstants.NOTIFY_COMMAND_CODE)
{ {
HandleNotification(frame.Data); HandleNotification(frame.Data);
return; return;
@ -2728,6 +2746,12 @@ namespace Kill.Bluetooth
private void HandleDeviceStateChangeNotification(byte[] data) private void HandleDeviceStateChangeNotification(byte[] data)
{ {
if (data == null || data.Length < 2)
{
LogWarning("设备状态变化通知数据长度不足");
return;
}
var notify = DeviceStateChangeNotification.FromBytes(data); var notify = DeviceStateChangeNotification.FromBytes(data);
if (notify.WorkMode > (byte)WorkMode.Eliminate) if (notify.WorkMode > (byte)WorkMode.Eliminate)
{ {
@ -2737,7 +2761,7 @@ namespace Kill.Bluetooth
Log($"设备状态变化通知: 工作模式=0x{notify.WorkMode:X2}"); Log($"设备状态变化通知: 工作模式=0x{notify.WorkMode:X2}");
UpdateDeviceState( UpdateDeviceState(
GetCurrentDeviceMac(), GetStateMac(),
BLEDeviceStateField.WorkMode, BLEDeviceStateField.WorkMode,
state => state.SetWorkMode((WorkMode)notify.WorkMode)); state => state.SetWorkMode((WorkMode)notify.WorkMode));
try try
@ -2752,11 +2776,17 @@ namespace Kill.Bluetooth
private void HandleErrorStatusNotification(byte[] data) private void HandleErrorStatusNotification(byte[] data)
{ {
if (data == null || data.Length < 8)
{
LogWarning("错误状态通知数据长度不足");
return;
}
var notify = ErrorStatusNotification.FromBytes(data); var notify = ErrorStatusNotification.FromBytes(data);
if (data != null && data.Length >= 8) if (data != null && data.Length >= 8)
{ {
UpdateDeviceState( UpdateDeviceState(
GetCurrentDeviceMac(), GetStateMac(),
BLEDeviceStateField.HardwareStatus, BLEDeviceStateField.HardwareStatus,
state => state.SetHardwareStatus(notify.Status)); state => state.SetHardwareStatus(notify.Status));
} }
@ -2773,6 +2803,12 @@ namespace Kill.Bluetooth
private void HandleParameterChangeNotification(byte[] data) private void HandleParameterChangeNotification(byte[] data)
{ {
if (data == null || data.Length < 3)
{
LogWarning("参数变化通知数据长度不足");
return;
}
var notify = ParameterChangeNotification.FromBytes(data); var notify = ParameterChangeNotification.FromBytes(data);
Log($"参数变化通知: 命令=0x{notify.CommandCode:X2}, 数据长度={(notify.Payload?.Length ?? 0)}"); Log($"参数变化通知: 命令=0x{notify.CommandCode:X2}, 数据长度={(notify.Payload?.Length ?? 0)}");
@ -2794,7 +2830,7 @@ namespace Kill.Bluetooth
/// </summary> /// </summary>
private void ApplyParameterChangeToDeviceState(ParameterChangeNotification notify) private void ApplyParameterChangeToDeviceState(ParameterChangeNotification notify)
{ {
string deviceMac = GetCurrentDeviceMac(); string deviceMac = GetStateMac();
if (string.IsNullOrEmpty(deviceMac) || notify.Payload == null) if (string.IsNullOrEmpty(deviceMac) || notify.Payload == null)
{ {
return; return;
@ -2805,35 +2841,52 @@ namespace Kill.Bluetooth
switch (notify.CommandCode) switch (notify.CommandCode)
{ {
case BLEConstants.CMD_LANGUAGE_SETTING: case BLEConstants.CMD_LANGUAGE_SETTING:
if (notify.Payload.Length < 1) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.Language, UpdateDeviceState(deviceMac, BLEDeviceStateField.Language,
state => state.SetLanguage(LanguageSetting.FromBytes(notify.Payload))); state => state.SetLanguage(LanguageSetting.FromBytes(notify.Payload)));
break; break;
case BLEConstants.CMD_SCHEDULE_TASK: case BLEConstants.CMD_SCHEDULE_TASK:
if (notify.Payload.Length < 7) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.ScheduleTasks, UpdateDeviceState(deviceMac, BLEDeviceStateField.ScheduleTasks,
state => state.SetScheduleTasks(ScheduleTaskListResponse.FromBytes(0x00, notify.Payload).Tasks)); state => state.SetScheduleTasks(ScheduleTaskListResponse.FromBytes(0x00, notify.Payload).Tasks));
break; break;
case BLEConstants.CMD_DEVICE_LOCK:
if (notify.Payload.Length < 1) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.LockState,
state => state.SetLockState(DeviceLockControl.FromBytes(notify.Payload)));
break;
case BLEConstants.CMD_FILL_LIGHT_CONTROL: case BLEConstants.CMD_FILL_LIGHT_CONTROL:
if (notify.Payload.Length < 3) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.FillLight, UpdateDeviceState(deviceMac, BLEDeviceStateField.FillLight,
state => state.SetFillLight(FillLightControl.FromBytes(notify.Payload))); state => state.SetFillLight(FillLightControl.FromBytes(notify.Payload)));
break; break;
case BLEConstants.CMD_ANGLE_CONTROL: case BLEConstants.CMD_ANGLE_CONTROL:
if (notify.Payload.Length < 2) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.Angle, UpdateDeviceState(deviceMac, BLEDeviceStateField.Angle,
state => state.SetAngle(AngleControl.FromBytes(notify.Payload))); state => state.SetAngle(AngleControl.FromBytes(notify.Payload)));
break; break;
case BLEConstants.CMD_DISTANCE_CONTROL: case BLEConstants.CMD_DISTANCE_CONTROL:
if (notify.Payload.Length < 4) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.Distance, UpdateDeviceState(deviceMac, BLEDeviceStateField.Distance,
state => state.SetDistance(DistanceControl.FromBytes(notify.Payload))); state => state.SetDistance(DistanceControl.FromBytes(notify.Payload)));
break; break;
case BLEConstants.CMD_VISUAL_DETECTION_SETTING: case BLEConstants.CMD_VISUAL_DETECTION_SETTING:
if (notify.Payload.Length < 2) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.VisualDetection, UpdateDeviceState(deviceMac, BLEDeviceStateField.VisualDetection,
state => state.SetVisualDetection(VisualDetectionSetting.FromBytes(notify.Payload))); state => state.SetVisualDetection(VisualDetectionSetting.FromBytes(notify.Payload)));
break; break;
case BLEConstants.CMD_MILLIMETER_WAVE_SETTING:
if (notify.Payload.Length < 4) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.MillimeterWave,
state => state.SetMillimeterWave(MillimeterWaveSetting.FromBytes(notify.Payload)));
break;
case BLEConstants.CMD_FILL_LIGHT_CONNECTION_STATUS: case BLEConstants.CMD_FILL_LIGHT_CONNECTION_STATUS:
if (notify.Payload.Length < 1) return;
UpdateDeviceState(deviceMac, BLEDeviceStateField.FillLightConnection, UpdateDeviceState(deviceMac, BLEDeviceStateField.FillLightConnection,
state => state.SetFillLightConnection(FillLightConnectionStatus.FromBytes(notify.Payload))); state => state.SetFillLightConnection(FillLightConnectionStatus.FromBytes(notify.Payload)));
break; break;
default: default:
// 未映射的字段:不写仓库,避免误覆盖 // 暂不写入本地状态,避免异常参数覆盖正确值
break; break;
} }
} }
@ -2845,11 +2898,17 @@ namespace Kill.Bluetooth
private void HandleCapacitorStateNotification(byte[] data) private void HandleCapacitorStateNotification(byte[] data)
{ {
if (data == null || data.Length < 2)
{
LogWarning("蓄能状态通知数据长度不足");
return;
}
var notify = CapacitorStateNotification.FromBytes(data); var notify = CapacitorStateNotification.FromBytes(data);
if (data != null && data.Length >= 2) if (data != null && data.Length >= 2)
{ {
UpdateDeviceState( UpdateDeviceState(
GetCurrentDeviceMac(), GetStateMac(),
BLEDeviceStateField.CapacitorState, BLEDeviceStateField.CapacitorState,
state => state.SetCapacitorState(notify)); state => state.SetCapacitorState(notify));
} }
@ -2866,12 +2925,18 @@ namespace Kill.Bluetooth
private void HandleFillLightConnectionNotification(byte[] data) private void HandleFillLightConnectionNotification(byte[] data)
{ {
if (data == null || data.Length < 2)
{
LogWarning("补光灯连接状态通知数据长度不足");
return;
}
var notify = FillLightConnectionNotification.FromBytes(data); var notify = FillLightConnectionNotification.FromBytes(data);
if (data != null && data.Length >= 2) if (data != null && data.Length >= 2)
{ {
var status = new FillLightConnectionStatus { IsConnected = notify.IsConnected }; var status = new FillLightConnectionStatus { IsConnected = notify.IsConnected };
UpdateDeviceState( UpdateDeviceState(
GetCurrentDeviceMac(), GetStateMac(),
BLEDeviceStateField.FillLightConnection, BLEDeviceStateField.FillLightConnection,
state => state.SetFillLightConnection(status)); state => state.SetFillLightConnection(status));
} }
@ -2898,7 +2963,9 @@ namespace Kill.Bluetooth
var timeoutResponse = new BLEResponse var timeoutResponse = new BLEResponse
{ {
Status = BLEConstants.STATUS_TIMEOUT Status = BLEConstants.STATUS_TIMEOUT,
// 超时无数据,给空数组避免回调里 BitConverter.ToString(response.Data) 抛 ArgumentNullException
Data = new byte[0]
}; };
_pendingCallback?.Invoke(timeoutResponse); _pendingCallback?.Invoke(timeoutResponse);

View File

@ -52,6 +52,8 @@ namespace Kill.Bluetooth
public VisualDetectionSetting? VisualDetection { get; private set; } public VisualDetectionSetting? VisualDetection { get; private set; }
public MillimeterWaveSetting? MillimeterWave { get; private set; }
public LanguageSetting? Language { get; private set; } public LanguageSetting? Language { get; private set; }
public HardwareStatus? HardwareStatus { get; private set; } public HardwareStatus? HardwareStatus { get; private set; }
@ -183,6 +185,21 @@ namespace Kill.Bluetooth
return true; return true;
} }
internal bool SetMillimeterWave(MillimeterWaveSetting setting)
{
if (MillimeterWave.HasValue &&
MillimeterWave.Value.Enable == setting.Enable &&
MillimeterWave.Value.Sensitivity == setting.Sensitivity &&
MillimeterWave.Value.ActualSafeDistance == setting.ActualSafeDistance)
{
return false;
}
MillimeterWave = setting;
MarkUpdated();
return true;
}
internal bool SetLanguage(LanguageSetting language) internal bool SetLanguage(LanguageSetting language)
{ {
if (Language.HasValue && Language.Value.Language == language.Language) if (Language.HasValue && Language.Value.Language == language.Language)

View File

@ -202,6 +202,18 @@ namespace Kill.Bluetooth
return; return;
} }
#if UNITY_ANDROID && !UNITY_EDITOR
// 安卓:蓝牙已被系统关闭时直接静默跳过。
// 断开清理/自动重连等后台流程会反复触发初始化,若继续走到 CheckAndEnableBluetooth
// 会弹"蓝牙未开启"Toast 并广播 OnError,造成关蓝牙后一连串误报。
// 用户主动触发的初始化走 ForceInitialize,仍能得到提示。
if (!CheckIfBluetoothIsOn())
{
Log("蓝牙未开启,跳过蓝牙初始化");
return;
}
#endif
Log("正在初始化蓝牙..."); Log("正在初始化蓝牙...");
// 先检查蓝牙是否开启,如果没有则尝试启用 // 先检查蓝牙是否开启,如果没有则尝试启用
@ -218,7 +230,10 @@ namespace Kill.Bluetooth
public void ForceInitialize() public void ForceInitialize()
{ {
_userDeniedBluetooth = false; _userDeniedBluetooth = false;
Initialize(); // 用户主动触发:不走 Initialize 的静默跳过守卫,
// 蓝牙未开启时由 CheckAndEnableBluetooth 给出提示
Log("正在初始化蓝牙...");
CheckAndEnableBluetooth(DoInitialize);
} }
/// <summary> /// <summary>
@ -476,7 +491,8 @@ namespace Kill.Bluetooth
} }
if (IsConnected) if (IsConnected)
{ {
Disconnect(); // 蓝牙已被系统关闭,0x70 协议告别帧发不出去,跳过直接清理本地状态
Disconnect(sendProtocolCommand: false);
} }
if (IsScanning) if (IsScanning)
{ {
@ -595,6 +611,13 @@ namespace Kill.Bluetooth
} }
else else
{ {
// 蓝牙已被系统关闭时,插件所有操作(写入/扫描/断开)的失败都会进入这个全局错误回调,
// 此刻并非初始化失败,状态已由 MonitorBluetoothState 清理过,只记录不广播,避免误报
if (!CheckIfBluetoothIsOn())
{
Log($"蓝牙已关闭,忽略蓝牙错误: {error}");
return;
}
Log($"蓝牙初始化失败: {error}"); Log($"蓝牙初始化失败: {error}");
_enableBtCoroutine = null; _enableBtCoroutine = null;
OnError?.Invoke($"初始化失败: {error}"); OnError?.Invoke($"初始化失败: {error}");
@ -676,6 +699,14 @@ namespace Kill.Bluetooth
/// <param name="timeout">扫描超时时间(秒),默认10秒</param> /// <param name="timeout">扫描超时时间(秒),默认10秒</param>
public void StartScan(float timeout = 999f) public void StartScan(float timeout = 999f)
{ {
// 蓝牙已被系统关闭时直接跳过:此时 BluetoothLeScanner 为 null,
// 插件扫描调用会抛 NullPointerException(曾导致主页 Init 中断、设备状态不刷新)
if (!CheckIfBluetoothIsOn())
{
Log("蓝牙未开启,跳过扫描");
return;
}
if (!IsInitialized) if (!IsInitialized)
{ {
Log("蓝牙未初始化,自动触发初始化流程..."); Log("蓝牙未初始化,自动触发初始化流程...");
@ -693,6 +724,12 @@ namespace Kill.Bluetooth
/// </summary> /// </summary>
private void DoStartScan(float timeout) private void DoStartScan(float timeout)
{ {
if (!CheckIfBluetoothIsOn())
{
Log("蓝牙未开启,跳过扫描");
return;
}
if (IsScanning) if (IsScanning)
{ {
Log("已经在扫描中,先停止当前扫描..."); Log("已经在扫描中,先停止当前扫描...");
@ -907,7 +944,7 @@ namespace Kill.Bluetooth
/// 断开当前连接(带协议通知) /// 断开当前连接(带协议通知)
/// 先发送0x70断开命令给设备,再断开BLE连接 /// 先发送0x70断开命令给设备,再断开BLE连接
/// </summary> /// </summary>
public void Disconnect(bool backHome=false) public void Disconnect(bool backHome=false, bool sendProtocolCommand=true)
{ {
needBackHome=backHome; needBackHome=backHome;
if (!IsConnected) if (!IsConnected)
@ -933,21 +970,23 @@ namespace Kill.Bluetooth
} }
// 先通过BLE协议发送断开命令(0x70) // 先通过BLE协议发送断开命令(0x70)
Log($"检查BLECommunicationManager: Instance={(BLECommunicationManager.Instance != null ? "存在" : "null")}"); if (sendProtocolCommand && BLECommunicationManager.Instance != null)
if (BLECommunicationManager.Instance != null)
{ {
Log("发送断开命令(0x70)到设备..."); Log("发送断开命令(0x70)到设备...");
BLECommunicationManager.Instance.Disconnect((success) => BLECommunicationManager.Instance.Disconnect((success) =>
{ {
Log($"断开命令发送结果: {(success ? "成功" : "失败")}"); // 0x70 设备不会回复,success 仅表示已提交插件写入,不代表设备已收到
Log($"断开命令已提交: {(success ? "是" : "否")}");
// 无论命令是否成功,都执行BLE断开 // 无论命令是否成功,都执行BLE断开
PerformBLEDisconnect(addressToDisconnect); PerformBLEDisconnect(addressToDisconnect);
}); });
} }
else else
{ {
Log("BLECommunicationManager.Instance 为 null,跳过0x70命令,直接断开BLE"); Log(!sendProtocolCommand
// 如果没有BLE通信管理器,直接断开BLE ? "蓝牙不可用,跳过0x70命令,直接断开BLE"
: "BLECommunicationManager.Instance 为 null,跳过0x70命令,直接断开BLE");
// 如果没有BLE通信管理器或蓝牙不可用,直接断开BLE
PerformBLEDisconnect(addressToDisconnect); PerformBLEDisconnect(addressToDisconnect);
} }
} }
@ -1000,7 +1039,9 @@ namespace Kill.Bluetooth
// ChangeAimMac 切换设备时是 Disconnect + StartScan 同步调用, // ChangeAimMac 切换设备时是 Disconnect + StartScan 同步调用,
// 本次清理会停掉刚启动的扫描,这里若仍有连接目标则自动重新扫描,避免连接流程中断 // 本次清理会停掉刚启动的扫描,这里若仍有连接目标则自动重新扫描,避免连接流程中断
if (!string.IsNullOrEmpty(aimMac) && !IsConnected) // 蓝牙已被系统关闭时不重扫(扫描只会触发一轮失败的初始化),
// 等蓝牙重新打开后由 MonitorBluetoothState 的自动初始化流程接管
if (!string.IsNullOrEmpty(aimMac) && !IsConnected && CheckIfBluetoothIsOn())
{ {
StartScan(10); StartScan(10);
} }

View File

@ -199,7 +199,7 @@ namespace Kill.Bluetooth
private IEnumerator StartUpgradeCoroutineInternal(BLEFrame frame, Action<bool> callback) private IEnumerator StartUpgradeCoroutineInternal(BLEFrame frame, Action<bool> callback)
{ {
BLEResponse response = new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR }; BLEResponse response = new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR, Data = new byte[0] };
bool completed = false; bool completed = false;
SendFrame(frame, (resp) => SendFrame(frame, (resp) =>
@ -495,7 +495,7 @@ namespace Kill.Bluetooth
private IEnumerator SendFrameCoroutine(BLEFrame frame, Action<BLEResponse> callback) private IEnumerator SendFrameCoroutine(BLEFrame frame, Action<BLEResponse> callback)
{ {
bool completed = false; bool completed = false;
BLEResponse response = new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR }; BLEResponse response = new BLEResponse { Status = BLEConstants.STATUS_DEVICE_ERROR, Data = new byte[0] };
SendFrame(frame, (resp) => SendFrame(frame, (resp) =>
{ {

View File

@ -494,6 +494,8 @@ namespace Kill.Bluetooth.Protocol
public static BLEResponse FromFrame(BLEFrame frame) public static BLEResponse FromFrame(BLEFrame frame)
{ {
var response = new BLEResponse(); var response = new BLEResponse();
// 无载荷的响应(如写ACK)也保证 Data 非空,避免回调中 BitConverter.ToString(null) 崩溃
response.Data = new byte[0];
if (frame.Data != null && frame.Data.Length > 0) if (frame.Data != null && frame.Data.Length > 0)
{ {
response.Status = frame.Data[0]; response.Status = frame.Data[0];

View File

@ -1,296 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using Firebase.Extensions;
namespace Kill.Managers
{
public class FireBaseCtrl : MonoBehaviour
{
Firebase.Auth.FirebaseAuth auth;
Firebase.Auth.FirebaseUser user;
Firebase.FirebaseApp app;
public static FireBaseCtrl Instance;
Action<bool, string> loginStatusCallBack;
// 登录状态属性
public bool IsLoggedIn { get; private set; } = false;
public string CurrentUserId { get; private set; } = "";
// 添加网络状态和初始化状态
public bool IsInitialized { get; private set; } = false;
public bool IsNetworkAvailable { get; private set; } = true; // 默认假设网络可用
// 添加登录状态枚举
public enum LoginState
{
Unknown, // 未知状态
NotLoggedIn, // 用户未登录
LoggedIn, // 用户已登录
Initializing, // 正在初始化
NetworkError, // 网络错误
AuthPending // 认证正在进行中
}
public LoginState CurrentLoginState { get; private set; } = LoginState.Unknown;
void Awake()
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
public async Task Init()
{
await Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task =>
{
var dependencyStatus = task.Result;
if (dependencyStatus == Firebase.DependencyStatus.Available)
{
// Create and hold a reference to your FirebaseApp,
// where app is a Firebase.FirebaseApp property of your application class.
app = Firebase.FirebaseApp.DefaultInstance;
// Set a flag here to indicate whether Firebase is ready to use by your app.
}
else
{
Debug.LogError(string.Format(
"===Could not resolve all Firebase dependencies: {0}", dependencyStatus));
// Firebase Unity SDK is not safe to use here.
}
});
CurrentLoginState = LoginState.Initializing;
try
{
// 检查Firebase依赖
var dependencyStatus = await Firebase.FirebaseApp.CheckAndFixDependenciesAsync();
if (dependencyStatus == Firebase.DependencyStatus.Available)
{
auth = Firebase.Auth.FirebaseAuth.DefaultInstance;
auth.StateChanged += AuthStateChanged;
AuthStateChanged(this, null);
IsInitialized = true;
Debug.Log("===FirebaseCtrl Init");
// 检查初始状态
CheckInitialState();
}
else
{
Debug.LogError($"===Firebase依赖不可用: {dependencyStatus}");
CurrentLoginState = LoginState.NetworkError;
}
}
catch (Exception ex)
{
Debug.LogError($"===Firebase初始化失败: {ex.Message}");
CurrentLoginState = LoginState.NetworkError;
}
}
void CheckInitialState()
{
// 检查是否有当前用户
if (auth.CurrentUser != null)
{
CurrentLoginState = LoginState.LoggedIn;
IsLoggedIn = true;
CurrentUserId = auth.CurrentUser.UserId;
}
else
{
CurrentLoginState = LoginState.NotLoggedIn;
IsLoggedIn = false;
CurrentUserId = "";
}
}
public void SetLoginStatusCallBack(Action<bool, string> callBack)
{
loginStatusCallBack = callBack;
if (user != null)
loginStatusCallBack?.Invoke(true, user.UserId);
}
void AuthStateChanged(object sender, System.EventArgs eventArgs)
{
if (auth.CurrentUser != user)
{
bool signedIn = user != auth.CurrentUser && auth.CurrentUser != null;
if (!signedIn && user != null)
{
Debug.Log("===用户登出 " + user.UserId);
IsLoggedIn = false;
CurrentUserId = "";
CurrentLoginState = LoginState.NotLoggedIn;
loginStatusCallBack?.Invoke(false, user.UserId);
}
user = auth.CurrentUser;
if (signedIn)
{
Debug.Log("===用户登录 " + user.UserId);
IsLoggedIn = true;
CurrentUserId = user.UserId;
CurrentLoginState = LoginState.LoggedIn;
loginStatusCallBack?.Invoke(true, user.UserId);
}
}
}
// 同步检查当前登录状态的方法
public bool CheckLoginStatus()
{
return auth != null && auth.CurrentUser != null;
}
// 更详细的登录状态检查方法
public LoginStateDetail GetDetailedLoginStatus()
{
var detail = new LoginStateDetail();
// 检查Firebase是否已初始化
if (!IsInitialized)
{
detail.State = LoginState.Initializing;
detail.Description = "Firebase正在初始化";
return detail;
}
// 检查网络状态
if (!IsNetworkAvailable)
{
detail.State = LoginState.NetworkError;
detail.Description = "网络不可用,请检查网络连接";
return detail;
}
// 检查认证状态
if (auth != null)
{
if (auth.CurrentUser != null)
{
detail.State = LoginState.LoggedIn;
detail.Description = "用户已登录";
detail.UserId = auth.CurrentUser.UserId;
detail.DisplayName = auth.CurrentUser.DisplayName;
}
else
{
detail.State = LoginState.NotLoggedIn;
detail.Description = "用户未登录";
}
}
else
{
detail.State = LoginState.Unknown;
detail.Description = "认证服务未准备好";
}
return detail;
}
public void CreatUserByEmail(string email, string password, Action<bool, string> callBack)
{
if (!IsInitialized)
{
callBack(false, "服务未初始化完成,请稍后重试");
return;
}
CurrentLoginState = LoginState.AuthPending;
auth.CreateUserWithEmailAndPasswordAsync(email, password).ContinueWith(task =>
{
if (task.IsCanceled)
{
Debug.Log("===取消创建用户");
CurrentLoginState = LoginState.NotLoggedIn;
callBack(false, "取消创建用户");
return;
}
if (task.IsFaulted)
{
Debug.Log("===创建用户失败 " + task.Exception);
callBack(false, GetFriendlyErrorMessage(task.Exception));
return;
}
Firebase.Auth.AuthResult result = task.Result;
CurrentLoginState = LoginState.LoggedIn;
callBack(true, "");
Debug.LogFormat("===创建用户成功: {0} ({1})",
result.User.DisplayName, result.User.UserId);
});
}
public void LoginWithEmail(string email, string password, Action<bool, string> callBack)
{
if (!IsInitialized)
{
callBack(false, "服务未初始化完成,请稍后重试");
return;
}
CurrentLoginState = LoginState.AuthPending;
auth.SignInWithEmailAndPasswordAsync(email, password).ContinueWith(task =>
{
if (task.IsCanceled)
{
Debug.Log("===登录取消");
CurrentLoginState = LoginState.NotLoggedIn;
callBack(false, "登录取消");
return;
}
if (task.IsFaulted)
{
string errorMessage = GetFriendlyErrorMessage(task.Exception);
Debug.Log("===登录出错" + task.Exception);
callBack(false, errorMessage);
return;
}
Firebase.Auth.AuthResult result = task.Result;
CurrentLoginState = LoginState.LoggedIn;
callBack(true, result.User.UserId);
Debug.LogFormat("登录成功: {0} ({1})",
string.IsNullOrEmpty(result.User.DisplayName) ? "" : result.User.DisplayName,
result.User.UserId);
});
}
public void Logout()
{
auth.SignOut();
CurrentLoginState = LoginState.NotLoggedIn;
}
// 获取友好的错误信息
string GetFriendlyErrorMessage(Exception exception)
{
if (exception == null) return "未知错误";
var firebaseEx = exception.GetBaseException() as Firebase.FirebaseException;
return $"{firebaseEx.ErrorCode}:{firebaseEx.Message}";
}
}
// 登录状态详细信息类
public class LoginStateDetail
{
public FireBaseCtrl.LoginState State { get; set; }
public string Description { get; set; }
public string UserId { get; set; }
public string DisplayName { get; set; }
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 5b783fd033f637a4ca20115bfeeee4fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -3,7 +3,9 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
#if UNITY_EDITOR
using UnityEditor; using UnityEditor;
#endif
using UnityEngine; using UnityEngine;
namespace Kill.Managers namespace Kill.Managers

View File

@ -60,6 +60,7 @@ namespace Kill.UI.Pages
private int TOTAL_STATUS_COUNT = 8; // 总共需要查询的状态数量 private int TOTAL_STATUS_COUNT = 8; // 总共需要查询的状态数量
private float statusQueryTimeout = 15f; // 状态查询超时时间(秒) private float statusQueryTimeout = 15f; // 状态查询超时时间(秒)
private Coroutine statusQueryTimeoutCoroutine; // 超时协程 private Coroutine statusQueryTimeoutCoroutine; // 超时协程
private Coroutine deviceStatusQueryCoroutine; // 首次状态全量查询协程
// 串行查询相关 // 串行查询相关
private Queue<Action> statusQueryQueue = new Queue<Action>(); // 状态查询队列 private Queue<Action> statusQueryQueue = new Queue<Action>(); // 状态查询队列
@ -286,6 +287,12 @@ namespace Kill.UI.Pages
/// </summary> /// </summary>
private void StartDeviceStateInitialization() private void StartDeviceStateInitialization()
{ {
if (deviceStatusQueryCoroutine != null)
{
StopCoroutine(deviceStatusQueryCoroutine);
deviceStatusQueryCoroutine = null;
}
isDeviceStateInitializing = true; isDeviceStateInitializing = true;
pendingStatusCount = TOTAL_STATUS_COUNT; pendingStatusCount = TOTAL_STATUS_COUNT;
@ -334,6 +341,11 @@ namespace Kill.UI.Pages
StopCoroutine(statusQueryTimeoutCoroutine); StopCoroutine(statusQueryTimeoutCoroutine);
statusQueryTimeoutCoroutine = null; statusQueryTimeoutCoroutine = null;
} }
if (deviceStatusQueryCoroutine != null)
{
StopCoroutine(deviceStatusQueryCoroutine);
deviceStatusQueryCoroutine = null;
}
bluetoothDeviceInfoCoroutine=StartCoroutine(CheckDeviceError()); bluetoothDeviceInfoCoroutine=StartCoroutine(CheckDeviceError());
Debug.Log("[HomePageCtrl] 设备状态初始化完成"); Debug.Log("[HomePageCtrl] 设备状态初始化完成");
} }
@ -363,6 +375,11 @@ namespace Kill.UI.Pages
isBluetoothUserLoggedIn = false; isBluetoothUserLoggedIn = false;
isQueryingStatus = false; isQueryingStatus = false;
statusQueryQueue?.Clear(); statusQueryQueue?.Clear();
if (deviceStatusQueryCoroutine != null)
{
StopCoroutine(deviceStatusQueryCoroutine);
deviceStatusQueryCoroutine = null;
}
UIManager.Instance?.ClearBackAction(); UIManager.Instance?.ClearBackAction();
// 刷新首页设备连接状态UI(延迟到主循环执行,与连接成功时保持一致) // 刷新首页设备连接状态UI(延迟到主循环执行,与连接成功时保持一致)
pendingUIUpdates.Add(() => pendingUIUpdates.Add(() =>
@ -372,6 +389,7 @@ namespace Kill.UI.Pages
if(bluetoothDeviceInfoCoroutine!=null) if(bluetoothDeviceInfoCoroutine!=null)
{ {
StopCoroutine(bluetoothDeviceInfoCoroutine); StopCoroutine(bluetoothDeviceInfoCoroutine);
bluetoothDeviceInfoCoroutine = null;
} }
// 设备断开重新加载主页(保持原行为,当前在主页也会重新加载) // 设备断开重新加载主页(保持原行为,当前在主页也会重新加载)
// 但切换设备(ChangeAimMac)导致的断开不重新加载,避免主页被销毁重建导致并发初始化 // 但切换设备(ChangeAimMac)导致的断开不重新加载,避免主页被销毁重建导致并发初始化
@ -398,7 +416,7 @@ namespace Kill.UI.Pages
StartDeviceStateInitialization(); StartDeviceStateInitialization();
// 串行查询设备状态 // 串行查询设备状态
StartCoroutine(QueryAllDeviceStatusSequential()); deviceStatusQueryCoroutine = StartCoroutine(QueryAllDeviceStatusSequential());
} }
/// <summary> /// <summary>
@ -493,9 +511,13 @@ namespace Kill.UI.Pages
/// <summary> /// <summary>
/// 按 MAC 隔离:当前设备不匹配(已切换/未连接/选中设备不同)则丢弃通知 /// 按 MAC 隔离:当前设备不匹配(已切换/未连接/选中设备不同)则丢弃通知
/// </summary> /// </summary>
private bool IsNotificationForCurrentDevice() private bool IsNotificationForCurrentDevice(BLEDeviceState state = null)
{ {
string mac = BLECommunicationManager.Instance?.GetCurrentDeviceMac(); string mac = state?.DeviceMac;
if (string.IsNullOrEmpty(mac))
{
mac = BLECommunicationManager.Instance?.GetCurrentDeviceMac();
}
string selectedMac = selectedDevice?.ble_mac; string selectedMac = selectedDevice?.ble_mac;
if (string.IsNullOrEmpty(mac) || string.IsNullOrEmpty(selectedMac)) return false; if (string.IsNullOrEmpty(mac) || string.IsNullOrEmpty(selectedMac)) return false;
return string.Equals(mac, selectedMac, StringComparison.OrdinalIgnoreCase); return string.Equals(mac, selectedMac, StringComparison.OrdinalIgnoreCase);
@ -509,7 +531,7 @@ namespace Kill.UI.Pages
private void OnDeviceStateChanged(BLEDeviceState state, BLEDeviceStateField field) private void OnDeviceStateChanged(BLEDeviceState state, BLEDeviceStateField field)
{ {
if (state == null) return; if (state == null) return;
if (!IsNotificationForCurrentDevice()) return; if (!IsNotificationForCurrentDevice(state)) return;
if (isDeviceStateInitializing) return; if (isDeviceStateInitializing) return;
switch (field) switch (field)
@ -519,7 +541,10 @@ namespace Kill.UI.Pages
{ {
int mode = (int)state.WorkMode.Value; int mode = (int)state.WorkMode.Value;
currentWorkMode = state.WorkMode.Value; currentWorkMode = state.WorkMode.Value;
DataManager.Instance.deviceConfig.work_mode = DeviceConfig.ToServerString(state.WorkMode.Value); if (DataManager.Instance.deviceConfig != null)
{
DataManager.Instance.deviceConfig.work_mode = DeviceConfig.ToServerString(state.WorkMode.Value);
}
pendingUIUpdates.Add(() => deviceCtrl?.InitWrokMode(mode)); pendingUIUpdates.Add(() => deviceCtrl?.InitWrokMode(mode));
} }
break; break;
@ -576,12 +601,15 @@ namespace Kill.UI.Pages
case BLEDeviceStateField.Language: case BLEDeviceStateField.Language:
if (state.Language.HasValue) if (state.Language.HasValue)
{ {
DataManager.Instance.deviceConfig.language = state.Language.Value.IsChinese ? 1 : 0; if (DataManager.Instance.deviceConfig != null)
DataManager.Instance.SyncDeviceConfigToServer(); {
DataManager.Instance.deviceConfig.language = state.Language.Value.IsChinese ? 1 : 0;
DataManager.Instance.SyncDeviceConfigToServer();
}
} }
break; break;
default: default:
// 其他字段(Connection / LockState / HardwareStatus / CapacitorState / FillLightConnection)暂不在主页直接刷新 // 其他字段(Connection / HardwareStatus / CapacitorState / FillLightConnection)暂不在主页直接刷新
break; break;
} }
} }
@ -977,6 +1005,24 @@ namespace Kill.UI.Pages
} }
public async Task Init() public async Task Init()
{
// Init 在 async 链中抛异常会被静默吞掉:LoadingUI 引用计数无法归零(表现为卡 loading),
// 且页面状态刷新(UpdateDeviceState)不会执行。这里统一兜底:记录完整异常并保证 Loading 收尾。
try
{
await InitInternal();
}
catch (Exception ex)
{
Debug.LogError($"[HomePageCtrl] Init 异常: {ex}");
}
finally
{
LoadingUI.Hide();
}
}
private async Task InitInternal()
{ {
if (DataManager.Instance.userInfo.device_count == 0 || (OwnedDevices.Count == 0 && SharedDevices.Count == 0)) if (DataManager.Instance.userInfo.device_count == 0 || (OwnedDevices.Count == 0 && SharedDevices.Count == 0))

135
README.md
View File

@ -1,3 +1,134 @@
# killapp # killapp —— 激光灭蚊设备控制 App
灭蚊app 控制「激光灭害虫设备」(photonmatrix)的配套手机 App,Unity 制作,Android + iOS 双平台。
用户通过 App 完成设备绑定(BLE 扫描 + 扫码)、配网、日常控制(工作模式 / FOV / 透镜 / 补光灯 / 定时任务)、
杀蚊数据统计(柱状图 / 雷达热力图 / 杀蚊视频)、固件 OTA 升级、设备共享等。
---
## 技术栈与环境
| 项 | 值 |
|---|---|
| Unity | 2022.3.62f3c1 |
| 应用包名 | `com.photonmatrix.photonmatrix`(Android / iOS 相同) |
| 当前版本 | bundleVersion 1.0.7,AndroidBundleVersionCode 4(均在 `ProjectSettings/ProjectSettings.asset`) |
| UI 方案 | UGUI + DOTween,无 Addressables |
| 热更方案 | 自研 AssetBundle 热更(`LoadRes` + `NetworkCtrl`) |
| 第三方 | Firebase SDK(仅 Auth)、apple-signin-unity、Shatalmic BLE 插件、zxing、NativeGallery、ExternalDependencyManager |
| 主场景 | `Assets/Scenes/MainScene.unity`(唯一业务场景);`BlueToothTest.unity` 为蓝牙调试场景 |
`Packages/manifest.json` 非常精简(ugui、timeline、visualscripting、assetbundlebrowser、android-logcat 等),
Firebase 是以 Assets 方式导入而非 UPM。
## 整体架构
**单场景 + 常驻 Manager + AssetBundle 动态页面**:
- `MainScene` 的 `Manager` 节点挂载全部管理器:`NetworkCtrl`、`DataManager`、`UIManager`、`LoadRes`、`LanguageManager`、`FirebaseAuthManager`。
- `Canvas/bg` 是页面容器,页面按 prefab 从 AssetBundle 动态加载实例化(旧的销毁、新的实例化)。
- 页面逻辑全部写在 prefab 挂的 Ctrl 类里,各单例之间直接互相引用(`HomePageCtrl.Instance`、`DataManager.Instance` 等)。
**启动流程**(`NetworkCtrl.Init()`):
`LoadRes.Init`(AB 环境)→ 创建 `HttpRequestManager` / `RequestQueueManager` / `NetworkStateManager` →
从服务器查 AB 版本并差量热更下载(MD5 校验)→ `DataManager.Init`(token 自动登录)→ `UIManager.Init`(有 token 进主页,否则进登录页)。
## 目录结构
```
Assets/
├── Scenes/ MainScene(主场景)、BlueToothTest(蓝牙调试)
├── Scripts/
│ ├── Bluetooth/ BLE 核心(当前开发重点)
│ │ ├── BluetoothManager.cs Shatalmic 插件封装:扫描/连接/断开事件
│ │ ├── BLECommunicationManager.cs 协议层:指令收发、事件分发(全项目最大文件)
│ │ ├── BLEDeviceState.cs 按 MAC 隔离的设备运行状态模型
│ │ ├── OTAManager.cs 固件版本解析与升级传输
│ │ ├── Protocol/ 帧结构体 BLEProtocolStructs.cs + CRC16.cs
│ │ └── BLE通信方案V2.2.md BLE 协议文档(App ↔ BLE 透传 ↔ STM32H7 MCU)
│ ├── Core/ Loom(BLE 回调线程切主线程)、状态栏沉浸、安卓文件选择
│ ├── Managers/ DataManager / DataBase(DTO) / FirebaseAuthManager / LanguageManager / LoadRes / FireBaseCtrl(死代码)
│ ├── Network/ NetworkCtrl(门面+启动编排) / HttpRequestManager / RequestQueueManager / NetworkStateManager
│ ├── UI/
│ │ ├── UIManager.cs 页面管理、安卓返回键、token 自动登录
│ │ ├── Pages/ 各页面逻辑(Home / Login / ConnectDevice / DeviceInfo / Video / Self / Rank / Safetylearning)
│ │ └── Components/ Toast / Loading / Barchart / RadarHeatmap / SectorScanEffect / QRCode 等通用控件
│ └── Utils/ MainThread、ValidationUtils、iOSWiFiHelper
├── Res/ AB 包源资源(bluetooth / common / language / ui)
├── StreamingAssets/ 首包 AB:common.ab、language.ab、ui_common.ab + list.json
├── Plugins/ Shatalmic 运行脚本、NativeGallery、DOTween、zxing、iOS 原生桥(.mm)
├── AppleAuth/ Sign in with Apple
└── Firebase/ Firebase SDK(实际只用 Auth)
```
## 核心模块备忘
### 蓝牙 BLE(开发重点)
- 链路:App ↔ BLE 模块(透传,Service `FFE0`,读/写/Notify 均走 `FFE1`)↔ MCU(UART9)。
- `BLECommunicationManager` 是几十条指令的收发与事件分发中心:
注册/指纹认证、定时任务、语言、LCD/RGB、WiFi 配网、补光灯、激光、角度/距离、
毫米波雷达、视觉检测、工作模式、硬件状态、统计数据、蚊子数据等。
每条指令对应 `XxxRead` / `XxxWrite` 方法 + `OnXxxReceived` 事件。
- 设备**主动通知**(状态变化 0x01 / 错误 0x02 / 参数变化 0x06 / 蓄能 0x08 / 补光灯连接 0x09 等)
汇入 `OnDeviceNotificationReceived`,再**按设备 MAC 隔离**后广播;分支名
`feature/ble-config-notification` 即指这套「配置变更主动上报」机制。
- 设备状态保存在 `BLEDeviceState`(按 MAC 为边界),统一通过
`OnDeviceStateChanged(BLEDeviceState, BLEDeviceStateField)` 通知 UI。
- Shatalmic 回调来自非主线程,UI 相关处理必须 `Loom.QueueOnMainThread` 切回主线程。
- Android/iOS 无法直接取 BLE 真实 MAC,设备 MAC 通过 BLE 命令从设备端获取(设备序列号用于绑定)。
### 网络与后端
- **服务器地址**(配置在 MainScene 里 `NetworkCtrl.serverBaseUrls`):
测试 `https://nextreal.cn/photon-matrix-api`,正式 `https://api.photonmatrixlab.com`。
**切换后门**:首页左下角连点 5 次(`enableServerBackdoor` 控制开关,发布前应置 0);
环境持久化在 PlayerPrefs `network_server_env`。
- `HttpRequestManager`:UnityWebRequest 封装,重试(指数退避)、**SSE 流式**(WiFi OTA 进度)、多平台超时。
- `RequestQueueManager`:优先级请求队列,并发上限 5。
- `NetworkStateManager`:每 3 秒轮询 `internetReachability`,发连接/断开事件。
- `ResponseCode`:业务码统一处理,**601 = Token 失效 → 全局登出**。
- 主要接口(`{Base}/api/v1/...`):认证(login/register/email-code/token-login/firebase-bind)、
设备(bind/list/config/schedule-tasks/fingerprints/share/command)、统计上报与查询
(kill-count/records/videos/heatmap/leaderboard)、通知、OTA(latest/push/transfer 进度 SSE)、
考试、反馈、`app/version/bundle`(AB 热更清单)。
### 数据与账号
- `DataManager`:运行时数据中枢(token、用户信息、自有/共享设备、当前选中设备、设备配置、指纹、定时任务)。
PlayerPrefs 持久化:`token`、`userData`、`selectedDeviceMac`、设备列表缓存 `ownedDevicesCache_<userId>` 等。
- `DataBase.cs`:纯 DTO 定义(登录/用户/设备/配置/OTA/消息/统计/考试…),非 Mono。
- 第三方登录:`FirebaseAuthManager`(Google 用 FederatedOAuthProvider,Apple 用 apple-signin-unity + nonce),
结果换自家后端 token(`auth/firebase/bind`)。旧版 `FireBaseCtrl.cs` 已无任何引用,属死代码。
### UI 框架
- `UIManager.PageName` 枚举 8 个主页面:login / safetylearning / home / connectDevice / deviceInfo / self / video / rank。
- 页面 prefab 按 bundle 名 `ui_xxxpage` 从 AB 加载(配置在 MainScene 的 UIManager `pages` 列表)。
- 设置类子弹窗不走 UIManager,由各 Ctrl 自己 Instantiate + `RegisterBackAction`(返回键栈,双击退出)。
- 首页 `HomePageCtrl` 是功能核心:蓝牙/WiFi 双通道控制、状态订阅、
**锁扣防误触(LockButtonPlane,180 秒无操作自动上锁)**、下拉刷新、统计上报、跳转子页面。
### 资源与热更
- `LoadRes`:编辑器「本地」模式直读 `Assets/Res/`;真机「资源包」模式优先 `persistentDataPath/*.ab`(热更落盘),
回退 StreamingAssets 首包;bundle 缓存在内存字典。
- 热更清单来自 `app/version/bundle`,由 `NetworkCtrl` 做差量下载 + MD5 校验,进度显示在 `updateLoading`。
- 语言词条也在 AB 里:`language/language.json`、`country.json`(`LanguageManager` 读取,PlayerPrefs `LanguageType`)。
### 其他关键组件
- `Loom`:线程池 + 主线程队列派发(BLE 回调切主线程用)。`Utils/MainThread.cs` 功能重复,并存。
- `RadarHeatmap`:按角度(0-180)+距离换算极坐标摆放热力点(数据来自 `stats/device/heatmap`)。
- `SectorScanEffect`:首页扇形扫描波纹动画(fillAmount 表现 FOV、缩放表现探测距离)。
- `iOSWiFiHelper`:iOS14+ 取当前 WiFi SSID(需定位权限),配网页自动填 SSID;Android 直接读。
- `NativeGallery`:杀蚊视频保存到系统相册。
## 构建与发布备忘
- 版本号:`ProjectSettings/ProjectSettings.asset` 的 `bundleVersion` / `AndroidBundleVersionCode`。
- Android 签名:`user.keystore`(项目根目录)。
- iOS:`ITSAppUsesNonExemptEncryption` 已配置,避免每次手动答加密问卷(commit 457fee5)。
- 根目录的 `*.apk` / `*_mapping.txt` 为历史构建产物(含 Release 符号表),命名习惯如
`photonmatrix_1.0.7_0903.apk`;测试构建会带后缀如 `ota蓝牙优先`。
- 打正式包前检查:`NetworkCtrl.enableServerBackdoor` 应为 0(场景内字段)。