📖 Handbuch

Dokumentation fĂŒr BLE Presence v1.0

Voraussetzungen

Master (Raspberry Pi)

Scanner

Zu trackende GerÀte

Jedes BLE-GerĂ€t, das regelmĂ€ĂŸig Advertisements sendet — z.B. BLE-Tags, Shelly-GerĂ€te, iBeacons. Protokolle: BLE/Eddystone/BTHome/IRK.

Button-Events funktionieren aktuell nur mit BTHome Devices, z.B. Shelly BLU Button.

Aufgrund der wechselnden MAC-Adresse und properitÀren Protokollen eignen sich keine Apple AirTags und AirTag-kompatible GerÀte, oder Samsung Tags.


iPhone / Android
Über das auslesen des Identity Resolving Key (IRK) können Telefone mit random MAC-Adrersse auch getrackt werden.
Hierzu ist zu sagen das die meisten aktuellen Telefone aggressive Energiespar-Modi nutzen und bei ausgeschaltetem Display oft keine Signale gesndet werden.


Empfehlung: Shelly BLU Button, Feasycom Eddystone Devices.

Installation

1. DietPi installieren

In den folgenden Links steht, wie man DietPi installiert:

Die nachfolgenden Dateien helfen dir, DietPi schnell und einfach fĂŒr den ersten Start zu konfigurieren:

dietpi.txt — Hier ist soweit alles vorkonfiguriert fĂŒr den normalen Gebrauch.

dietpi-wifi.txt — Hier mĂŒsst ihr, wenn ihr WLAN nutzen wollt, eure SSID (WLAN-Name) und WLAN-Passwort eintragen. Groß-/Kleinschreibung beachten!

WLAN

Beide Dateien liegen auf der SD-Karte im Boot-Verzeichnis, auf das ihr ĂŒber z.B. den Windows Explorer zugreifen könnt.

Explorer

2. DietPi erster Start

Nach dem Einstecken braucht der Pi ein paar Minuten, bis er per SSH erreichbar ist.

Um uns mit dem Pi zu verbinden, nutzen wir SSH. Ich empfehle PuTTY, das es HIER als Portable-Version gibt.

In PuTTY tragen wir den Hostname oder die IP ein und drĂŒcken „Open".

PuTTY

FĂŒr den ersten Login nutzt ihr:

login as: root
root@master's password: dietpi

DietPi fÀngt mit der Grundinstallation an.

Ihr werdet aufgefordert, die Passwörter fĂŒr die User root und dietpi zu Ă€ndern. Nutzt sichere Passwörter, auch wenn alles nur im internen Netz lĂ€uft.

Password

Die serial/UART-Konsole könnt ihr abgeschaltet lassen. „NO" wĂ€hlen.

Serial

Da wir ĂŒber die dietpi.txt schon alles vorkonfiguriert haben, kannst du hier ohne Änderung auf „Install" und „Select" gehen.

Software

Kurz noch bestÀtigen und es wird alles eingerichtet. Das kann je nach Hardware ein paar Minuten dauern.

Software OK
💡 Tipp
Wenn ihr weder den Hostname noch die IP kennt, nutzt Angry IP Scanner Portable

3. Master installieren

Einfach die untere Zeile kopieren und einfĂŒgen, das war es schon:

cd / && rm -rf install.* && wget http://ble-presence.de/download/install.sh && bash install.sh | tee /install.log

Nach dem Reboot ist die Web-OberflÀche mit admin/admin unter eurer IP erreichbar.

NĂ€chste Schritte

  1. Web-Interface öffnen: http://[raspberry-ip]
  2. Master Konfiguration durchfĂŒhren / MQTT konfigurieren
  3. ESP32 einrichten (min. 1 Scanner)
  4. Unter „GerĂ€te" → „Start Distributed Scan" einen ersten Scan starten
  5. Erkannte BLE-GerÀte benennen und per Alias zuordnen
💡 Tipp
Wichtig ist als erstes der MQTT Broker, ohne den geht fast nichts.
Alles andere könnt ihr auch spÀter noch einrichten.

GerÀte-Katalog

Hier findest du eine Übersicht kompatibler BLE-GerĂ€te, die mit BLE Presence getestet wurden. Die Tabelle wird laufend erweitert.

Hersteller Bezeichnung Formfaktor Protokolle UUID konfig. Button Batterie Lebensdauer Bewertung Bezugsquelle Preis ca.
Shelly BLU Button Button BTHome — ✅ ✅ CR2032 ~1 Jahr ⭐⭐⭐⭐⭐ shelly.com ~10 €
Shelly BLU Door/Window Sensor BTHome — — ✅ CR2032 ~1 Jahr ⭐⭐⭐⭐ shelly.com ~15 €
Feasycom FSC-BP108 Tag Eddystone / iBeacon ✅ — ✅ CR2477 ~2–3 Jahre ⭐⭐⭐⭐⭐ feasycom.com ~8 €
Feasycom FSC-BP104 Tag Eddystone / iBeacon ✅ — ✅ CR2032 ~1 Jahr ⭐⭐⭐⭐ feasycom.com ~5 €
Android Handy Beacon Scope App Smartphone iBeacon / Eddystone / AltBeacon ✅ — — — ⭐⭐⭐ Google Play Kostenlos
⚠ Wichtig — Feasycom UUID Ă€ndern!
Feasycom-Tags werden ab Werk mit einer Standard-UUID ausgeliefert. Diese muss vor der Nutzung ĂŒber die Feasycom-App geĂ€ndert werden, da sonst alle Tags die gleiche Kennung senden und nicht unterscheidbar sind.
💡 Tipp
GerÀte mit konfigurierbarer UUID sind ideal, da sie eine feste Kennung senden. GerÀte mit wechselnder MAC-Adresse (z.B. AirTags) funktionieren nicht mit BLE Presence.

Eddystone / UUID

Eddystone ist ein von Google entwickeltes BLE-Beacon-Protokoll. BLE Presence unterstĂŒtzt Eddystone-UID, das eine feste Kennung bestehend aus Namespace (10 Bytes) und Instance (6 Bytes) sendet.

Aufbau einer Eddystone-UID

Feld LĂ€nge Beschreibung
Namespace 10 Bytes Identifiziert die Gruppe/das System (z.B. alle Tags einer Wohnung)
Instance 6 Bytes Identifiziert das einzelne GerÀt innerhalb des Namespace

Konfiguration am Tag

  1. Tag per Hersteller-App verbinden
  2. Eddystone-UID-Frame aktivieren
  3. Namespace einheitlich fĂŒr alle Tags setzen (z.B. 0x00112233445566778899)
  4. Instance pro Tag eindeutig vergeben (z.B. 0x000000000001, 0x000000000002, 
)
  5. Sende-Intervall einstellen (empfohlen: 500–1000 ms)
💡 Tipp
Ein kĂŒrzeres Sende-Intervall verbessert die Erkennung, reduziert aber die Batterielebensdauer. 1000 ms ist ein guter Kompromiss.

iBeacon UUID

iBeacon ist Apples BLE-Beacon-Protokoll. Es verwendet eine dreiteilige Kennung aus UUID, Major und Minor.

Aufbau einer iBeacon-Kennung

Feld LĂ€nge Beschreibung
UUID 16 Bytes Identifiziert die Anwendung/das System
Major 2 Bytes Gruppe, z.B. Etage oder Bereich (0–65535)
Minor 2 Bytes Einzelnes GerĂ€t innerhalb der Gruppe (0–65535)

Konfiguration am Tag

  1. Tag per App verbinden
  2. iBeacon-Frame aktivieren
  3. UUID fĂŒr alle Tags gleich setzen (z.B. 12345678-1234-1234-1234-123456789ABC)
  4. Major z.B. pro Etage vergeben (1 = EG, 2 = OG)
  5. Minor pro GerÀt eindeutig vergeben (1, 2, 3, 
)
  6. Sende-Intervall einstellen (empfohlen: 500–1000 ms)
⚠ Wichtig
Die UUID/Namespace muss in BLE Presence nicht separat hinterlegt werden — das System erkennt die GerĂ€te automatisch anhand ihrer Advertisement-Daten beim Discovery-Scan.

Android App

FĂŒr die Einrichtung und Konfiguration von BLE-Tags wird die jeweilige Hersteller-App benötigt.

Hersteller-Apps

Hersteller App Funktionen
Feasycom Feasycom Tool UUID/Namespace konfigurieren, Sende-Intervall, TX Power, Protokoll wÀhlen
Shelly Shelly Smart Control Button-Aktionen konfigurieren, Firmware-Updates

UUID konfigurieren

Bei GerĂ€ten mit konfigurierbarer UUID (z.B. Feasycom-Tags) wird die UUID ĂŒber die Hersteller-App geĂ€ndert:

  1. Hersteller-App installieren und Bluetooth aktivieren
  2. Tag in der App suchen und verbinden
  3. UUID, Major/Minor (iBeacon) oder Namespace/Instance (Eddystone) setzen
  4. Änderungen speichern — das GerĂ€t sendet ab sofort mit der neuen Kennung
⚠ Wichtig
Feasycom-Tags mĂŒssen vor der Nutzung unbedingt per Feasycom-App mit einer eigenen UUID konfiguriert werden. Ab Werk senden alle Tags die gleiche Standard-UUID!

Android Handy als Beacon

Mit der App Beacon Scope kann jedes Android-Handy (ab Android 5.0) selbst als BLE-Beacon genutzt werden. Das Handy simuliert dann ein iBeacon-, Eddystone- oder AltBeacon-Signal und wird von BLE Presence wie ein normaler Tag erkannt.

  1. Beacon Scope aus dem Google Play Store installieren
  2. App öffnen und unter Transmit einen neuen Beacon erstellen
  3. Protokoll wÀhlen (iBeacon, Eddystone oder AltBeacon)
  4. UUID/Namespace konfigurieren und Beacon starten
  5. Die App sendet auch im Hintergrund, bis man den Beacon manuell stoppt
â„č Gut zu wissen
Beacon Scope kann auch als BLE-Scanner genutzt werden, um alle Beacons in der NĂ€he anzuzeigen — praktisch zum Testen und PrĂŒfen der SignalstĂ€rke (RSSI) vor der Einrichtung.
⚠ EinschrĂ€nkungen
Ein Handy als Beacon verbraucht deutlich mehr Akku als dedizierte Tags. Außerdem wird kein Batterie-Level an BLE Presence gemeldet. FĂŒr dauerhaften Einsatz sind echte BLE-Tags die bessere Wahl.

Apple

Content...

Lizenz

BLE Presence verwendet ein Lizenzsystem auf Basis von Ed25519-signierten SchlĂŒsseln. Jeder Lizenz-Key ist kryptografisch an eine bestimmte Hardware-ID gebunden und kann nicht auf andere GerĂ€te ĂŒbertragen werden.

Sinn der Lizenz ist interne VerschlĂŒsselungen zu generieren um das System jetzt und vor allem in noch kommenden Versionen abzusichern.

Lizenztypen

Typ GĂŒltigkeit Beschreibung
FULL Unbegrenzt Vollversion nach Kauf. Alle Features dauerhaft freigeschaltet.
DEV Unbegrenzt Entwickler-Lizenz, wird nur intern vergeben.
⚠ Ohne gĂŒltige Lizenz
Das System lÀuft weiterhin, aber die MQTT- und Home-Assistant-Integration ist deaktiviert. Lokale BLE-Scans funktionieren, die Daten werden jedoch nicht an externe Systeme weitergeleitet.

Hardware-ID

Bei der Installation wird automatisch eine eindeutige Hardware-ID fĂŒr den Raspberry Pi erzeugt. Sie hat das Format:

BLE-XXXX-XXXX

Die Hardware-ID findest du an folgenden Stellen:

Die ID basiert auf den Hardware-Eigenschaften des Raspberry Pi und bleibt bei Neuinstallation gleich, solange dieselbe Hardware verwendet wird.

💡 Tipp
Die Hardware-ID kann im Web-Interface per Klick in die Zwischenablage kopiert werden. Du brauchst sie fĂŒr die Trial-Anforderung und den Lizenzkauf.

Lizenz erstellen

  1. Öffne den Shop: ble-presence.de/shop.php
  2. Trage deine Hardware-ID und E-Mail-Adresse ein
  3. Der Key wird direkt auf der Seite angezeigt und kann kopiert oder als Datei heruntergeladen werden
  4. ZusÀtzlich erhÀltst du den Key per E-Mail
💡 Tipp
Der Key ist eine lange Zeichenkette. Nutze am besten die Kopier- oder Download-Funktion auf der Erfolgsseite, um Tippfehler zu vermeiden.

Lizenz aktivieren

Nach Erhalt des Lizenz-Keys wird dieser im Web-Interface des Masters aktiviert:

  1. Öffne das Web-Interface: http://[raspberry-ip]
  2. Gehe zu Konfiguration → Master → Lizenz
  3. Im Bereich „Lizenz" siehst du deine Hardware-ID und den aktuellen Status
  4. FĂŒge den Lizenz-Key in das Eingabefeld ein
  5. Klicke auf „Aktivieren"

Nach erfolgreicher Aktivierung wechselt der Status und die MQTT-Integration wird sofort freigeschaltet. Ein Neustart der Services ist nicht nötig.

⚠ Wichtig
Der Lizenz-Key ist an die angezeigte Hardware-ID gebunden. Ein Key, der fĂŒr eine andere Hardware-ID erzeugt wurde, wird abgelehnt.

Lizenzstatus

Der aktuelle Lizenzstatus wird im Web-Interface unter Konfiguration → Lizenz als farbiger Badge angezeigt:

Badge Bedeutung
🟱 LIZENZIERT Vollversion aktiv, alle Features freigeschaltet
đŸ”” DEVELOPER Entwickler-Lizenz, unbegrenzt gĂŒltig
⚫ KEINE LIZENZ Kein Key eingegeben — MQTT deaktiviert

Lizenz deaktivieren

Falls du den Key entfernen möchtest (z.B. bei Hardware-Wechsel), kannst du ĂŒber den „Deaktivieren"-Button im Lizenz-Bereich die aktive Lizenz löschen. Die MQTT-Integration wird daraufhin wieder gesperrt.

Troubleshooting Lizenz

Problem Lösung
Key wird abgelehnt Hardware-ID prĂŒfen — stimmt sie mit dem Key ĂŒberein? Key erneut kopieren (keine Leerzeichen, vollstĂ€ndig).
MQTT trotz Lizenz inaktiv PrĂŒfe ob der MQTT-Broker konfiguriert ist: Konfiguration → MQTT. Lizenzstatus in den Logs prĂŒfen: sudo journalctl -u ble_aggregator -n 50
Hardware-ID geÀndert Die ID Àndert sich nur bei einem Wechsel der Hardware. In diesem Fall einen neuen Key anfordern.

Web-Konfiguration Master

Die gesamte Konfiguration erfolgt ĂŒber Konfiguration > Master im Web-Interface. Änderungen werden in INI-Dateien gespeichert und die entsprechenden Services automatisch neu gestartet.

Konfigurationsgruppen

Gruppe Beschreibung
☁ Main MQTT-Broker, Topics, Home Assistant
đŸŽ›ïž Aggregator Timeouts, RSSI-Filter, Button-Events, Statistik, Tracking
📡 Scanner Scan-Daemon, Cron-Jobs, Logging
💡 Logging Debug Logging
📾 Screenshot: Konfigurationsseite

MQTT Einstellungen

Parameter Default Beschreibung
base_topic presence Basis-Topic fĂŒr alle MQTT-Nachrichten
scanner_name master Name dieses Scanners
broker localhost MQTT Broker IP-Adresse
port 1883 MQTT Port
username MQTT Benutzername
password MQTT Passwort
websocket_port 9001 MQTT Websocket Port
â„č Hinweis
Habt ihr schon einen Broker, dann könnt ihr diesen auch nutzen.
Einfach den Haken bei "Lokalen Mosquitto Broker aktivieren & konfigurieren?" entfernen.
Alle Daten gelten dann fĂŒr den externen Broker.

Aggregator

Der Aggregator sammelt Daten aller Scanner, bestimmt den besten Scanner pro GerÀt und publiziert das aggregierte Ergebnis.

Aggregator Einstellungen

Parameter Default Beschreibung
device_timeout 120 Sekunden bis ein GerÀt als offline gilt
scanner_timeout 180 Sekunden bis ein Scanner als offline gilt
cleanup_interval 60 Wie oft auf Timeouts geprĂŒft wird
prefer_stronger_rssi false Immer den stÀrksten Scanner verwenden
â„č Hinweis Die effektive Offline-Erkennung kann bis zu device_timeout + cleanup_interval dauern. Beispiel: 120 + 60 = max. 180 Sekunden.

Aggregator Button Events

Parameter Default Beschreibung
event_mode immediate / collect Sendet Button-Event sofort, oder wartet collect_time (0.3 Sek.) und sendet vom stÀrksten Scanner
deduplication_timeout 1.0 Sekunden bis der nÀchste Button-Event des gleichen GerÀts weitergegeben wird.

Aggregator UDP Export

Parameter Default Beschreibung
enabled Sollen UDP-Nachrichten genutzt werden, oder nur MQTT.
host IP des NetzwerkgerÀts, das die UDP-Nachricht bekommen soll, z.B. MiniServer.
port Port der vom EmpfĂ€nger fĂŒr die Nachricht genutzt wird.

Statistik

Parameter Default Beschreibung
enabled Sollen die Statistiken aufgezeichnet werden?
directory /var/www/html/ble/statistics In diesem Verzeichnis werden die Daten gesammelt. Nur in begrĂŒndeten FĂ€llen Ă€ndern!
cleanup_hours 24 Alle X Stunden werden alte Daten gelöscht. (Anwesenheit Àlter 30 Tage / Batterie Àlter 2 Jahre)

Scanner

Hier kann eingestellt werden ob der Master auch ein Scanner sein soll, und Optionen fĂŒr alle Scanner eingestellt werden.

Scan Daemon Steuerung

Parameter Default Beschreibung
Starten / Stoppen gestoppt Hier kann der Scanner des Masters aktiviert werden. Dann scannt der Master auch nach Devices. (Kein Batterie-Scan)

Allgemein

Parameter Default Beschreibung
report_offline_battery nein Wenn ein GerĂ€t offline ist, wĂŒrde bei Ja 0% Batterie zurĂŒck kommen. Bei Nein behĂ€lt er den zuletzt gesehenen Wert.

Discovery / Scan

Parameter Default Beschreibung
timeout 15 So lange sucht er GerĂ€te im Discovery-Modus und meldet sie danach zurĂŒck.

Zeitplan

Parameter Default Beschreibung
battery_scan_time 3:00 Um diese Uhrzeit startet der automatische Batterie-Scan. Es ist eine Chain: Es ermittelt immer nur ein Scanner die Daten. Wenn dieser fertig ist, startet der nÀchste.

UDP Einstellungen

Parameter Default Beschreibung
enabled Hier wird von jedem Scanner einzeln an UDP gesendet, nicht ĂŒber den Aggregator. (Viele Daten!)
host IP des NetzwerkgerÀts, das die UDP-Nachricht bekommen soll, z.B. MiniServer.
port Port der vom EmpfĂ€nger fĂŒr die Nachricht genutzt wird.

Logging

Das System kann Daten sammeln. Um nicht unnötig viele Logs zu schreiben kann man hier das Level einstellen.

Logging Einstellungen

Parameter Default Beschreibung
log_level WARNING Level des System-Log
console_level INFO Level der Konsolen Ausgabe
bleak_level WARNING Level des Bleak-Stack fĂŒr Bluetooth (Wenn Master als Scanner arbeitet)

GerÀte verwalten

Unter GerÀte werden alle bekannten BLE-Devices aufgelistet. Hier kann man:

Distributed Scan

Ein koordinierter Scan ĂŒber alle verbundenen Scanner (Master + ESP32 Clients). Findet neue BLE-GerĂ€te im gesamten Haus gleichzeitig.

GerÀte Verwaltung

ESP32 Hardware

Folgende ESP32-Varianten werden unterstĂŒtzt:

Hardware Chip RAM Hinweis
ESP32 DevKit / WROOM ESP32-WROOM-32 ~60KB frei GĂŒnstiger Standard
ESP32-D1 Mini ESP32-WROOM-32 ~60KB frei Kompakter Formfaktor
ESP32-S3 ohne PSRam ESP32-S3 ~70KB frei Empfohlen — mehr RAM fĂŒr BLE
Xiao ESP32-S3 mit PSRam ESP32-S3 ~135KB frei Sehr kompakt
Olimex ESP32-POE (LAN8720) ESP32-WROOM-32 ~95KB frei LAN-POE
WT32-ETH01 (LAN8720) ESP32-WROOM-32 ~95KB frei LAN
Olimex ESP32-GATEWAY (LAN8710) ESP32-WROOM-32 ~95KB frei LAN
Waveshare ESP32-S3-ETH (W5500) ESP32-S3 ~135KB frei LAN / POE möglich
LilyGO T-ETH-Lite S3 (W5500) ESP32-S3 ~135KB frei LAN / POE möglich
💡 Empfehlung ESP32-S3 ist die beste Wahl — mehr freier RAM bedeutet stabileres BLE-Scanning mit NimBLE.

Firmware flashen

Erstinstallation (Full-Version)

    OPTION 1
  1. Über den ESP Web Tools Flasher
  2. Dieses Tool lÀuft nur auf Microsoft Edge, Chrome oder Chrome basierenden Browsern
  3. Mit dem ESP Flash Tool flashen

    OPTION 2
  1. Firmware unter WebUI → System → Udapte Firmware Downloads herunterladen (Full-Version)
  2. ESP32 per USB an den Computer anschließen
  3. Mit dem ESP Flash Tool flashen

OTA Updates

ESP32-Scanner können remote aktualisiert werden:

Vom Scanner selbst

  1. Scanner-WebUI öffnen → Update klicken
  2. Update-Firmware (.bin) hochladen
  3. Scanner startet automatisch neu

Zentral vom Master

  1. System → Scanner Firmware-Status öffnen
  2. Veraltete Scanner werden markiert (gelbe Legende)
  3. Per Klick auf „Update" wird die neue Firmware ĂŒbertragen
â„č Firmware-Varianten Es gibt getrennte Firmware-Dateien fĂŒr ESP32-WROOM und ESP32-S3. Das System erkennt die Hardware automatisch.
Firmware Update

ESP32 Einrichtung

  1. Nach dem Flashen öffnet der ESP32 einen WLAN-Hotspot - NAME: ESP32-BLE-XXXXXX
  2. Mit dem Hotspot verbinden - Passwort: 12345678 und Setup-Seite öffnen
  3. WLAN-Zugangsdaten, Master-URL und Scanner-Name (muss einzigartig sein) konfigurieren
  4. Feste-IP vergeben wenn gewĂŒnscht, und speichern
  5. Nach dem speichern startet der ESP32
  6. Am Master WebUI unter Konfiguartion → ESP32-Clients den Scanner anlegen.
  7. Screenshot
  8. Name muss gleich der Scanner ID sein.
  9. API-Key kopieren, speichern, und den Key im WebUI des Scanner eintragen.
  10. ESP32 Scanner Web-UI

ESP32 Web-UI

Jeder ESP32 Scanner hat ein eigenes Web-Interface unter seiner IP-Adresse mit:

Installieren

Im WebUI des Shelly -> Scripts -> Create Script

  1. Script-Name vergeben
  2. Skript aus der nĂ€chsten Sektion kopieren und einfĂŒgen
  3. API-Key erstellen Master -> WebUI -> Konfiguration -> Shelly Scanner.
  4. IP vom Master und den API Key im Skript eintragen.
  5. Skript speichern und starten
💡 Empfehlung
Im Shelly WebUI -> Scripts den Schalter "run on startup" aktivieren.
So startet das Skript bei jedem Start des Shelly automatisch.

Skript

Hier das Skript fĂŒr den Shelly als Scanner.



let C = {
  name: "Scanner - Name",
  host: "IP -> Master",
  api_key: "API-KEY -> Master WEBUI",
  scan_interval: 5,
  hb_interval: 10,
  offline_after: 30,
  debug: false
};

let VERSION="1.0.0";
let BTHOME="fcd2";
let EDDYSTONE="feaa";
let CALIB_UUID_PREFIX="B1E0CA11";
let _P="/shelly/";
let _H="0123456789abcdef";
let _s3="\x04\x05\x0A\x42\x4B\x4C";
let _s4="\x3E\x4D\x4E\x4F\x50";
let _s2="\x02\x03\x06\x07\x08\x0B\x0C\x0E\x12\x13\x14\x3D\x40\x41\x43\x45\x46\x47\x48\x49\x4A\x51\x52";
 
function objSize(id){
  let c=String.fromCharCode(id);
  if(_s3.indexOf(c)>=0)return 3;
  if(_s4.indexOf(c)>=0)return 4;
  if(_s2.indexOf(c)>=0)return 2;
  if(id<=0x53)return 1;
  return -1;
}
 
let known={};
let knownUuid={};
let discMode=false;
let discDevs={};
let discTimer=null;
let devs={};
let pending=[];
let ready=false;
let scanning=false;
let bootTs=0;
let lastPid={};
let calibBuf={};
let batchTimer=null;
let hbTimer=null;
let pollTimer=null;
let retryTimer=null;
let offlineInitTimer=null;
 
function ts(){return Math.floor(Date.now()/1000);}
 
function mem(tag){
  Shelly.call("Sys.GetStatus",{},function(r){
    if(r)print("[MEM] "+tag+" free="+r.ram_free);
  });
}
 
function macKey(a){return a.split(":").join("").toLowerCase();}
 
function toHex(b){return _H[(b>>4)&0xF]+_H[b&0xF];}
 
function url(p){return "http://"+C.host+":8099"+p;}
 
function post(p,body,cb){
  if(C.api_key)body.api_key=C.api_key;
  Shelly.call("HTTP.Request",{
    method:"POST",url:url(p),body:JSON.stringify(body),
    content_type:"application/json",timeout:5
  },function(r,ec){
    if(ec!==0||!r||!r.body){if(cb)cb(null);return;}
    try{if(cb)cb(JSON.parse(r.body));}catch(e){if(cb)cb(null);}
  });
}
 
function get(p,cb){
  let u=url(p);
  if(C.api_key)u+=(u.indexOf("?")>=0?"&":"?")+"api_key="+C.api_key;
  Shelly.call("HTTP.Request",{
    method:"GET",url:u,timeout:5
  },function(r,ec){
    if(ec!==0||!r||!r.body){if(cb)cb(null);return;}
    try{if(cb)cb(JSON.parse(r.body));}catch(e){if(cb)cb(null);}
  });
}
 
function parseBTHome(raw){
  if(!raw||raw.length<2)return null;
  let result={pid:-1,battery:-1,button:-1};
  if(raw.charCodeAt(0)&0x01)return null;
  let i=1;
  while(i=raw.length)break;
      i+=1+raw.charCodeAt(i);
      continue;
    }
    let size=objSize(id);
    if(size<0||i+size>raw.length)break;
    if(id===0x00||id===0x01||id===0x3A){
      let val=0;
      for(let b=0;b100)return 100;
  return pct;
}
 
function parseIBeacon(advData){
  if(!advData)return null;
  let raw=BLE.GAP.ParseManufacturerData(advData);
  if(!raw||raw.length<25)return null;
  if(raw.charCodeAt(0)!==0x4C||raw.charCodeAt(1)!==0x00)return null;
  if(raw.charCodeAt(2)!==0x02||raw.charCodeAt(3)!==0x15)return null;
  let u="";
  for(let i=4;i<20;i++){
    u+=toHex(raw.charCodeAt(i));
    if(i===7||i===9||i===11||i===13)u+="-";
  }
  let txp=raw.charCodeAt(24);
  if(txp>127)txp-=256;
  return {uuid:u,major:(raw.charCodeAt(20)<<8)|raw.charCodeAt(21),minor:(raw.charCodeAt(22)<<8)|raw.charCodeAt(23),txpower:txp};
}
 
function isCalibBeacon(ib){
  if(!ib||!ib.uuid)return false;
  return ib.uuid.substring(0,8).toUpperCase()===CALIB_UUID_PREFIX;
}
 
function handleCalibBeacon(ib,rssi){
  let key=ib.major+":"+ib.minor;
  let e=calibBuf[key];
  if(!e){e={s:0,c:0,t:ib.txpower};calibBuf[key]=e;}
  e.s+=rssi;
  e.c++;
}
 
function sendCalibration(){
  let readings=[];
  for(let key in calibBuf){
    let e=calibBuf[key];
    if(e.c===0)continue;
    let parts=key.split(":");
    let avg=Math.round(e.s/e.c);
    readings.push({major:parseInt(parts[0]),minor:parseInt(parts[1]),rssi:avg,distance:Math.round(Math.pow(10,(e.t-avg)/25)*100)/100});
  }
  calibBuf={};
  if(readings.length===0)return;
  if(C.debug)print("[CAL] "+readings.length);
  post(_P+"calibration",{scanner:C.name,readings:readings},null);
}
 
function sendButtonEvent(mac,event,rssi,pid){
  let names=["none","press","double_press","triple_press","long_press"];
  let name=(event>=1&&event<=4)?names[event]:"unknown";
  if(C.debug)print("[BTN] "+mac+" -> "+name+" pid="+JSON.stringify(pid));
  post(_P+"event",{scanner:C.name,mac:mac,event:name,rssi:rssi,pid:pid,timestamp:ts()},null);
}
 
function loadKnown(macs){
  known={};
  knownUuid={};
  if(!macs)return;
  for(let i=0;i0)knownUuid[m]=true;
  }
}
 
function register(){
  print("[REG] Registering...");
  let info=Shelly.getDeviceInfo();
  let wifi=Shelly.getComponentStatus("wifi");
  post(_P+"register",{
    scanner:C.name,
    ip:(wifi&&wifi.sta_ip)?wifi.sta_ip:"0.0.0.0",
    model:info?(info.model||"Shelly"):"Shelly",
    firmware:info?(info.fw_id||"?"):"?",
    scan_interval:C.scan_interval
  },function(d){
    if(!d||!d.success){
      print("[REG] Failed, retry 10s");
      if(retryTimer)Timer.clear(retryTimer);
      retryTimer=Timer.set(10000,false,register);
      return;
    }
    loadKnown(d.known_macs);
    if(C.debug){
      let cM=0,cU=0;
      for(let k in known)cM++;
      for(let k in knownUuid)cU++;
      print("[REG] OK: "+cM+"M "+cU+"U");
    }else{
      print("[REG] OK");
    }
    mem("post-reg");
    if(d.scan_interval)C.scan_interval=d.scan_interval;
    ready=true;
    startScan();
    startTimers();
  });
}
 
function startScan(){
  if(scanning)return;
  BLE.Scanner.Subscribe(onScan);
  BLE.Scanner.Start({duration_ms:-1,active:true});
  scanning=true;
}
 
function onScan(ev,res){
  if(ev!==BLE.Scanner.SCAN_RESULT||!res||!res.addr)return;
 
  if(discMode){
    let mac=res.addr.toUpperCase();
    let ib=parseIBeacon(res.advData);
    let dk=ib?ib.uuid:macKey(mac);
    if(!discDevs[dk]||res.rssi>discDevs[dk].r){
      let entry={m:mac,n:res.local_name||"",r:res.rssi,tp:"mac"};
      if(ib){
        entry.tp="ibeacon";
        entry.iu=ib.uuid.toUpperCase();
        entry.ima=ib.major;
        entry.imi=ib.minor;
      }else if(res.service_data&&res.service_data[BTHOME]){
        entry.tp="bthome";
      }
      discDevs[dk]=entry;
    }
    return;
  }
 
  let ib=parseIBeacon(res.advData);
  if(ib&&isCalibBeacon(ib)){
    handleCalibBeacon(ib,res.rssi);
    return;
  }
 
  let mac=res.addr.toUpperCase();
  let mk=macKey(mac);
  let isMac=known[mk]===true;
  let isUuid=false;
  let dk=mk;
 
  if(!isMac){
    if(ib&&knownUuid[ib.uuid]){
      isUuid=true;
      dk=ib.uuid;
    }else{
      return;
    }
  }
 
  let t=ts();
  let d=devs[dk];
  if(!d){
    d={o:false,s:0,r:-100,m:mac,t:0,b:-1,u:isUuid,ui:""};
    devs[dk]=d;
  }
 
  d.s=t;
  d.r=res.rssi;
  d.m=mac;
  if(isUuid&&ib)d.ui=ib.uuid;
 
  let bth=null;
  if(res.service_data&&res.service_data[BTHOME]){
    bth=parseBTHome(res.service_data[BTHOME]);
  }
 
  if(bth&&bth.pid>=0){
    if(lastPid[dk]===bth.pid)return;
    lastPid[dk]=bth.pid;
  }
 
  if(bth&&bth.battery>=0&&bth.battery<=100)d.b=bth.battery;
 
  if(d.b<0&&res.service_data&&res.service_data[EDDYSTONE]){
    let eB=parseEddystoneBatt(res.service_data[EDDYSTONE]);
    if(eB>=0)d.b=eB;
  }
 
  if(bth&&bth.button>=1){
    sendButtonEvent(mac,bth.button,res.rssi,bth.pid);
  }
 
  let wasOff=!d.o;
  d.o=true;
 
  let scan={address:mac,rssi:res.rssi,is_online:1,local_name:""};
  if(d.b>=0)scan.battery=d.b;
 
  if(isUuid&&ib){
    scan.ibeacon_uuid=ib.uuid;
    scan.ibeacon_major=ib.major;
    scan.ibeacon_minor=ib.minor;
    scan.ibeacon_txpower=ib.txpower;
  }
 
  if(wasOff){
    if(C.debug){
      let l=isUuid?dk.substring(0,8)+"...":mac;
      print("[ON] "+l+" R:"+res.rssi);
    }
    if(pending.length<20)pending.push(scan);
    d.t=t;
    sendBatch();
  }else if(t-d.t>=C.scan_interval){
    if(pending.length<20)pending.push(scan);
    d.t=t;
  }
}
 
function checkOffline(){
  let t=ts();
  let ct=C.offline_after*3;
  for(let mk in devs){
    let d=devs[mk];
    let age=t-d.s;
    if(d.o&&age>=C.offline_after){
      d.o=false;
      d.t=t;
      if(C.debug){
        let l=d.u?d.ui.substring(0,8)+"...":d.m;
        print("[OFF] "+l);
      }
      let os={address:d.m,rssi:-100,is_online:0,local_name:""};
      if(d.u&&d.ui)os.ibeacon_uuid=d.ui;
      if(pending.length<20)pending.push(os);
    }
    if(!d.o&&age>=ct){
      delete devs[mk];
      delete lastPid[mk];
    }
  }
}
 
function sendBatch(){
  if(!ready)return;
  checkOffline();
  if(pending.length===0)return;
  let scans=pending;
  pending=[];
  if(C.debug)print("[TX] "+scans.length);
  post(_P+"scan",{scanner:C.name,scans:scans,discovery:false},function(d){
    if(!d)print("[ERR] Batch failed");
  });
}
 
function heartbeat(){
  if(!ready)return;
  sendCalibration();
  Shelly.call("Sys.GetStatus",{},function(sr){
    let hb={scanner:C.name,uptime:ts()-bootTs};
    if(sr&&sr.ram_free){
      hb.free_mem=sr.ram_free;
      if(C.debug)print("[MEM] hb free="+sr.ram_free);
    }
    post(_P+"heartbeat",hb,function(d){
      if(!d||!d.success){
        print("[ERR] HB failed");
        ready=false;
        if(retryTimer)Timer.clear(retryTimer);
        retryTimer=Timer.set(5000,false,register);
      }
    });
  });
}
 
function offlineInit(){
  offlineInitTimer=null;
  let cnt=0;
  for(let mk in known){
    if(!devs[mk]){
      if(pending.length<20){
        pending.push({address:mk,rssi:-100,is_online:0,local_name:""});
        cnt++;
      }
    }
  }
  for(let uu in knownUuid){
    if(!devs[uu]){
      let c="";
      for(let j=0;j0)sendBatch();
}
 
function startTimers(){
  if(batchTimer)Timer.clear(batchTimer);
  batchTimer=Timer.set(C.scan_interval*1000,true,sendBatch);
  if(hbTimer)Timer.clear(hbTimer);
  hbTimer=Timer.set(C.hb_interval*1000,true,heartbeat);
  if(offlineInitTimer)Timer.clear(offlineInitTimer);
  offlineInitTimer=Timer.set(C.offline_after*1000,false,offlineInit);
  if(pollTimer)Timer.clear(pollTimer);
  pollTimer=Timer.set(300000,true,pollDevices);
}
 
function startDiscovery(duration){
  if(discMode)return "already running";
  if(!duration||duration<5)duration=15;
  discMode=true;
  discDevs={};
  if(discTimer)Timer.clear(discTimer);
  discTimer=Timer.set(duration*1000,false,discEnd);
  return "started";
}
 
function discEnd(){
  discMode=false;
  discTimer=null;
  sendDiscoveryResults();
}
 
function sendDiscoveryResults(){
  let devices=[];
  for(let k in discDevs){
    let dd=discDevs[k];
    let entry={mac:dd.m,name:dd.n,rssi:dd.r,type:dd.tp};
    if(dd.iu){
      entry.ibeacon_uuid=dd.iu;
      entry.ibeacon_major=dd.ima;
      entry.ibeacon_minor=dd.imi;
    }
    devices.push(entry);
  }
  discDevs={};
  let dUrl="http://"+C.host+"/ble/api.php?action=report_discovery";
  if(C.api_key)dUrl+="&api_key="+C.api_key;
  Shelly.call("HTTP.Request",{
    method:"POST",url:dUrl,
    body:JSON.stringify({client_id:C.name,timestamp:ts(),devices:devices}),
    content_type:"application/json",timeout:15
  },function(r,ec){
    if(ec!==0||!r||r.code!==200)print("[DISC] Send failed");
  });
}
 
function pollDevices(){
  get(_P+"devices?scanner="+C.name,function(d){
    if(!d||!d.success||!d.known_macs)return;
    loadKnown(d.known_macs);
  });
}
 
function status(){
  let kon=0,koff=0,kn=0,ku=0;
  for(let mk in devs){if(devs[mk].o)kon++;else koff++;}
  for(let k in known)kn++;
  for(let k in knownUuid)ku++;
  return JSON.stringify({ready:ready,known_mac:kn,known_uuid:ku,online:kon,offline:koff,uptime:ts()-bootTs,pending:pending.length,discovery:discMode});
}
 
bootTs=ts();
print("=== BLE Presence Shelly v"+VERSION+" ===");
print("[BLE] "+C.name+" -> "+C.host+(C.api_key?" [API]":" [NO KEY]"));
mem("boot");
retryTimer=Timer.set(3000,false,register);

Installieren

Content...

Grundriss einrichten

Unter Position → Grundriss Editor wird der Grundriss konfiguriert:

  1. Grundriss hochladen — PNG oder JPG des Grundrisses
  2. Maßstab kalibrieren — Pixel pro Meter einstellen (z.B. 68 px/m). Wichtig fĂŒr korrekte Distanzberechnung!
  3. Zonen zeichnen — RĂ€ume als farbige Polygone auf dem Grundriss markieren
  4. Etagen verwalten — Mehrere Stockwerke mit eigenem Grundriss
💡 Tipp Den Maßstab am besten anhand einer bekannten Strecke kalibrieren (z.B. eine Wand mit bekannter LĂ€nge in Metern).
Grundriss Editor

Scanner platzieren

Im Grundriss-Editor werden Scanner per Drag & Drop auf dem Plan positioniert. Die Position bestimmt die Trilateration:

Anti-Flapping

Das Anti-Flapping-System verhindert, dass GerÀte stÀndig zwischen RÀumen hin- und herspringen.

Parameter Default Beschreibung
enabled true Master-Schalter: Aktiviert Positionierung komplett
antiflapping true Anti-Flapping mit Hysterese
hysteresis 8 dB — neuer Scanner muss so viel besser sein
stability_time 30 Sekunden — Kandidat muss so lange besser bleiben
rssi_history 5 Anzahl RSSI-Werte fĂŒr Rolling Average

Wie es funktioniert

  1. FĂŒr jedes GerĂ€t wird der aktuelle „beste Scanner" (=Raum) gespeichert
  2. Ein neuer Scanner wird erst dann Kandidat, wenn er den aktuellen um hysteresis dB ĂŒbertrifft
  3. Der Kandidat muss fĂŒr stability_time Sekunden konstant besser bleiben
  4. Erst dann wird der Raum tatsÀchlich gewechselt
  5. Ein Kandidat bekommt 3 Fehlversuche, bevor er verworfen wird
⚠ Empfehlung Hysterese nicht unter 6 dB setzen — BLE-Signale schwanken naturgemĂ€ĂŸ stark. Höhere Werte (8–10 dB) ergeben stabilere Raumzuordnung.

Live Map

Die Live Map unter Position zeigt alle GerÀte in Echtzeit auf dem Grundriss:

Die Karte aktualisiert sich ĂŒber MQTT WebSocket in Echtzeit. GerĂ€te bewegen sich mit CSS-Transitions sanft ĂŒber den Plan.

Live Map

Loxone

Anbindung an Loxone ĂŒber UDP-Export:

  1. In der Konfiguration Aggregator UDP Export aktivieren
  2. Ziel-IP und Port des Loxone Miniservers eintragen
  3. In Loxone einen UDP-Eingang anlegen
  4. Im WebUI → GerĂ€te auf das Symbol klicken.
    Hier findet ihr die Befehlserkennung fĂŒr den UDP Eingang.

Home Assistant

BLE Presence integriert sich ĂŒber MQTT Auto-Discovery in Home Assistant:

# Beispiel-Automation (Home Assistant YAML)
automation:
  - trigger:
      platform: state
      entity_id: sensor.ble_dieter
      to: "Wohnzimmer"
    action:
      service: light.turn_on
      target:
        entity_id: light.wohnzimmer

API

Es können alle Daten auch ĂŒber den API-Server im Json Format ausgelesen werden.

URL Beschreibung
http://raspberrypi:8099/ Alles
http://raspberrypi:8099/stats Statistik
http://raspberrypi:8099/devices Alle GerÀte
http://raspberrypi:8099/online Nur Online
http://raspberrypi:8099/scanners Scanner
http://raspberrypi:8099/device/AA:BB:CC:DD:EE:FF o. UUID Device einzeln
http://raspberrypi:8099/calibration/auto Werte der Autokalibrierung

MQTT Topics

BLE Presence verwendet folgende Topic-Struktur (Beispiel mit base_topic = presence):

Topic Beschreibung
presence/master/discovery Scan-Ergebnisse des Masters
presence/[scanner]/discovery Scan-Ergebnisse eines Scanners
presence/aggregated/discovery Aggregierte Ergebnisse (bester Scanner pro GerÀt)
presence/aggregated/positioning Berechnete Positionen (Trilateration)
presence/aggregated/scanner/+/status Scanner Online/Offline Status

Aggregated Discovery Payload

{
  "devices": [
    {
      "mac": "DC:0D:30:23:07:69",
      "alias": "Dieter",
      "rssi": -64,
      "scanner": "west",
      "battery": 100,
      "status": "online",
      "room": "Wohnzimmer",
      "last_seen": "2026-02-06T12:45:00"
    }
  ]
}

Statistiken

Unter Statistik werden Anwesenheits- und Batteriedaten aufgezeichnet:

Statistiken

Logs & Debugging

Logs können ĂŒber die Web-UI oder direkt via Journal eingesehen werden:

# Aggregator Logs
sudo journalctl -u ble_aggregator -f

# Scan Daemon Logs
sudo journalctl -u ble_tool -f

# ESP32 WebSerial Monitor
# Im ESP32-WebUI: "Monitor" Button

Log-Level

In der Konfiguration unter Logging einstellbar:

Level Beschreibung
DEBUG Alle Details (sehr viel Output)
INFO Normaler Betrieb (empfohlen)
WARNING Nur Warnungen und Fehler
ERROR Nur Fehler

System Updates

Unter WebUI → System → Update wird die installierte Version mit der verfĂŒgbaren verglichen. Updates können direkt ĂŒber das Web-UI eingespielt werden.

ESP32 Updates

Unter WebUI → System → Update wird die installierte Version der Scanner mit der verfĂŒgbaren verglichen. Updates können per Remote-Update ĂŒber das Web-UI eingespielt werden.

Troubleshooting

Dashboard zeigt „Nicht verbunden"

GerÀte werden nicht erkannt

ESP32 verbindet sich nicht

GerÀte springen zwischen RÀumen

Hoher RAM-Verbrauch am ESP32

💡 Allgemeiner Tipp
Die meisten Probleme lassen sich ĂŒber die Logs diagnostizieren.
Im Zweifel log_level = DEBUG setzen und die Ausgabe prĂŒfen.
✏ CMS