V Visla Hardware
Firmware / BLE Beacon Radar / Antifurto

BLE Beacon Radar / Antifurto

XIAO nRF52840 + Expansion · prossimità beacon
testato al banco · 2026-07

Radar di prossimità per beacon BLE + demo arm/disarm antifurto. In RADAR il buzzer becca più fitto quanto sei vicino (come un metal-detector); in ARM disarma sotto soglia e riarma se il beacon si perde per 10 s. Il tasto (long-press) associa un singolo MAC, così segue solo quel beacon.

Seeed XIAO nRF52840 + Expansion Arduino · Seeed nRF52 core
Componenti usati
nRF52840
MCU · BLE Cortex-M4F
Nordic Semiconductor
SSD1306
OLED 128×64 (Expansion)
Buzzer
Feedback acustico (Expansion)
Pulsante
Modo / associazione (Expansion)
ble_beacon_radar.ino
216 righe GitHub
/*
 * xiao-ble-beacon-radar — XIAO nRF52840 + Expansion Board (OLED + buzzer + tasto)
 *
 * Radar di prossimità per beacon BLE + demo arm/disarm antifurto Visla.
 *
 * PULSANTE (D1):
 *   click corto  → cambia modo: RADAR ⇄ ARM
 *   pressione 1.5s → ASSOCIA il beacon più vicino (o annulla l'associazione)
 *
 * RADAR: beep tanto più fitti/acuti quanto più sei vicino (metal detector).
 * ARM:   sotto soglia per N letture → DISARMA. Beacon perso 10s → RIARMA.
 *
 * ⚠️ Una volta associato segue SOLO quel MAC: un antifurto che disarma
 *    davanti al beacon di chiunque non serve a niente.
 */

#include <bluefruit.h>
#include <U8x8lib.h>

U8X8_SSD1306_128X64_NONAME_SW_I2C oled(D5, D4, U8X8_PIN_NONE);

#define BUZZER      A3
#define BUTTON      D1
#define RSSI_NEAR  -45      // "addosso"
#define RSSI_FAR   -85      // "quasi perso"
#define ARM_ON     -60      // sotto → disarma
#define ARM_OFF    -75      // sopra → riarma (isteresi)
#define ARM_HITS     3      // letture consecutive prima di disarmare
#define LOST_MS  10000
#define EMA_A     0.30f

enum Mode { RADAR, ARM };
static Mode  mode = RADAR;
static uint8_t target[6];
static bool  paired = false, haveTgt = false, armed = true;
static float ema = -100;
static uint32_t lastSeen = 0, lastBeep = 0, lastUi = 0, btnDown = 0;
static int   hits = 0;
static uint8_t lastCount = 0;
static bool  countInit = false;
static volatile uint8_t ringPending = 0;
static bool  longDone = false;

void beep(int f, int ms) {
  long half = 500000L / f, cyc = (long)ms * 1000L / (half * 2);
  for (long i = 0; i < cyc; i++) {
    digitalWrite(BUZZER, HIGH); delayMicroseconds(half);
    digitalWrite(BUZZER, LOW);  delayMicroseconds(half);
  }
}
void chirpUp()   { beep(1400,70); delay(40); beep(2000,70); delay(40); beep(2600,110); }
void chirpDown() { beep(2600,70); delay(40); beep(1400,140); }
/* DIN-DON da campanello */
void dingDong()  { beep(1319,180); delay(30); beep(1047,320); }

static uint8_t vislaFlags = 0, vislaCount = 0, vislaBatt = 0;
static bool    isVisla = false;

/* iBeacon (02 15) oppure beacon VISLA (FF FF 'V' ver ...) */
static bool isBeacon(ble_gap_evt_adv_report_t* r, bool* visla) {
  uint8_t b[31];
  uint8_t n = Bluefruit.Scanner.parseReportByType(r, BLE_GAP_AD_TYPE_MANUFACTURER_SPECIFIC_DATA, b, sizeof(b));
  *visla = false;
  if (n >= 9 && b[0] == 'V') {          // Bluefruit toglie i 2 byte di company ID
    *visla = true;
    vislaFlags = b[6]; vislaCount = b[7]; vislaBatt = b[8];
    return true;
  }
  for (int i = 0; i + 1 < n; i++) if (b[i] == 0x02 && b[i+1] == 0x15) return true;
  return false;
}

void scan_cb(ble_gap_evt_adv_report_t* rep) {
  bool v = false;
  if (rep->type.scan_response || !isBeacon(rep, &v)) { Bluefruit.Scanner.resume(); return; }
  bool isTgt = haveTgt && memcmp(target, rep->peer_addr.addr, 6) == 0;

  // ASSOCIATO: solo il suo MAC. LIBERO: insegue il più forte (+6dB di isteresi).
  if (!paired && (!haveTgt || (!isTgt && rep->rssi > ema + 6))) {
    memcpy(target, rep->peer_addr.addr, 6);
    haveTgt = true; ema = rep->rssi; isTgt = true;
    Serial.printf("\n>>> target: %02X:%02X:%02X:%02X:%02X:%02X\n",
      target[5],target[4],target[3],target[2],target[1],target[0]);
  }
  if (isTgt) {
    ema = EMA_A * rep->rssi + (1 - EMA_A) * ema; lastSeen = millis(); isVisla = v;
    if (v) {
      if (!countInit) { lastCount = vislaCount; countInit = true; }
      else if (vislaCount != lastCount) {
        ringPending += (uint8_t)(vislaCount - lastCount);   // recupera i click persi
        lastCount = vislaCount;
      }
    }
  }
  Bluefruit.Scanner.resume();
}

void macStr(char* s) {
  sprintf(s, "%02X:%02X:%02X:%02X", target[3], target[2], target[1], target[0]);
}

void draw(float r, bool lost) {
  oled.clearLine(0);
  oled.drawString(0, 0, mode == RADAR ? "RADAR " : "ARMING");
  oled.drawString(9, 0, paired ? "[LOCK]" : "[auto]");

  char b[20];
  if (lost) {
    oled.clearLine(1); oled.clearLine(2);
    oled.draw2x2String(0, 1, "  --  ");
    oled.clearLine(3); oled.clearLine(4);
    oled.draw2x2String(0, 3, "assente ");
  } else {
    sprintf(b, "%4d dBm", (int)r);
    oled.draw2x2String(0, 1, b);
    float d = pow(10.0, (-55.0 - r) / 20.0);
    sprintf(b, "~%4.1f m  ", d);
    oled.draw2x2String(0, 3, b);
  }

  int bars = lost ? 0 : constrain(map((int)r, RSSI_FAR, RSSI_NEAR, 0, 14), 0, 14);
  char bar[17]; bar[0] = '[';
  for (int i = 0; i < 14; i++) bar[i+1] = i < bars ? '#' : ' ';
  bar[15] = ']'; bar[16] = 0;
  oled.drawString(0, 5, bar);

  oled.clearLine(6);
  if (isVisla && !lost) {
    sprintf(b, "VISLA %s b%d%%", (vislaFlags & 1) ? "PRESS" : "  .  ", vislaBatt);
    oled.drawString(0, 6, b);
  } else if (haveTgt) { macStr(b); oled.drawString(0, 6, b); }
  else oled.drawString(0, 6, "--:--:--:--");

  oled.clearLine(7);
  if (mode == ARM) oled.drawString(0, 7, armed ? "** ARMATO **" : ">> DISARMATO");
}

void setup() {
  pinMode(BUZZER, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);
  Serial.begin(115200);
  oled.begin(); oled.setFont(u8x8_font_chroma48medium8_r);
  oled.clear(); oled.drawString(0, 0, "VISLA  RADAR");
  oled.drawString(0, 2, "click: modo");
  oled.drawString(0, 3, "hold : associa");

  Bluefruit.begin(0, 1);
  Bluefruit.setName("VislaRadar");
  Bluefruit.Scanner.setRxCallback(scan_cb);
  Bluefruit.Scanner.restartOnDisconnect(false);
  Bluefruit.Scanner.setInterval(160, 160);
  Bluefruit.Scanner.useActiveScan(false);
  Bluefruit.Scanner.start(0);

  beep(1200, 60); delay(60); beep(1800, 60);
  delay(1200); oled.clear();
}

void loop() {
  // --- pulsante: click = modo, hold = associa ---
  bool down = digitalRead(BUTTON) == LOW;
  if (down && !btnDown) { btnDown = millis(); longDone = false; }
  if (down && !longDone && millis() - btnDown > 1500) {
    longDone = true;
    if (paired) { paired = false; haveTgt = false; ema = -100;
                  Serial.println("### associazione ANNULLATA"); beep(800, 150); }
    else if (haveTgt) { paired = true; armed = true; hits = 0;
                  char m[20]; macStr(m);
                  Serial.printf("### ASSOCIATO a %s — seguo solo lui\n", m);
                  beep(2000,80); delay(50); beep(2600,80); delay(50); beep(3000,150); }
    else beep(500, 200);   // niente da associare
  }
  if (!down && btnDown) {
    if (!longDone && millis() - btnDown > 40) {     // click corto
      mode = (mode == RADAR) ? ARM : RADAR; hits = 0; armed = true;
      Serial.printf("\n### MODO: %s\n", mode == RADAR ? "RADAR" : "ARM");
      beep(1500, 80);
    }
    btnDown = 0;
  }

  uint32_t now = millis();
  bool lost = !haveTgt || (now - lastSeen > 2500);
  float r = lost ? -100 : ema;

  if (now - lastUi > 250) { lastUi = now; draw(r, lost); }

  // 🔔 CAMPANELLO: il beacon Visla ha annunciato una pressione
  while (ringPending) {
    ringPending--;
    Serial.printf("\n🔔 DING-DONG!  (count %u, batt %u%%)\n", vislaCount, vislaBatt);
    oled.clearLine(7); oled.drawString(0, 7, ">>> DING-DONG! ");
    dingDong();
    lastUi = 0;
  }

  if (mode == RADAR) {
    if (lost) return;
    int gap = map(constrain((int)r, RSSI_FAR, RSSI_NEAR), RSSI_FAR, RSSI_NEAR, 900, 60);
    if (now - lastBeep > (uint32_t)gap) {
      lastBeep = now;
      beep(map(constrain((int)r, RSSI_FAR, RSSI_NEAR), RSSI_FAR, RSSI_NEAR, 900, 2600), 18);
    }
  } else {
    if (!lost && r > ARM_ON) { if (hits < ARM_HITS) hits++; }
    else if (lost || r < ARM_OFF) hits = 0;

    if (armed && hits >= ARM_HITS) {
      armed = false; Serial.println("\n>> DISARMATO (proprietario vicino)"); chirpUp();
    }
    if (!armed && lost && now - lastSeen > LOST_MS) {
      armed = true; hits = 0; Serial.println("\n** RIARMATO (proprietario lontano)"); chirpDown();
    }
  }
}