using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; using UnityEngine; using System.Runtime.InteropServices; using SmartBowSDK; namespace SmartBowSDK_BleWinHelper { /// /// Windows连接BluetoothLE /// 我的扫描逻辑默认了读写特征都在同一服务下 /// 扫描策略(对齐文档:广播含 16-bit 0xFFF0 + 名称含 “WF”;现网兼容旧名): /// - 粗筛 0xFFF0:依赖 DLL 的 AdvertisementWatcher/Combined;纯 DeviceInformationOnly 无法在扫描期按广播 FFF0 过滤。 /// 连接后仍由 targetService(FFF0)GATT 校验。 /// - 精筛名称:默认在设备名中查找子串 (默认 “WF”),**默认不区分大小写**;勾选 后仅字面匹配(如仅 “WF” 不匹配 “wf”)。 /// 关键字可在 Inspector 改为如 “WF_” 以收紧前缀。另可选各 Aim 类型白名单全名(忽略大小写,兼容青凤鸾旧名)。 /// 关闭 则仅走关键字子串(无白名单全名)。 /// - 按 MAC 连接时以 MAC 为准,FFF0 仍后验。 /// - 若 已保存绑定 MAC(bInitMac),默认仅连接该 MAC,避免枪类白名单含 Bbow 时误连弓箭。 /// public class BleWinHelper : MonoBehaviour { /// 与文档 #define TARGET_SERVICE_UUID 0xFFF0 一致(Windows 扫描期不可见,仅文档与后验服务 UUID 对应)。 public const ushort BleAdvertisedServiceUuid16 = 0xFFF0; /// 文档默认 FILTER_NAME_KEY; 留空时不做关键字子串匹配,仅白名单全名(若允许)。 public const string BleFilterNameKeyDefault = "WF"; [Tooltip("名称精筛子串,默认 WF;可改为 WF_ 等以收紧匹配。留空则仅能通过下方白名单全名命中(若允许)。")] public string bleFilterNameKey = BleFilterNameKeyDefault; [Tooltip("关=关键字不区分大小写(wf/WF/Wf 均可);开=严格按字面(如仅 WF 命中,wf 不命中)。")] public bool bleFilterNameKeyCaseSensitive = false; /// 扫描阶段最长时长(秒),到时停止枚举并由排序后的目标列表开始匹配。 public const float DeviceScanPhaseDurationSeconds = 10f; /// 目标设备列表最多候选数,满则停止扫描。 public const int MaxScanTargetCandidates = 10; /// 排序时该旧设备名固定排在列表末尾(与管道串中的名称一致)。 public const string LegacyDeviceNameSortLast = "Bbow_20210501"; [Header("BLE 扫描(文档:0xFFF0 广播 + 名称关键字;Windows 见类注释)")] [Tooltip("开=名称含关键字(见 bleFilterNameKey)或白名单全名命中(忽略大小写,旧设备)。关=仅关键字子串(仍受大小写开关影响)。")] public bool allowLegacyDeviceNameWhitelist = true; [Tooltip("仅当 logBleVerbosePollStream 开启时生效:开=verbose 流里打印周边/未过精筛;关=verbose 流里只打印精筛候选。")] public bool logAllBleAdvertisements = false; [Tooltip("开=每次 Poll 按旧逻辑刷屏(ShouldEmitBleListStyleLog);关=不打印该类逐条日志(推荐)。")] public bool logBleVerbosePollStream = false; [Tooltip("开=每新增一台进「目标候选列表」打一行;扫描 FINISHED 再打一次完整列表。关=不打。")] public bool logBleScanListEvents = true; [Tooltip("须非 DeviceInformationOnly 才能在扫描期按广播 0xFFF0 过滤无关设备。扫描时长由本脚本 10s / 满 10 台控制。")] public BleApi.BleScanMode bleDllScanMode = BleApi.BleScanMode.Combined; [Tooltip("AimDeviceInfo 已保存绑定 MAC 时,仅匹配该 MAC,避免枪类白名单含 Bbow 时误连弓箭后再断开。")] public bool restrictConnectToSavedMacWhenBonded = true; [Tooltip("当存档存在绑定 MAC(bInitMac)时:扫描中一旦命中该 MAC 且可连接,立即选中并结束扫描,不经名称筛选与长等待;扫描总时长上限见 fastReconnectScanMaxSeconds。")] public bool enableFastReconnectBySavedMac = true; [Tooltip("仅当 enableFastReconnectBySavedMac 且存档有 MAC 时生效:扫描最长秒数(找不到则结束);一般 2~5。")] public float fastReconnectScanMaxSeconds = 3f; [Header("BLE 诊断(PollData)")] [Tooltip("开=true:写入 SmartBowSDK.BleWinrtDllSharedInbox,打印每帧从原生队列排空的通知包数量(需工程引用含 BleWinrtDllSharedInbox 的 SmartBowSDK.dll)。")] public bool logBleDllInboxVerbose = false; [Tooltip("须与 logBleDllInboxVerbose 同时开启;每包一行 deviceId/len(极易刷屏)。")] public bool logBleDllInboxEachPacket = false; public string LogTag = "BleWinHelper-Log: "; public bool bDebug = true; private void Log(string text) { if (bDebug) Debug.Log(LogTag + text); } private void Warn(string text) { if (bDebug) Debug.LogWarning(LogTag + text); } private void Error(string text) { if (bDebug) Debug.Log(LogTag + text); } private string targetDeviceNameAxis = "Bbow_20210501 | ARTEMIS | HOUYI | HOUYI Pro | ARTEMIS Pro"; private string targetDeviceName = "Bbow_20210501 | ARTEMIS Pro"; private string targetDeviceNameHOUYIPro = "HOUYI Pro | Bbow_20210501"; private string targetDeviceNameGun = "Pistol | Pistol M9 | Bbow_20210501"; private string targetDeviceNameGun_M17 = "Pistol M17"; private string targetDeviceNameGun_M416 = "Rifle M416"; /// 对应文档 16-bit 0xFFF0 的 128-bit GATT Service(连接后粗筛实质校验)。 private string targetService = "{0000fff0-0000-1000-8000-00805f9b34fb}"; private string targetCharacteristicsNotify = "{0000fff1-0000-1000-8000-00805f9b34fb}"; private string targetCharacteristicsWrite = "{0000fff2-0000-1000-8000-00805f9b34fb}"; private bool isConnectLocking = false; private bool isScanningDevices = false; private bool isScanningServices = false; private bool isScanningCharacteristics = false; private bool isSubscribed = false; private bool isSubscribing = false; private string lastError = null; [SerializeField] private string selectedDeviceId = null; private Dictionary> deviceList = new Dictionary>(); private List serviceList = new List(); private List characteristicsList = new List(); private struct ScanCandidateEntry { public string DeviceId; public string Name; public int RssiDbm; public bool RssiValid; public bool IsConnectable; } private readonly Dictionary _scanCandidatesById = new Dictionary(); private float _deviceScanPhaseStartTime; private float _scanPhaseTimeoutSeconds = DeviceScanPhaseDurationSeconds; private bool _scanPhaseCompletionScheduled; private float _connectedTime = 0; private float _receiveDataTime = 0; private float _heartBeatInterval = 0; // [合并-DLL Win11] 扫描结束回调(是否已选中设备)。DLL 版会接到 bluetoothWindows.OnScanEnded。 // 【冲突/对接】若你工程里的 BluetoothWindows 有 OnScanEnded,请在 RegisterTo 里取消下面注释的那一行赋值。 private Action OnScanEnded; private Action OnConnected; /// /// 主动调用Disconnect()不会触发该委托 /// private Action OnConnectionFailed; /// /// 注册window对象 /// /// 挂载对象 /// 关联的BluetoothWindows /// 提示的log标签 /// public static BleWinHelper RegisterTo(GameObject o, BluetoothWindows bluetoothWindows, string logTip = "first") { //if (_Instance) //{ // Error("Register fail, because only one can be registered."); // return null; //} GameObject obj = new GameObject("BleWinHelper" + logTip); obj.transform.SetParent(o.transform); BleWinHelper bleWinHelper = obj.AddComponent(); //日志名字 bleWinHelper.LogTag = "BleWinHelper-" + logTip + ": "; bluetoothWindows.Connect = bleWinHelper.Connect; bluetoothWindows.Disconnect = bleWinHelper.Disconnect; bluetoothWindows.Write = bleWinHelper.Write; bluetoothWindows.WriteByte = bleWinHelper.WriteByte; bleWinHelper.OnConnected = () => bluetoothWindows.OnConnected?.Invoke(); bleWinHelper.OnConnectionFailed = () => bluetoothWindows.OnConnectionFailed?.Invoke(); bleWinHelper.OnScanEnded = (bool bSelectedDeviceId) => bluetoothWindows.OnScanEnded?.Invoke(bSelectedDeviceId); BleWinrtDllSharedInbox.PacketReceived += (deviceID, bytes) => { if (deviceID == bleWinHelper.selectedDeviceId && bleWinHelper.isSubscribed) bleWinHelper._receiveDataTime = Time.realtimeSinceStartup; if (deviceID == bleWinHelper.selectedDeviceId) bluetoothWindows.OnCharacteristicChanged?.Invoke(deviceID, bytes); }; //bleWinHelper.bDebug = true; return bleWinHelper; } /// /// 设置心跳检测 /// 1.每次收到的蓝牙数据都视为心跳 /// 2.帮助触发蓝牙断开监听 /// /// 心跳检测间隔 public void SetHeartBeat(float interval) { _heartBeatInterval = interval; } //private static BleWinHelper _Instance; void Awake() { // _Instance = this; } void OnDestroy() { // if (_Instance == this) _Instance = null; BleApi.Quit(); } void Update() { BleWinrtDllSharedInbox.VerboseLogPollDispatch = logBleDllInboxVerbose; BleWinrtDllSharedInbox.LogEachPacket = logBleDllInboxEachPacket; BleWinrtDllSharedInbox.PumpOncePerUnityFrame(); BleApi.ScanStatus status; // ------------------------------------------------------------------------- // 设备扫描阶段(DLL:StartDeviceScan → PollDevice 循环 → Stop / FINISHED) // 数据流概览: // ① DLL 侧(见 bleDllScanMode)可先做广播 0xFFF0 粗筛;② 本脚本做名称关键字 + 白名单精筛; // ③ 绑定 MAC 策略;④ 可选「存档 MAC 快速命中」;⑤ 其余设备进入排序候选表,扫描结束后再 TrySelect。 // ------------------------------------------------------------------------- if (isScanningDevices) { // 每次 Update 尽可能排空 DLL 队列:非阻塞 PollDevice,直到无 AVAILABLE 为止。 BleApi.DeviceUpdate res = new BleApi.DeviceUpdate(); do { // block=false:无数据立即返回,避免卡死主线程。 status = BleApi.PollDevice(ref res, false); if (status == BleApi.ScanStatus.AVAILABLE) { // 已在前面某次 Poll 中选定了设备(快速重连或 FINISHED 前逻辑):跳过后续 AVAILABLE,等 FINISHED。 if (selectedDeviceId != null) continue; // --- 合并 WinRT 推送的增量字段到 deviceList(按 deviceId 唯一键)--- EnsureDeviceListEntry(res.id); if (res.nameUpdated) deviceList[res.id]["name"] = res.name; if (res.isConnectableUpdated) deviceList[res.id]["isConnectable"] = res.isConnectable.ToString(); if (res.rssiUpdated) { deviceList[res.id]["rssi"] = res.rssiDbm.ToString(); deviceList[res.id]["rssiValid"] = "True"; } string deviceName = deviceList[res.id]["name"]; // 当前帧 isConnectable 或与历史缓存「或」:避免只收到旧缓存时误判不可连。 bool isConnectable = res.isConnectable || deviceList[res.id]["isConnectable"] == "True"; // --- 分支 A:有存档 MAC 且开启快速重连时,优先只按 MAC 命中(跳过名称精筛与候选表)--- if (TryFastSelectBySavedBondMac(res.id, isConnectable)) continue; // --- 分支 B:名称精筛(关键字 + 可选旧名白名单)+ 绑定 MAC 一致性 --- // PickFineFilterPipeForCurrentType:按 Aim 类型取 Inspector 管道串,供白名单全名匹配。 string finePipe = PickFineFilterPipeForCurrentType((AimDeviceType)AimHandler.ins.aimDeviceInfo.type); bool qualifies = DeviceNamePassesFineFilter(deviceName, finePipe) && WinRtDeviceIdMatchesSavedBondMac(res.id); // 可选:逐条 Poll 详细日志(易刷屏);仅当 logBleVerbosePollStream 开启时走 ShouldEmitBleListStyleLog。 if (logBleVerbosePollStream && ShouldEmitBleListStyleLog(logAllBleAdvertisements, qualifies, res, deviceName)) LogBleVerbosePollStreamLine(qualifies, deviceName, res.id, res); // --- 分支 C:通过精筛且有名 → 写入「目标候选」字典(最多 MaxScanTargetCandidates),满则请求 DLL 停扫 --- if (qualifies && !string.IsNullOrWhiteSpace(deviceName)) { // MaybeUpsertScanCandidate 返回 true 表示本轮是「首次」加入该 deviceId,用于只打一次「新进」日志。 if (MaybeUpsertScanCandidate(res.id, deviceName.Trim(), isConnectable, res) && logBleScanListEvents) LogBleScanNewCandidateLine(deviceName, res.id, res); } } else if (status == BleApi.ScanStatus.FINISHED) { // DLL 宣布扫描结束:关标志位,再统一做「候选排序 + TrySelect + GATT 协程」。 isScanningDevices = false; CompleteScanPhaseAndSubscribe(); break; } } while (status == BleApi.ScanStatus.AVAILABLE); // --- 主动停扫:候选已满或超过本阶段超时(有存档 MAC 时超时可能缩短为 fastReconnectScanMaxSeconds)--- // 仅 native Stop;isScanningDevices 仍为 true,直到随后某帧 Poll 到 FINISHED 再收尾。 if (isScanningDevices && selectedDeviceId == null) { bool full = _scanCandidatesById.Count >= MaxScanTargetCandidates; bool timeout = Time.realtimeSinceStartup - _deviceScanPhaseStartTime >= _scanPhaseTimeoutSeconds; if (full || timeout) BleApi.StopDeviceScan(); } } if (isScanningServices) { BleApi.Service res; do { status = BleApi.PollService(out res, false); if (status == BleApi.ScanStatus.AVAILABLE) { serviceList.Add(res.uuid); } else if (status == BleApi.ScanStatus.FINISHED) { isScanningServices = false; } } while (status == BleApi.ScanStatus.AVAILABLE); } if (isScanningCharacteristics) { BleApi.Characteristic res; do { status = BleApi.PollCharacteristic(out res, false); if (status == BleApi.ScanStatus.AVAILABLE) { Log("res.userDescription:" + res.userDescription + ",res.uuid:" + res.uuid); characteristicsList.Add(res.uuid); } else if (status == BleApi.ScanStatus.FINISHED) { isScanningCharacteristics = false; } } while (status == BleApi.ScanStatus.AVAILABLE); } { BleApi.ErrorMessage res; BleApi.GetError(out res); if (lastError != res.msg) { Error(res.msg); lastError = res.msg; //对应设备才断开 if (lastError.Contains("SendDataAsync") && lastError.Contains(selectedDeviceId) && isSubscribed) { HandleConnectFail(); } } } } /// /// WinRT 设备 Id 末尾一般为真实 BLE MAC,例如 ...-d3:5c:89:57:68:de /// static string ExtractMacFromDeviceId(string deviceId) { if (string.IsNullOrEmpty(deviceId)) return null; int lastDash = deviceId.LastIndexOf('-'); if (lastDash >= 0 && lastDash < deviceId.Length - 1) return deviceId.Substring(lastDash + 1); return deviceId; } /// 无广播名时不打空引号,减少无效日志。 static string FormatScanDeviceNameForLog(string name) => string.IsNullOrWhiteSpace(name) ? "无广播名" : name.Trim(); string BleListMetaDescription() { string keyDisp = string.IsNullOrEmpty(bleFilterNameKey) ? "(无,仅白名单)" : bleFilterNameKey; string caseDisp = bleFilterNameKeyCaseSensitive ? "敏感" : "不敏感"; return allowLegacyDeviceNameWhitelist ? $"关键字「{keyDisp}」({caseDisp})或旧名" : $"仅关键字「{keyDisp}」({caseDisp})"; } static string FormatRssiForLog(BleApi.DeviceUpdate res) => res.rssiUpdated ? $"{res.rssiDbm} dBm" : "—"; /// /// 逐条 Poll 的详细日志(需 )。与 不同:后者仅在首次进入候选表时打印。 /// void LogBleVerbosePollStreamLine(bool qualifies, string deviceName, string deviceId, BleApi.DeviceUpdate res) { string macLog = ExtractMacFromDeviceId(deviceId) ?? deviceId; string rssiLog = FormatRssiForLog(res); string nameDisp = FormatScanDeviceNameForLog(deviceName); string keyDisp = string.IsNullOrEmpty(bleFilterNameKey) ? "(无,仅白名单)" : bleFilterNameKey; string caseDisp = bleFilterNameKeyCaseSensitive ? "敏感" : "不敏感"; if (logAllBleAdvertisements) Log($"BLE | 粗:广播0x{BleAdvertisedServiceUuid16:X4}(DLL模式) | 精:{(qualifies ? "候选" : "否")}{(allowLegacyDeviceNameWhitelist ? "+白名单" : $"/仅关键字「{keyDisp}」({caseDisp})")} | 名:{nameDisp} | MAC:{macLog} | RSSI:{rssiLog}"); else Log($"BLE列表 | 0x{BleAdvertisedServiceUuid16:X4} | {BleListMetaDescription()} | 名:{nameDisp} | MAC:{macLog} | RSSI:{rssiLog}"); } /// 某 deviceId 首次进入 时打印一行,便于对照「名称 + MAC + RSSI」。 void LogBleScanNewCandidateLine(string deviceName, string deviceId, BleApi.DeviceUpdate res) { string macLog = ExtractMacFromDeviceId(deviceId) ?? deviceId; string rssiLog = FormatRssiForLog(res); string nameDisp = FormatScanDeviceNameForLog(deviceName); Log($"BLE列表·新进 | 0x{BleAdvertisedServiceUuid16:X4} | {BleListMetaDescription()} | 名:{nameDisp} | MAC:{macLog} | RSSI:{rssiLog}"); } /// 内、排序后打印完整候选列表(与日志中的顺序一致)。 void LogBleScanFinalSummary() { var list = _scanCandidatesById.Values.ToList(); list.Sort(CompareScanCandidates); Log($"BLE列表·结束 | 共 {list.Count} 台({LegacyDeviceNameSortLast} 置尾 / 同名 RSSI 序)"); for (int i = 0; i < list.Count; i++) { var c = list[i]; string mac = ExtractMacFromDeviceId(c.DeviceId) ?? c.DeviceId; string rssi = c.RssiValid ? $"{c.RssiDbm} dBm" : "—"; Log($" [{i + 1}] 名:{FormatScanDeviceNameForLog(c.Name)} | MAC:{mac} | RSSI:{rssi} | 可连:{c.IsConnectable}"); } } /// 仅名称/可连有变化时必打;仅有 RSSI 且仍无广播名则不打,避免刷屏。 static bool ShouldLogScanLine(BleApi.DeviceUpdate res, string deviceNameCached) { if (!(res.nameUpdated || res.isConnectableUpdated || res.rssiUpdated)) return false; if (res.rssiUpdated && !res.nameUpdated && !res.isConnectableUpdated && string.IsNullOrWhiteSpace(deviceNameCached)) return false; return true; } /// /// 规范式列表:默认仅当「通过精筛」且名称或可连有更新时打印(不刷无广播名周边设备、不刷纯 RSSI)。 /// logAll=true 时退回详细模式(仍遵守 ShouldLogScanLine 对空名的 RSSI 抑制)。 /// static bool ShouldEmitBleListStyleLog(bool logAll, bool passesFineFilter, BleApi.DeviceUpdate res, string deviceNameCached) { if (logAll) return ShouldLogScanLine(res, deviceNameCached); if (!passesFineFilter) return false; return res.nameUpdated || res.isConnectableUpdated; } /// 精筛:名称含 (大小写由 );若 则另允许管道串中全名(忽略大小写,旧设备)。 bool DeviceNamePassesFineFilter(string deviceName, string pipeSeparatedLegacyNames) { if (string.IsNullOrWhiteSpace(deviceName)) return false; string trimmed = deviceName.Trim(); var keyCmp = bleFilterNameKeyCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; if (!string.IsNullOrEmpty(bleFilterNameKey) && trimmed.IndexOf(bleFilterNameKey, keyCmp) >= 0) return true; if (!allowLegacyDeviceNameWhitelist) return false; if (string.IsNullOrWhiteSpace(pipeSeparatedLegacyNames)) return false; foreach (var part in pipeSeparatedLegacyNames.Split('|')) { if (string.Equals(part.Trim(), trimmed, StringComparison.OrdinalIgnoreCase)) return true; } return false; } string PickFineFilterPipeForCurrentType(AimDeviceType type) { if (type == AimDeviceType.HOUYIPRO) return targetDeviceNameHOUYIPro; if (type == AimDeviceType.ARTEMISPRO) return targetDeviceName; if (type == AimDeviceType.Gun) return targetDeviceNameGun; if (type == AimDeviceType.PistolM17) return targetDeviceNameGun_M17; if (type == AimDeviceType.RifleM416) return targetDeviceNameGun_M416; return targetDeviceNameAxis; } static string NormalizeMacHexDigits(string mac) { if (string.IsNullOrEmpty(mac)) return ""; return string.Concat(mac.Where(Uri.IsHexDigit)).ToUpperInvariant(); } bool TryGetSavedBondMacFromAim(out string mac) { mac = null; if (AimHandler.ins == null || AimHandler.ins.aimDeviceInfo == null) return false; var info = AimHandler.ins.aimDeviceInfo; if (!info.bInitMac || string.IsNullOrWhiteSpace(info.mac)) return false; mac = info.mac; return true; } /// 无绑定 MAC 记录时不限制;有记录则 WinRT 设备尾段 MAC 须与存档一致(双向 Contains 兼容格式差异)。 bool WinRtDeviceIdMatchesSavedBondMac(string deviceId) { if (!restrictConnectToSavedMacWhenBonded) return true; if (!TryGetSavedBondMacFromAim(out string saved)) return true; string a = NormalizeMacHexDigits(ExtractMacFromDeviceId(deviceId)); string b = NormalizeMacHexDigits(saved); if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return true; return a.Contains(b) || b.Contains(a); } /// 存档 MAC 与 WinRT deviceId 尾段是否视为同一设备(双向 Contains);任一侧无法解析为十六进制则返回 false。 static bool SavedMacMatchesWinRtDeviceId(string savedMac, string deviceId) { string a = NormalizeMacHexDigits(ExtractMacFromDeviceId(deviceId)); string b = NormalizeMacHexDigits(savedMac); if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false; return a.Contains(b) || b.Contains(a); } /// /// 有绑定 MAC 且 :扫描中一旦 WinRT deviceId 尾段与存档 MAC 匹配且可连接, /// 立即设置 (不经 / 候选表)。 /// 随后仍等待 ,由 进入 GATT。 /// bool TryFastSelectBySavedBondMac(string deviceId, bool isConnectable) { if (!enableFastReconnectBySavedMac || !isConnectable) return false; if (!TryGetSavedBondMacFromAim(out string saved)) return false; if (!SavedMacMatchesWinRtDeviceId(saved, deviceId)) return false; selectedDeviceId = deviceId; BleApi.StopDeviceScan(); if (logBleScanListEvents || bDebug) Log($"BLE·快速重连 | 按存档 MAC 命中({NormalizeMacHexDigits(saved)}),跳过名称筛选;等待 FINISHED 后建链。"); return true; } float ResolveScanPhaseTimeoutSeconds() { if (enableFastReconnectBySavedMac && TryGetSavedBondMacFromAim(out _)) return Mathf.Min(DeviceScanPhaseDurationSeconds, Mathf.Max(0.5f, fastReconnectScanMaxSeconds)); return DeviceScanPhaseDurationSeconds; } private bool TrySelectDevice(string filterNames, string deviceName, bool isConnectable, string typeName, string deviceId) { if (!isConnectable || string.IsNullOrWhiteSpace(deviceName)) return false; if (!DeviceNamePassesFineFilter(deviceName, filterNames)) return false; if (!WinRtDeviceIdMatchesSavedBondMac(deviceId)) return false; selectedDeviceId = deviceId; StopDeviceScan(); Log($"匹配设备 [{typeName}] {FormatScanDeviceNameForLog(deviceName)} | MAC:{ExtractMacFromDeviceId(deviceId) ?? deviceId}"); return true; } void EnsureDeviceListEntry(string id) { if (!deviceList.ContainsKey(id)) { deviceList[id] = new Dictionary { { "name", "" }, { "isConnectable", "False" }, { "rssi", "" }, { "rssiValid", "False" } }; return; } var d = deviceList[id]; if (!d.ContainsKey("rssi")) d["rssi"] = ""; if (!d.ContainsKey("rssiValid")) d["rssiValid"] = "False"; } /// /// 将通过精筛的设备写入 (并镜像核心字段到 的维护过程在 Poll 上半段)。 /// 同一 deviceId 重复出现:更新名称 / 可连 / RSSI,不增加计数。 /// 新建条目若使总数达到 :调用 促使 DLL 结束扫描。 /// /// 是否为本轮扫描中首次加入该 deviceId(用于只打一次 )。 bool MaybeUpsertScanCandidate(string deviceId, string nameTrimmed, bool isConnectable, BleApi.DeviceUpdate res) { if (_scanCandidatesById.TryGetValue(deviceId, out var existing)) { existing.Name = nameTrimmed; existing.IsConnectable = isConnectable || existing.IsConnectable; if (res.rssiUpdated) { existing.RssiDbm = res.rssiDbm; existing.RssiValid = true; } _scanCandidatesById[deviceId] = existing; return false; } if (_scanCandidatesById.Count >= MaxScanTargetCandidates) return false; _scanCandidatesById[deviceId] = new ScanCandidateEntry { DeviceId = deviceId, Name = nameTrimmed, IsConnectable = isConnectable, RssiDbm = res.rssiDbm, RssiValid = res.rssiUpdated }; if (_scanCandidatesById.Count >= MaxScanTargetCandidates) BleApi.StopDeviceScan(); return true; } /// 排序:名称 Ordinal; 置尾;同名按 RSSI 强到弱(dBm 越大越前)。 static int CompareScanCandidates(ScanCandidateEntry a, ScanCandidateEntry b) { bool aBbow = string.Equals(a.Name?.Trim(), LegacyDeviceNameSortLast, StringComparison.Ordinal); bool bBbow = string.Equals(b.Name?.Trim(), LegacyDeviceNameSortLast, StringComparison.Ordinal); if (aBbow != bBbow) return aBbow ? 1 : -1; int nameCmp = string.Compare(a.Name, b.Name, StringComparison.Ordinal); if (nameCmp != 0) return nameCmp; int ra = a.RssiValid ? a.RssiDbm : int.MinValue; int rb = b.RssiValid ? b.RssiDbm : int.MinValue; return rb.CompareTo(ra); } static string TypeNameForLog(AimDeviceType type) { if (type == AimDeviceType.HOUYIPRO) return "HOUYIPro"; if (type == AimDeviceType.ARTEMISPRO) return "ARTEMISPRO"; if (type == AimDeviceType.Gun) return "Pistol"; if (type == AimDeviceType.PistolM17) return "PistolM17"; if (type == AimDeviceType.RifleM416) return "RifleM416"; return "Axis"; } /// /// 扫描已结束且未走快速重连选中:将 排序后, /// 从前往后调用 (结合当前 Aim 类型白名单管道),直到第一个可连且仍过精筛的设备。 /// void TryPickDeviceFromSortedScanCandidates() { if (AimHandler.ins == null || AimHandler.ins.aimDeviceInfo == null) return; var type = (AimDeviceType)AimHandler.ins.aimDeviceInfo.type; string pipe = PickFineFilterPipeForCurrentType(type); string label = TypeNameForLog(type); var list = _scanCandidatesById.Values.ToList(); list.Sort(CompareScanCandidates); if (list.Count > 0) Log($"扫描结束:从候选列表按序尝试连接 [{label}]({LegacyDeviceNameSortLast} 置尾 / 同名 RSSI)。"); foreach (var c in list) { if (TrySelectDevice(pipe, c.Name, c.IsConnectable, label, c.DeviceId)) return; } } /// /// DLL 返回 时调用(且仅处理一次,见 )。 /// 顺序:可选打印候选总表 → 若尚无 则从排序列表挑选 → 启动 (GATT 发现与订阅)→ 回调 。 /// void CompleteScanPhaseAndSubscribe() { if (_scanPhaseCompletionScheduled) return; _scanPhaseCompletionScheduled = true; if (logBleScanListEvents) LogBleScanFinalSummary(); if (selectedDeviceId == null) TryPickDeviceFromSortedScanCandidates(); StartCoroutine(ScanServiceAndCharacteristicsToSubscribe()); Log(" ScanStatus FINISHED !"); OnScanEnded?.Invoke(selectedDeviceId != null); } void LateUpdate() { if ( _heartBeatInterval > 0 && isSubscribed && Time.realtimeSinceStartup - _connectedTime >= _heartBeatInterval && Time.realtimeSinceStartup - _receiveDataTime >= _heartBeatInterval ) { HandleConnectFail(); } } /// /// 入口:重置连接相关状态 → / → 记录扫描阶段起点与超时秒数。 /// 后续逻辑均在 的 Poll 循环与 中完成。 /// private bool Connect() { if (isConnectLocking || isScanningDevices) { Warn("Connect Invalid, because is in connect."); return false; } // [合并-DLL Win11] 开始连接前重置状态,避免脏数据 ReinitAfterConnectFail(); if (bleDllScanMode == BleApi.BleScanMode.DeviceInformationOnly) Warn("BleDllScanMode=DeviceInformationOnly:扫描期无法按广播 0xFFF0 过滤耳机/手环等,建议 Combined 或 AdvertisementWatcherFff0。"); BleApi.SetBleScanMode(bleDllScanMode); BleApi.StartDeviceScan(); isConnectLocking = true; isScanningDevices = true; _deviceScanPhaseStartTime = Time.realtimeSinceStartup; _scanPhaseTimeoutSeconds = ResolveScanPhaseTimeoutSeconds(); string keyLog = string.IsNullOrEmpty(bleFilterNameKey) ? $"(无子串,仅白名单)" : bleFilterNameKey; string caseLog = bleFilterNameKeyCaseSensitive ? "区分大小写" : "不区分大小写"; if (enableFastReconnectBySavedMac && TryGetSavedBondMacFromAim(out string savedMac)) Log($"开始扫描:检测到存档 MAC({NormalizeMacHexDigits(savedMac)}),优先按 MAC 快速匹配(≤{_scanPhaseTimeoutSeconds:0.#}s),命中即连;否则再走名称列表。"); else Log($"开始扫描:广播 0x{BleAdvertisedServiceUuid16:X4}(DLL 模式)+ 名称子串「{keyLog}」({caseLog})/ 旧名白名单;最长 {_scanPhaseTimeoutSeconds:0.#}s,最多 {MaxScanTargetCandidates} 台候选,结束后按序匹配。"); return true; } private void StopDeviceScan() { if (!isScanningDevices) return; BleApi.StopDeviceScan(); isScanningDevices = false; Log($"Stop DeviceScan!"); } private IEnumerator ScanServiceAndCharacteristicsToSubscribe() { isSubscribing = true; if (selectedDeviceId == null) { HandleConnectFail(); // [合并-DLL 冲突/未接入] DLL 版: smartBowHelper.InvokeOnBluetoothError(BluetoothError.Unknown, "选择设备 DeviceId 不存在!"); 本脚本无 SmartBowHelper 引用,请按需在上层处理 yield break; } Log("SelectedDeviceId OK"); BleApi.ScanServices(selectedDeviceId); isScanningServices = true; serviceList.Clear(); while (isScanningServices) yield return null; bool findTargetService = false; foreach (string service in serviceList) { if (service == targetService) { findTargetService = true; Log("FindTargetService OK"); break; } } if (!findTargetService) { HandleConnectFail(); // [合并-DLL 冲突/未接入] DLL 版: smartBowHelper.InvokeOnBluetoothError(BluetoothError.Unknown, "目标设备服务 service 不存在!"); yield break; } BleApi.ScanCharacteristics(selectedDeviceId, targetService); isScanningCharacteristics = true; characteristicsList.Clear(); while (isScanningCharacteristics) yield return null; bool findTargetCharacteristicsNotify = false; bool findTargetCharacteristicsWrite = false; foreach (string characteristics in characteristicsList) { if (characteristics == targetCharacteristicsNotify) { findTargetCharacteristicsNotify = true; Log("FindTargetCharacteristicsNotify OK"); } else if (characteristics == targetCharacteristicsWrite) { findTargetCharacteristicsWrite = true; Log("FindTargetCharacteristicsWrite OK"); } } if (!findTargetCharacteristicsNotify || !findTargetCharacteristicsWrite) { HandleConnectFail(); // [合并-DLL 冲突/未接入] DLL 版: smartBowHelper.InvokeOnBluetoothError(BluetoothError.Unknown, "目标设备特征值 Characteristics 不存在!"); yield break; } BleApi.SubscribeCharacteristic(selectedDeviceId, targetService, targetCharacteristicsNotify, false); isSubscribed = true; isSubscribing = false; // [合并-DLL Win11] 订阅成功后启动 DLL 推送线程(与 HandleConnectFail/Disconnect 中 Stop 成对) BleApi.StartBlePushThread(); Log("SubscribeCharacteristicNotify OK"); _connectedTime = Time.realtimeSinceStartup; OnConnected?.Invoke(); } private void HandleConnectFail() { // [合并-DLL Win11] 先停推送线程再断开 BleApi.StopBlePushThread(); if (selectedDeviceId != null) BleApi.Disconnect(selectedDeviceId); bool isLockBefore = isConnectLocking; ReinitAfterConnectFail(); if (isLockBefore) OnConnectionFailed?.Invoke(); } private void ReinitAfterConnectFail() { isConnectLocking = false; isScanningDevices = false; isScanningServices = false; isScanningCharacteristics = false; isSubscribed = false; isSubscribing = false; selectedDeviceId = null; _scanPhaseCompletionScheduled = false; _scanCandidatesById.Clear(); deviceList.Clear(); serviceList.Clear(); characteristicsList.Clear(); } private bool Write(string text) { if (!isSubscribed) return false; byte[] payload = Encoding.ASCII.GetBytes(text); BleApi.BLEData data = new BleApi.BLEData(); data.buf = new byte[512]; data.size = (short)payload.Length; data.deviceId = selectedDeviceId; data.serviceUuid = targetService; data.characteristicUuid = targetCharacteristicsWrite; for (int i = 0; i < payload.Length; i++) data.buf[i] = payload[i]; // no error code available in non-blocking mode BleApi.SendData(in data, false); //Log("Write(" + text + ")"); return true; } private bool WriteByte(byte[] payload) { if (!isSubscribed) return false; BleApi.BLEData data = new BleApi.BLEData(); data.buf = new byte[512]; data.size = (short)payload.Length; data.deviceId = selectedDeviceId; data.serviceUuid = targetService; data.characteristicUuid = targetCharacteristicsWrite; for (int i = 0; i < payload.Length; i++) data.buf[i] = payload[i]; // no error code available in non-blocking mode BleApi.SendData(in data, false); Log("Write(byte[])"); return true; } private bool Disconnect() { if (!isConnectLocking) { Warn("Disconnect Invalid, because not in connect."); return false; } if (isSubscribing) { Warn("Disconnect Invalid, because is subscribing."); return false; } // [合并-DLL Win11] BleApi.StopBlePushThread(); if (isScanningDevices) StopDeviceScan(); if (selectedDeviceId != null) BleApi.Disconnect(selectedDeviceId); ReinitAfterConnectFail(); Log("Disconnect OK"); return true; } } }