Compare commits

..

17 Commits

Author SHA1 Message Date
f750378e9d no message 2026-05-03 11:00:08 +02:00
45bc779556 Last change for Demo Actor 2026-02-19 12:32:03 +01:00
f825695658 Python calibration tool: simplify graphs, fix CancelledError
- Remove orange "last shot peak" line (unreliable at 20Hz)
- Remove rolling max curve (not useful in practice)
- Add specific asyncio.CancelledError catch in ble_loop with retry
  message when device is already connected elsewhere
- Clean up titles: show only current val + threshold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 10:15:51 +01:00
f33fd5b216 Python: calibration tool — ligne orange pic dernier tir + titre simplifié
Ajoute une ligne horizontale orange sur chaque graphe indiquant le niveau
atteint au moment du dernier tir détecté, avec % du seuil dans le titre.
Permet de comprendre pourquoi un tir est déclenché même si le pic BLE
(20Hz) ne semble pas dépasser le seuil (résolution temporelle limitée).
Supprime le pic 1s (remplacé par le pic dernier tir, plus pertinent).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:52:03 +01:00
f23ddf9a82 Python: calibration tool — simplification visuelle + pic max 1s dans titre
- Supprime la 2ème courbe (trompeuse, difficile à interpréter correctement)
- Affiche dans le titre : valeur courante + pic max sur 1s + % du seuil
- Les fonds rouges (trigger actif XIAO) restent la référence de décision
- Légende mise à jour : brut / seuil / trigger actif

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:47:09 +01:00
3bb8d6c58a Python: calibration tool — correction logique fenêtre glissante
Remplace la moyenne glissante par un max glissant sur 3 samples (60ms),
ce qui reflète fidèlement la logique réelle du XIAO : la valeur brute
est comparée au seuil et le trigger reste actif 60ms (pas de moyenne).
Légende mise à jour en conséquence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:40:21 +01:00
6048ed9066 Python: calibration tool — ajout courbe moyenne glissante + légende
Affiche la moyenne glissante (même fenêtre 60ms que le XIAO) en plus
de la courbe brute sur chaque graphe, avec légende. Permet de comprendre
visuellement pourquoi un tir est déclenché même si le pic brut semble
ne pas atteindre le seuil.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:39:18 +01:00
ad72c2aad5 no message 2026-02-19 09:33:13 +01:00
d6f928ec31 UE: update Blueprint and test map assets
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:30:57 +01:00
aa8df59af2 PS_Win_BLE: fix crash on disconnect - use TWeakObjectPtr in all async dispatches
All Dispatch* functions now capture UPS_BLE_Device via TWeakObjectPtr instead of
a raw pointer, preventing an access violation when the UObject is garbage collected
before the GameThread lambda executes (EXCEPTION_ACCESS_VIOLATION @ 0x60).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:25:16 +01:00
10ca1e19c7 Arduino: disable IMU BLE streaming when not in debug mode
IMU data (roll/pitch/yaw) is now only sent over BLE when debugMode != DEBUG_OFF,
as originally intended for production. Removes the TODO_TEST workaround.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:12:03 +01:00
7c2cb387f2 PS_Win_BLE : fix crash shutdown + cache caractéristiques GATT
Crash shutdown (Index >= 0) :
- OnPreExit se déclenche encore trop tard (GC déjà en cours).
- Remplacé par OnEnginePreExit qui fire avant le shutdown des core
  modules, donc avant toute destruction d'UObject. RemoveFromRoot()
  est maintenant appelé au bon moment.

Cache caractéristiques GATT :
- Bug : Read/Write/Subscribe rappelaient GetCharacteristicsAsync(Cached)
  à chaque opération, ce qui peut retourner un ordre différent du
  discovery initial (Uncached) → mauvaise caractéristique ciblée.
- Fix : les GattCharacteristic sont maintenant stockées dans
  FPS_GattServiceHandle::Characteristics (std::vector) lors du
  ConnectDevice, et réutilisées directement via [CI] dans toutes
  les opérations ultérieures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:28:05 +01:00
d53dd36194 Fix crash shutdown + IMU BLE toujours actif pour tests
PS_Win_BLE : crash à la fermeture d'Unreal (assertion Index >= 0 dans
UObjectArray) car RemoveFromRoot() était appelé dans ShutdownModule()
alors que l'UObject array est déjà partiellement détruit. Fix : cleanup
du BLEManager déplacé dans un delegate FCoreDelegates::OnPreExit qui
s'exécute plus tôt, avant la destruction des UObjects.

uproject : désactivation explicite de WinBluetoothLE (les nœuds
Blueprint restaient dans le groupe Bluetooth Low Energy car le plugin
était chargé automatiquement depuis le dossier Plugins/).

Firmware : envoi IMU BLE découplé du debug mode pour pouvoir tester
la caractéristique 6E400002 à distance (TODO_TEST — remettre la
condition debugMode != DEBUG_OFF quand Pico Motion Tracker intégré).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:23:13 +01:00
0573cd9a57 PS_Win_BLE : catégories Blueprint → ASTERION|Win_BLE
Toutes les entrées Blueprint (UFUNCTION, UPROPERTY, UCLASS, USTRUCT, UENUM)
sont désormais rangées dans ASTERION|Win_BLE au lieu de "PS BLE".
64 occurrences mises à jour dans les 4 headers publics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:54:35 +01:00
113eddef46 PS_Win_BLE : fix erreurs de compilation WinRT
- Suppression des 'using namespace Windows::*' au scope global
  (conflit avec le namespace Windows d'Unreal via AllowWindowsPlatformTypes)
- Remplacement par un macro PS_BLE_WINRT_NS avec aliases locaux
  (WinBT, WinAdv, WinGAP, WinStr) utilisés dans chaque fonction
- Ajout de FPS_BLEDeviceHandle et FPS_GattServiceHandle : wrappers
  heap-alloués pour les types WinRT qui suppriment operator new
- Suppression warning C4265 (dtor non-virtual interne aux headers WinRT)
- Plugin charge sans erreur dans UE 5.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:46:01 +01:00
d593bbd9fd Ajout plugin PS_Win_BLE : réécriture native WinRT sans DLL tierce
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 <noreply@anthropic.com>
2026-02-18 16:27:23 +01:00
1836e4e2b1 no message 2026-02-18 16:12:57 +01:00
25 changed files with 1985 additions and 42 deletions

View File

@@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(powershell -Command \"Get-ChildItem ''C:\\\\Program Files'',''C:\\\\Program Files \\(x86\\)'',''C:\\\\Arduino'',''D:\\\\'' -Filter ''arduino.exe'' -Recurse -Depth 3 -ErrorAction SilentlyContinue | Select -First 3 -ExpandProperty FullName\")",
"Bash(\"/c/Program Files/Epic Games/UE_5.5/Engine/Build/BatchFiles/Build.bat\" PS_BLE_Tracker Win64 Development -project=\"C:/ASTERION/GIT/PS_BLE_Tracker/Unreal/PS_BLE_Tracker.uproject\" -waitmutex)",
"Bash(powershell -Command \"\\(Get-ItemProperty ''HKLM:\\\\SOFTWARE\\\\WOW6432Node\\\\Arduino'' -ErrorAction SilentlyContinue\\).Install_Dir; \\(Get-ItemProperty ''HKLM:\\\\SOFTWARE\\\\Arduino'' -ErrorAction SilentlyContinue\\).Install_Dir; \\(Get-ItemProperty ''HKCU:\\\\SOFTWARE\\\\Arduino'' -ErrorAction SilentlyContinue\\).Install_Dir\")",
"Bash(powershell -Command \"Get-Command arduino -ErrorAction SilentlyContinue | Select -ExpandProperty Source; Get-ItemProperty ''HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\*'',''HKLM:\\\\SOFTWARE\\\\WOW6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Uninstall\\\\*'' -ErrorAction SilentlyContinue | Where-Object DisplayName -like ''*Arduino*'' | Select DisplayName,InstallLocation | Format-List\")",
"Bash(powershell -Command \"Get-ChildItem ''$env:APPDATA\\\\Microsoft\\\\Windows\\\\Start Menu'' -Filter ''*arduino*'' -Recurse -ErrorAction SilentlyContinue | Select FullName; Get-ChildItem ''C:\\\\ProgramData\\\\Microsoft\\\\Windows\\\\Start Menu'' -Filter ''*arduino*'' -Recurse -ErrorAction SilentlyContinue | Select FullName; Get-ChildItem ''$env:USERPROFILE\\\\Desktop'' -Filter ''*arduino*'' -ErrorAction SilentlyContinue | Select FullName\")",
"Read(//c/Users/j_foucher/AppData/Local/Arduino15/**)",
"Read(//c/Users/j_foucher/AppData/Roaming/Arduino15/**)",
"Bash(powershell -Command \"Get-ChildItem ''C:\\\\'' -Directory -ErrorAction SilentlyContinue | ForEach-Object { Get-ChildItem $_FullName -Filter ''arduino.exe'' -ErrorAction SilentlyContinue -Depth 2 } | Select -First 3 -ExpandProperty FullName\")",
"Bash(powershell -Command \"Get-ChildItem ''C:\\\\Program Files'',''C:\\\\Program Files \\(x86\\)'',''C:\\\\Users\\\\j_foucher'' -Directory -ErrorAction SilentlyContinue | ForEach-Object { Get-ChildItem $_FullName -Filter ''arduino.exe'' -Recurse -Depth 3 -ErrorAction SilentlyContinue } | Select -First 5 -ExpandProperty FullName\")",
"Bash(powershell -Command \"Get-Process | Where-Object { $_.ProcessName -match ''arduino|java'' -and $_.Path } | Select ProcessName,Path | Format-List\")",
"Read(//c/Users/j_foucher/AppData/Local/**)",
"Bash(find /c/Users/j_foucher -maxdepth 6 -name boards.txt -path *nrf*)",
"Bash(ARDUINO_DIR=\"/c/Program Files/WindowsApps/ArduinoLLC.ArduinoIDE_1.8.57.0_x86__mdqgnx93n4wtt\")",
"Bash(\"$ARDUINO_DIR/arduino-builder\" -hardware \"$ARDUINO_DIR/hardware\" -hardware \"/c/Users/j_foucher/Documents/ArduinoData/packages\" -tools \"$ARDUINO_DIR/tools-builder\" -tools \"/c/Users/j_foucher/Documents/ArduinoData/packages\" -libraries \"/c/Users/j_foucher/Documents/Arduino/libraries\" -fqbn \"Seeeduino:mbed:xiaonRF52840Sense\" -build-path \"/c/Users/j_foucher/AppData/Local/Temp/arduino_build\" -build-cache \"/c/Users/j_foucher/AppData/Local/Temp/arduino_cache\" -verbose \"C:/ASTERION/GIT/PS_BLE_Tracker/Arduino/PS_BLE_ShotDetection/PS_BLE_ShotDetection.ino\")",
"Bash(mkdir -p /c/Users/j_foucher/AppData/Local/Temp/arduino_build)",
"Bash(cmd.exe //c \"\"\"C:\\\\Program Files\\\\WindowsApps\\\\ArduinoLLC.ArduinoIDE_1.8.57.0_x86__mdqgnx93n4wtt\\\\arduino_debug.exe\"\" --verify --board Seeeduino:mbed:xiaonRF52840Sense --pref build.path=C:\\\\Users\\\\j_foucher\\\\AppData\\\\Local\\\\Temp\\\\arduino_build \"\"C:\\\\ASTERION\\\\GIT\\\\PS_BLE_Tracker\\\\Arduino\\\\PS_BLE_ShotDetection\\\\PS_BLE_ShotDetection.ino\"\" 2>&1\")",
"Bash(powershell -Command \"& ''C:\\\\Program Files\\\\WindowsApps\\\\ArduinoLLC.ArduinoIDE_1.8.57.0_x86__mdqgnx93n4wtt\\\\arduino_debug.exe'' --verify --board Seeeduino:mbed:xiaonRF52840Sense --pref ''build.path=C:\\\\Users\\\\j_foucher\\\\AppData\\\\Local\\\\Temp\\\\arduino_build'' ''C:\\\\ASTERION\\\\GIT\\\\PS_BLE_Tracker\\\\Arduino\\\\PS_BLE_ShotDetection\\\\PS_BLE_ShotDetection.ino'' 2>&1\")",
"Bash(where winget:*)",
"Bash(winget install:*)",
"Bash(export PATH=\"$PATH:/c/Program Files/Arduino CLI\")",
"Bash(arduino-cli version:*)",
"Bash(arduino-cli core:*)",
"Bash(arduino-cli lib:*)",
"Bash(arduino-cli compile:*)"
]
}
}

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ Unreal/Intermediate/
Unreal/Plugins/WinBluetoothLE/Intermediate/ Unreal/Plugins/WinBluetoothLE/Intermediate/
Unreal/Binaries/ Unreal/Binaries/
Unreal/Plugins/WinBluetoothLE/Binaries/ Unreal/Plugins/WinBluetoothLE/Binaries/
Unreal/Plugins/PS_Win_BLE/Binaries/
Unreal/Plugins/PS_Win_BLE/Intermediate/

View File

@@ -574,8 +574,7 @@ void loop() {
sendDebugData(accelMag, gyroMag, audioLevel); sendDebugData(accelMag, gyroMag, audioLevel);
printDebugSerial(accelMag, gyroMag, audioLevel); printDebugSerial(accelMag, gyroMag, audioLevel);
// ─── IMU BLE 10 Hz (debug uniquement) ─── // ─── IMU BLE 10 Hz ───
// En production (debugMode OFF), pas d'envoi IMU → charge BLE réduite pour Unreal
if (debugMode != DEBUG_OFF && now-lastSend>=100) { if (debugMode != DEBUG_OFF && now-lastSend>=100) {
lastSend=now; lastSend=now;
float dt=(now-lastUpdate)/1000.0f; lastUpdate=now; float dt=(now-lastUpdate)/1000.0f; lastUpdate=now;

View File

@@ -13,7 +13,7 @@ from collections import deque
from bleak import BleakClient, BleakScanner from bleak import BleakClient, BleakScanner
DEVICE_NAME = "XIAO Airsoft Pro" DEVICE_NAME = "XIAO Airsoft Pro"
DEVICE_ADDRESS = "" # Laisser vide pour scan automatique par nom, ou mettre l'adresse MAC pour connexion directe DEVICE_ADDRESS = "46:35:F1:51:51:5A" # Adresse MAC directe — plus fiable que le scan par nom
DEBUG_CHAR_UUID = "6E400005-B5A3-F393-E0A9-E50E24DCCA9E" DEBUG_CHAR_UUID = "6E400005-B5A3-F393-E0A9-E50E24DCCA9E"
SHOT_CHAR_UUID = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E" SHOT_CHAR_UUID = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E"
CONFIG_CHAR_UUID = "6E400006-B5A3-F393-E0A9-E50E24DCCA9E" CONFIG_CHAR_UUID = "6E400006-B5A3-F393-E0A9-E50E24DCCA9E"
@@ -34,7 +34,9 @@ thresholds = {"accel": 2.5, "gyro": 200.0, "audio": 3000} # PDM : 0-32767
min_sensors = 3 # Nb de capteurs requis simultanément (1-3) — NUM6=+1 NUM4=-1 min_sensors = 3 # Nb de capteurs requis simultanément (1-3) — NUM6=+1 NUM4=-1
shot_count = 0 shot_count = 0
shot_pending = False # Flag : un tir reçu, à enregistrer dans shot_buf au prochain debug tick shot_pending = False # Flag : un tir reçu, à enregistrer dans shot_buf au prochain debug tick
ble_status = "🔍 Connexion..." # Pic max au moment du dernier tir (mis à jour au moment où shot_pending devient True)
last_shot_peak = {"accel": 0.0, "gyro": 0.0, "audio": 0}
ble_status = "[..] Connexion..."
ble_running = True ble_running = True
audio_max_global = 1000 # Tracks le max absolu jamais vu audio_max_global = 1000 # Tracks le max absolu jamais vu
ble_client = None # Référence au client BLE actif ble_client = None # Référence au client BLE actif
@@ -44,6 +46,9 @@ debug_mode = 3 # 0=OFF, 1=RAW, 2=TRIGGERS, 3=FULL (actif par défaut)
import numpy as np import numpy as np
X = np.arange(WINDOW_SIZE) # axe X fixe, ne change jamais X = np.arange(WINDOW_SIZE) # axe X fixe, ne change jamais
# Fenêtre glissante pour afficher le pic max récent dans le titre (informatif seulement)
PEAK_WINDOW = 20 # ~1s à 20Hz
# ─── BLE ──────────────────────────────────────────────── # ─── BLE ────────────────────────────────────────────────
def debug_callback(sender, data): def debug_callback(sender, data):
global audio_max_global, shot_pending global audio_max_global, shot_pending
@@ -63,10 +68,14 @@ def debug_callback(sender, data):
shot_pending = False shot_pending = False
def shot_callback(sender, data): def shot_callback(sender, data):
global shot_count, shot_pending global shot_count, shot_pending, last_shot_peak
if data[0] == 1: if data[0] == 1:
shot_count += 1 shot_count += 1
shot_pending = True shot_pending = True
# Capturer le pic courant au moment exact du tir (meilleure approximation possible en BLE)
last_shot_peak["accel"] = accel_buf[-1] if accel_buf else 0.0
last_shot_peak["gyro"] = gyro_buf[-1] if gyro_buf else 0.0
last_shot_peak["audio"] = audio_buf[-1] if audio_buf else 0
async def find_device(): async def find_device():
"""Scan par nom (toutes les 0.5s pendant 30s max). """Scan par nom (toutes les 0.5s pendant 30s max).
@@ -122,10 +131,10 @@ async def ble_loop():
global ble_status, ble_running, ble_client, config_pending global ble_status, ble_running, ble_client, config_pending
while ble_running: while ble_running:
try: try:
ble_status = f"🔍 Recherche '{DEVICE_NAME}'..." ble_status = f"[?] Recherche '{DEVICE_NAME}'..."
device = await find_device() device = await find_device()
if not device: if not device:
ble_status = "⚠️ XIAO non trouvé — réessai..." ble_status = "[!] XIAO non trouve — reessai..."
await asyncio.sleep(5) await asyncio.sleep(5)
continue continue
# device peut être une string (adresse directe) ou un BLEDevice # device peut être une string (adresse directe) ou un BLEDevice
@@ -135,13 +144,13 @@ async def ble_loop():
else: else:
addr = device.address addr = device.address
display_name = device.name or DEVICE_NAME display_name = device.name or DEVICE_NAME
ble_status = f"📡 Connexion {display_name}..." ble_status = f"[..] Connexion {display_name}..."
async with BleakClient(addr, timeout=15.0) as client: async with BleakClient(addr, timeout=15.0) as client:
ble_client = client ble_client = client
await client.start_notify(DEBUG_CHAR_UUID, debug_callback) await client.start_notify(DEBUG_CHAR_UUID, debug_callback)
await client.start_notify(SHOT_CHAR_UUID, shot_callback) await client.start_notify(SHOT_CHAR_UUID, shot_callback)
ble_status = f" {display_name} ({addr})" ble_status = f"[OK] {display_name} ({addr})"
# À la connexion : lire la config sauvegardée dans le XIAO (flash) # À la connexion : lire la config sauvegardée dans le XIAO (flash)
try: try:
raw = await client.read_gatt_char(CONFIG_CHAR_UUID) raw = await client.read_gatt_char(CONFIG_CHAR_UUID)
@@ -173,10 +182,14 @@ async def ble_loop():
config_pending = False config_pending = False
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
ble_client = None ble_client = None
ble_status = " Déconnecté — reconnexion..." ble_status = "[X] Deconnecte — reconnexion..."
except asyncio.CancelledError:
ble_client = None
ble_status = "[!] Connexion annulee (device deja connecte?) — reessai..."
await asyncio.sleep(3)
except Exception as e: except Exception as e:
ble_client = None ble_client = None
ble_status = f" {str(e)[:50]}" ble_status = f"[X] {str(e)[:50]}"
await asyncio.sleep(5) await asyncio.sleep(5)
def run_ble(): def run_ble():
@@ -192,9 +205,9 @@ from matplotlib.animation import FuncAnimation
BG = '#1a1a2e' BG = '#1a1a2e'
PANEL = '#16213e' PANEL = '#16213e'
TEXT = '#e0e0e0' TEXT = '#e0e0e0'
CA = '#4fc3f7' # accel CA = '#4fc3f7' # accel brut
CG = '#81c784' # gyro CG = '#81c784' # gyro brut
CM = '#ce93d8' # audio CM = '#ce93d8' # audio brut
CT = '#ef5350' # trigger / seuil CT = '#ef5350' # trigger / seuil
CS = '#ff9800' # shot (orange vif) CS = '#ff9800' # shot (orange vif)
GRID = '#2a2a4a' GRID = '#2a2a4a'
@@ -222,15 +235,23 @@ axes[3].set_ylim(-0.1, 1.5)
axes[3].set_yticks([]) # pas de graduations Y sur la timeline axes[3].set_yticks([]) # pas de graduations Y sur la timeline
# Créer les artistes UNE SEULE FOIS — on ne les recrée jamais # Créer les artistes UNE SEULE FOIS — on ne les recrée jamais
line_a, = axes[0].plot(X, list(accel_buf), color=CA, lw=1.5) line_a, = axes[0].plot(X, list(accel_buf), color=CA, lw=1.5, label='Valeur brute')
line_g, = axes[1].plot(X, list(gyro_buf), color=CG, lw=1.5) line_g, = axes[1].plot(X, list(gyro_buf), color=CG, lw=1.5, label='Valeur brute')
line_m, = axes[2].plot(X, list(audio_buf), color=CM, lw=1.5) line_m, = axes[2].plot(X, list(audio_buf), color=CM, lw=1.5, label='Valeur brute')
line_s, = axes[3].plot(X, list(shot_buf), color=CS, lw=0, marker='|', line_s, = axes[3].plot(X, list(shot_buf), color=CS, lw=0, marker='|',
markersize=20, markeredgewidth=2.5) # barres verticales markersize=20, markeredgewidth=2.5)
thr_a = axes[0].axhline(thresholds["accel"], color=CT, ls='--', lw=1.5) thr_a = axes[0].axhline(thresholds["accel"], color=CT, ls='--', lw=1.5, label='Seuil (trigger XIAO)')
thr_g = axes[1].axhline(thresholds["gyro"], color=CT, ls='--', lw=1.5) thr_g = axes[1].axhline(thresholds["gyro"], color=CT, ls='--', lw=1.5, label='Seuil (trigger XIAO)')
thr_m = axes[2].axhline(thresholds["audio"], color=CT, ls='--', lw=1.5) thr_m = axes[2].axhline(thresholds["audio"], color=CT, ls='--', lw=1.5, label='Seuil (trigger XIAO)')
# Légendes
for ax in axes[:3]:
lines = ax.get_lines()
ax.legend(loc='upper left', fontsize=7, facecolor=PANEL, edgecolor='#444466',
labelcolor=TEXT, framealpha=0.8,
handles=[lines[0],
plt.Rectangle((0,0),1,1, fc=CT, alpha=0.25, label='Trigger actif (XIAO)')])
from matplotlib.patches import Rectangle from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection from matplotlib.collections import PatchCollection
@@ -264,12 +285,12 @@ def update_spans(spans, trig_list):
def update(frame): def update(frame):
# Snapshot des buffers (rapide) # Snapshot des buffers (rapide)
a = np.array(accel_buf) a = np.array(accel_buf)
g = np.array(gyro_buf) g = np.array(gyro_buf)
m = np.array(audio_buf) m = np.array(audio_buf, dtype=float)
s = np.array(shot_buf) s = np.array(shot_buf)
# Mise à jour des données des lignes # Mise à jour des données des lignes brutes
line_a.set_ydata(a) line_a.set_ydata(a)
line_g.set_ydata(g) line_g.set_ydata(g)
line_m.set_ydata(m) line_m.set_ydata(m)
@@ -296,21 +317,9 @@ def update(frame):
span.set_alpha(0.85 if i < len(sl) and sl[i] > 0 else 0) span.set_alpha(0.85 if i < len(sl) and sl[i] > 0 else 0)
# Titres avec valeurs courantes # Titres avec valeurs courantes
titles[0].set_text( titles[0].set_text(f"Accelerometre val: {a[-1]:.2f} G seuil: {thresholds['accel']:.1f} G [NUM7=+0.1 NUM1=-0.1]")
f"Accelerometre " titles[1].set_text(f"Gyroscope val: {g[-1]:.0f} dps seuil: {thresholds['gyro']:.0f} dps [NUM8=+10 NUM2=-10]")
f"val: {a[-1]:.2f} G " titles[2].set_text(f"Microphone PDM val: {m[-1]:.0f} seuil: {thresholds['audio']} [NUM9=+500 NUM3=-500]")
f"seuil: {thresholds['accel']:.1f} G "
f"[NUM7=+0.1 NUM1=-0.1]")
titles[1].set_text(
f"Gyroscope "
f"val: {g[-1]:.0f} dps "
f"seuil: {thresholds['gyro']:.0f} dps "
f"[NUM8=+10 NUM2=-10]")
titles[2].set_text(
f"Microphone PDM "
f"val: {m[-1]:.0f} "
f"seuil: {thresholds['audio']} "
f"[NUM9=+500 NUM3=-500]")
titles[3].set_text(f"Tirs detectes : {shot_count} (fenetre glissante)") titles[3].set_text(f"Tirs detectes : {shot_count} (fenetre glissante)")
status_txt.set_text( status_txt.set_text(

View File

@@ -17,6 +17,14 @@
"TargetAllowList": [ "TargetAllowList": [
"Editor" "Editor"
] ]
},
{
"Name": "WinBluetoothLE",
"Enabled": false
},
{
"Name": "PS_Win_BLE",
"Enabled": true
} }
] ]
} }

View File

@@ -0,0 +1,8 @@
[FilterPlugin]
; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and
; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively.
;
; Examples:
; /README.txt
; /Extras/...
; /Binaries/ThirdParty/*.dll

View File

@@ -0,0 +1,20 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "PS Win BLE",
"Description": "Bluetooth Low Energy for Windows - Native WinRT implementation",
"Category": "Connectivity",
"CreatedBy": "ASTERION VR",
"EngineVersion": "5.5.0",
"CanContainContent": true,
"Installed": false,
"Modules": [
{
"Name": "PS_Win_BLE",
"Type": "Runtime",
"LoadingPhase": "PreLoadingScreen",
"PlatformAllowList": ["Win64"]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,63 @@
// 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
});
// C++/WinRT headers (winrt/Windows.Devices.Bluetooth.h, etc.)
// Located in the Windows SDK — we pick the version used by UE5.5
string WinSDKDir = "C:/Program Files (x86)/Windows Kits/10";
string WinSDKVersion = "10.0.22621.0";
PrivateIncludePaths.Add(Path.Combine(WinSDKDir, "Include", WinSDKVersion, "cppwinrt"));
PrivateIncludePaths.Add(Path.Combine(WinSDKDir, "Include", WinSDKVersion, "winrt"));
// Allow WinRT headers in C++ code
bEnableExceptions = true;
}
}
}

View File

@@ -0,0 +1,239 @@
// Copyright (C) 2025 ASTERION VR
#include "PS_BLEDevice.h"
#include "PS_BLEModule.h"
#include "PS_BLEManager.h"
// ─────────────────────────────────────────────────────────────────────────────
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<uint8> 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<const uint8*>(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;
}

View File

@@ -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
}

View File

@@ -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<UPS_BLE_Device>();
Dev->RefToModule = BLEModule;
Dev->RefToManager = this;
Dev->DeviceID = Rec.ID;
Dev->RSSI = (int32)Rec.RSSI;
Dev->DeviceName = Rec.Name.IsEmpty() ? TEXT("<noname>") : 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<FPS_DeviceRecord>& 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

View File

@@ -0,0 +1,814 @@
// 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 "Misc/CoreDelegates.h"
#include "Modules/ModuleManager.h"
// ─── WinRT includes (Windows only) ───────────────────────────────────────────
// NOTE: AllowWindowsPlatformTypes must wrap the WinRT headers.
// We do NOT place any "using namespace" at file scope because Unreal also
// defines a ::Windows namespace (via AllowWindowsPlatformTypes) and that would
// cause "ambiguous symbol" errors. All WinRT types are fully qualified with
// winrt:: inside each function body.
#if PLATFORM_WINDOWS
#include "Windows/AllowWindowsPlatformTypes.h"
#include "Windows/AllowWindowsPlatformAtomics.h"
#pragma warning(push)
#pragma warning(disable: 4265 4668 4946 5204 5220) // 4265: WinRT internal non-virtual dtor (harmless)
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Devices.Bluetooth.h>
#include <winrt/Windows.Devices.Bluetooth.Advertisement.h>
#include <winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h>
#include <winrt/Windows.Storage.Streams.h>
#pragma warning(pop)
#include "Windows/HideWindowsPlatformAtomics.h"
#include "Windows/HideWindowsPlatformTypes.h"
// ─── Convenient namespace aliases used ONLY inside function bodies ────────────
// (Defined as macros so we can paste them at the top of each #if PLATFORM_WINDOWS block)
#define PS_BLE_WINRT_NS \
namespace WinBT = winrt::Windows::Devices::Bluetooth; \
namespace WinAdv = winrt::Windows::Devices::Bluetooth::Advertisement; \
namespace WinGAP = winrt::Windows::Devices::Bluetooth::GenericAttributeProfile; \
namespace WinFnd = winrt::Windows::Foundation; \
namespace WinStr = winrt::Windows::Storage::Streams;
// ─── Scanner state (heap-allocated so WinRT types don't appear in the header) ─
struct FPS_ScannerState
{
winrt::Windows::Devices::Bluetooth::Advertisement::BluetoothLEAdvertisementWatcher Watcher{ nullptr };
winrt::event_token ReceivedToken;
winrt::event_token StoppedToken;
};
// ─── WinRT object wrappers ────────────────────────────────────────────────────
// WinRT types delete operator new — we wrap them in plain structs so they can
// live on the heap via regular new/delete (stored as void* in UObject headers).
struct FPS_BLEDeviceHandle
{
winrt::Windows::Devices::Bluetooth::BluetoothLEDevice Device{ nullptr };
winrt::event_token ConnectionStatusToken;
};
struct FPS_GattServiceHandle
{
winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattDeviceService Service{ nullptr };
// Characteristics cached at discovery time (same order as ActiveServices[SI].Characteristics)
std::vector<winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattCharacteristic> Characteristics;
};
#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<UPS_BLE_Manager>();
LocalBLEManager->AddToRoot(); // prevent GC
LocalBLEManager->AttachModule(this);
// Cleanup UObjects before Unreal's UObject array is destroyed.
// Both ShutdownModule() and OnPreExit fire too late (UObjectArray already
// partially torn down). OnEnginePreExit fires earlier, before GC teardown.
FCoreDelegates::OnEnginePreExit.AddLambda([this]()
{
if (LocalBLEManager)
{
LocalBLEManager->DisconnectAll();
LocalBLEManager->RemoveFromRoot();
LocalBLEManager = nullptr;
}
});
#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();
// NOTE: LocalBLEManager cleanup (DisconnectAll + RemoveFromRoot) is handled
// in the OnPreExit delegate registered in StartupModule, because ShutdownModule
// is called after the UObject array has started to be destroyed (causes Index >= 0 crash).
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
PS_BLE_WINRT_NS
if (ScannerHandle)
{
FPS_ScannerState* State = static_cast<FPS_ScannerState*>(ScannerHandle);
try
{
if (State->Watcher &&
State->Watcher.Status() == WinAdv::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
PS_BLE_WINRT_NS
if (!Ref) return false;
ScannerCleanup();
FPS_ScannerState* State = new FPS_ScannerState();
State->Watcher = WinAdv::BluetoothLEAdvertisementWatcher();
State->Watcher.ScanningMode(WinAdv::BluetoothLEScanningMode::Active);
ScannerHandle = State;
FString FilterCopy = Filter;
UPS_BLE_Manager* MgrRef = Ref;
State->ReceivedToken = State->Watcher.Received(
[MgrRef, FilterCopy](WinAdv::BluetoothLEAdvertisementWatcher const&,
WinAdv::BluetoothLEAdvertisementReceivedEventArgs const& Args)
{
FString Name = Args.Advertisement().LocalName().c_str();
if (!FilterCopy.IsEmpty())
{
TArray<FString> 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](WinAdv::BluetoothLEAdvertisementWatcher const&,
WinAdv::BluetoothLEAdvertisementWatcherStoppedEventArgs const&)
{
TArray<FPS_DeviceRecord> Empty;
DispatchDiscoveryEnd(MgrRef, Empty);
});
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::StartDiscoveryInBackground(UPS_BLE_Manager* Ref, int32 DurationMs, const FString& Filter)
{
#if PLATFORM_WINDOWS
PS_BLE_WINRT_NS
if (!Ref) return false;
ScannerCleanup();
TSharedPtr<TArray<FPS_DeviceRecord>, ESPMode::ThreadSafe> Collected =
MakeShared<TArray<FPS_DeviceRecord>, ESPMode::ThreadSafe>();
FPS_ScannerState* State = new FPS_ScannerState();
State->Watcher = WinAdv::BluetoothLEAdvertisementWatcher();
State->Watcher.ScanningMode(WinAdv::BluetoothLEScanningMode::Active);
ScannerHandle = State;
FString FilterCopy = Filter;
UPS_BLE_Manager* MgrRef = Ref;
State->ReceivedToken = State->Watcher.Received(
[MgrRef, FilterCopy, Collected](WinAdv::BluetoothLEAdvertisementWatcher const&,
WinAdv::BluetoothLEAdvertisementReceivedEventArgs const& Args)
{
FString Name = Args.Advertisement().LocalName().c_str();
uint64 Addr = Args.BluetoothAddress();
for (const FPS_DeviceRecord& R : *Collected)
{
if (R.ID == Addr) return;
}
if (!FilterCopy.IsEmpty())
{
TArray<FString> 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](WinAdv::BluetoothLEAdvertisementWatcher const&,
WinAdv::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
PS_BLE_WINRT_NS
if (ScannerHandle)
{
FPS_ScannerState* State = static_cast<FPS_ScannerState*>(ScannerHandle);
try
{
if (State->Watcher &&
State->Watcher.Status() == WinAdv::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;
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, Addr]()
{
PS_BLE_WINRT_NS
try
{
auto BLEDev = WinBT::BluetoothLEDevice::FromBluetoothAddressAsync(Addr).get();
if (!BLEDev)
{
DispatchDeviceDisconnected(Device);
return;
}
FPS_BLEDeviceHandle* Handle = new FPS_BLEDeviceHandle();
Handle->Device = BLEDev;
Handle->ConnectionStatusToken = BLEDev.ConnectionStatusChanged(
[Device](WinBT::BluetoothLEDevice const& Dev, winrt::Windows::Foundation::IInspectable const&)
{
if (Dev.ConnectionStatus() == WinBT::BluetoothConnectionStatus::Disconnected)
{
DispatchDeviceDisconnected(Device);
}
});
Device->NativeDeviceHandle = Handle;
auto SvcResult = BLEDev.GetGattServicesAsync(WinBT::BluetoothCacheMode::Uncached).get();
if (SvcResult.Status() != WinGAP::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);
FPS_GattServiceHandle* SvcHandle = new FPS_GattServiceHandle();
SvcHandle->Service = Svc;
Device->NativeGattServices.Add(SvcHandle);
FPS_ServiceItem SvcItem;
GUID g = Svc.Uuid();
SvcItem.ServiceUUID = UPS_BLE_Device::GUIDToString(&g);
SvcItem.ServiceName = SvcItem.ServiceUUID;
auto CharResult = SvcHandle->Service.GetCharacteristicsAsync(WinBT::BluetoothCacheMode::Uncached).get();
if (CharResult.Status() == WinGAP::GattCommunicationStatus::Success)
{
auto Chars = CharResult.Characteristics();
for (uint32_t ci = 0; ci < Chars.Size(); ci++)
{
auto Ch = Chars.GetAt(ci);
// Cache the characteristic object so all subsequent operations
// (Read / Write / Subscribe) use the exact same instance and index
// as what was discovered here — no re-fetch needed.
SvcHandle->Characteristics.push_back(Ch);
FPS_CharacteristicItem ChItem;
GUID cg = Ch.Uuid();
ChItem.CharacteristicUUID = UPS_BLE_Device::GUIDToString(&cg);
ChItem.CharacteristicName = ChItem.CharacteristicUUID;
auto Props = Ch.CharacteristicProperties();
uint8 Desc = 0;
using GP = WinGAP::GattCharacteristicProperties;
if ((Props & GP::Broadcast) != GP::None) Desc |= 0x01;
if ((Props & GP::ExtendedProperties) != GP::None) Desc |= 0x02;
if ((Props & GP::Notify) != GP::None) Desc |= 0x04;
if ((Props & GP::Indicate) != GP::None) Desc |= 0x08;
if ((Props & GP::Read) != GP::None) Desc |= 0x10;
if ((Props & GP::Write) != GP::None) Desc |= 0x20;
if ((Props & GP::WriteWithoutResponse) != GP::None) Desc |= 0x40;
if ((Props & GP::AuthenticatedSignedWrites) != GP::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
PS_BLE_WINRT_NS
if (!Device) return false;
for (void* SvcPtr : Device->NativeGattServices)
{
if (SvcPtr) delete static_cast<FPS_GattServiceHandle*>(SvcPtr);
}
Device->NativeGattServices.Empty();
if (Device->NativeDeviceHandle)
{
delete static_cast<FPS_BLEDeviceHandle*>(Device->NativeDeviceHandle);
Device->NativeDeviceHandle = nullptr;
}
return true;
#else
return false;
#endif
}
bool UPS_BLE_Module::IsDeviceConnected(UPS_BLE_Device* Device)
{
#if PLATFORM_WINDOWS
PS_BLE_WINRT_NS
if (!Device || !Device->NativeDeviceHandle) return false;
try
{
auto* Handle = static_cast<FPS_BLEDeviceHandle*>(Device->NativeDeviceHandle);
return Handle->Device.ConnectionStatus() == WinBT::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 >= (uint8)Device->NativeGattServices.Num()) return false;
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]()
{
PS_BLE_WINRT_NS
try
{
auto* SvcH = static_cast<FPS_GattServiceHandle*>(Device->NativeGattServices[SI]);
if (CI >= SvcH->Characteristics.size()) return;
auto Ch = SvcH->Characteristics[CI];
auto Result = Ch.ReadValueAsync(WinBT::BluetoothCacheMode::Uncached).get();
EPS_GATTStatus Status = (Result.Status() == WinGAP::GattCommunicationStatus::Success)
? EPS_GATTStatus::Success : EPS_GATTStatus::Failure;
TArray<uint8> Data;
if (Result.Status() == WinGAP::GattCommunicationStatus::Success)
{
auto Reader = WinStr::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<uint8>& Data)
{
#if PLATFORM_WINDOWS
if (!Device || SI >= (uint8)Device->NativeGattServices.Num()) return false;
TArray<uint8> DataCopy = Data;
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI, DataCopy]()
{
PS_BLE_WINRT_NS
try
{
auto* SvcH = static_cast<FPS_GattServiceHandle*>(Device->NativeGattServices[SI]);
if (CI >= SvcH->Characteristics.size()) return;
auto Ch = SvcH->Characteristics[CI];
auto Writer = WinStr::DataWriter();
for (uint8 B : DataCopy) Writer.WriteByte(B);
auto WriteStatus = Ch.WriteValueAsync(Writer.DetachBuffer(),
WinGAP::GattWriteOption::WriteWithResponse).get();
EPS_GATTStatus GattStatus = (WriteStatus == WinGAP::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 >= (uint8)Device->NativeGattServices.Num()) return false;
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]()
{
PS_BLE_WINRT_NS
try
{
auto* SvcH = static_cast<FPS_GattServiceHandle*>(Device->NativeGattServices[SI]);
if (CI >= SvcH->Characteristics.size()) return;
auto Ch = SvcH->Characteristics[CI];
auto WriteStatus = Ch.WriteClientCharacteristicConfigurationDescriptorAsync(
WinGAP::GattClientCharacteristicConfigurationDescriptorValue::Notify).get();
EPS_GATTStatus GattStatus = (WriteStatus == WinGAP::GattCommunicationStatus::Success)
? EPS_GATTStatus::Success : EPS_GATTStatus::Failure;
if (WriteStatus == WinGAP::GattCommunicationStatus::Success)
{
auto Token = Ch.ValueChanged(
[Device, SI, CI](WinGAP::GattCharacteristic const&,
WinGAP::GattValueChangedEventArgs const& Args)
{
PS_BLE_WINRT_NS
auto Reader = WinStr::DataReader::FromBuffer(Args.CharacteristicValue());
TArray<uint8> Data;
Data.SetNumUninitialized(Reader.UnconsumedBufferLength());
for (uint8& B : Data) B = Reader.ReadByte();
DispatchNotify(Device, SI, CI, EPS_GATTStatus::Success, MoveTemp(Data));
});
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 >= (uint8)Device->NativeGattServices.Num()) return false;
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [Device, SI, CI]()
{
PS_BLE_WINRT_NS
try
{
auto* SvcH = static_cast<FPS_GattServiceHandle*>(Device->NativeGattServices[SI]);
if (CI >= SvcH->Characteristics.size()) return;
auto Ch = SvcH->Characteristics[CI];
uint64 Key = ((uint64)SI << 8) | CI;
if (winrt::event_token** TokenPtr = reinterpret_cast<winrt::event_token**>(Device->NotifyTokens.Find(Key)))
{
Ch.ValueChanged(**TokenPtr);
delete *TokenPtr;
Device->NotifyTokens.Remove(Key);
}
auto WriteStatus = Ch.WriteClientCharacteristicConfigurationDescriptorAsync(
WinGAP::GattClientCharacteristicConfigurationDescriptorValue::None).get();
EPS_GATTStatus GattStatus = (WriteStatus == WinGAP::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<FPS_DeviceRecord>& Devices)
{
if (!Mgr) return;
TArray<FPS_DeviceRecord> 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
{
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
AsyncTask(ENamedThreads::GameThread, [WeakDev]()
{
if (!WeakDev.IsValid()) return;
WeakDev->RefToManager->JustConnectedDevice(WeakDev.Get());
WeakDev->OnConnect.Broadcast(WeakDev.Get(), WeakDev->ActiveServices);
});
}
}
void UPS_BLE_Module::DispatchDeviceDisconnected(UPS_BLE_Device* Dev)
{
if (!Dev) return;
if (IsInGameThread())
{
Dev->RefToManager->JustDisconnectedDevice(Dev);
Dev->OnDisconnect.Broadcast(Dev);
}
else
{
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
AsyncTask(ENamedThreads::GameThread, [WeakDev]()
{
if (!WeakDev.IsValid()) return;
WeakDev->RefToManager->JustDisconnectedDevice(WeakDev.Get());
WeakDev->OnDisconnect.Broadcast(WeakDev.Get());
});
}
}
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
{
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
AsyncTask(ENamedThreads::GameThread, [WeakDev]()
{
if (!WeakDev.IsValid()) return;
WeakDev->RefToManager->JustDiscoveredServices(WeakDev.Get());
WeakDev->OnServicesDiscovered.Broadcast(WeakDev.Get(), WeakDev->ActiveServices);
});
}
}
void UPS_BLE_Module::DispatchRead(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray<uint8> 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
{
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
AsyncTask(ENamedThreads::GameThread, [WeakDev, SI, CI, Status, Data]()
{
if (!WeakDev.IsValid()) return;
if (SI < WeakDev->ActiveServices.Num() && CI < WeakDev->ActiveServices[SI].Characteristics.Num())
WeakDev->OnRead.Broadcast(Status, WeakDev.Get(), WeakDev->ActiveServices[SI].ServiceUUID,
WeakDev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID, Data);
});
}
}
void UPS_BLE_Module::DispatchNotify(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray<uint8> 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
{
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
AsyncTask(ENamedThreads::GameThread, [WeakDev, SI, CI, Status, Data]()
{
if (!WeakDev.IsValid()) return;
if (SI < WeakDev->ActiveServices.Num() && CI < WeakDev->ActiveServices[SI].Characteristics.Num())
WeakDev->OnNotify.Broadcast(Status, WeakDev.Get(), WeakDev->ActiveServices[SI].ServiceUUID,
WeakDev->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;
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
auto Fire = [WeakDev, SI, CI, Status]()
{
if (!WeakDev.IsValid()) return;
if (SI < WeakDev->ActiveServices.Num() && CI < WeakDev->ActiveServices[SI].Characteristics.Num())
WeakDev->OnWrite.Broadcast(Status, WeakDev.Get(), WeakDev->ActiveServices[SI].ServiceUUID,
WeakDev->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;
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
auto Fire = [WeakDev, SI, CI, Status]()
{
if (!WeakDev.IsValid()) return;
if (SI < WeakDev->ActiveServices.Num() && CI < WeakDev->ActiveServices[SI].Characteristics.Num())
{
WeakDev->ActiveServices[SI].Characteristics[CI].subscribed = true;
WeakDev->OnSubscribe.Broadcast(Status, WeakDev.Get(), WeakDev->ActiveServices[SI].ServiceUUID,
WeakDev->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;
TWeakObjectPtr<UPS_BLE_Device> WeakDev(Dev);
auto Fire = [WeakDev, SI, CI, Status]()
{
if (!WeakDev.IsValid()) return;
if (SI < WeakDev->ActiveServices.Num() && CI < WeakDev->ActiveServices[SI].Characteristics.Num())
{
WeakDev->ActiveServices[SI].Characteristics[CI].subscribed = false;
WeakDev->OnUnsubscribe.Broadcast(Status, WeakDev.Get(), WeakDev->ActiveServices[SI].ServiceUUID,
WeakDev->ActiveServices[SI].Characteristics[CI].CharacteristicUUID);
}
};
if (IsInGameThread()) Fire();
else AsyncTask(ENamedThreads::GameThread, Fire);
}
IMPLEMENT_MODULE(UPS_BLE_Module, PS_Win_BLE)
#undef LOCTEXT_NAMESPACE

View File

@@ -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 = "ASTERION|Win_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 = "ASTERION|Win_BLE", meta = (DisplayName = "Address as String", ReturnDisplayName = "Address"))
FString DeviceAddressAsString();
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|Win_BLE", meta = (DisplayName = "Address as Int64", ReturnDisplayName = "Int64"))
int64 DeviceAddressAsInt64();
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "ASTERION|Win_BLE", meta = (DisplayName = "Address as MAC", ReturnDisplayName = "MAC"))
FPS_MACAddress DeviceAddressAsMAC();
// ─── Actions ──────────────────────────────────────────────────────────────
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Connect();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Disconnect();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE", meta = (DisplayName = "Is Connected", ReturnDisplayName = "Connected"))
bool IsConnected();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void DiscoverServices();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Read(const FString& ServiceUUID, const FString& CharacteristicUUID);
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Write(const FString& ServiceUUID, const FString& CharacteristicUUID, TArray<uint8> Data);
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Subscribe(const FString& ServiceUUID, const FString& CharacteristicUUID);
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void Unsubscribe(const FString& ServiceUUID, const FString& CharacteristicUUID);
// ─── Properties ───────────────────────────────────────────────────────────
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE")
FString DeviceName = "<noname>";
UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsString", Category = "ASTERION|Win_BLE")
FString AddressAsString;
UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsInt64", Category = "ASTERION|Win_BLE")
int64 AddressAsInt64 = 0;
UPROPERTY(BlueprintReadOnly, BlueprintGetter = "DeviceAddressAsMAC", Category = "ASTERION|Win_BLE")
FPS_MACAddress AddressAsMAC;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE")
int32 RSSI = 0;
UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = "ASTERION|Win_BLE")
TArray<FPS_ServiceItem> ActiveServices;
// ─── Delegates ────────────────────────────────────────────────────────────
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnConnect OnConnect;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnDisconnect OnDisconnect;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnServicesDiscovered OnServicesDiscovered;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnRead OnRead;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnNotify OnNotify;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnWrite OnWrite;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnSubscribe OnSubscribe;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_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<void*> NativeGattServices; // per-service GattDeviceService handles
TMap<uint64, void*> 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);
};

View File

@@ -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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_BLE", meta = (DisplayName = "Descriptor To String", ReturnDisplayName = "Descriptor"))
static FString DescriptorToString(const uint8& Descriptor);
/** True if running inside the Unreal Editor */
UFUNCTION(BlueprintPure, Category = "ASTERION|Win_BLE", meta = (DisplayName = "Is Editor Running", ReturnDisplayName = "EditorRunning"))
static bool IsEditorRunning();
};

View File

@@ -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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_BLE")
void StopDiscovery();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE")
void DisconnectAll();
UFUNCTION(BlueprintCallable, Category = "ASTERION|Win_BLE", meta = (DisplayName = "Reset BLE Adapter", ReturnDisplayName = "Success"))
bool ResetBLEAdapter();
// ─── MAC utils ────────────────────────────────────────────────────────────
UFUNCTION(BlueprintPure, Category = "ASTERION|Win_BLE", meta = (DisplayName = "MAC to String", ReturnDisplayName = "Address"))
static FString MACToString(const FPS_MACAddress& Address);
UFUNCTION(BlueprintPure, Category = "ASTERION|Win_BLE", meta = (DisplayName = "String to MAC", ReturnDisplayName = "MAC"))
static FPS_MACAddress StringToMAC(const FString& Address, bool& Valid);
// ─── Properties ───────────────────────────────────────────────────────────
UPROPERTY(BlueprintReadOnly, VisibleAnywhere, Category = "ASTERION|Win_BLE")
TArray<UPS_BLE_Device*> FoundDevices;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE")
bool bIsScanInProgress = false;
// ─── Delegates ────────────────────────────────────────────────────────────
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnAnyBLEDeviceConnected OnAnyDeviceConnected;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnAnyBLEDeviceDisconnected OnAnyDeviceDisconnected;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnAnyServiceDiscovered OnAnyServiceDiscovered;
UPROPERTY(BlueprintAssignable, Category = "ASTERION|Win_BLE") FPS_OnBLEError OnBLEError;
// ─── Internal callbacks (called from UPS_BLE_Module dispatch) ─────────────
void JustDiscoveredDevice(const FPS_DeviceRecord& Rec);
void JustDiscoveryEnd(const TArray<FPS_DeviceRecord>& 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);
};

View File

@@ -0,0 +1,65 @@
// 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;
// ─── 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<uint8>& 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;
bool bInitialized = false;
private:
// 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<FPS_DeviceRecord>& 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<uint8> Data);
static void DispatchNotify(UPS_BLE_Device* Dev, uint8 SI, uint8 CI, EPS_GATTStatus Status, TArray<uint8> 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);
};

View File

@@ -0,0 +1,134 @@
// Copyright (C) 2025 ASTERION VR
#pragma once
#include "CoreMinimal.h"
#include "PS_BLETypes.generated.h"
// ─── ENUMS ───────────────────────────────────────────────────────────────────
UENUM(BlueprintType, Category = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_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 = "ASTERION|Win_BLE")
struct FPS_MACAddress
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b0 = 0;
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b1 = 0;
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b2 = 0;
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b3 = 0;
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b4 = 0;
UPROPERTY(BlueprintReadWrite, Category = "ASTERION|Win_BLE") uint8 b5 = 0;
};
USTRUCT(BlueprintType, Atomic, Category = "ASTERION|Win_BLE")
struct FPS_CharacteristicItem
{
GENERATED_BODY()
public:
FGuid cUUID;
bool subscribed = false;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") uint8 Descriptor = 0;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") FString CharacteristicName;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") FString CharacteristicUUID;
};
USTRUCT(BlueprintType, Atomic, Category = "ASTERION|Win_BLE")
struct FPS_ServiceItem
{
GENERATED_BODY()
public:
FGuid sUUID;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") FString ServiceName;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") FString ServiceUUID;
UPROPERTY(BlueprintReadOnly, Category = "ASTERION|Win_BLE") TArray<FPS_CharacteristicItem> Characteristics;
};
// ─── INTERNAL DEVICE RECORD (forward-declared here so Manager can use it) ────
// This is a plain C++ struct (no UCLASS/USTRUCT) used internally between
// the module and the manager — not exposed to Blueprint.
struct FPS_DeviceRecord
{
uint64 ID = 0;
int64 RSSI = 0;
FString Name;
void* NativeDeviceRef = nullptr;
};
// ─── 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<UPS_BLE_Device*>&, DiscoveredDevices);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FPS_OnServicesDiscovered, UPS_BLE_Device* const, Device, const TArray<FPS_ServiceItem>&, ServicesList);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FPS_OnConnect, UPS_BLE_Device* const, Device, const TArray<FPS_ServiceItem>&, 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<FPS_ServiceItem>&, ServicesList);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FiveParams(FPS_OnRead, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID, const TArray<uint8>&, Data);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_FiveParams(FPS_OnNotify, EPS_GATTStatus, Status, UPS_BLE_Device*, Device, FString, ServiceUUID, FString, CharacteristicUUID, const TArray<uint8>&, 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);

2
remove worktree.txt Normal file
View File

@@ -0,0 +1,2 @@
Supprimer E:\ASTERION\GIT\PS_BLE_Tracker\.claude\worktrees\hardcore-taussig
git worktree prune dans le repo