A passive Bluetooth Low Energy listening post.
Everything around you is gossiping in BLE advertisements — phones, watches, earbuds, TVs, cars, tracker tags — each one broadcasting what it is and how loud it is. Almost nobody listens. nightjar does: it collects the advertisements, folds them into a registry of devices, sees through the address rotation phones use to hide, and over time learns the pattern of life of the space it sits in — including the tag that keeps turning up wherever you go.
Receive-only, by design. nightjar never connects, never pairs, never sends data. It only listens to what is already being shouted (one honest footnote about the radio itself, below).
The listening post walks around, too. The ios/ folder holds a native
iOS companion: the same receive-only ears in your pocket, with a live
"in earshot" list and session recording. It writes logs in the exact
JSONL format the Python side reads, so a session captured on the phone
feeds straight into report and trackers on the computer.
There is no App Store build. You install it the way it was built: open
the project in Xcode, sign it with your own Apple ID, and sideload
it — ios/README.md walks through it, including the honest caveats
(iOS shows per-phone UUIDs instead of hardware addresses, and Apple's
free signing expires after 7 days).
pip install .
For development:
pip install -e ".[dev]"
python -m unittest discover
No Bluetooth hardware handy, or just want to see it work? Skip straight to the demo.
Listen until you stop it:
nightjar
Listen for a minute, saving everything:
nightjar scan --duration 60 --log sightings.jsonl --out summary.json
As devices appear you get a line each — address, signal, what it is as far as its broadcasts say — and on exit a summary of who was here, one row per device rather than per address, with the rotating phones folded back together:
— 7 devices, 211 sightings; 1 rotated addresses —
DEVICE KIND CLOSEST SEEN SIGNAL WINDOW NOTES
AirPods Pro AirPods Pro -42 dBm 61 ▇█▇▇▇▇▇▇▇▇▇▇▇▇ ● 20:42:31-20:43:33 by name + fingerprint
AirPods Pro: L 80% (in ear), R 90% (in ear), case 70%, lid open, white
Apple device (Nearby Info) Apple device (Nearby Info) -58 dBm 44 ▅▆▅▅▆▅▅▆▅▅▆▅▅▆ ● 20:42:31-20:43:33 14 addresses (rotates); by payload fingerprint
Find My device (AirTag-clas… Find My device (AirTag-class) -66 dBm 30 ▄▄▅▄▄▅▄▄▄▄▅▄▄▄ ● 20:42:34-20:43:30 tracker-shaped
Find My network broadcast (separated from owner), battery medium
Sessions label themselves with where you're listening, detected from
the Wi-Fi network the host is on (the analysis commands use labels as
places). --label home overrides the detection when you want your own
word for a place; NIGHTJAR_LABEL and NIGHTJAR_LOG in the
environment set standing defaults for a post that always runs in the
same spot.
--post NAME names which ear this is — the Mac, the phone, a sniffer
by the door — as distinct from the place it's in. One listener needs
no post; several do, and that's where fusion comes in.
Defaults to the hostname, or $NIGHTJAR_POST.
Add --live for the radar: a full-screen view, strongest signals on
top, each device with its range band, trend arrow, how long since it
was last heard, and a sparkline of its signal history, redrawn every
second. --addresses adds the raw per-address table to the summary,
--json turns the whole run into a stream of JSON events for other
programs, and --adapter hci1 picks a radio on Linux.
A BLE scanner has two modes. In passive mode the radio only listens. In active mode it answers each advertisement with a standard scan request, which the device may answer with a scan response — that is where many device names live. Scan requests carry nothing but the scanner's own address, but they are transmissions, and "nothing is sent" should mean nothing is sent.
So nightjar asks for passive scanning first and tells you when it can't have it:
- Windows scans passively.
- Linux scans passively when BlueZ has its experimental features
on (
Experimental = truein/etc/bluetooth/main.conf, orbluetoothd --experimental); BlueZ's passive mode only reports advertisements that match a filter, so nightjar casts a wide net (common Flags values, the big makers' manufacturer prefixes, the tracker and beacon service UUIDs) rather than everything. - macOS cannot scan passively at all; CoreBluetooth doesn't offer it.
When passive isn't available nightjar prints one line saying so and
falls back to active scanning. --active chooses active outright and
silences the notice (you'll hear more names); --passive refuses the
fallback and exits instead. The mode in force is recorded in every
session's log header.
scan answers when you stop it; watch speaks when something
happens:
nightjar watch --log sightings.jsonl
nightjar watch --webhook https://discord.com/api/webhooks/...
nightjar watch --webhook https://ntfy.sh/my-topic --ignore "D4:61" --ignore "Chase's Watch"
It alerts — on the console, into the session log, and to a webhook if
one is configured (--webhook or NIGHTJAR_WEBHOOK_URL; Discord,
Slack and ntfy are recognized from the URL, anything else gets a JSON
body) — when a new device appears after a warmup, and when a
tracker-shaped device has stayed near you across several hours at
close range. Each alert fires once per device. Webhook delivery runs
on its own thread, so a slow endpoint never stalls the radio. Your own
tags and wearables match the tracker shape too: name them with
--ignore (an address prefix or a name, repeatable, or
NIGHTJAR_IGNORE=D4:61,Chase's Watch) and they stay quiet.
--warmup, --tracker-min-minutes, --tracker-min-hours and
--tracker-min-rssi tune the thresholds; --live works here too,
with the latest alerts pinned under the radar.
Pattern of life over everything you've logged — who is a regular, on which days, in which places, at what hours (a 24-hour activity profile per device), how close they usually get, and whose broadcasts get decoded into plain language:
nightjar report sightings.jsonl
nightjar report logs/ --since 7 --place office --wide
nightjar report sightings.jsonl --json
Logs can be files, directories of files, or globs. --since DAYS and
--place LABEL narrow the sessions; --wide adds first/last seen,
usual days and average stay; --min-sessions hides the one-offs;
--sort reorders.
Hunt for a tag that is following you — tracker-shaped, turning up in session after session, in more than one place, present at both ends of a listening session, at close range. Suspects are reported with their evidence and a confidence word:
nightjar trackers sightings.jsonl
nightjar trackers logs/ --ignore "D4:61" --json
And everything the logs know about one device — its sessions, places, hours, signal, decoded details — by address prefix, name, kind or identity key:
nightjar show "D4:61" sightings.jsonl
nightjar show airpods logs/
And what broke the pattern — a regular that normally shows at this hour and didn't, or a never-seen device arriving when the place is normally quiet. Absence is as loud as presence:
nightjar anomalies logs/
nightjar anomalies logs/ --observation-days 1 --json
anomalies splits the logs into a baseline (what's normal) and a recent
window (what just happened, --observation-days), and raises missing
regulars and new-at-odd-hour arrivals. It's analysis over logs,
not a live alarm — run it after a session, or on a schedule (cron, a
launchd timer). It stays silent when there's too little history to know
what's normal, and never judges a device whose identity rotates across
epochs, because it can't honestly match one to the day before.
One listener is one ear. Point several at your own space — the Mac
indoors, the phone in your pocket, a sniffer by the door — give each a
--post name, and the logs fuse into one picture. report shows which
posts heard each device; trackers gains a sense it can't have from a
single ear:
nightjar trackers mac.jsonl phone.jsonl # logs from two posts
nightjar report ~/nightjar/*.jsonl
The payoff is exoneration. A tag that is genuinely following you is
in exactly one place at a time — yours. So when two posts at two
different places hear one device within the same moment, it cannot be
a single object riding along: it's infrastructure broadcasting widely,
or two different tags. trackers clears it, in a "Cleared by fusion"
section, instead of flagging it:
Cleared by fusion (1):
Tile tracker — 14 sightings
- heard at home (mac) and office (phone) within the same moment —
it can't be one tag travelling with you
A tag carried from place to place in sequence — home in the morning, office by noon — still has the following shape, and is still a suspect. Logs without posts behave exactly as before; fusion only adds evidence, never removes the old.
The simplest way to feed many posts into one brain: have each write to a shared folder (an iCloud Drive folder the Mac watches, say) and point the analysis commands at the folder. The iOS companion writes the same format with its own post name, and cheap leaf nodes (an ESP32-class sniffer) can append to the same log as long as they match the log contract.
An identity is a candidate only if it is tracker-shaped: unnamed, and broadcasting like a standalone tag — a Find My accessory in its separated-from-owner format (not one riding with its owner, which broadcasts differently), a Google Find My Device network beacon, a Tile, a SmartThings Find tag. Phones that rotate by design (Exposure Notification, for instance) are never candidates. Then:
| Evidence | Points |
|---|---|
present in several sessions (--min-sessions, default 2) |
+2 |
| seen under more than one place label | +2 |
near you for many hours, adding up presence per session (--min-span-hours, default 8) |
+1 |
| present near both the start and the end of some session | +1 |
stayed at close range (median RSSI ≥ --min-rssi, default −80) |
+1 |
Five points make a suspect (--threshold); seven is "strong". The
bar is set so that a tag which merely lives somewhere you go — a
colleague's keys on the same desk every day — can't reach it on
persistence alone (sessions + hours + range is 4). It takes a second
place, or riding along from the start of a session to its end, to tip
over. Evidence, not proof: a tag you never listened near leaves no
trace here, and your own tags match the shape perfectly.
No Bluetooth hardware handy? Two ways to try it. A live fake neighbourhood — a few resident devices, tags of three persuasions, and one phone that rotates its address the way real phones do:
nightjar --simulate --duration 20
nightjar --simulate --live
Or a whole week-in-the-life: labelled home and office sessions over
several days, residents in each place, a coworker's Tile that never
leaves the office, a coworker's AirTag riding with its owner — and one
separated AirTag that is wherever the listener is. demo writes the
log and runs report and trackers on it:
nightjar demo --days 3
nightjar show "Find My" nightjar-demo.jsonl
Identification is from broadcast metadata only — no connection, ever.
- Apple Continuity (manufacturer data 0x004C): the message chain is parsed in full. Proximity Pairing resolves AirPods and Beats to their model, both buds' battery and charging state, which are in-ear, the case battery and lid, and colour (the layout OpenPods established from captures). iBeacons decode UUID, major, minor and calibrated power. Nearby Info reports the device's state code (only the verified "watch worn & unlocked" code gets a label; the rest are shown numerically rather than guessed at). Nearby Action names what a device is inviting (Wi-Fi password share, Apple Pay, setup…), and Tethering Source gives an iPhone's hotspot battery and signal bars.
- Find My (AirTags, lost Apple devices): the fixed-format separated broadcast, documented in the AirGuard research, with battery from its status byte — and the short form an accessory sends while its owner is nearby, which is not the shape of a tracker.
- Google: Fast Pair earbuds (model id while discoverable), Eddystone beacons (UID, URL, telemetry, EID), and the Find My Device network's tracker beacons that share Eddystone's UUID.
- Microsoft: the Connected Devices Platform beacon every Windows machine, Xbox and Surface sends names its device class outright; Swift Pair announces a device in pairing mode with its display name.
- Tile, Samsung SmartThings Find, Exposure Notification, Ruuvi environmental sensors (temperature, humidity, pressure), and a curated table of makers by company id and of well-known service UUIDs for everything else.
- Drones (Remote ID) — the digital licence plate drones broadcast
under the FAA rule (ASTM F3411 / Open Drone ID), service data 0xFFFA:
the aircraft's serial and type, its live position, altitude, heading
and speed, and the operator's position. A drone keys on its serial, so
it stays one identity across address changes, and
showreconstructs its flight track. The bit packing is verified against the reference decoder, opendroneid-core-c. See Drones.
Rotating phones are re-identified by a fingerprint of the stable core of their broadcasts. The fingerprint is epoch-scoped: a phone rotates those bytes along with its address on a schedule, so correlation is strong inside an epoch and fades across epochs — that fade is the privacy design working, and nightjar labels the basis of every identity honestly. Where the radio says so (Linux, Windows), the address type itself is recorded: a resolvable private address is one that will rotate.
A drone flying over broadcasts a Remote ID "licence plate" the same way
nightjar hears everything else, so it simply shows up — in the live
radar, the summary, report, and (if you're watching) as an alert:
nightjar watch --home 45.5121,-122.6587 # your position, opt-in
...
ALERT 21:14:03 Drone overhead: Drone (Remote ID): serial 1581F…, multirotor,
airborne, alt 60 m, 8.0 m/s NE, height 41 m AGL, operator 210 m N (takeoff point)
--home LAT,LON (or $NIGHTJAR_HOME) is opt-in and never written into
any device's log; it only turns the operator coordinates the drone
broadcasts into a distance and bearing from you. Without it, the
operator's raw coordinates are shown.
Because a Remote ID serial is a permanent identifier, a drone stays one
identity across the addresses it rotates through, and show reconstructs
the flight from the log:
nightjar show "Drone" sightings.jsonl --home 45.5121,-122.6587
...
flight track:
351 positions, 2026-10-12 08:04 → 2026-10-13 21:14
max altitude 60 m (geodetic)
20:19:55 45.53864,-122.96248 alt 60 m, 8.0 m/s S
…
operator 210 m N (takeoff point)
Try it with no hardware: nightjar demo flies a synthetic survey drone
over the house, and nightjar watch --simulate will alert on it.
nightjar logs a drone's position and its operator's position as broadcast fields, like any other sighting — and stops there. It builds no pilot-tracking on top; the pattern-of-life and tracker analysis treat a drone as a device, nothing more.
JSON Lines, one file per post (append-only) or per session. A run writes a header, a sighting per advertisement, any alerts, and a footer:
{"type": "session", "session_id": "61523c0f1d29", "label": "home", "label_source": "manual", "post": "mac", "started_at": 1792028551.45, "scan_mode": "passive", "format": 1, "nightjar_version": "2.2.0"}
{"type": "sighting", "seen_at": 1792028551.85, "address": "C1:2A:9D:44:10:7E", "name": "AirPods Pro", "rssi": -43, "manufacturer_data": {"76": "0719010e202a9807..."}, "service_uuids": [], "tx_power": null, "service_data": {"0000feaa-...": "40..."}, "address_type": "random"}
{"type": "alert", "seen_at": 1792028701.03, "kind": "new-device", "text": "New device appeared: ...", "identity_key": "fp:0b3f1aaf321f", "label": "...", "device_kind": "...", "address": "...", "data": {}}
{"type": "session_end", "session_id": "61523c0f1d29", "ended_at": 1792028555.52, "sightings": 11}manufacturer_data keys are company ids as decimal strings, values
the payload without the company id, lowercase hex; service_data is
keyed by full lowercase UUID. service_data, address_type,
scan_mode and the footer are nightjar 2 additions, and post (which
ear heard it) is a 2.1 addition — all optional. Logs from 1.x and 2.0
(and from the iOS companion) load unchanged, headerless
0.1 logs are split into sessions on gaps, and a corrupt line is
skipped and counted rather than fatal.
- macOS — works out of the box, but CoreBluetooth hides real MAC
addresses: devices appear under per-host UUIDs instead, and only
active scanning is available. Wi-Fi-based place detection may
return a redacted SSID unless the terminal has Location permission
(nightjar also tries
system_profiler); a manual--labelalways works. - Linux — needs BlueZ; the process needs Bluetooth capability
(run as root, or grant
CAP_NET_RAW/CAP_NET_ADMINto the Python binary). Passive scanning needs BlueZ experimental features. - Windows — works out of the box, passively.
- iPhone — a native companion app in
ios/puts the listening post in your pocket and writes the same log format, so a session recorded on the phone feeds straight intoreportandtrackerson the Mac.
- Collection — log every advertisement. ✅
- Registry — fold sightings into devices with histories. ✅
- Identification — read what a device is from its broadcast format and see through address rotation. ✅
- Pattern of life — presence over days and sessions: regulars, days present, usual hours, places heard. ✅
- Counter-detection — flag the tag that keeps turning up near you: tracker-shaped, persistent across sessions and places, present at both ends of a session, at close range — the unwanted-tracking pattern the Apple/Google spec describes, scored with the evidence shown. ✅
- The post stands guard —
watchmode with once-per-device alerts to the console, the log and a webhook. ✅ - Many ears, one brain — several posts around one space, fused into one picture; a device heard at two places at once is cleared, because a tag that follows you is in one place at a time. ✅
- It thinks in places —
anomalies: the regular that didn't show, the newcomer at an odd hour. Absence is as loud as presence. ✅ - Drones — decode the Remote ID licence plate aircraft broadcast over BLE: what it is, where it is, where its operator stands. ✅
nightjar observes broadcast advertisements — signals devices send to everyone, on purpose. Run it in spaces where you have a right to listen. It does not intercept connections or private traffic, because it cannot: it only hears what is shouted.
AGPL-3.0 — see LICENSE.
