Radio Presence Scanner (BLE)
April 26, 2026 · View on GitHub
A lightweight tool to estimate nearby presence from BLE radio signals (phones, earbuds, wearables).
Design notes:
- The library is poll-based (no internal background daemon).
- You get fresh data every time you call
sample()/sample_sync(). - BLE scan duration is configured via
ble_scan_secondsinconfig.yaml.
Tested on macOS and Raspberry Pi.

Usage Modes
The project supports two main usage modes:
- Library / module mode (no HTTP server):
- Use
PresenceDetectordirectly from Python. - Good for embedding presence logic into your own application.
- CLI examples in this mode: one-shot and
--loop.
- Use
- Server mode (HTTP + dashboard UI):
- Start backend with
radio-presence-scanner --server. - Serves both API (
/api/state) and test dashboard UI (/) from one process. - Good for quick testing/visualization during development.
- Start backend with
Requirements
- Python 3.11+
- Bluetooth enabled
- On macOS, grant Bluetooth permissions to your terminal/IDE
Installation
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -e .
Configuration
Minimal config.yaml example:
# Radius in meters to count a BLE device as nearby
radius_m: 2.0
# Remove device after this many consecutive missed scans
max_missed_scans: 2
# Distance smoothing factor: 0=very stable, 1=very reactive
ema_alpha: 0.55
# Expected RSSI at 1 meter (dBm) when beacon tx power is missing
tx_power_default: -59
# Environment attenuation factor: 2.0 open space, 2.2-3.0 real world
path_loss_n: 2.2
# Duration of each BLE scan in seconds
ble_scan_seconds: 4.0
# Unique-visitor history bucket size in minutes
visitor_bucket_minutes: 10
# Retention for unique-visitor history in hours
visitor_retention_hours: 4.0
CLI Usage
# One-shot sample
radio-presence-scanner
radio-presence-scanner --config /path/to/custom-config.yaml
# Built-in continuous loop mode (no extra pause)
radio-presence-scanner --loop
Polling behavior:
- Without
--loop, CLI executes one poll and exits. - With
--loop, CLI performs continuous scans with no extra pause between scans. - Each poll includes one BLE scan of
ble_scan_secondsfrom config.
CLI Output
Default CLI output is human-readable and optimized for quick inspection:
- One-line summary (
BLE,unique,changes,delta_vs_prev) near_deviceblock with nearby BLE entries only- Device lines include only available fields (missing values are omitted).
Example:
2026-03-22T13:25:29+00:00 | BLE=16 | unique=12 | changes=4 | delta_vs_prev: +1 appeared, 1 became_near, 2 rssi_shift
near_device: 7
- ble addr=137f6ecbaf69 name="AirPods Pro di Francesco" guess=apple/apple/audio/high mfg="Apple, Inc." distance=0.48m conf=high rssi=-52
BLE Guess Fields
vendor: guessed vendor (apple,google,samsung,android_oem,unknown)platform: guessed platform (apple,android,unknown)family: guessed category (phone,audio,wearable,beacon,unknown)confidence: guess confidence (high,medium,low)
guess=vendor/platform/family/confidence is heuristic and best-effort; it is not a guaranteed device identity.
Notes on Apple BLE Anonymity
apple_unknownidentifies BLE advertisements with Apple manufacturer data but no public device name.- On Linux/Raspberry Pi these are commonly shown with randomized MAC addresses.
- On macOS, BLE addresses may appear as CoreBluetooth UUID-like identifiers instead of MAC addresses.
mfgmay show a resolved company name (e.g.Apple, Inc.) or a hex fallback (e.g.0x004c) when only company ID is available.- On iPhone/iPad, turning Bluetooth off from Control Center usually disconnects accessories but can keep BLE available for Apple features (AirDrop, AirPlay, Apple Watch, Continuity, Location Services), so BLE activity may still be visible to this scanner.
- To fully disable Bluetooth radio activity, turn Bluetooth off in Settings (
Settings > Bluetooth), not only from Control Center.
Test Radar Dashboard
For testing and visualization only, there is a built-in dashboard:
- Backend in
src/radio_presence_scanner/server.py - Static frontend in
web/
This does not change the package API and is not used by the library runtime.
# Single process: API + static frontend served together
radio-presence-scanner --server --config config.yaml --server-host 127.0.0.1 --server-port 8787
Then open:
http://127.0.0.1:8787
Dashboard notes:
- Radar angle is synthetic (stable per device key), not real direction.
- Radar radius uses RSSI-based distance estimation.
- "Phones only" toggle filters BLE observations to likely phone devices (enabled by default). The setting is runtime-only and resets on restart.
- "Unique Visitors" chart tracks distinct BLE devices seen within the configured radius over time, bucketed by
visitor_bucket_minutesand retained forvisitor_retention_hours.
API endpoints:
GET /api/state— current snapshot (devices, counts, visitor history)POST /api/settings— update runtime settings (e.g.{"phones_only": false})
Distance and Accuracy
BLE distance is estimated from RSSI using a log-distance model, so it is not exact.
- Useful to classify near vs far
- Sensitive to orientation, obstacles, human body absorption, radio noise
For outdoor usage, calibrate path_loss_n at the actual installation point.
Change Detection
Change counters are derived from tracked-device deltas:
appeared: active this scan, absent in previous active setdisappeared: removed aftermax_missed_scansconsecutive scans without sightingsmoved_near/moved_far: BLE near/far zone transition at configuredradius_msignal_shift: RSSI delta >= 8 dBm for the same tracked keyname_changed: visible BLE local-name changed for the same tracked key
Behavior is optimized for spotting per-scan changes.
BLE Lookup Dictionaries
The scanner enriches BLE observations with local lookup dictionaries in radio_presence_scanner/lookups.py:
- Company ID -> company name (
manufacturer_names) - Service UUID -> service name (
service_names) - Appearance code -> appearance label (
appearance_name)
Lookup notes:
- Dictionaries are intentionally small and practical, not exhaustive.
- Unknown IDs are preserved as raw values; CLI falls back to hex (e.g.
0x004c) when no display name is available. - Behavior can differ across OS stacks (BlueZ on Linux/Raspberry Pi vs CoreBluetooth on macOS), so the same physical device may expose different metadata.
Use As a Python Library
The package exposes a one-shot library API. Your application is expected to poll it at the cadence you need.
import asyncio
from radio_presence_scanner import PresenceDetector
async def main() -> None:
detector = PresenceDetector.from_config_file("config.yaml")
# One poll: scan duration comes from config (ble_scan_seconds).
result = await detector.sample()
print("nearby_devices_count:", result.snapshot.nearby_devices_count)
print("ble_seen:", result.ble_seen)
print("changes:", result.snapshot.changes)
asyncio.run(main())
Synchronous usage is also available:
from radio_presence_scanner import PresenceDetector
detector = PresenceDetector.from_config_file("config.yaml")
result = detector.sample_sync()
print("nearby_devices_count:", result.snapshot.nearby_devices_count)
print("changes:", result.snapshot.changes)
Polling example (application-managed loop):
import asyncio
from radio_presence_scanner import PresenceDetector
async def main() -> None:
detector = PresenceDetector.from_config_file("config.yaml")
while True:
result = await detector.sample()
print(result.observed_at.isoformat(), result.snapshot.nearby_devices_count)
await asyncio.sleep(5.0) # application polling interval (optional)
asyncio.run(main())
API Compatibility Notes
DetectionResult.filter_statshas been removed.DeviceScanSnapshotnow exposeschangesfor per-scan change counters.ttl_secondshas been replaced bymax_missed_scansin configuration.- BLE enrichments are exposed through
DeviceEstimate.metadata.
BLE Metadata Fields
Fields currently emitted by the scanner in DeviceEstimate.metadata:
service_uuids: normalized lower-case service UUID listservice_names: known service names resolved from UUIDstx_power: advertiser tx power if presentmanufacturer_ids: integer company ID listmanufacturer_names: known company names resolved from manufacturer IDsmanufacturer_data: hex payload per company ID (e.g."0x004c": "...")appearance_id,appearance_name: appearance value if surfaced by the BLE stackapple_company_id:trueif Apple company ID is presentvendor_guess,platform_guess,device_family_guessclassification_confidence,classification_reasons
Raspberry Pi
On Raspberry Pi:
- BLE works with BlueZ + built-in/USB adapters
- Good target for running as a
systemdservice
Enable Bluetooth
Use this sequence when BLE scans return zero devices or after a fresh setup.
Install/enable Bluetooth stack:
sudo apt update
sudo apt install -y bluez bluetooth
sudo systemctl enable --now bluetooth
Unblock and power on controller:
sudo rfkill unblock bluetooth
sudo bluetoothctl power on
Verify status:
rfkill list bluetooth
bluetoothctl show
sudo systemctl status bluetooth --no-pager
Expected:
rfkill:Soft blocked: nobluetoothctl show:Powered: yes- Service status:
active (running)
Quick BLE scan sanity check:
bluetoothctl --timeout 12 scan on
If it discovers nearby devices, the BLE stack is working and radio-presence-scanner should see BLE observations.
Troubleshooting
1) Bluetooth blocked/off on Raspberry Pi
Use the full procedure in the "Enable Bluetooth" section above. Quick indicator:
- If
BLE=0andbluetoothctl showreportsPowered: no, run the unblock/power-on steps from that section.
2) Code path mismatch between Mac and Raspberry Pi
Symptoms:
- Same run looks different on Mac vs Pi after updates
- One host misses fields like
mfg,vendor_guess, etc.
Quick check:
python - <<'PY'
import radio_presence_scanner.ble as b
print(b.__file__)
PY
Expected path should point to:
.../src/radio_presence_scanner/ble.py
3) Virtual environment / pip issues (PEP 668)
Symptoms:
error: externally-managed-environment- Packages install into system Python instead of project venv
Fix:
deactivate 2>/dev/null || true
rm -rf .venv
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -U pip setuptools wheel
python -m pip install -e .
Verification:
which python
python -m pip -V
Both should point to .venv.
Author
- Author: francesco.pace@gmail.com
License
Distributed under the MIT License.
Copyright (c) 2026 francesco.pace@gmail.com.
See LICENSE for details.