From d30569e6a159ba51a04699df6552143b4f7f746f Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Wed, 18 Feb 2026 09:20:51 +0100 Subject: [PATCH] Add arduino and python code --- Arduino/xiao_airsoft_pro/xiao_airsoft_pro.ino | 537 ++++++++++++++++++ Python/Xiao_BLE_tools/xiao_ble_diagnostic.py | 127 +++++ .../Xiao_BLE_tools/xiao_calibration_tool.py | 277 +++++++++ 3 files changed, 941 insertions(+) create mode 100644 Arduino/xiao_airsoft_pro/xiao_airsoft_pro.ino create mode 100644 Python/Xiao_BLE_tools/xiao_ble_diagnostic.py create mode 100644 Python/Xiao_BLE_tools/xiao_calibration_tool.py diff --git a/Arduino/xiao_airsoft_pro/xiao_airsoft_pro.ino b/Arduino/xiao_airsoft_pro/xiao_airsoft_pro.ino new file mode 100644 index 0000000..cd84207 --- /dev/null +++ b/Arduino/xiao_airsoft_pro/xiao_airsoft_pro.ino @@ -0,0 +1,537 @@ +/* + * XIAO nRF52840 Sense - Airsoft Tracker Pro v3.2 + * Microphone PDM natif (pas analogRead !) + * Détection multi-capteurs : Accel + Gyro + Micro PDM + */ + +#include +#include +#include +#include +#include // ← Bibliothèque PDM intégrée nRF52840 + +// ====== PINS LED ====== +#define LED_RED 11 +#define LED_GREEN 12 +#define LED_BLUE 13 + +// ====== FLASH ====== +#define FLASH_STORAGE_START 0x7F000 + +// ====== ENUMS (déclarés en premier) ====== +enum LedState { + LED_BOOT, LED_NO_PAIRING, LED_ADVERTISING, + LED_CONNECTED, LED_SHOT_FLASH, LED_REJECTED, LED_ERROR +}; + +enum DebugMode { + DEBUG_OFF, DEBUG_RAW, DEBUG_TRIGGERS, DEBUG_FULL +}; + +// ====== STRUCTURES ====== +struct PairingData { + uint32_t magic; + char authorizedMAC[18]; + uint8_t reserved[10]; +}; + +struct ShotConfig { + float accelThreshold; // G + float gyroThreshold; // deg/s + uint16_t audioThreshold; // 0-32767 (PDM 16-bit) + uint16_t accelWindow; // ms + uint16_t gyroWindow; // ms + uint16_t audioWindow; // ms + uint8_t minSensors; // 1-3 + uint16_t shotCooldown; // ms + bool useAccel; + bool useGyro; + bool useAudio; +}; + +// ====== VARIABLES GLOBALES ====== +PairingData pairing; +ShotConfig shotConfig; +const uint32_t MAGIC_NUMBER = 0xCAFEBABE; + +// IMU +LSM6DS3 imu(I2C_MODE, 0x6A); +float ax, ay, az, gx, gy, gz; +float roll=0, pitch=0, yaw=0; +unsigned long lastUpdate=0, lastSend=0; +const float alpha = 0.98f; + +// ====== PDM MICROPHONE ====== +#define PDM_BUFFER_SIZE 256 +short pdmBuffer[PDM_BUFFER_SIZE]; +volatile int pdmSamplesReady = 0; +volatile int16_t pdmPeak = 0; // Pic absolu du dernier buffer + +void onPDMdata() { + int bytesAvailable = PDM.available(); + PDM.read(pdmBuffer, bytesAvailable); + int samples = bytesAvailable / 2; + + // Calculer le pic absolu dans ce buffer + int16_t peak = 0; + for (int i = 0; i < samples; i++) { + int16_t v = abs(pdmBuffer[i]); + if (v > peak) peak = v; + } + pdmPeak = peak; + pdmSamplesReady = 1; +} + +// Valeur audio lissée pour debug +float audioSmoothed = 0; + +// ====== DÉTECTION TIR ====== +unsigned long lastShotTime = 0; +bool accelTrigger = false; +bool gyroTrigger = false; +bool audioTrigger = false; +unsigned long accelTriggerTime = 0; +unsigned long gyroTriggerTime = 0; +unsigned long audioTriggerTime = 0; + +// ====== LED ====== +LedState currentLedState = LED_BOOT; +unsigned long ledTimer = 0; +unsigned long shotFlashTimer = 0; +unsigned long connectionTime = 0; +bool ledBlinkState = false; +bool isAdvertising = false; + +// ====== DEBUG ====== +DebugMode debugMode = DEBUG_OFF; +unsigned long lastDebugSend = 0; +const uint16_t DEBUG_RATE = 50; // 20 Hz + +// ====== UUIDs BLE ====== +const char* SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"; +const char* IMU_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"; +const char* SHOT_CHAR_UUID = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E"; +const char* DEBUG_CHAR_UUID = "6E400005-B5A3-F393-E0A9-E50E24DCCA9E"; +const char* CONFIG_CHAR_UUID = "6E400006-B5A3-F393-E0A9-E50E24DCCA9E"; + +// ====== BLE ====== +BLEService motionService(SERVICE_UUID); +BLECharacteristic imuChar (IMU_CHAR_UUID, BLERead|BLENotify, 12, true); +BLECharacteristic shotChar (SHOT_CHAR_UUID, BLERead|BLENotify, 1, true); +BLECharacteristic debugChar (DEBUG_CHAR_UUID, BLERead|BLENotify, 20, true); +BLECharacteristic configChar (CONFIG_CHAR_UUID, BLERead|BLEWrite, 32, true); + +// ════════════════════════════════════════════════ +// LED +// ════════════════════════════════════════════════ +void setLED(uint8_t r, uint8_t g, uint8_t b) { + digitalWrite(LED_RED, r ? LOW : HIGH); + digitalWrite(LED_GREEN, g ? LOW : HIGH); + digitalWrite(LED_BLUE, b ? LOW : HIGH); +} + +void changeLedState(LedState s) { + currentLedState = s; + ledTimer = millis(); + ledBlinkState = false; +} + +void updateLED() { + unsigned long now = millis(); + if (currentLedState == LED_SHOT_FLASH) { + if (now - shotFlashTimer < 60) { setLED(255,0,0); return; } + else currentLedState = LED_CONNECTED; + } + switch (currentLedState) { + case LED_BOOT: + if (now-ledTimer>200){ ledTimer=now; ledBlinkState=!ledBlinkState; + setLED(ledBlinkState?128:0, 0, ledBlinkState?128:0); } break; + case LED_NO_PAIRING: + if (now-ledTimer>1000){ ledTimer=now; ledBlinkState=!ledBlinkState; + setLED(ledBlinkState?255:0, ledBlinkState?200:0, 0); } break; + case LED_ADVERTISING: + if (now-ledTimer>500){ ledTimer=now; ledBlinkState=!ledBlinkState; + setLED(0,0,ledBlinkState?255:0); } break; + case LED_CONNECTED: setLED(0,255,0); break; + case LED_REJECTED: + if (now-ledTimer>150){ ledTimer=now; ledBlinkState=!ledBlinkState; + setLED(ledBlinkState?255:0,0,0); } break; + case LED_ERROR: setLED(255,0,0); break; + default: break; + } +} + +// ════════════════════════════════════════════════ +// CONFIG +// ════════════════════════════════════════════════ +void initDefaultConfig() { + shotConfig.accelThreshold = 2.5f; + shotConfig.gyroThreshold = 200.0f; + shotConfig.audioThreshold = 3000; // Seuil PDM (0-32767) + shotConfig.accelWindow = 20; + shotConfig.gyroWindow = 20; + shotConfig.audioWindow = 15; + shotConfig.minSensors = 2; + shotConfig.shotCooldown = 80; + shotConfig.useAccel = true; + shotConfig.useGyro = true; + shotConfig.useAudio = true; +} + +void printConfig() { + Serial.println("\n╔══════════ CONFIG ══════════╗"); + Serial.print("Accel : "); Serial.print(shotConfig.accelThreshold,1); + Serial.print(" G ["); Serial.print(shotConfig.useAccel?"ON":"OFF"); Serial.println("]"); + Serial.print("Gyro : "); Serial.print(shotConfig.gyroThreshold,0); + Serial.print(" °/s ["); Serial.print(shotConfig.useGyro?"ON":"OFF"); Serial.println("]"); + Serial.print("Audio : "); Serial.print(shotConfig.audioThreshold); + Serial.print(" PDM ["); Serial.print(shotConfig.useAudio?"ON":"OFF"); Serial.println("]"); + Serial.print("MinSens: "); Serial.println(shotConfig.minSensors); + Serial.print("Cooldown: "); Serial.print(shotConfig.shotCooldown); Serial.println(" ms"); + Serial.println("╚════════════════════════════╝\n"); +} + +// ════════════════════════════════════════════════ +// FLASH +// ════════════════════════════════════════════════ +void loadPairingData() { + memcpy(&pairing, (void*)FLASH_STORAGE_START, sizeof(PairingData)); + if (pairing.magic == MAGIC_NUMBER) { + Serial.print("🔒 PC : "); Serial.println(pairing.authorizedMAC); + changeLedState(LED_ADVERTISING); + } else { + Serial.println("🔓 Aucun PC enregistré"); + pairing.magic = 0; + memset(pairing.authorizedMAC, 0, sizeof(pairing.authorizedMAC)); + changeLedState(LED_NO_PAIRING); + } +} + +void savePairingData(const char* mac) { + pairing.magic = MAGIC_NUMBER; + strncpy(pairing.authorizedMAC, mac, 17); + pairing.authorizedMAC[17] = '\0'; + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + NRF_NVMC->ERASEPAGE = FLASH_STORAGE_START; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + uint32_t* src=(uint32_t*)&pairing, *dst=(uint32_t*)FLASH_STORAGE_START; + for(int i=0;i<(int)(sizeof(PairingData)/4);i++){ + dst[i]=src[i]; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + } + NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + Serial.print("💾 "); Serial.println(pairing.authorizedMAC); +} + +void resetPairing() { + pairing.magic = 0; memset(pairing.authorizedMAC,0,sizeof(pairing.authorizedMAC)); + NRF_NVMC->CONFIG=NVMC_CONFIG_WEN_Een; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + NRF_NVMC->ERASEPAGE=FLASH_STORAGE_START; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + NRF_NVMC->CONFIG=NVMC_CONFIG_WEN_Ren; while(NRF_NVMC->READY==NVMC_READY_READY_Busy){} + Serial.println("🗑️ Reset"); changeLedState(LED_NO_PAIRING); +} + +bool isAuthorized(const char* mac) { + if (pairing.magic != MAGIC_NUMBER) { savePairingData(mac); return true; } + return (strcmp(pairing.authorizedMAC, mac) == 0); +} + +// ════════════════════════════════════════════════ +// CALLBACKS BLE +// ════════════════════════════════════════════════ +void onConnect(BLEDevice central) { + Serial.print("🔗 "); Serial.println(central.address()); + if (!isAuthorized(central.address().c_str())) { + Serial.println("❌ REFUSÉ"); + changeLedState(LED_REJECTED); delay(3000); + central.disconnect(); changeLedState(LED_ADVERTISING); return; + } + Serial.println("✅ CONNECTÉ"); + changeLedState(LED_CONNECTED); + isAdvertising=false; lastUpdate=connectionTime=millis(); +} + +void onDisconnect(BLEDevice central) { + Serial.print("❌ DÉCO ("); + Serial.print((millis()-connectionTime)/1000); + Serial.println("s)"); + changeLedState(LED_ADVERTISING); isAdvertising=true; +} + +void onConfigWrite(BLEDevice central, BLECharacteristic c) { + uint8_t data[32]; + int len = c.readValue(data, 32); + if (len >= (int)sizeof(ShotConfig)) { + memcpy(&shotConfig, data, sizeof(ShotConfig)); + Serial.println("⚙️ Config reçue"); printConfig(); + } +} + +// ════════════════════════════════════════════════ +// DÉTECTION TIR +// ════════════════════════════════════════════════ +bool checkWindow(unsigned long t, uint16_t w) { + return (millis()-t) <= w; +} + +bool detectShot() { + unsigned long now = millis(); + if ((now-lastShotTime) < shotConfig.shotCooldown) return false; + if (accelTrigger && !checkWindow(accelTriggerTime, shotConfig.accelWindow)) accelTrigger=false; + if (gyroTrigger && !checkWindow(gyroTriggerTime, shotConfig.gyroWindow)) gyroTrigger=false; + if (audioTrigger && !checkWindow(audioTriggerTime, shotConfig.audioWindow)) audioTrigger=false; + uint8_t n=0; + if (accelTrigger && shotConfig.useAccel) n++; + if (gyroTrigger && shotConfig.useGyro) n++; + if (audioTrigger && shotConfig.useAudio) n++; + if (n >= shotConfig.minSensors) { + accelTrigger=gyroTrigger=audioTrigger=false; return true; + } + return false; +} + +// ════════════════════════════════════════════════ +// DEBUG +// ════════════════════════════════════════════════ +void sendDebugData(float accelMag, float gyroMag, uint16_t audioLevel) { + if (debugMode==DEBUG_OFF || !BLE.connected()) return; + unsigned long now=millis(); + if ((now-lastDebugSend)3) shotConfig.minSensors=1; + Serial.print("MinSensors:"); Serial.println(shotConfig.minSensors); break; + case 'H': case 'h': + Serial.println("\n╔═════ COMMANDES ═════╗"); + Serial.println("R : Reset pairing"); + Serial.println("C : Config"); + Serial.println("D : Debug cycle"); + Serial.println("T : Test tir"); + Serial.println("1/2/3 : Toggle A/G/M"); + Serial.println("+/- : Accel ±0.1G"); + Serial.println("G/g : Gyro ±10°/s"); + Serial.println("M/n : Audio PDM ±200"); + Serial.println("S : Min capteurs"); + Serial.println("╚═════════════════════╝\n"); break; + default: break; + } +} + +// ════════════════════════════════════════════════ +// SETUP +// ════════════════════════════════════════════════ +void setup() { + Serial.begin(115200); + delay(2000); + + Serial.println("\n╔══════════════════════════════════════════╗"); + Serial.println("║ XIAO Airsoft Tracker Pro v3.2 ║"); + Serial.println("║ Accel + Gyro + Micro PDM natif ║"); + Serial.println("╚══════════════════════════════════════════╝\n"); + + pinMode(LED_RED, OUTPUT); + pinMode(LED_GREEN,OUTPUT); + pinMode(LED_BLUE, OUTPUT); + changeLedState(LED_BOOT); + + initDefaultConfig(); + loadPairingData(); + + // IMU + Serial.print("🔧 IMU... "); + if (imu.begin()!=0) { + Serial.println("❌"); changeLedState(LED_ERROR); + while(1){updateLED();delay(1);} + } + Serial.println("✅"); + + // PDM Microphone + Serial.print("🎙️ PDM Micro... "); + PDM.onReceive(onPDMdata); + // Mono, 16000 Hz (standard nRF52840) + if (!PDM.begin(1, 16000)) { + Serial.println("❌ PDM init failed"); + // On continue sans micro plutôt que de bloquer + shotConfig.useAudio = false; + Serial.println(" Audio désactivé"); + } else { + Serial.println("✅ (16kHz mono)"); + PDM.setGain(20); // Gain réduit (0-80) — augmentez si signal trop faible + } + + // BLE + Serial.print("📡 BLE... "); + if (!BLE.begin()) { + Serial.println("❌"); changeLedState(LED_ERROR); + while(1){updateLED();delay(1);} + } + Serial.println("✅"); + delay(500); + + BLE.setDeviceName("XIAO Airsoft Pro"); + BLE.setLocalName("XIAO Airsoft Pro"); + BLE.setConnectionInterval(0x0006, 0x0C80); + BLE.setAdvertisedService(motionService); + motionService.addCharacteristic(imuChar); + motionService.addCharacteristic(shotChar); + motionService.addCharacteristic(debugChar); + motionService.addCharacteristic(configChar); + BLE.addService(motionService); + + uint8_t z[12]={0}; + imuChar.writeValue(z,12); + shotChar.writeValue((uint8_t)0); + + configChar.setEventHandler(BLEWritten, onConfigWrite); + BLE.setEventHandler(BLEConnected, onConnect); + BLE.setEventHandler(BLEDisconnected, onDisconnect); + + BLE.advertise(); + isAdvertising=true; + + Serial.println("✅ Prêt !"); + printConfig(); + Serial.println("Tapez 'H' pour l'aide"); + Serial.println("⚠️ Seuil audio PDM : 0-32767 (ajuster avec M/n)\n"); +} + +// ════════════════════════════════════════════════ +// LOOP +// ════════════════════════════════════════════════ +void loop() { + BLE.poll(); + updateLED(); + handleSerialCommand(); + + if (!BLE.connected()) return; + + unsigned long now = millis(); + + // ─── Lecture IMU ─── + ax = imu.readFloatAccelX(); + ay = imu.readFloatAccelY(); + az = imu.readFloatAccelZ(); + gx = imu.readFloatGyroX() * DEG_TO_RAD; + gy = imu.readFloatGyroY() * DEG_TO_RAD; + gz = imu.readFloatGyroZ() * DEG_TO_RAD; + + float accelMag = sqrt(ax*ax + ay*ay + az*az); + float gyroMag = sqrt(gx*gx + gy*gy + gz*gz) * RAD_TO_DEG; + + // ─── Lecture PDM ─── + // pdmPeak est mis à jour par l'ISR onPDMdata() + uint16_t audioLevel = (uint16_t)pdmPeak; + + // Lissage pour affichage debug + audioSmoothed = 0.7f * audioSmoothed + 0.3f * audioLevel; + + // ─── Triggers ─── + unsigned long n = millis(); + if (shotConfig.useAccel && accelMag>=shotConfig.accelThreshold && !accelTrigger) { + accelTrigger=true; accelTriggerTime=n; } + if (shotConfig.useGyro && gyroMag>=shotConfig.gyroThreshold && !gyroTrigger) { + gyroTrigger=true; gyroTriggerTime=n; } + if (shotConfig.useAudio && audioLevel>=shotConfig.audioThreshold && !audioTrigger) { + audioTrigger=true; audioTriggerTime=n; } + + // ─── Détection tir ─── + if (detectShot()) { + lastShotTime=now; + shotChar.writeValue((uint8_t)1); + shotFlashTimer=now; changeLedState(LED_SHOT_FLASH); + Serial.print("💥 TIR ! A:");Serial.print(accelMag,1); + Serial.print(" G:");Serial.print(gyroMag,0); + Serial.print(" M:");Serial.println(audioLevel); + } + + // ─── Debug ─── + sendDebugData(accelMag, gyroMag, audioLevel); + printDebugSerial(accelMag, gyroMag, audioLevel); + + // ─── IMU BLE 10 Hz ─── + if (now-lastSend>=100) { + lastSend=now; + float dt=(now-lastUpdate)/1000.0f; lastUpdate=now; + roll+=gx*dt; pitch+=gy*dt; yaw+=gz*dt; + float ar=atan2f(ay,az); + float ap=atan2f(-ax,sqrtf(ay*ay+az*az)); + roll =alpha*roll +(1-alpha)*ar; + pitch=alpha*pitch+(1-alpha)*ap; + uint8_t payload[12]; + memcpy(&payload[0],&roll,4); + memcpy(&payload[4],&pitch,4); + memcpy(&payload[8],&yaw,4); + imuChar.writeValue(payload,12); + } +} diff --git a/Python/Xiao_BLE_tools/xiao_ble_diagnostic.py b/Python/Xiao_BLE_tools/xiao_ble_diagnostic.py new file mode 100644 index 0000000..5819e83 --- /dev/null +++ b/Python/Xiao_BLE_tools/xiao_ble_diagnostic.py @@ -0,0 +1,127 @@ +""" +XIAO BLE Diagnostic Tool +Scan et test de connexion BLE +""" + +import asyncio +from bleak import BleakScanner, BleakClient + +# ⚠️ VÉRIFIEZ CES VALEURS ⚠️ +DEVICE_ADDRESS = "00:a5:54:89:f1:ec" # ← Votre adresse MAC +DEVICE_NAME = "XIAO Airsoft Pro" # ← Nouveau nom dans le code v2 + +# UUIDs (version pro) +SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" +IMU_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" +SHOT_CHAR_UUID = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E" +DEBUG_CHAR_UUID = "6E400005-B5A3-F393-E0A9-E50E24DCCA9E" +CONFIG_CHAR_UUID = "6E400006-B5A3-F393-E0A9-E50E24DCCA9E" + +async def scan_all(): + """Scan tous les appareils BLE visibles""" + print("\n🔍 Scan BLE en cours (10 secondes)...") + print(" Assurez-vous que le XIAO est allumé (LED bleue clignotante)\n") + + devices = await BleakScanner.discover(timeout=10.0) + + if not devices: + print("❌ Aucun appareil BLE trouvé !") + print(" → Vérifiez que le Bluetooth Windows est activé") + print(" → Vérifiez que le XIAO est allumé") + return [] + + print(f"📡 {len(devices)} appareil(s) trouvé(s) :\n") + for d in sorted(devices, key=lambda x: x.rssi, reverse=True): + marker = " ← XIAO !" if (d.name and "XIAO" in d.name) else "" + marker2 = " ← XIAO !" if d.address.lower() == DEVICE_ADDRESS.lower() else marker + print(f" [{d.rssi:4d} dBm] {d.address} | {d.name or '(sans nom)'}{marker2}") + + return devices + +async def test_connection(): + """Tente une connexion et liste les services""" + print(f"\n🔌 Tentative de connexion à {DEVICE_ADDRESS}...") + + device = await BleakScanner.find_device_by_address(DEVICE_ADDRESS, timeout=10.0) + + if not device: + print("❌ Appareil non trouvé à cette adresse !") + print(" → Vérifiez l'adresse MAC dans le Moniteur Série Arduino") + print(" → Ou lancez d'abord le scan pour voir l'adresse réelle") + return + + print(f"✅ Appareil trouvé : {device.name} ({device.address})") + + try: + async with BleakClient(device, timeout=10.0) as client: + print(f"✅ Connecté !\n") + print("📋 Services et caractéristiques disponibles :\n") + + for service in client.services: + print(f" Service : {service.uuid}") + for char in service.characteristics: + props = ", ".join(char.properties) + known = "" + if char.uuid.upper() == IMU_CHAR_UUID.upper(): + known = " ← IMU (Roll/Pitch/Yaw)" + elif char.uuid.upper() == SHOT_CHAR_UUID.upper(): + known = " ← Shot Event" + elif char.uuid.upper() == DEBUG_CHAR_UUID.upper(): + known = " ← Debug Data" + elif char.uuid.upper() == CONFIG_CHAR_UUID.upper(): + known = " ← Configuration" + print(f" ├─ {char.uuid} [{props}]{known}") + print() + + print("✅ Connexion OK - Toutes les caractéristiques sont accessibles !") + + except Exception as e: + print(f"❌ Erreur de connexion : {e}") + print("\n Solutions possibles :") + print(" → Supprimez 'XIAO Airsoft Pro' dans les paramètres Bluetooth Windows") + print(" → Redémarrez le XIAO (bouton RESET)") + print(" → Relancez ce diagnostic") + +async def main(): + print("╔══════════════════════════════════════╗") + print("║ XIAO BLE Diagnostic Tool ║") + print("╚══════════════════════════════════════╝") + print(f"\nAdresse cible : {DEVICE_ADDRESS}") + print(f"Nom cible : {DEVICE_NAME}") + + # Étape 1 : Scan + devices = await scan_all() + + # Vérifier si notre appareil est visible + found = any( + d.address.lower() == DEVICE_ADDRESS.lower() or + (d.name and "XIAO" in d.name) + for d in devices + ) + + if not found: + print(f"\n⚠️ '{DEVICE_NAME}' non trouvé dans le scan !") + print(" → LED du XIAO : quelle couleur voyez-vous ?") + print(" 🔵 Bleu clignotant = OK, cherchez dans la liste ci-dessus") + print(" 🟡 Jaune = Aucun PC enregistré, essayez de vous connecter") + print(" 🔴 Rouge = Erreur hardware") + print(" Éteinte = XIAO pas alimenté\n") + + # Chercher un XIAO par nom + xiao_devices = [d for d in devices if d.name and "XIAO" in d.name] + if xiao_devices: + print("🔎 XIAO trouvé avec un nom différent :") + for d in xiao_devices: + print(f" {d.address} | {d.name}") + print(f"\n ⚠️ Mettez à jour DEVICE_ADDRESS dans ce script et dans vos autres scripts !") + return + + # Étape 2 : Test connexion + await test_connection() + + print("\n📝 Si tout fonctionne ici, mettez à jour l'adresse dans :") + print(f" xiao_unreal_bridge.py → DEVICE_ADDRESS = \"{DEVICE_ADDRESS}\"") + print(f" xiao_calibration_tool.py → DEVICE_ADDRESS = \"{DEVICE_ADDRESS}\"") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/Python/Xiao_BLE_tools/xiao_calibration_tool.py b/Python/Xiao_BLE_tools/xiao_calibration_tool.py new file mode 100644 index 0000000..b7a59d4 --- /dev/null +++ b/Python/Xiao_BLE_tools/xiao_calibration_tool.py @@ -0,0 +1,277 @@ +""" +XIAO Airsoft Calibration Tool v3 +- Rendu optimisé : set_data() sans redraw complet +- Fenêtre glissante fixe (pas de grossissement des buffers) +- Aucune latence même après des heures +""" + +import asyncio +import struct +import threading +from collections import deque +from bleak import BleakClient, BleakScanner + +DEVICE_NAME = "XIAO Airsoft Pro" +DEBUG_CHAR_UUID = "6E400005-B5A3-F393-E0A9-E50E24DCCA9E" +SHOT_CHAR_UUID = "6E400004-B5A3-F393-E0A9-E50E24DCCA9E" + +# Fenêtre fixe : WINDOW_SIZE points affichés, jamais plus +WINDOW_SIZE = 200 # ~10s à 20Hz — ajustez si besoin + +# Buffers circulaires de taille fixe +accel_buf = deque([0.0] * WINDOW_SIZE, maxlen=WINDOW_SIZE) +gyro_buf = deque([0.0] * WINDOW_SIZE, maxlen=WINDOW_SIZE) +audio_buf = deque([0] * WINDOW_SIZE, maxlen=WINDOW_SIZE) +accel_trig = deque([False] * WINDOW_SIZE, maxlen=WINDOW_SIZE) +gyro_trig = deque([False] * WINDOW_SIZE, maxlen=WINDOW_SIZE) +audio_trig = deque([False] * WINDOW_SIZE, maxlen=WINDOW_SIZE) + +thresholds = {"accel": 2.5, "gyro": 200.0, "audio": 3000} # PDM : 0-32767 +shot_count = 0 +ble_status = "🔍 Connexion..." +ble_running = True +audio_max_global = 1000 # Tracks le max absolu jamais vu + +import numpy as np +X = np.arange(WINDOW_SIZE) # axe X fixe, ne change jamais + +# ─── BLE ──────────────────────────────────────────────── +def debug_callback(sender, data): + global audio_max_global + if len(data) < 14: + return + accel_buf.append( struct.unpack(' audio_max_global: + audio_max_global = val + accel_trig.append(bool(data[11])) + gyro_trig.append( bool(data[12])) + audio_trig.append(bool(data[13])) + +def shot_callback(sender, data): + global shot_count + if data[0] == 1: + shot_count += 1 + +async def find_device(): + found = None + def cb(device, adv): + nonlocal found + if device.name and DEVICE_NAME.lower() in device.name.lower(): + found = device + scanner = BleakScanner(cb) + await scanner.start() + for _ in range(20): + if found: break + await asyncio.sleep(0.5) + await scanner.stop() + return found + +async def ble_loop(): + global ble_status, ble_running + while ble_running: + try: + ble_status = f"🔍 Recherche '{DEVICE_NAME}'..." + device = await find_device() + if not device: + ble_status = "⚠️ XIAO non trouvé — réessai..." + await asyncio.sleep(5) + continue + ble_status = f"📡 Connexion..." + async with BleakClient(device, timeout=15.0) as client: + await client.start_notify(DEBUG_CHAR_UUID, debug_callback) + await client.start_notify(SHOT_CHAR_UUID, shot_callback) + ble_status = f"✅ {device.name} ({device.address})" + while client.is_connected and ble_running: + await asyncio.sleep(0.5) + ble_status = "❌ Déconnecté — reconnexion..." + except Exception as e: + ble_status = f"❌ {str(e)[:50]}" + await asyncio.sleep(5) + +def run_ble(): + asyncio.run(ble_loop()) + +# ─── MATPLOTLIB optimisé ──────────────────────────────── +import matplotlib +matplotlib.use('TkAgg') +import matplotlib.pyplot as plt +import matplotlib.gridspec as gridspec +from matplotlib.animation import FuncAnimation + +BG = '#1a1a2e' +PANEL = '#16213e' +TEXT = '#e0e0e0' +CA = '#4fc3f7' # accel +CG = '#81c784' # gyro +CM = '#ce93d8' # audio +CT = '#ef5350' # trigger / seuil +GRID = '#2a2a4a' + +fig = plt.figure(figsize=(13, 9), facecolor=BG) +gs = gridspec.GridSpec(3, 1, hspace=0.5, top=0.92, bottom=0.06) + +axes = [fig.add_subplot(gs[i]) for i in range(3)] +for ax in axes: + ax.set_facecolor(PANEL) + ax.tick_params(colors=TEXT, labelsize=8) + for sp in ax.spines.values(): + sp.set_edgecolor('#444466') + ax.grid(True, alpha=0.2, color=GRID) + ax.set_xlim(0, WINDOW_SIZE - 1) + +axes[0].set_ylabel("G", color=TEXT, fontsize=9) +axes[1].set_ylabel("°/s", color=TEXT, fontsize=9) +axes[2].set_ylabel("Niveau",color=TEXT, fontsize=9) +axes[2].set_xlabel("Échantillons (fenêtre glissante)", + color=TEXT, fontsize=8) + +# 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_g, = axes[1].plot(X, list(gyro_buf), color=CG, lw=1.5) +line_m, = axes[2].plot(X, list(audio_buf), color=CM, lw=1.5) + +thr_a = axes[0].axhline(thresholds["accel"], color=CT, ls='--', lw=1.5) +thr_g = axes[1].axhline(thresholds["gyro"], color=CT, ls='--', lw=1.5) +thr_m = axes[2].axhline(thresholds["audio"], color=CT, ls='--', lw=1.5) + +# Zones de trigger : rectangles pré-créés (un par point) +# On utilise une collection de spans pour les triggers +from matplotlib.patches import Rectangle +from matplotlib.collections import PatchCollection + +# Titres dynamiques +titles = [ + axes[0].set_title("", color=TEXT, fontsize=10, fontweight='bold', pad=5), + axes[1].set_title("", color=TEXT, fontsize=10, fontweight='bold', pad=5), + axes[2].set_title("", color=TEXT, fontsize=10, fontweight='bold', pad=5), +] + +status_txt = fig.text(0.01, 0.97, "", color=TEXT, fontsize=9, va='top') +help_txt = fig.text(0.99, 0.97, + "Q/A Accel±0.1G W/S Gyro±10°/s E/D Audio±500 R Reset ESC Quitter", + color='#8888aa', fontsize=8, va='top', ha='right') + +# Fonds de trigger (spans) — créés une fois, rendus invisibles par défaut +# On dessine juste une image de fond qu'on met à jour +trig_spans_a = [axes[0].axvspan(i, i+1, alpha=0, color=CT) for i in range(0, WINDOW_SIZE, 1)] +trig_spans_g = [axes[1].axvspan(i, i+1, alpha=0, color=CT) for i in range(0, WINDOW_SIZE, 1)] +trig_spans_m = [axes[2].axvspan(i, i+1, alpha=0, color=CT) for i in range(0, WINDOW_SIZE, 1)] + +def update_spans(spans, trig_list): + """Met à jour l'alpha des spans sans en créer de nouveaux""" + tl = list(trig_list) + for i, span in enumerate(spans): + span.set_alpha(0.25 if i < len(tl) and tl[i] else 0) + +def update(frame): + # Snapshot des buffers (rapide) + a = np.array(accel_buf) + g = np.array(gyro_buf) + m = np.array(audio_buf) + + # Mise à jour des données des lignes + line_a.set_ydata(a) + line_g.set_ydata(g) + line_m.set_ydata(m) + + # Mise à jour des seuils + thr_a.set_ydata([thresholds["accel"], thresholds["accel"]]) + thr_g.set_ydata([thresholds["gyro"], thresholds["gyro"]]) + thr_m.set_ydata([thresholds["audio"], thresholds["audio"]]) + + # Ylim adaptatif + axes[0].set_ylim(0, max(thresholds["accel"] * 1.8, a.max() * 1.2, 2.0)) + axes[1].set_ylim(0, max(thresholds["gyro"] * 1.8, g.max() * 1.2, 100)) + # ylim audio basé sur le max GLOBAL jamais vu (jamais réduit automatiquement) + axes[2].set_ylim(0, max(audio_max_global * 1.2, thresholds["audio"] * 2.0, 1000)) + + # Triggers + update_spans(trig_spans_a, accel_trig) + update_spans(trig_spans_g, gyro_trig) + update_spans(trig_spans_m, audio_trig) + + # Titres avec valeurs courantes + titles[0].set_text( + f"Accéléromètre " + f"val: {a[-1]:.2f} G " + f"seuil: {thresholds['accel']:.1f} G " + f"[Q=+0.1 A=-0.1]") + titles[1].set_text( + f"Gyroscope " + f"val: {g[-1]:.0f} °/s " + f"seuil: {thresholds['gyro']:.0f} °/s " + f"[W=+10 S=-10]") + titles[2].set_text( + f"Microphone PDM " + f"val: {m[-1]:.0f} " + f"seuil: {thresholds['audio']} " + f"[E=+500 D=-500]") + + status_txt.set_text( + f"{ble_status} | 💥 Tirs détectés : {shot_count}") + + return (line_a, line_g, line_m, + thr_a, thr_g, thr_m, + *titles, status_txt) + +def on_key(event): + k = event.key + if k == 'q': thresholds["accel"] = round(thresholds["accel"] + 0.1, 1) + elif k == 'a': thresholds["accel"] = round(max(0.3, thresholds["accel"] - 0.1), 1) + elif k == 'w': thresholds["gyro"] = thresholds["gyro"] + 10 + elif k == 's': thresholds["gyro"] = max(20, thresholds["gyro"] - 10) + elif k == 'e': thresholds["audio"] = thresholds["audio"] + 500 + elif k == 'd': thresholds["audio"] = max(200, thresholds["audio"] - 500) + elif k == 'r': + global audio_max_global + audio_max_global = 1000 + for b in (accel_buf, gyro_buf, audio_buf, + accel_trig, gyro_trig, audio_trig): + b.clear() + b.extend([0] * WINDOW_SIZE) + print("Courbes réinitialisées") + elif k == 'escape': + global ble_running + ble_running = False + plt.close('all') + + # Affichage console + if k in ('q','a'): print(f"Accel seuil → {thresholds['accel']:.1f} G") + elif k in ('w','s'): print(f"Gyro seuil → {thresholds['gyro']:.0f} °/s") + elif k in ('e','d'): print(f"Audio seuil → {thresholds['audio']}") + +def on_close(event): + global ble_running + ble_running = False + +fig.canvas.mpl_connect('key_press_event', on_key) +fig.canvas.mpl_connect('close_event', on_close) + +def main(): + print("╔══════════════════════════════════════════════╗") + print("║ XIAO Airsoft Calibration Tool v3 ║") + print("║ Rendu optimisé — aucune latence ║") + print("╚══════════════════════════════════════════════╝") + print(f"\n🎯 Cible : '{DEVICE_NAME}'") + print("⚠️ Tapez 'D' dans le Moniteur Série Arduino") + print(" jusqu'à voir 'Debug : FULL'\n") + + ble_thread = threading.Thread(target=run_ble, daemon=True) + ble_thread.start() + + anim = FuncAnimation( + fig, update, + interval=80, # ~12 fps, largement suffisant + blit=False, # blit=True cause des bugs de spans + cache_frame_data=False + ) + plt.show() + + global ble_running + ble_running = False + +if __name__ == "__main__": + main()