From d593bbd9fd92975bf10be95a11bbda263cf4b9ba Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Wed, 18 Feb 2026 16:27:23 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20plugin=20PS=5FWin=5FBLE=20:=20r=C3=A9?= =?UTF-8?q?=C3=A9criture=20native=20WinRT=20sans=20DLL=20tierce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace WinBluetoothLE (dépendant de BLEproto64.dll) par une implémentation C++ native utilisant les APIs WinRT Windows.Devices.Bluetooth. Structure : - PS_Win_BLE.uplugin / PS_Win_BLE.Build.cs - PS_BLETypes.h : enums (EPS_GATTStatus, EPS_BLEError, EPS_CharacteristicDescriptor), structs (FPS_MACAddress, FPS_ServiceItem, FPS_CharacteristicItem), delegates (FPS_OnConnect, FPS_OnNotify, FPS_OnRead, etc.) - PS_BLEModule : startup WinRT, scan BLE (Live/Background), connect/disconnect, Read/Write/Subscribe/Unsubscribe, dispatch GameThread - PS_BLEDevice : UObject par périphérique BLE, gestion handles WinRT natifs - PS_BLEManager : gestionnaire global, discovery, MAC utils - PS_BLELibrary : Blueprint function library (GetBLEManager, descriptors, etc.) - Content/Sample : assets Blueprint copiés depuis WinBluetoothLE Préfixe PS_ sur tous les fichiers, classes, structs et enums. Copyright (C) 2025 ASTERION VR Co-Authored-By: Claude Opus 4.6 --- .../Content/Sample/BP_BLEActor.uasset | Bin 0 -> 142753 bytes .../PS_Win_BLE/Content/Sample/BP_Pawn.uasset | Bin 0 -> 24587 bytes .../Content/Sample/GM_Bluetooth.uasset | Bin 0 -> 20689 bytes .../PS_Win_BLE/Content/Sample/PS_BLETest.umap | Bin 0 -> 48502 bytes .../Plugins/PS_Win_BLE/Resources/Icon128.png | Bin 0 -> 68458 bytes .../Source/PS_Win_BLE/PS_Win_BLE.Build.cs | 56 ++ .../PS_Win_BLE/Private/PS_BLEDevice.cpp | 251 ++++++ .../PS_Win_BLE/Private/PS_BLELibrary.cpp | 98 +++ .../PS_Win_BLE/Private/PS_BLEManager.cpp | 202 +++++ .../PS_Win_BLE/Private/PS_BLEModule.cpp | 734 ++++++++++++++++++ .../Source/PS_Win_BLE/Public/PS_BLEDevice.h | 106 +++ .../Source/PS_Win_BLE/Public/PS_BLELibrary.h | 53 ++ .../Source/PS_Win_BLE/Public/PS_BLEManager.h | 91 +++ .../Source/PS_Win_BLE/Public/PS_BLEModule.h | 74 ++ .../Source/PS_Win_BLE/Public/PS_BLETypes.h | 122 +++ 15 files changed, 1787 insertions(+) create mode 100644 Unreal/Plugins/PS_Win_BLE/Content/Sample/BP_BLEActor.uasset create mode 100644 Unreal/Plugins/PS_Win_BLE/Content/Sample/BP_Pawn.uasset create mode 100644 Unreal/Plugins/PS_Win_BLE/Content/Sample/GM_Bluetooth.uasset create mode 100644 Unreal/Plugins/PS_Win_BLE/Content/Sample/PS_BLETest.umap create mode 100644 Unreal/Plugins/PS_Win_BLE/Resources/Icon128.png create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/PS_Win_BLE.Build.cs create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEDevice.cpp create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLELibrary.cpp create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEManager.cpp create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEModule.cpp create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEDevice.h create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLELibrary.h create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEManager.h create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEModule.h create mode 100644 Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLETypes.h diff --git a/Unreal/Plugins/PS_Win_BLE/Content/Sample/BP_BLEActor.uasset b/Unreal/Plugins/PS_Win_BLE/Content/Sample/BP_BLEActor.uasset new file mode 100644 index 0000000000000000000000000000000000000000..a870fc8f7903d098d8251d54a5248d64c6712442 GIT binary patch literal 142753 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�VRF6@|gqS1@(Y#T?cKK5qP`3LNGPd|HZ`hrst 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 NgCEdnh+MYusoL}WY0f$=H4qymSr6J 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 WEYvck-q)8jj@kG40vW6T|Nj7e>gUb? literal 0 HcmV?d00001 diff --git a/Unreal/Plugins/PS_Win_BLE/Content/Sample/PS_BLETest.umap b/Unreal/Plugins/PS_Win_BLE/Content/Sample/PS_BLETest.umap new file mode 100644 index 0000000000000000000000000000000000000000..390dcd2e09ed69ad17f71fbde325a9ed87b4376e GIT binary patch literal 48502 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 literal 0 HcmV?d00001 diff --git a/Unreal/Plugins/PS_Win_BLE/Resources/Icon128.png b/Unreal/Plugins/PS_Win_BLE/Resources/Icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..04e19281c4c0d3bb3dd6cf389a88f5923fa11d22 GIT binary patch literal 68458 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 M+OEmb#{K924@UpRVgLXD literal 0 HcmV?d00001 diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/PS_Win_BLE.Build.cs b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/PS_Win_BLE.Build.cs new file mode 100644 index 0000000..4be0dbb --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/PS_Win_BLE.Build.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2025 ASTERION VR + +using UnrealBuildTool; +using System.IO; + +public class PS_Win_BLE : ModuleRules +{ + public PS_Win_BLE(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + // WinRT support requires C++17 and specific Windows SDK headers + CppStandard = CppStandardVersion.Cpp17; + + PublicIncludePaths.AddRange( + new string[] { + } + ); + + PrivateIncludePaths.AddRange( + new string[] { + } + ); + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + "Projects", + } + ); + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + } + ); + + if (Target.Platform == UnrealTargetPlatform.Win64) + { + // WinRT / Windows BLE native APIs + PublicSystemLibraries.AddRange(new string[] + { + "windowsapp.lib", // WinRT APIs (Windows.Devices.Bluetooth, etc.) + "runtimeobject.lib" // RoInitialize / WinRT activation + }); + + // Allow WinRT headers in C++ code + bEnableExceptions = true; + } + } +} diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEDevice.cpp b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEDevice.cpp new file mode 100644 index 0000000..2c8b9aa --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEDevice.cpp @@ -0,0 +1,251 @@ +// Copyright (C) 2025 ASTERION VR + +#include "PS_BLEDevice.h" +#include "PS_BLEModule.h" +#include "PS_BLEManager.h" + +#if PLATFORM_WINDOWS +#include "Windows/AllowWindowsPlatformTypes.h" +#include "Windows/AllowWindowsPlatformAtomics.h" +#pragma warning(push) +#pragma warning(disable: 4668 4946 5204 5220) +#include +#pragma warning(pop) +#include "Windows/HideWindowsPlatformAtomics.h" +#include "Windows/HideWindowsPlatformTypes.h" +using namespace winrt; +#endif + +// ───────────────────────────────────────────────────────────────────────────── + +UPS_BLE_Device::UPS_BLE_Device() +{ +} + +UPS_BLE_Device::~UPS_BLE_Device() +{ + if (RefToModule) + { + RefToModule->DisconnectDevice(this); + } + ActiveServices.Empty(); +} + +// ─── Actions ───────────────────────────────────────────────────────────────── + +void UPS_BLE_Device::Connect() +{ + if (IsConnected()) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::AlreadyConnected, AddressAsString, "", ""); + return; + } + if (!RefToModule->ConnectDevice(this)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::ConnectFailed, AddressAsString, "", ""); + } +} + +void UPS_BLE_Device::DiscoverServices() +{ + ActiveServices.Empty(); + if (!RefToModule->ConnectDevice(this)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::ConnectFailed, AddressAsString, "", ""); + } +} + +void UPS_BLE_Device::Disconnect() +{ + if (!IsConnected()) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::CanNotDisconnect, AddressAsString, "", ""); + return; + } + if (!RefToModule->DisconnectDevice(this)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::DisconnectFailed, AddressAsString, "", ""); + } +} + +bool UPS_BLE_Device::IsConnected() +{ + if (bDestroyInProgress) return false; + return RefToModule->IsDeviceConnected(this); +} + +void UPS_BLE_Device::Read(const FString& ServiceUUID, const FString& CharacteristicUUID) +{ + if (!IsConnected()) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::NeedConnectionFirst, AddressAsString, "", ""); return; } + + EPS_CharacteristicDescriptor Desc; + uint16 SrvChar = FindInList(ServiceUUID, CharacteristicUUID, Desc); + if (SrvChar == 0xFFFF) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::NonReadableChar, AddressAsString, ServiceUUID, CharacteristicUUID); return; } + + if (((uint8)Desc & (uint8)EPS_CharacteristicDescriptor::Readable) == 0) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::NonReadableChar, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + uint8 SI = (SrvChar >> 8); + uint8 CI = (SrvChar & 0xFF); + if (!RefToModule->ReadCharacteristic(this, SI, CI)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::ReadingFailed, AddressAsString, ServiceUUID, CharacteristicUUID); + } +} + +void UPS_BLE_Device::Write(const FString& ServiceUUID, const FString& CharacteristicUUID, TArray Data) +{ + if (!IsConnected()) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::NeedConnectionFirst, AddressAsString, "", ""); return; } + if (Data.Num() == 0) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::ZeroLengthWrite, AddressAsString, ServiceUUID, CharacteristicUUID); return; } + + EPS_CharacteristicDescriptor Desc; + uint16 SrvChar = FindInList(ServiceUUID, CharacteristicUUID, Desc); + if (SrvChar == 0xFFFF || ((uint8)Desc & (uint8)EPS_CharacteristicDescriptor::Writable) == 0) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::NonWritableChar, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + uint8 SI = (SrvChar >> 8); + uint8 CI = (SrvChar & 0xFF); + if (!RefToModule->WriteCharacteristic(this, SI, CI, Data)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::WritingFailed, AddressAsString, ServiceUUID, CharacteristicUUID); + } +} + +void UPS_BLE_Device::Subscribe(const FString& ServiceUUID, const FString& CharacteristicUUID) +{ + if (!IsConnected()) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::NeedConnectionFirst, AddressAsString, "", ""); return; } + + EPS_CharacteristicDescriptor Desc; + uint16 SrvChar = FindInList(ServiceUUID, CharacteristicUUID, Desc); + const uint8 Mask = (uint8)EPS_CharacteristicDescriptor::Notifiable | (uint8)EPS_CharacteristicDescriptor::Indicable; + if (SrvChar == 0xFFFF || ((uint8)Desc & Mask) == 0) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::CanNotSubscribe, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + uint8 SI = (SrvChar >> 8); + uint8 CI = (SrvChar & 0xFF); + if (ActiveServices[SI].Characteristics[CI].subscribed) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::AlreadySubscribed, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + if (!RefToModule->SubscribeCharacteristic(this, SI, CI)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::SubscriptionFailed, AddressAsString, ServiceUUID, CharacteristicUUID); + } +} + +void UPS_BLE_Device::Unsubscribe(const FString& ServiceUUID, const FString& CharacteristicUUID) +{ + if (!IsConnected()) { RefToManager->OnBLEError.Broadcast(EPS_BLEError::NeedConnectionFirst, AddressAsString, "", ""); return; } + + EPS_CharacteristicDescriptor Desc; + uint16 SrvChar = FindInList(ServiceUUID, CharacteristicUUID, Desc); + const uint8 Mask = (uint8)EPS_CharacteristicDescriptor::Notifiable | (uint8)EPS_CharacteristicDescriptor::Indicable; + if (SrvChar == 0xFFFF || ((uint8)Desc & Mask) == 0) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::CanNotSubscribe, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + uint8 SI = (SrvChar >> 8); + uint8 CI = (SrvChar & 0xFF); + if (!ActiveServices[SI].Characteristics[CI].subscribed) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::NotSubscribed, AddressAsString, ServiceUUID, CharacteristicUUID); + return; + } + + if (!RefToModule->UnsubscribeCharacteristic(this, SI, CI)) + { + RefToManager->OnBLEError.Broadcast(EPS_BLEError::UnsubscribeFailed, AddressAsString, ServiceUUID, CharacteristicUUID); + } +} + +// ─── Address getters ────────────────────────────────────────────────────────── + +FString UPS_BLE_Device::DeviceAddressAsString() +{ + FString Result; + for (int32 i = 5; i >= 0; i--) + { + uint8 B = (DeviceID >> (i * 8)) & 0xFF; + ByteToHex(B, Result); + if (i > 0) Result.AppendChar(TEXT(':')); + } + return Result; +} + +int64 UPS_BLE_Device::DeviceAddressAsInt64() +{ + return (int64)(DeviceID & 0x0000FFFFFFFFFFFF); +} + +FPS_MACAddress UPS_BLE_Device::DeviceAddressAsMAC() +{ + FPS_MACAddress MAC; + MAC.b0 = (DeviceID) & 0xFF; + MAC.b1 = (DeviceID >> 8) & 0xFF; + MAC.b2 = (DeviceID >> 16) & 0xFF; + MAC.b3 = (DeviceID >> 24) & 0xFF; + MAC.b4 = (DeviceID >> 32) & 0xFF; + MAC.b5 = (DeviceID >> 40) & 0xFF; + return MAC; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +uint16 UPS_BLE_Device::FindInList(const FString& ServiceUUID, const FString& CharUUID, EPS_CharacteristicDescriptor& OutDescriptor) const +{ + for (int32 SI = 0; SI < ActiveServices.Num(); SI++) + { + if (ActiveServices[SI].ServiceUUID.Equals(ServiceUUID, ESearchCase::IgnoreCase)) + { + for (int32 CI = 0; CI < ActiveServices[SI].Characteristics.Num(); CI++) + { + if (ActiveServices[SI].Characteristics[CI].CharacteristicUUID.Equals(CharUUID, ESearchCase::IgnoreCase)) + { + OutDescriptor = (EPS_CharacteristicDescriptor)ActiveServices[SI].Characteristics[CI].Descriptor; + return (uint16)((SI << 8) | (CI & 0xFF)); + } + } + } + } + OutDescriptor = EPS_CharacteristicDescriptor::Unknown; + return 0xFFFF; +} + +FString UPS_BLE_Device::GUIDToString(const void* WinRTGuid) +{ + // WinRT GUID layout matches Win32 GUID: Data1(u32) Data2(u16) Data3(u16) Data4[8] + const uint8* Raw = static_cast(WinRTGuid); + auto HexByte = [](uint8 B) -> FString { FString S; ByteToHex(B, S); return S; }; + + // Data1 — little-endian uint32 + FString Result = TEXT("{"); + Result += HexByte(Raw[3]); Result += HexByte(Raw[2]); + Result += HexByte(Raw[1]); Result += HexByte(Raw[0]); + Result += TEXT("-"); + // Data2 — little-endian uint16 + Result += HexByte(Raw[5]); Result += HexByte(Raw[4]); + Result += TEXT("-"); + // Data3 — little-endian uint16 + Result += HexByte(Raw[7]); Result += HexByte(Raw[6]); + Result += TEXT("-"); + // Data4[0..1] + Result += HexByte(Raw[8]); Result += HexByte(Raw[9]); + Result += TEXT("-"); + // Data4[2..7] + for (int32 i = 10; i < 16; i++) Result += HexByte(Raw[i]); + Result += TEXT("}"); + return Result; +} diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLELibrary.cpp b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLELibrary.cpp new file mode 100644 index 0000000..5e3c5d8 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLELibrary.cpp @@ -0,0 +1,98 @@ +// Copyright (C) 2025 ASTERION VR + +#include "PS_BLELibrary.h" +#include "PS_BLEModule.h" +#include "PS_BLEManager.h" +#include "Modules/ModuleManager.h" + +UPS_BLE_Library::UPS_BLE_Library(const FObjectInitializer& ObjectInitializer) + : Super(ObjectInitializer) +{ +} + +UPS_BLE_Manager* UPS_BLE_Library::GetBLEManager(bool& IsPluginLoaded, bool& BLEAdapterFound) +{ + UPS_BLE_Module* Mod = (UPS_BLE_Module*)FModuleManager::Get().GetModule(FName("PS_Win_BLE")); + if (!Mod) + { + IsPluginLoaded = false; + BLEAdapterFound = false; + return nullptr; + } + IsPluginLoaded = true; + BLEAdapterFound = Mod->bInitialized; + return Mod->LocalBLEManager; +} + +uint8 UPS_BLE_Library::NibbleToByte(const FString& Nibble, bool& Valid) +{ + Valid = false; + if (Nibble.IsEmpty()) return 0; + TCHAR C = Nibble[0]; + Valid = true; + if (C >= TEXT('0') && C <= TEXT('9')) return C - TEXT('0'); + if (C >= TEXT('A') && C <= TEXT('F')) return C - TEXT('A') + 10; + if (C >= TEXT('a') && C <= TEXT('f')) return C - TEXT('a') + 10; + Valid = false; + return 0; +} + +void UPS_BLE_Library::GetDescriptorBits( + const EPS_CharacteristicDescriptor& Descriptor, + bool& Broadcastable, bool& ExtendedProperties, + bool& Notifiable, bool& Indicable, + bool& Readable, bool& Writable, + bool& WriteNoResponse, bool& SignedWrite) +{ + uint8 D = (uint8)Descriptor; + Broadcastable = (D & 0x01) != 0; + ExtendedProperties = (D & 0x02) != 0; + Notifiable = (D & 0x04) != 0; + Indicable = (D & 0x08) != 0; + Readable = (D & 0x10) != 0; + Writable = (D & 0x20) != 0; + WriteNoResponse = (D & 0x40) != 0; + SignedWrite = (D & 0x80) != 0; +} + +EPS_CharacteristicDescriptor UPS_BLE_Library::MakeDescriptor( + const bool Broadcastable, const bool ExtendedProperties, + const bool Notifiable, const bool Indicable, + const bool Readable, const bool Writable, + const bool WriteNoResponse, const bool SignedWrite) +{ + uint8 Result = 0; + if (Broadcastable) Result |= (uint8)EPS_CharacteristicDescriptor::Broadcast; + if (ExtendedProperties) Result |= (uint8)EPS_CharacteristicDescriptor::ExtendedProps; + if (Notifiable) Result |= (uint8)EPS_CharacteristicDescriptor::Notifiable; + if (Indicable) Result |= (uint8)EPS_CharacteristicDescriptor::Indicable; + if (Readable) Result |= (uint8)EPS_CharacteristicDescriptor::Readable; + if (Writable) Result |= (uint8)EPS_CharacteristicDescriptor::Writable; + if (WriteNoResponse) Result |= (uint8)EPS_CharacteristicDescriptor::WriteNoResponse; + if (SignedWrite) Result |= (uint8)EPS_CharacteristicDescriptor::SignedWrite; + return (EPS_CharacteristicDescriptor)Result; +} + +FString UPS_BLE_Library::DescriptorToString(const uint8& Descriptor) +{ + FString Result; + if (Descriptor & 0x01) Result += TEXT("Broadcastable "); + if (Descriptor & 0x02) Result += TEXT("ExtendedProperties "); + if (Descriptor & 0x04) Result += TEXT("Notifiable "); + if (Descriptor & 0x08) Result += TEXT("Indicable "); + if (Descriptor & 0x10) Result += TEXT("Readable "); + if (Descriptor & 0x20) Result += TEXT("Writable "); + if (Descriptor & 0x40) Result += TEXT("WriteNoResponse "); + if (Descriptor & 0x80) Result += TEXT("SignedWrite "); + Result.TrimEndInline(); + return Result; +} + +bool UPS_BLE_Library::IsEditorRunning() +{ +#if WITH_EDITOR + return true; +#else + return false; +#endif +} diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEManager.cpp b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEManager.cpp new file mode 100644 index 0000000..e9bcca8 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEManager.cpp @@ -0,0 +1,202 @@ +// Copyright (C) 2025 ASTERION VR + +#include "PS_BLEManager.h" +#include "PS_BLEModule.h" +#include "PS_BLEDevice.h" +#include "Misc/MessageDialog.h" + +#define LOCTEXT_NAMESPACE "PS_Win_BLE" + +// ───────────────────────────────────────────────────────────────────────────── + +UPS_BLE_Manager::UPS_BLE_Manager() +{ + if (!bAttached) + { + UPS_BLE_Module* Mod = (UPS_BLE_Module*)FModuleManager::Get().GetModule(FName("PS_Win_BLE")); + if (Mod) AttachModule(Mod); + } +} + +UPS_BLE_Manager::~UPS_BLE_Manager() +{ + FoundDevices.Empty(); +} + +void UPS_BLE_Manager::AttachModule(UPS_BLE_Module* Module) +{ + if (!bAttached && Module) + { + BLEModule = Module; + bAttached = true; + } +} + +// ─── Discovery ──────────────────────────────────────────────────────────────── + +void UPS_BLE_Manager::StartDiscoveryLive( + const FPS_OnNewBLEDeviceDiscovered& OnNewDevice, + const FPS_OnDiscoveryEnd& OnEnd, + const int64 DurationMs, const FString& NameFilter) +{ + bIsScanInProgress = true; + FoundDevices.Empty(); + PendingOnNewDevice = OnNewDevice; + PendingOnDiscoveryEnd = OnEnd; + BLEModule->StartDiscoveryLive(this, (int32)DurationMs, NameFilter); +} + +void UPS_BLE_Manager::StartDiscoveryInBackground( + const FPS_OnDiscoveryEnd& OnEnd, + const int64 DurationMs, const FString& NameFilter) +{ + bIsScanInProgress = true; + FoundDevices.Empty(); + PendingOnDiscoveryEnd = OnEnd; + BLEModule->StartDiscoveryInBackground(this, (int32)DurationMs, NameFilter); +} + +void UPS_BLE_Manager::StopDiscovery() +{ + BLEModule->StopDiscovery(); + bIsScanInProgress = false; +} + +void UPS_BLE_Manager::DisconnectAll() +{ + for (UPS_BLE_Device* Dev : FoundDevices) + { + if (Dev && Dev->IsConnected()) + { + Dev->bDestroyInProgress = true; + BLEModule->DisconnectDevice(Dev); + } + } +} + +bool UPS_BLE_Manager::ResetBLEAdapter() +{ + // Nothing to reset in native WinRT — the BLE adapter is managed by the OS. + // We just verify BLE is available by checking module init state. + if (BLEModule && BLEModule->bInitialized) return true; + FMessageDialog::Open(EAppMsgType::Ok, + LOCTEXT("PS_BLE_NoAdapter", "Bluetooth adapter not found or does not support LE mode.")); + return false; +} + +// ─── Internal callbacks ─────────────────────────────────────────────────────── + +UPS_BLE_Device* UPS_BLE_Manager::MakeNewDevice(const FPS_DeviceRecord& Rec) +{ + UPS_BLE_Device* Dev = NewObject(); + Dev->RefToModule = BLEModule; + Dev->RefToManager = this; + Dev->DeviceID = Rec.ID; + Dev->RSSI = (int32)Rec.RSSI; + Dev->DeviceName = Rec.Name.IsEmpty() ? TEXT("") : Rec.Name; + Dev->AddressAsString = Dev->DeviceAddressAsString(); + Dev->AddressAsInt64 = Dev->DeviceAddressAsInt64(); + Dev->AddressAsMAC = Dev->DeviceAddressAsMAC(); + FoundDevices.Add(Dev); + return Dev; +} + +int32 UPS_BLE_Manager::GetIndexByID(uint64 DeviceID) const +{ + for (int32 i = 0; i < FoundDevices.Num(); i++) + { + if (FoundDevices[i] && FoundDevices[i]->DeviceID == DeviceID) return i; + } + return -1; +} + +void UPS_BLE_Manager::JustDiscoveredDevice(const FPS_DeviceRecord& Rec) +{ + // Skip if already in list + if (GetIndexByID(Rec.ID) >= 0) return; + UPS_BLE_Device* Dev = MakeNewDevice(Rec); + PendingOnNewDevice.ExecuteIfBound(Dev); +} + +void UPS_BLE_Manager::JustDiscoveryEnd(const TArray& Devices) +{ + // For background discovery: build FoundDevices from the collected records + for (const FPS_DeviceRecord& Rec : Devices) + { + if (GetIndexByID(Rec.ID) < 0) + { + MakeNewDevice(Rec); + } + } + bIsScanInProgress = false; + PendingOnDiscoveryEnd.ExecuteIfBound(FoundDevices); +} + +void UPS_BLE_Manager::JustConnectedDevice(UPS_BLE_Device* Dev) +{ + OnAnyDeviceConnected.Broadcast(Dev); +} + +void UPS_BLE_Manager::JustDisconnectedDevice(UPS_BLE_Device* Dev) +{ + OnAnyDeviceDisconnected.Broadcast(Dev); +} + +void UPS_BLE_Manager::JustDiscoveredServices(UPS_BLE_Device* Dev) +{ + OnAnyServiceDiscovered.Broadcast(Dev, Dev->ActiveServices); +} + +// ─── MAC utils ──────────────────────────────────────────────────────────────── + +FString UPS_BLE_Manager::MACToString(const FPS_MACAddress& Addr) +{ + FString Result; + ByteToHex(Addr.b5, Result); Result += TEXT(":"); + ByteToHex(Addr.b4, Result); Result += TEXT(":"); + ByteToHex(Addr.b3, Result); Result += TEXT(":"); + ByteToHex(Addr.b2, Result); Result += TEXT(":"); + ByteToHex(Addr.b1, Result); Result += TEXT(":"); + ByteToHex(Addr.b0, Result); + return Result; +} + +uint8 UPS_BLE_Manager::HexNibble(TCHAR C, bool& Valid) +{ + Valid = true; + if (C >= TEXT('0') && C <= TEXT('9')) return C - TEXT('0'); + if (C >= TEXT('A') && C <= TEXT('F')) return C - TEXT('A') + 10; + if (C >= TEXT('a') && C <= TEXT('f')) return C - TEXT('a') + 10; + Valid = false; + return 0; +} + +FPS_MACAddress UPS_BLE_Manager::StringToMAC(const FString& Address, bool& Valid) +{ + FPS_MACAddress Blank, Result; + FString Tmp = Address.TrimStartAndEnd(); + Valid = false; + if (Tmp.Len() != 17) return Blank; + if (Tmp[2] != TEXT(':') || Tmp[5] != TEXT(':') || Tmp[8] != TEXT(':') || + Tmp[11] != TEXT(':') || Tmp[14] != TEXT(':')) return Blank; + + auto ReadByte = [&](int32 Idx) -> uint8 + { + bool V1, V2; + uint8 Hi = HexNibble(Tmp[Idx], V1); + uint8 Lo = HexNibble(Tmp[Idx+1], V2); + if (!V1 || !V2) { Valid = false; } + return (Hi << 4) | Lo; + }; + + Valid = true; + Result.b0 = ReadByte(0); + Result.b1 = ReadByte(3); + Result.b2 = ReadByte(6); + Result.b3 = ReadByte(9); + Result.b4 = ReadByte(12); + Result.b5 = ReadByte(15); + return Valid ? Result : Blank; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEModule.cpp b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEModule.cpp new file mode 100644 index 0000000..18fa643 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Private/PS_BLEModule.cpp @@ -0,0 +1,734 @@ +// Copyright (C) 2025 ASTERION VR + +#include "PS_BLEModule.h" +#include "PS_BLEManager.h" +#include "PS_BLEDevice.h" +#include "Async/Async.h" +#include "Misc/MessageDialog.h" +#include "Modules/ModuleManager.h" + +// ─── WinRT includes (Windows only) ─────────────────────────────────────────── +#if PLATFORM_WINDOWS + +// Unreal defines WIN32_LEAN_AND_MEAN which can strip some COM headers — we +// add the specific ones we need without touching the Unreal macros. +#include "Windows/AllowWindowsPlatformTypes.h" +#include "Windows/AllowWindowsPlatformAtomics.h" + +#pragma warning(push) +#pragma warning(disable: 4668 4946 5204 5220) + +#include +#include +#include +#include +#include +#include + +#pragma warning(pop) + +#include "Windows/HideWindowsPlatformAtomics.h" +#include "Windows/HideWindowsPlatformTypes.h" + +using namespace winrt; +using namespace Windows::Devices::Bluetooth; +using namespace Windows::Devices::Bluetooth::Advertisement; +using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; +using namespace Windows::Foundation; +using namespace Windows::Foundation::Collections; +using namespace Windows::Storage::Streams; + +// ─── Scanner state (allocated on heap so WinRT types don't leak into header) ─ +struct FPS_ScannerState +{ + BluetoothLEAdvertisementWatcher Watcher{ nullptr }; + winrt::event_token ReceivedToken; + winrt::event_token StoppedToken; +}; + +#endif // PLATFORM_WINDOWS + +#define LOCTEXT_NAMESPACE "PS_Win_BLE" + +// ───────────────────────────────────────────────────────────────────────────── +// Module startup / shutdown +// ───────────────────────────────────────────────────────────────────────────── + +void UPS_BLE_Module::StartupModule() +{ +#if PLATFORM_WINDOWS + try + { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + bWinRTCoInitialized = true; + } + catch (...) + { + // Already initialized in this apartment — that's fine. + bWinRTCoInitialized = false; + } + + bInitialized = true; + LocalBLEManager = NewObject(); + LocalBLEManager->AddToRoot(); // prevent GC + LocalBLEManager->AttachModule(this); +#else + FMessageDialog::Open(EAppMsgType::Ok, + LOCTEXT("PS_Win_BLE_Platform", "PS_Win_BLE: BLE is only supported on Windows 64-bit.")); +#endif +} + +void UPS_BLE_Module::ShutdownModule() +{ +#if PLATFORM_WINDOWS + ScannerCleanup(); + + if (LocalBLEManager) + { + LocalBLEManager->DisconnectAll(); + LocalBLEManager->RemoveFromRoot(); + LocalBLEManager = nullptr; + } + + if (bWinRTCoInitialized) + { + winrt::uninit_apartment(); + bWinRTCoInitialized = false; + } + + bInitialized = false; +#endif +} + +UPS_BLE_Manager* UPS_BLE_Module::GetBLEManager(const UPS_BLE_Module* Mod) +{ + return Mod ? Mod->LocalBLEManager : nullptr; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Discovery +// ───────────────────────────────────────────────────────────────────────────── + +void UPS_BLE_Module::ScannerCleanup() +{ +#if PLATFORM_WINDOWS + if (ScannerHandle) + { + FPS_ScannerState* State = static_cast(ScannerHandle); + try + { + if (State->Watcher && State->Watcher.Status() == BluetoothLEAdvertisementWatcherStatus::Started) + { + State->Watcher.Stop(); + } + } + catch (...) {} + delete State; + ScannerHandle = nullptr; + } +#endif +} + +bool UPS_BLE_Module::StartDiscoveryLive(UPS_BLE_Manager* Ref, int32 DurationMs, const FString& Filter) +{ +#if PLATFORM_WINDOWS + if (!Ref) return false; + ScannerCleanup(); + + FPS_ScannerState* State = new FPS_ScannerState(); + State->Watcher = BluetoothLEAdvertisementWatcher(); + State->Watcher.ScanningMode(BluetoothLEScanningMode::Active); + ScannerHandle = State; + + // Capture filter and manager ref + FString FilterCopy = Filter; + UPS_BLE_Manager* MgrRef = Ref; + + State->ReceivedToken = State->Watcher.Received( + [MgrRef, FilterCopy](BluetoothLEAdvertisementWatcher const&, BluetoothLEAdvertisementReceivedEventArgs const& Args) + { + FString Name = Args.Advertisement().LocalName().c_str(); + + // Apply name filter (comma-separated substrings) + if (!FilterCopy.IsEmpty()) + { + TArray Parts; + FilterCopy.ParseIntoArray(Parts, TEXT(","), true); + bool bMatch = false; + for (const FString& Part : Parts) + { + if (Name.Contains(Part.TrimStartAndEnd())) + { + bMatch = true; + break; + } + } + if (!bMatch) return; + } + + FPS_DeviceRecord Rec; + Rec.ID = Args.BluetoothAddress(); + Rec.RSSI = Args.RawSignalStrengthInDBm(); + Rec.Name = Name; + + DispatchDeviceDiscovered(MgrRef, Rec); + }); + + State->StoppedToken = State->Watcher.Stopped( + [MgrRef](BluetoothLEAdvertisementWatcher const&, BluetoothLEAdvertisementWatcherStoppedEventArgs const&) + { + TArray Empty; + DispatchDiscoveryEnd(MgrRef, Empty); + }); + + State->Watcher.Start(); + + // Auto-stop after DurationMs + if (DurationMs > 0) + { + int32 Ms = DurationMs; + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this, Ms]() + { + FPlatformProcess::Sleep(Ms / 1000.0f); + StopDiscovery(); + }); + } + + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::StartDiscoveryInBackground(UPS_BLE_Manager* Ref, int32 DurationMs, const FString& Filter) +{ +#if PLATFORM_WINDOWS + if (!Ref) return false; + ScannerCleanup(); + + // Collect all devices during scan, fire DiscoveryEnd at the end + TSharedPtr, ESPMode::ThreadSafe> Collected = + MakeShared, ESPMode::ThreadSafe>(); + + FPS_ScannerState* State = new FPS_ScannerState(); + State->Watcher = BluetoothLEAdvertisementWatcher(); + State->Watcher.ScanningMode(BluetoothLEScanningMode::Active); + ScannerHandle = State; + + FString FilterCopy = Filter; + UPS_BLE_Manager* MgrRef = Ref; + + State->ReceivedToken = State->Watcher.Received( + [MgrRef, FilterCopy, Collected](BluetoothLEAdvertisementWatcher const&, BluetoothLEAdvertisementReceivedEventArgs const& Args) + { + FString Name = Args.Advertisement().LocalName().c_str(); + + // Dedup by address + uint64 Addr = Args.BluetoothAddress(); + for (const FPS_DeviceRecord& R : *Collected) + { + if (R.ID == Addr) return; + } + + if (!FilterCopy.IsEmpty()) + { + TArray Parts; + FilterCopy.ParseIntoArray(Parts, TEXT(","), true); + bool bMatch = false; + for (const FString& Part : Parts) + { + if (Name.Contains(Part.TrimStartAndEnd())) { bMatch = true; break; } + } + if (!bMatch) return; + } + + FPS_DeviceRecord Rec; + Rec.ID = Addr; + Rec.RSSI = Args.RawSignalStrengthInDBm(); + Rec.Name = Name; + Collected->Add(Rec); + }); + + State->StoppedToken = State->Watcher.Stopped( + [MgrRef, Collected](BluetoothLEAdvertisementWatcher const&, BluetoothLEAdvertisementWatcherStoppedEventArgs const&) + { + DispatchDiscoveryEnd(MgrRef, *Collected); + }); + + State->Watcher.Start(); + + if (DurationMs > 0) + { + int32 Ms = DurationMs; + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this, Ms]() + { + FPlatformProcess::Sleep(Ms / 1000.0f); + StopDiscovery(); + }); + } + + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::StopDiscovery() +{ +#if PLATFORM_WINDOWS + if (ScannerHandle) + { + FPS_ScannerState* State = static_cast(ScannerHandle); + try + { + if (State->Watcher && State->Watcher.Status() == BluetoothLEAdvertisementWatcherStatus::Started) + { + State->Watcher.Stop(); + } + } + catch (...) { return false; } + return true; + } +#endif + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connect / Disconnect / IsConnected +// ───────────────────────────────────────────────────────────────────────────── + +bool UPS_BLE_Module::ConnectDevice(UPS_BLE_Device* Device) +{ +#if PLATFORM_WINDOWS + if (!Device) return false; + + uint64 Addr = Device->DeviceID; + UPS_BLE_Module* ModRef = this; + + // WinRT async connect + service discovery on background thread + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [ModRef, Device, Addr]() + { + try + { + // FromBluetoothAddressAsync is the standard WinRT connect path + auto Op = BluetoothLEDevice::FromBluetoothAddressAsync(Addr); + BluetoothLEDevice BLEDev = Op.get(); + + if (!BLEDev) + { + DispatchDeviceDisconnected(Device); + return; + } + + // Store native handle (AddRef via IUnknown kept alive by winrt wrapper) + Device->NativeDeviceHandle = new winrt::Windows::Devices::Bluetooth::BluetoothLEDevice(BLEDev); + + // Subscribe to connection-status change + BLEDev.ConnectionStatusChanged( + [Device](BluetoothLEDevice const& Dev, IInspectable const&) + { + if (Dev.ConnectionStatus() == BluetoothConnectionStatus::Disconnected) + { + DispatchDeviceDisconnected(Device); + } + }); + + // Discover GATT services + auto SvcResult = BLEDev.GetGattServicesAsync(BluetoothCacheMode::Uncached).get(); + if (SvcResult.Status() != GattCommunicationStatus::Success) + { + DispatchDeviceDisconnected(Device); + return; + } + + Device->ActiveServices.Empty(); + Device->NativeGattServices.Empty(); + + auto Services = SvcResult.Services(); + for (uint32_t si = 0; si < Services.Size(); si++) + { + auto Svc = Services.GetAt(si); + Device->NativeGattServices.Add(new GattDeviceService(Svc)); + + FPS_ServiceItem SvcItem; + GUID g = Svc.Uuid(); + SvcItem.ServiceUUID = UPS_BLE_Device::GUIDToString(&g); + SvcItem.ServiceName = SvcItem.ServiceUUID; // WinRT doesn't give a friendly name + + // Discover characteristics + auto CharResult = Svc.GetCharacteristicsAsync(BluetoothCacheMode::Uncached).get(); + if (CharResult.Status() == GattCommunicationStatus::Success) + { + auto Chars = CharResult.Characteristics(); + for (uint32_t ci = 0; ci < Chars.Size(); ci++) + { + auto Ch = Chars.GetAt(ci); + FPS_CharacteristicItem ChItem; + GUID cg = Ch.Uuid(); + ChItem.CharacteristicUUID = UPS_BLE_Device::GUIDToString(&cg); + ChItem.CharacteristicName = ChItem.CharacteristicUUID; + + // Map WinRT properties to our descriptor bits + auto Props = Ch.CharacteristicProperties(); + uint8 Desc = 0; + if ((Props & GattCharacteristicProperties::Broadcast) != GattCharacteristicProperties::None) Desc |= 0x01; + if ((Props & GattCharacteristicProperties::ExtendedProperties) != GattCharacteristicProperties::None) Desc |= 0x02; + if ((Props & GattCharacteristicProperties::Notify) != GattCharacteristicProperties::None) Desc |= 0x04; + if ((Props & GattCharacteristicProperties::Indicate) != GattCharacteristicProperties::None) Desc |= 0x08; + if ((Props & GattCharacteristicProperties::Read) != GattCharacteristicProperties::None) Desc |= 0x10; + if ((Props & GattCharacteristicProperties::Write) != GattCharacteristicProperties::None) Desc |= 0x20; + if ((Props & GattCharacteristicProperties::WriteWithoutResponse)!= GattCharacteristicProperties::None) Desc |= 0x40; + if ((Props & GattCharacteristicProperties::AuthenticatedSignedWrites)!= GattCharacteristicProperties::None) Desc |= 0x80; + ChItem.Descriptor = Desc; + + SvcItem.Characteristics.Add(ChItem); + } + } + Device->ActiveServices.Add(SvcItem); + } + + DispatchServicesDiscovered(Device); + DispatchDeviceConnected(Device); + } + catch (...) + { + DispatchDeviceDisconnected(Device); + } + }); + + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::DisconnectDevice(UPS_BLE_Device* Device) +{ +#if PLATFORM_WINDOWS + if (!Device) return false; + + // Close native GATT service handles + for (void* SvcPtr : Device->NativeGattServices) + { + if (SvcPtr) + { + delete static_cast(SvcPtr); + } + } + Device->NativeGattServices.Empty(); + + // Close device handle + if (Device->NativeDeviceHandle) + { + delete static_cast(Device->NativeDeviceHandle); + Device->NativeDeviceHandle = nullptr; + } + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::IsDeviceConnected(UPS_BLE_Device* Device) +{ +#if PLATFORM_WINDOWS + if (!Device || !Device->NativeDeviceHandle) return false; + try + { + BluetoothLEDevice* BLEDev = static_cast(Device->NativeDeviceHandle); + return BLEDev->ConnectionStatus() == BluetoothConnectionStatus::Connected; + } + catch (...) {} +#endif + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GATT Read / Write / Subscribe / Unsubscribe +// ───────────────────────────────────────────────────────────────────────────── + +bool UPS_BLE_Module::ReadCharacteristic(UPS_BLE_Device* Device, uint8 SI, uint8 CI) +{ +#if PLATFORM_WINDOWS + if (!Device || SI >= Device->NativeGattServices.Num()) return false; + + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]() + { + try + { + GattDeviceService* Svc = static_cast(Device->NativeGattServices[SI]); + auto Chars = Svc->GetCharacteristicsAsync(BluetoothCacheMode::Cached).get(); + if (Chars.Status() != GattCommunicationStatus::Success) return; + + auto Ch = Chars.Characteristics().GetAt(CI); + auto Result = Ch.ReadValueAsync(BluetoothCacheMode::Uncached).get(); + + EPS_GATTStatus Status = (Result.Status() == GattCommunicationStatus::Success) + ? EPS_GATTStatus::Success : EPS_GATTStatus::Failure; + + TArray Data; + if (Result.Status() == GattCommunicationStatus::Success) + { + auto Reader = DataReader::FromBuffer(Result.Value()); + Data.SetNumUninitialized(Reader.UnconsumedBufferLength()); + for (uint8& B : Data) B = Reader.ReadByte(); + } + + DispatchRead(Device, SI, CI, Status, MoveTemp(Data)); + } + catch (...) {} + }); + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::WriteCharacteristic(UPS_BLE_Device* Device, uint8 SI, uint8 CI, const TArray& Data) +{ +#if PLATFORM_WINDOWS + if (!Device || SI >= Device->NativeGattServices.Num()) return false; + + TArray DataCopy = Data; + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI, DataCopy]() + { + try + { + GattDeviceService* Svc = static_cast(Device->NativeGattServices[SI]); + auto Chars = Svc->GetCharacteristicsAsync(BluetoothCacheMode::Cached).get(); + if (Chars.Status() != GattCommunicationStatus::Success) return; + + auto Ch = Chars.Characteristics().GetAt(CI); + + auto Writer = DataWriter(); + for (uint8 B : DataCopy) Writer.WriteByte(B); + + auto Status = Ch.WriteValueAsync(Writer.DetachBuffer(), + GattWriteOption::WriteWithResponse).get(); + + EPS_GATTStatus GattStatus = (Status == GattCommunicationStatus::Success) + ? EPS_GATTStatus::Success : EPS_GATTStatus::Failure; + + DispatchWrite(Device, SI, CI, GattStatus); + } + catch (...) {} + }); + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::SubscribeCharacteristic(UPS_BLE_Device* Device, uint8 SI, uint8 CI) +{ +#if PLATFORM_WINDOWS + if (!Device || SI >= Device->NativeGattServices.Num()) return false; + + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]() + { + try + { + GattDeviceService* Svc = static_cast(Device->NativeGattServices[SI]); + auto Chars = Svc->GetCharacteristicsAsync(BluetoothCacheMode::Cached).get(); + if (Chars.Status() != GattCommunicationStatus::Success) return; + + auto Ch = Chars.Characteristics().GetAt(CI); + + // Write CCCD to enable notifications + auto WriteStatus = Ch.WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue::Notify).get(); + + EPS_GATTStatus GattStatus = (WriteStatus == GattCommunicationStatus::Success) + ? EPS_GATTStatus::Success : EPS_GATTStatus::Failure; + + if (WriteStatus == GattCommunicationStatus::Success) + { + // Register value-changed callback + auto Token = Ch.ValueChanged( + [Device, SI, CI](GattCharacteristic const&, GattValueChangedEventArgs const& Args) + { + auto Reader = DataReader::FromBuffer(Args.CharacteristicValue()); + TArray Data; + Data.SetNumUninitialized(Reader.UnconsumedBufferLength()); + for (uint8& B : Data) B = Reader.ReadByte(); + DispatchNotify(Device, SI, CI, EPS_GATTStatus::Success, MoveTemp(Data)); + }); + + // Store token (key = packed SI<<8|CI) + uint64 Key = ((uint64)SI << 8) | CI; + Device->NotifyTokens.Add(Key, new winrt::event_token(Token)); + } + + DispatchSubscribe(Device, SI, CI, GattStatus); + } + catch (...) {} + }); + return true; +#else + return false; +#endif +} + +bool UPS_BLE_Module::UnsubscribeCharacteristic(UPS_BLE_Device* Device, uint8 SI, uint8 CI) +{ +#if PLATFORM_WINDOWS + if (!Device || SI >= Device->NativeGattServices.Num()) return false; + + AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]() + { + try + { + GattDeviceService* Svc = static_cast(Device->NativeGattServices[SI]); + auto Chars = Svc->GetCharacteristicsAsync(BluetoothCacheMode::Cached).get(); + if (Chars.Status() != GattCommunicationStatus::Success) return; + + auto Ch = Chars.Characteristics().GetAt(CI); + + // Remove event token + uint64 Key = ((uint64)SI << 8) | CI; + if (winrt::event_token** TokenPtr = reinterpret_cast(Device->NotifyTokens.Find(Key))) + { + Ch.ValueChanged(**TokenPtr); + delete *TokenPtr; + Device->NotifyTokens.Remove(Key); + } + + // Write CCCD to disable notifications + auto WriteStatus = Ch.WriteClientCharacteristicConfigurationDescriptorAsync( + GattClientCharacteristicConfigurationDescriptorValue::None).get(); + + EPS_GATTStatus GattStatus = (WriteStatus == GattCommunicationStatus::Success) + ? EPS_GATTStatus::Success : EPS_GATTStatus::Failure; + + DispatchUnsubscribe(Device, SI, CI, GattStatus); + } + catch (...) {} + }); + return true; +#else + return false; +#endif +} + +// ───────────────────────────────────────────────────────────────────────────── +// GameThread dispatchers +// ───────────────────────────────────────────────────────────────────────────── + +void UPS_BLE_Module::DispatchDeviceDiscovered(UPS_BLE_Manager* Mgr, const FPS_DeviceRecord& Rec) +{ + if (!Mgr) return; + FPS_DeviceRecord RecCopy = Rec; + if (IsInGameThread()) { Mgr->JustDiscoveredDevice(RecCopy); } + else { AsyncTask(ENamedThreads::GameThread, [Mgr, RecCopy]() { Mgr->JustDiscoveredDevice(RecCopy); }); } +} + +void UPS_BLE_Module::DispatchDiscoveryEnd(UPS_BLE_Manager* Mgr, const TArray& Devices) +{ + if (!Mgr) return; + TArray Copy = Devices; + if (IsInGameThread()) { Mgr->JustDiscoveryEnd(Copy); } + else { AsyncTask(ENamedThreads::GameThread, [Mgr, Copy]() { Mgr->JustDiscoveryEnd(Copy); }); } +} + +void UPS_BLE_Module::DispatchDeviceConnected(UPS_BLE_Device* Dev) +{ + if (!Dev) return; + if (IsInGameThread()) { Dev->RefToManager->JustConnectedDevice(Dev); Dev->OnConnect.Broadcast(Dev, Dev->ActiveServices); } + else { AsyncTask(ENamedThreads::GameThread, [Dev]() { Dev->RefToManager->JustConnectedDevice(Dev); Dev->OnConnect.Broadcast(Dev, Dev->ActiveServices); }); } +} + +void UPS_BLE_Module::DispatchDeviceDisconnected(UPS_BLE_Device* Dev) +{ + if (!Dev) return; + if (IsInGameThread()) { Dev->RefToManager->JustDisconnectedDevice(Dev); Dev->OnDisconnect.Broadcast(Dev); } + else { AsyncTask(ENamedThreads::GameThread, [Dev]() { Dev->RefToManager->JustDisconnectedDevice(Dev); Dev->OnDisconnect.Broadcast(Dev); }); } +} + +void UPS_BLE_Module::DispatchServicesDiscovered(UPS_BLE_Device* Dev) +{ + if (!Dev) return; + if (IsInGameThread()) { Dev->RefToManager->JustDiscoveredServices(Dev); Dev->OnServicesDiscovered.Broadcast(Dev, Dev->ActiveServices); } + else { AsyncTask(ENamedThreads::GameThread, [Dev]() { Dev->RefToManager->JustDiscoveredServices(Dev); Dev->OnServicesDiscovered.Broadcast(Dev, Dev->ActiveServices); }); } +} + +void UPS_BLE_Module::DispatchRead(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray Data) +{ + if (!Dev) return; + if (IsInGameThread()) + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + Dev->OnRead.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID, Data); + } + else + { + AsyncTask(ENamedThreads::GameThread, [Dev, SI, CI, Status, Data]() + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + Dev->OnRead.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID, Data); + }); + } +} + +void UPS_BLE_Module::DispatchNotify(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray Data) +{ + if (!Dev) return; + if (IsInGameThread()) + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + Dev->OnNotify.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID, Data); + } + else + { + AsyncTask(ENamedThreads::GameThread, [Dev, SI, CI, Status, Data]() + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + Dev->OnNotify.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID, Data); + }); + } +} + +void UPS_BLE_Module::DispatchWrite(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status) +{ + if (!Dev) return; + auto Fire = [Dev, SI, CI, Status]() + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + Dev->OnWrite.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID); + }; + if (IsInGameThread()) Fire(); + else AsyncTask(ENamedThreads::GameThread, Fire); +} + +void UPS_BLE_Module::DispatchSubscribe(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status) +{ + if (!Dev) return; + auto Fire = [Dev, SI, CI, Status]() + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + { + Dev->ActiveServices[SI].Characteristics[CI].subscribed = true; + Dev->OnSubscribe.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID); + } + }; + if (IsInGameThread()) Fire(); + else AsyncTask(ENamedThreads::GameThread, Fire); +} + +void UPS_BLE_Module::DispatchUnsubscribe(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status) +{ + if (!Dev) return; + auto Fire = [Dev, SI, CI, Status]() + { + if (SI < Dev->ActiveServices.Num() && CI < Dev->ActiveServices[SI].Characteristics.Num()) + { + Dev->ActiveServices[SI].Characteristics[CI].subscribed = false; + Dev->OnUnsubscribe.Broadcast(Status, Dev, Dev->ActiveServices[SI].ServiceUUID, Dev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID); + } + }; + if (IsInGameThread()) Fire(); + else AsyncTask(ENamedThreads::GameThread, Fire); +} + +IMPLEMENT_MODULE(UPS_BLE_Module, PS_Win_BLE) + +#undef LOCTEXT_NAMESPACE diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEDevice.h b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEDevice.h new file mode 100644 index 0000000..225ee91 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEDevice.h @@ -0,0 +1,106 @@ +// Copyright (C) 2025 ASTERION VR + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "PS_BLETypes.h" +#include "PS_BLEDevice.generated.h" + +class UPS_BLE_Manager; +class UPS_BLE_Module; + +UCLASS(ClassGroup = (Custom), Category = "PS BLE", meta = (BlueprintSpawnableComponent)) +class PS_WIN_BLE_API UPS_BLE_Device : public UObject +{ + GENERATED_BODY() + + friend class UPS_BLE_Module; + friend class UPS_BLE_Manager; + +public: + UPS_BLE_Device(); + virtual ~UPS_BLE_Device() override; + + // ─── Getters Blueprint ──────────────────────────────────────────────────── + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Address as String", ReturnDisplayName = "Address")) + FString DeviceAddressAsString(); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Address as Int64", ReturnDisplayName = "Int64")) + int64 DeviceAddressAsInt64(); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Address as MAC", ReturnDisplayName = "MAC")) + FPS_MACAddress DeviceAddressAsMAC(); + + // ─── Actions ────────────────────────────────────────────────────────────── + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Connect(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Disconnect(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE", meta = (DisplayName = "Is Connected", ReturnDisplayName = "Connected")) + bool IsConnected(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void DiscoverServices(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Read(const FString& ServiceUUID, const FString& CharacteristicUUID); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Write(const FString& ServiceUUID, const FString& CharacteristicUUID, TArray Data); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Subscribe(const FString& ServiceUUID, const FString& CharacteristicUUID); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void Unsubscribe(const FString& ServiceUUID, const FString& CharacteristicUUID); + + // ─── Properties ─────────────────────────────────────────────────────────── + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") + FString DeviceName = ""; + + UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsString", Category = "PS BLE") + FString AddressAsString; + + UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsInt64", Category = "PS BLE") + int64 AddressAsInt64 = 0; + + UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsMAC", Category = "PS BLE") + FPS_MACAddress AddressAsMAC; + + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") + int32 RSSI = 0; + + UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = "PS BLE") + TArray ActiveServices; + + // ─── Delegates ──────────────────────────────────────────────────────────── + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnConnect OnConnect; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnDisconnect OnDisconnect; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnServicesDiscovered OnServicesDiscovered; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnRead OnRead; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnNotify OnNotify; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnWrite OnWrite; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnSubscribe OnSubscribe; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnUnsubscribe OnUnsubscribe; + + // ─── Internal state ─────────────────────────────────────────────────────── + uint64 DeviceID = 0; + UPS_BLE_Manager* RefToManager = nullptr; + UPS_BLE_Module* RefToModule = nullptr; + bool bDestroyInProgress = false; + + // Native WinRT handles (opaque void* — cast to WinRT types in .cpp) + void* NativeDeviceHandle = nullptr; // IBluetoothLEDevice + void* NativeGattSession = nullptr; // GattSession + TArray NativeGattServices; // per-service GattDeviceService handles + TMap NotifyTokens; // characteristic handle -> event token + + // Finds service+char indices from UUID strings, returns 0xFFFF if not found + uint16 FindInList(const FString& ServiceUUID, const FString& CharUUID, EPS_CharacteristicDescriptor& OutDescriptor) const; + + // Converts a 128-bit GUID (from WinRT GUID struct) to "{xxxxxxxx-xxxx-...}" FString + static FString GUIDToString(const void* WinRTGuid); +}; diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLELibrary.h b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLELibrary.h new file mode 100644 index 0000000..1adbb99 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLELibrary.h @@ -0,0 +1,53 @@ +// Copyright (C) 2025 ASTERION VR + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "PS_BLETypes.h" +#include "PS_BLELibrary.generated.h" + +class UPS_BLE_Manager; + +UCLASS() +class PS_WIN_BLE_API UPS_BLE_Library : public UBlueprintFunctionLibrary +{ + GENERATED_UCLASS_BODY() + +public: + /** Return the BLE Manager singleton */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "PS BLE", meta = ( + DisplayName = "Get BLE Manager", + ReturnDisplayName = "BLE Manager", + Keywords = "Bluetooth LE BLE manager PS")) + static UPS_BLE_Manager* GetBLEManager(bool& IsPluginLoaded, bool& BLEAdapterFound); + + /** Convert a hex nibble character to byte (0-15). Valid=false if invalid character. */ + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Nibble To Byte", ReturnDisplayName = "Byte")) + static uint8 NibbleToByte(const FString& Nibble, bool& Valid); + + /** Decompose a characteristic descriptor byte into individual capability booleans */ + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Get Descriptor Bits")) + static void GetDescriptorBits( + const EPS_CharacteristicDescriptor& Descriptor, + bool& Broadcastable, bool& ExtendedProperties, + bool& Notifiable, bool& Indicable, + bool& Readable, bool& Writable, + bool& WriteNoResponse, bool& SignedWrite); + + /** Build a characteristic descriptor byte from individual capability booleans */ + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Make BLE Descriptor")) + static EPS_CharacteristicDescriptor MakeDescriptor( + const bool Broadcastable, const bool ExtendedProperties, + const bool Notifiable, const bool Indicable, + const bool Readable, const bool Writable, + const bool WriteNoResponse, const bool SignedWrite); + + /** Convert a descriptor byte to a human-readable string */ + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Descriptor To String", ReturnDisplayName = "Descriptor")) + static FString DescriptorToString(const uint8& Descriptor); + + /** True if running inside the Unreal Editor */ + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "Is Editor Running", ReturnDisplayName = "EditorRunning")) + static bool IsEditorRunning(); +}; diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEManager.h b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEManager.h new file mode 100644 index 0000000..3984b8c --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEManager.h @@ -0,0 +1,91 @@ +// Copyright (C) 2025 ASTERION VR + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "PS_BLETypes.h" +#include "PS_BLEManager.generated.h" + +class UPS_BLE_Module; +class UPS_BLE_Device; + +UCLASS(ClassGroup = (Custom), Category = "PS BLE", meta = (BlueprintSpawnableComponent)) +class PS_WIN_BLE_API UPS_BLE_Manager : public UObject +{ + GENERATED_BODY() + + friend class UPS_BLE_Module; + +public: + UPS_BLE_Manager(); + virtual ~UPS_BLE_Manager() override; + + void AttachModule(UPS_BLE_Module* Module); + + // ─── Discovery ──────────────────────────────────────────────────────────── + UFUNCTION(BlueprintCallable, Category = "PS BLE", meta = ( + AutoCreateRefTerm = "OnNewDeviceDiscovered", + ToolTip = "Live scan: fires OnNewDeviceDiscovered each time a device is found. Filter is comma-separated name substrings.")) + void StartDiscoveryLive( + const FPS_OnNewBLEDeviceDiscovered& OnNewDeviceDiscovered, + const FPS_OnDiscoveryEnd& OnDiscoveryEnd, + const int64 DurationMs = 5000, + const FString& NameFilter = ""); + + UFUNCTION(BlueprintCallable, Category = "PS BLE", meta = ( + AutoCreateRefTerm = "OnDiscoveryEnd", + ToolTip = "Background scan: fires OnDiscoveryEnd once finished. Filter is comma-separated name substrings.")) + void StartDiscoveryInBackground( + const FPS_OnDiscoveryEnd& OnDiscoveryEnd, + const int64 DurationMs = 5000, + const FString& NameFilter = ""); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void StopDiscovery(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE") + void DisconnectAll(); + + UFUNCTION(BlueprintCallable, Category = "PS BLE", meta = (DisplayName = "Reset BLE Adapter", ReturnDisplayName = "Success")) + bool ResetBLEAdapter(); + + // ─── MAC utils ──────────────────────────────────────────────────────────── + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "MAC to String", ReturnDisplayName = "Address")) + static FString MACToString(const FPS_MACAddress& Address); + + UFUNCTION(BlueprintPure, Category = "PS BLE", meta = (DisplayName = "String to MAC", ReturnDisplayName = "MAC")) + static FPS_MACAddress StringToMAC(const FString& Address, bool& Valid); + + // ─── Properties ─────────────────────────────────────────────────────────── + UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = "PS BLE") + TArray FoundDevices; + + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") + bool bIsScanInProgress = false; + + // ─── Delegates ──────────────────────────────────────────────────────────── + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnAnyBLEDeviceConnected OnAnyDeviceConnected; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnAnyBLEDeviceDisconnected OnAnyDeviceDisconnected; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnAnyServiceDiscovered OnAnyServiceDiscovered; + UPROPERTY(BlueprintAssignable, Category = "PS BLE") FPS_OnBLEError OnBLEError; + + // ─── Internal callbacks (called from UPS_BLE_Module dispatch) ───────────── + void JustDiscoveredDevice(const FPS_DeviceRecord& Rec); + void JustDiscoveryEnd(const TArray& Devices); + void JustConnectedDevice(UPS_BLE_Device* Dev); + void JustDisconnectedDevice(UPS_BLE_Device* Dev); + void JustDiscoveredServices(UPS_BLE_Device* Dev); + +private: + UPS_BLE_Module* BLEModule = nullptr; + bool bAttached = false; + + UPS_BLE_Device* MakeNewDevice(const FPS_DeviceRecord& Rec); + int32 GetIndexByID(uint64 DeviceID) const; + + FPS_OnNewBLEDeviceDiscovered PendingOnNewDevice; + FPS_OnDiscoveryEnd PendingOnDiscoveryEnd; + + static uint8 HexNibble(TCHAR C, bool& Valid); +}; diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEModule.h b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEModule.h new file mode 100644 index 0000000..df21de1 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLEModule.h @@ -0,0 +1,74 @@ +// Copyright (C) 2025 ASTERION VR + +#pragma once + +#include "Modules/ModuleManager.h" +#include "CoreMinimal.h" +#include "PS_BLETypes.h" + +class UPS_BLE_Device; +class UPS_BLE_Manager; + +// ─── Internal device record (equivalent to TUE_dev_rec from old plugin) ────── +struct FPS_DeviceRecord +{ + uint64 ID = 0; + int64 RSSI = 0; + FString Name; + void* NativeDeviceRef = nullptr; // WinRT IBluetoothLEDevice* (opaque ptr for forward compat) +}; + +// ─── Module ─────────────────────────────────────────────────────────────────── + +class UPS_BLE_Module : public IModuleInterface +{ + friend class UPS_BLE_Device; + friend class UPS_BLE_Manager; + +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + + static UPS_BLE_Manager* GetBLEManager(const UPS_BLE_Module* Mod); + + // Discovery + bool StartDiscoveryLive(UPS_BLE_Manager* Ref, int32 DurationMs, const FString& Filter); + bool StartDiscoveryInBackground(UPS_BLE_Manager* Ref, int32 DurationMs, const FString& Filter); + bool StopDiscovery(); + + // Device lifecycle + bool ConnectDevice(UPS_BLE_Device* Device); + bool DisconnectDevice(UPS_BLE_Device* Device); + bool IsDeviceConnected(UPS_BLE_Device* Device); + + // GATT operations + bool ReadCharacteristic(UPS_BLE_Device* Device, uint8 ServiceIndex, uint8 CharIndex); + bool WriteCharacteristic(UPS_BLE_Device* Device, uint8 ServiceIndex, uint8 CharIndex, const TArray& Data); + bool SubscribeCharacteristic(UPS_BLE_Device* Device, uint8 ServiceIndex, uint8 CharIndex); + bool UnsubscribeCharacteristic(UPS_BLE_Device* Device, uint8 ServiceIndex, uint8 CharIndex); + + UPS_BLE_Manager* LocalBLEManager = nullptr; + +private: + bool bInitialized = false; + + // WinRT scanner (opaque handle — implementation in .cpp using WinRT types) + void* ScannerHandle = nullptr; + + // WinRT COM initialized + bool bWinRTCoInitialized = false; + + void ScannerCleanup(); + + // Internal callbacks dispatched to GameThread + static void DispatchDeviceDiscovered(UPS_BLE_Manager* Mgr, const FPS_DeviceRecord& Rec); + static void DispatchDiscoveryEnd(UPS_BLE_Manager* Mgr, const TArray& Devices); + static void DispatchDeviceConnected(UPS_BLE_Device* Dev); + static void DispatchDeviceDisconnected(UPS_BLE_Device* Dev); + static void DispatchServicesDiscovered(UPS_BLE_Device* Dev); + static void DispatchRead(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray Data); + static void DispatchNotify(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray Data); + static void DispatchWrite(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status); + static void DispatchSubscribe(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status); + static void DispatchUnsubscribe(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status); +}; diff --git a/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLETypes.h b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLETypes.h new file mode 100644 index 0000000..e1d4d95 --- /dev/null +++ b/Unreal/Plugins/PS_Win_BLE/Source/PS_Win_BLE/Public/PS_BLETypes.h @@ -0,0 +1,122 @@ +// Copyright (C) 2025 ASTERION VR + +#pragma once + +#include "CoreMinimal.h" +#include "PS_BLETypes.generated.h" + +// ─── ENUMS ─────────────────────────────────────────────────────────────────── + +UENUM(BlueprintType, Category = "PS BLE") +enum class EPS_GATTStatus : uint8 +{ + Success = 0x00 UMETA(DisplayName = "Success"), + ReadNotPermitted = 0x02 UMETA(DisplayName = "Read Not Permitted"), + WriteNotPermitted = 0x03 UMETA(DisplayName = "Write Not Permitted"), + InsuficientAutentication = 0x05 UMETA(DisplayName = "Insufficient Authentication"), + RequestNotSupported = 0x06 UMETA(DisplayName = "Request Not Supported"), + InvalidOffset = 0x07 UMETA(DisplayName = "Invalid Offset"), + InvalidAttributeLength = 0x0D UMETA(DisplayName = "Invalid Attribute Length"), + InsufficientEncryption = 0x0F UMETA(DisplayName = "Insufficient Encryption"), + Failure = 0xFF UMETA(DisplayName = "Failure") +}; + +UENUM(BlueprintType, Category = "PS BLE") +enum class EPS_CharacteristicDescriptor : uint8 +{ + Unknown = 0x00 UMETA(DisplayName = "Unknown"), + Broadcast = 0x01 UMETA(DisplayName = "Broadcastable"), + ExtendedProps = 0x02 UMETA(DisplayName = "Extended Properties"), + Notifiable = 0x04 UMETA(DisplayName = "Notifiable"), + Indicable = 0x08 UMETA(DisplayName = "Indicable"), + Readable = 0x10 UMETA(DisplayName = "Readable"), + Writable = 0x20 UMETA(DisplayName = "Writable"), + WriteNoResponse = 0x40 UMETA(DisplayName = "Write No Response"), + SignedWrite = 0x80 UMETA(DisplayName = "Signed Write") +}; + +UENUM(BlueprintType, Category = "PS BLE") +enum class EPS_BLEError : uint8 +{ + NoError = 0x00 UMETA(DisplayName = "Success"), + NonReadableChar = 0x01 UMETA(DisplayName = "Non Readable Characteristic"), + NonWritableChar = 0x03 UMETA(DisplayName = "Non Writable Characteristic"), + CanNotSubscribe = 0x04 UMETA(DisplayName = "Cannot subscribe to Non Notifiable/Indicable Characteristic"), + CanNotUnsubscribe = 0x05 UMETA(DisplayName = "Cannot unsubscribe if not previously subscribed"), + AlreadyConnected = 0x06 UMETA(DisplayName = "Device is already connected"), + CanNotDisconnect = 0x07 UMETA(DisplayName = "Cannot disconnect if not connected"), + NeedConnectionFirst = 0x08 UMETA(DisplayName = "Operation requires connection first"), + ZeroLengthWrite = 0x09 UMETA(DisplayName = "Zero length write"), + AlreadySubscribed = 0x0A UMETA(DisplayName = "Already subscribed"), + NotSubscribed = 0x0B UMETA(DisplayName = "Not subscribed"), + CommunicationFailed = 0x0C UMETA(DisplayName = "Communication Failed: No BLE adapter"), + WritingFailed = 0x0D UMETA(DisplayName = "Writing Failed: No BLE adapter"), + SubscriptionFailed = 0x0E UMETA(DisplayName = "Subscription Failed: No BLE adapter"), + UnsubscribeFailed = 0x0F UMETA(DisplayName = "Unsubscribe Failed: No BLE adapter"), + ReadingFailed = 0x10 UMETA(DisplayName = "Reading Failed: No BLE adapter"), + ConnectFailed = 0x11 UMETA(DisplayName = "Connect Failed: No BLE adapter"), + DisconnectFailed = 0x12 UMETA(DisplayName = "Disconnect Failed: No BLE adapter"), + Failure = 0xFF UMETA(DisplayName = "Unknown Failure") +}; + +// ─── STRUCTS ───────────────────────────────────────────────────────────────── + +USTRUCT(BlueprintType, Atomic, Category = "PS BLE") +struct FPS_MACAddress +{ + GENERATED_BODY() +public: + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b0 = 0; + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b1 = 0; + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b2 = 0; + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b3 = 0; + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b4 = 0; + UPROPERTY(BlueprintReadWrite, Category = "PS BLE") uint8 b5 = 0; +}; + +USTRUCT(BlueprintType, Atomic, Category = "PS BLE") +struct FPS_CharacteristicItem +{ + GENERATED_BODY() +public: + FGuid cUUID; + bool subscribed = false; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") uint8 Descriptor = 0; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") FString CharacteristicName; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") FString CharacteristicUUID; +}; + +USTRUCT(BlueprintType, Atomic, Category = "PS BLE") +struct FPS_ServiceItem +{ + GENERATED_BODY() +public: + FGuid sUUID; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") FString ServiceName; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") FString ServiceUUID; + UPROPERTY(BlueprintReadOnly, Category = "PS BLE") TArray Characteristics; +}; + +// ─── FORWARD DECLARATIONS ──────────────────────────────────────────────────── + +class UPS_BLE_Device; +class UPS_BLE_Manager; + +// ─── DELEGATES ─────────────────────────────────────────────────────────────── + +DECLARE_DYNAMIC_DELEGATE_OneParam(FPS_OnNewBLEDeviceDiscovered, UPS_BLE_Device* const, Device); +DECLARE_DYNAMIC_DELEGATE_OneParam(FPS_OnDiscoveryEnd, const TArray&, DiscoveredDevices); + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FPS_OnServicesDiscovered, UPS_BLE_Device* const, Device, const TArray&, ServicesList); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FPS_OnConnect, UPS_BLE_Device* const, Device, const TArray&, ServicesList); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam (FPS_OnDisconnect, UPS_BLE_Device* const, Device); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam (FPS_OnAnyBLEDeviceConnected, UPS_BLE_Device* const, Device); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam (FPS_OnAnyBLEDeviceDisconnected,UPS_BLE_Device* const, Device); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FPS_OnAnyServiceDiscovered, UPS_BLE_Device* const, Device, const TArray&, ServicesList); + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FiveParams(FPS_OnRead, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID, const TArray&, Data); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FiveParams(FPS_OnNotify, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID, const TArray&, Data); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FPS_OnWrite, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FPS_OnSubscribe, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FPS_OnUnsubscribe, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID); +DECLARE_DYNAMIC_MULTICAST_DELEGATE_FourParams(FPS_OnBLEError, EPS_BLEError, ErrorCode, FString, DeviceAddress, FString, ServiceUUID, FString, CharacteristicUUID);