dumbtv
dumbtv가 ADB로 Fire TV에서 광고·추적·업데이트 알림을 제거, 루트·앱 변경 없이, 무료 오픈소스
Passive counter-surveillance detector for the CYD, Seeed XIAO ESP32S3, and other ESP32 models — spots body cameras, ALPR, IP cameras, BLE trackers and Remote ID drones, and tells you what changed.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A passive counter-surveillance detector for the Seeed Studio XIAO ESP32S3
and the dual-band ESP32-C5,
built by Forged in Feathers Technology.
It tells you what is watching you. Built to sit in one place and watch that
place: it learns what is normally there, then reports what is new. It listens
for the radio signatures of body cameras, licence-plate readers, IP cameras,
Bluetooth trackers, smart glasses and Remote ID drones, scores what it finds,
pushes notifications, and serves the log on your network.
Observer and omnivore: it eats surveillance signals.README 전문 보기 ↗A passive counter-surveillance detector for eleven ESP32 boards, in two families: headless sensors — the Seeed Studio XIAO ESP32S3, the dual-band ESP32-C5 and the C6 — and the "Cheap Yellow Display" boards, which have a screen and a touch panel. Built by Forged in Feathers Technology.
It tells you what is watching you. Built to sit in one place and watch that place: it learns what is normally there, then reports what is new. It listens for the radio signatures of body cameras, licence-plate readers, IP cameras, Bluetooth trackers, smart glasses and Remote ID drones — and for the equipment that transmits rather than watches: a Flipper, a pwnagotchi, a Pineapple, a flood of deauthentication frames knocking devices off a network. It scores what it finds and either pushes notifications and serves the log on your network, or shows it on its own screen, depending on the board.
Observer and omnivore: it eats surveillance signals.
Five of the eleven profiles, one firmware: the 2.8" and 3.5" Cheap Yellow Displays, the C5 display board that sees both bands, and the round AMOLED wearing its watch face — with a headless node open on its cell beside them, running the same build with nothing to show it on.
The choice is not about speed or memory. It is whether the device reports to you over the network or to your hand.
A headless board — the S3, either C5, the C6 — pushes notifications to Gotify, ntfy, Pushover, a webhook or Telegram, and serves its log on your network. It is the one to leave somewhere and read from elsewhere.
A Cheap Yellow Display shows what it sees on its own screen, and with touch you can set a baseline, dismiss a finding, join a network by typing the password on the glass, and read the console's address without a serial cable. It sends no notifications at all — the notifier is left out of the build, which is what makes it fit on a chip with no PSRAM, and every memory problem this project has had was the TLS handshake behind a notification. A screen is a personal display, so the trade is a fair one.
Seven of the eleven have screens: the 2.8" board in its two panel revisions, the 3.5", the C5 one below, the two round ones after it, and the CrowPanel 1.46", which adds a knob that turns the page. See the screen for what they can do and boards, which are not the same as chips for the profiles.
RockBase's NM-CYD-C5 is a Cheap Yellow Display with an ESP32-C5 behind it, and it is the first board here that both sees 5 GHz and has a screen to say so on — every other display board is a plain ESP32 that cannot sweep the upper band at all, and every board that could was headless.
The same 2.8" ST7789 panel as the classic CYD and almost none of the same pins, taken from RockBase's own TFT_eSPI setup rather than from a description, which gave the display chip select as GPIO 3 where it is 23. Two things about it are genuinely different: the touch controller's interrupt line is not wired anywhere, so the XPT2046 driver gained a polled mode — the interrupt was only ever an optimisation, and the pressure threshold was always what decided — and the panel takes its pixels in the opposite byte order to the other ST7789 boards here, which took two readings to establish because each setting alone produces a plausible wrongness.
What held it back was neither of those. Its touch bounds are the compiled defaults, because nobody has measured this panel — and the numbers in every other profile are one bench unit's, which is a thinner claim than it looks. A resistive sheet varies between assemblies of the same product, so shipping a profile meant shipping somebody else's measurements and hoping. The calibrate action settles it on the device instead: two presses and the panel in the owner's hand teaches it its own bounds. See calibrating a panel.
The two round ones are different enough to describe separately. Waveshare's
ESP32-S3-Touch-AMOLED-1.43 is a round 1.43" AMOLED on an S3 with 16 MB of
flash and 8 MB of octal PSRAM — capacitive touch rather than resistive, a
hardware clock, and an accelerometer, which no other board here has. That
last one is what makes the tailing class possible: it is the only board
that can tell being carried from sitting still.
One product ships with either of two panel controllers, so the firmware asks the panel which it is at boot rather than being built for one. That is what made it safe to offer for download — a stranger cannot know which revision arrived in the post, and the failure, a blank screen or an image six pixels sideways, would tell them nothing.
Brightness is a command rather than a pin on this panel, and over four
data lines a command does not travel as itself: with no D/C line the opcode
goes in the address phase, so a write is 0x02, the register, a pad byte,
packed into one 32-bit word. Sending the bare register is ignored silently —
which is what shipped at first, so the brightness button cycled four
settings, saved each to NVS, redrew the screen and changed nothing. The panel
sat at full brightness through every soak run on this board.
Capacitive touch removes most of what the resistive path has to do, and adds one requirement instead: a bare finger, or a capacitive stylus. A passive plastic one registers as nothing at all, correctly and permanently, and the device cannot tell that apart from nobody touching it. The controller reports how many fingers are down and where each one is, so there is no pressure threshold and no median filter. It shares its two wires with the motion sensor and the clock, and the driver says which of the three answered at startup, because "the panel is not answering" and "the panel is not there" look identical in a log that mentions neither.
What this paragraph used to claim, and no longer does, is that a capacitive panel needs no calibration — that it reports panel pixels and the two days of arithmetic the Cheap Yellow Displays cost simply do not happen here. That was a statement about one chip's factory configuration dressed up as a property of the interface. A CST816 reports in whatever range its firmware was configured for, which on a round panel need not be the panel's own, and the assumption had been written into three places at once: the mapping, the raw accessor a calibration reads, and the settings page that offers the button. All three had to be wrong together for a capacitive panel to have no way of being told it was misaligned. Calibration is offered wherever there is a touch panel now.
A round screen is the first one here that is not a rectangle, and the text grid handles it by not trying to be clever. The largest square inside a circle of 466 pixels is 329 across, which is 41 columns by 20 rows — slightly more text than the 2.8" boards show, in a smaller space — and the four corners are simply never drawn into. Touches outside that square are clamped into it rather than discarded: the crescents are live glass, not bezel, and the seventy pixels below the button bar are where a finger aiming at the bar actually lands. Text does not reflow around a curve legibly at eight pixels a character, and a page of readings clipped at the corners is worse than one that is smaller and whole.
Waveshare's ESP32-S3-Touch-LCD-1.85 is the other round one: a 360x360 LCD on the same S3, with the same hardware clock and accelerometer, and two things no other board here has — a speaker and a microphone.
Almost everything about it is behind something else. Its ST77916 panel is driven over four data lines like the AMOLED, but both its reset line and the touch controller's are behind a TCA9554 I/O expander rather than on pins, and the touch controller sits on a second I2C bus of its own. The expander's two remaining lines are the accelerometer's interrupt outputs, so lines are claimed as outputs on demand rather than all at once.
The screen is smaller than the 1.43" in pixels and larger in millimetres, which matters more than it sounds. 360 pixels across 47 mm is 7.7 per millimetre against the AMOLED's 12.8, so text that is cramped on one is comfortable on the other, and a keyboard key that is unusable at 1.9 mm is merely small at 4.2.
The speaker and microphone are what make it the only board that can sound an alert and the only one that can look for ultrasonic beacons — and, because they sit centimetres apart, the only one that can prove its own beacon detector by emitting a tone and listening for it.
While patrolling, it listens and does not answer. Nothing it observes can observe it back, because nothing leaves the radio:
| BLE scan | passive — no SCAN_REQ is ever emitted |
| Wi-Fi access-point scan | passive, no probe requests |
| Wi-Fi sniff | receive-only |
| BLE warning bursts | off unless built in — see below |
That last row is the one exception, and it is off in every default build. See Warning the neighbours for what it costs and how a build without it cannot do it at all.
The Wi-Fi scan being passive matters as much as the BLE one. An active scan broadcasts probe requests carrying the device's own MAC on every channel, every cycle, and probe requests are exactly what presence analytics and Wi-Fi tracking systems collect. A detector that announced itself to the things it was built to notice would be self-defeating. The cost is dwell time, not coverage: access points beacon around ten times a second, hidden ones included.
It does transmit in the other two modes, and there is no way around that:
Both are entered deliberately, and patrol is where it spends most of its time.
Detection runs across three phases:
| Phase | What it catches |
|---|---|
| BLE passive scan (continuous) | trackers, body cameras, smart glasses, Remote ID drones, followers |
| Wi-Fi passive scan (~10 s/cycle) | camera and ALPR vendor APs, camera-keyword SSIDs |
| Wi-Fi promiscuous sniff (~5 s/cycle, ch 1–13) | Remote ID beacons, hidden and non-broadcasting APs |
Classification uses four independent kinds of evidence, and the UI tells you which one fired so you can judge a hit rather than just trust it:
0x004C/type 0x12), Samsung,
Tile (0xFEED), Galaxy SmartTag (0xFD5A), Google Fast Pair (0xFE2C),
ASTM F3411 Remote ID over BLE (0xFFFA/0x0D) and over Wi-Fi
(vendor IE FA:0B:BC/0x0D), SquachMesh (0xFFFF + SQM1), and Flipper
Zero (company 0x0E29, service UUIDs 0x3081–0x3083).A vendor prefix is an inference: this address block belongs to that company, so the device is probably theirs. It fails in two common ways. Modern phones and most trackers randomise their address, leaving no prefix to look up at all, and the IEEE registry does not cover every assignment.
Access points supporting Wi-Fi Protected Setup put an element in every beacon naming their manufacturer, model and device name in plain text. That is not an inference, it is the device stating what it is, and it works when the prefix is unregistered, unknown, or absent. Cameras are the class this helps most, which is also the class scored lowest on vendor evidence alone.
So a WPS manufacturer or model is matched against the same keyword table as an SSID, and unlike an SSID it is allowed to override the vendor prefix. An SSID is free text somebody chose; a WPS element is firmware describing its own hardware.
How much this helps depends on what is around you, and it can be nothing at all. Counting the vendor elements in 150 beacons from about 30 access points in one flat found 450 of them and not a single WPS element among them. Ubiquiti access points do not advertise it, and neither did anything else in range. Where the element is absent the vendor prefix is all there is, so treat a manufacturer string as a bonus rather than something to rely on.
The fields arrive in a frame from a device under nobody's control, so they are length checked, bounds checked and stripped to printable characters before reaching a log line, a JSON response or the console. The parser is ESP-IDF-free and unit tested against truncated elements, attributes claiming to be longer than the frame that holds them, and control characters aimed at the console.
Each device present counts its class (bodycam, ALPR and deauth-flood 6, tracker/drone/glasses/hunter 3, telematics 2, camera 1, accessory 1, peer-detector and fixture 0, follower capped — see below), and the score is their sum.
Six is one alert by itself, and that is the whole of the split. Six names an act: a body camera is recording, a plate reader is reading plates, a flood of deauthentication frames is knocking devices off a network. Three names a capability: a Flipper, a drone, a tracker are all things that could be aimed at you and usually are not, so they read caution alone and alert in company.
The score is what is here now, not a history of it.
It used to accumulate: every device added its points again every two minutes, against a decay of one point a minute in total. Anything worth two points or more therefore outran the decay on its own, so the score climbed to its ceiling and stayed there. Ninety-nine meant "something persistent has been here a while" — never "how much is here" or "how serious". A board on a desk with a Flipper and a few phones sat at 99 and alert indefinitely, and a level that is always on is not read.
So the score is a sum over the devices currently tracked, worked out when asked. It rises when something arrives and falls when it leaves; the device table's own thirty-minute expiry is what brings it down, and is slow enough that nothing flickers. There is no accumulator, no decay and no per-device cooldown to tune — one device present fifty times is still one device.
Followers cannot move the verdict at all. Together they contribute at most two — the top of clear — so the device lists every one of them and still says "nothing here is identified as surveillance", which are two true statements rather than one hedge. The verdict is reserved for things identified as what they are.
Two is also less than the gap between caution and alert, which buys an invariant worth stating plainly: no quantity of unidentified devices can push an identified one over the line. A Flipper in an empty room and a Flipper in a crowded bar both read caution, because the crowd is not evidence about the Flipper.
That ceiling took four attempts, and the first three were wrong about what a follower means. Duration is not evidence — everybody in a restaurant has been near you for an hour. Rotation is not evidence either, which took hardware to see: every modern phone changes its Bluetooth address every quarter of an hour, so "survived a rotation" describes a phone behaving normally, not a device evading notice. A house full of them put a board at seventeen and alert. Capping the class at the top of caution fixed the number and left the verdict wrong in a quieter way: two boards soaked overnight in an ordinary room sat at exactly the cap for eight hours, amber throughout, with nothing identified on either. A warning that is always on is not a warning.
What remains true is that a follower is, by definition, unidentified. That is worth listing and worth a glance. It is not worth an alarm, because the device cannot say what the thing is. Within the cap the ordering still holds — mere presence counts one, a rotation counts two — so the list still says which of them has been followed across a rotation, while the ceiling keeps the class inside clear.
Sustained presence is not lost; it has moved to where it belongs. A device does not become a follower until it has been there five minutes. Duration decides what something is, and the score says what is here.
The sniffer sweeps a list of channels, not a range, because 5 GHz channel numbers are not contiguous.
2.4 GHz — every chip. Channels 1–13, swept in full every patrol cycle, with the dwell split evenly across them. A beacon interval is typically ~102 ms, so anything under about 120 ms per channel starts missing APs outright; 13 channels in a 5 s sweep leaves comfortable margin.
5 GHz — ESP32-C5 only. Twenty-five channels across UNII-1, UNII-2A, UNII-2C and UNII-3. Sweeping all of them in one cycle would push the dwell under a beacon interval, so 5 GHz is covered a slice of five channels per cycle, advancing each sweep: 2.4 GHz stays fully covered every cycle and 5 GHz comes round in five. The practical consequence is latency, not blindness. A 5 GHz camera takes a few cycles longer to appear than a 2.4 GHz one.
DFS channels are included. The radar obligations that come with them apply to transmitting, and this radio only ever listens. Channels the configured regulatory domain refuses are skipped at runtime rather than being compiled out, because the country setting is not known at build time.
The ESP32-S3 has one radio on one channel. Channel-hopping to sniff and staying associated to an access point are mutually exclusive, so Observore cannot both watch the band and serve you a web page at the same time.
BLE scanning continues in all three; it is unaffected by the Wi-Fi channel, and it is where most detections come from.
| Gesture | Effect |
|---|---|
| hold 1.5 s | swap between patrol and uplink |
| hold 4 s | raise the console SoftAP |
On a board with a screen the short hold does something else: it sets the
baseline, and the screen says what it did — baseline set: 14 now ignored,
in amber, for a few seconds. That is the one action a person standing at the
device needs, and swapping modes by hand mattered only when the console was the
only way to see anything. The long hold still raises the console, which is
where the Wi-Fi gets configured.
With no network configured the short hold gives you the console instead, since that is where a network gets configured. The firmware logs how long it saw the button held, so a press a shade too short is distinguishable from a button that is not responding.
All Wi-Fi detection — the access-point scan and the promiscuous sniff both — runs only while patrolling. A device parked permanently on the uplink is a BLE-only detector: no Remote ID drones, no hidden access points. So Observore alternates.
| Setting | Default | Meaning |
|---|---|---|
OBSERVORE_PATROL_WINDOW_S | 120 s | patrolling, full sensor |
OBSERVORE_UPLINK_WINDOW_S | 30 s | on your network, pushing and serving |
OBSERVORE_UPLINK_MAX_S | 180 s | hard ceiling on one uplink visit |
OBSERVORE_CONSOLE_IDLE_S | 45 s | console silence before the window may close |
OBSERVORE_UPLINK_GRACE_S | 45 s | tolerate a dropped uplink before patrolling |
OBSERVORE_UPLINK_RETRY_S | 300 s | backoff after a failed join |
The uplink window is held open while somebody is reading the console, so you
are never cut off mid-page, but only up to OBSERVORE_UPLINK_MAX_S. The
console page polls every two seconds, so a tab left open would otherwise keep
the device on the uplink indefinitely. A user interface must not be able to
blind the detector, so the hold has a hard ceiling.
The console is therefore reachable within about two minutes rather than continuously. If it does not answer, it is patrolling: wait for the next uplink window, or hold the button for 1.5 s to go there now.
The button means "switch now" and alternation continues from there, so a single press can never strand the device in a mode it will not leave.
Read these before trusting it.
smart-glasses hit as a lead.0xFE2C. It is
reported because Google's Find Hub trackers use it too.class or oui rule will hide a real
threat that happens to share a category or vendor with something you
dismissed. Prefer mac, name or fingerprint rules where you can.Observore is a receiver. It observes broadcasts that are, by design, transmitted publicly and unencrypted. It does not deauthenticate, inject, jam, associate, crack, or interfere with anything.
It never receives the content of a communication. The Wi-Fi sniffer is filtered to management frames alone:
wifi_promiscuous_filter_t filter = {.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT};
with a callback that drops anything which is not WIFI_PKT_MGMT. Data frames
never reach the application — only beacons, probe requests and the vendor
elements Remote ID travels in. Where interception law turns on content against
metadata, which is most places, that distinction is the substance of the
matter, and it is a property of the code rather than a promise.
The one exception to "receiver" is
CONFIG_OBSERVORE_MESH_TX, off in every shipped build, which makes the device
warn other nodes over BLE. A build with it on is a radio transmitter, and
distributing such hardware brings EU RED and FCC Part 15 into scope.
MAC addresses are personal data under the GDPR and the UK GDPR: they are identifiers that single out a device, and a device usually singles out a person. A home installation plausibly sits inside the household exemption. A board carried into public, logging the addresses of strangers to flash, is where that gets thin — and that is a real way to use this, not a hypothetical.
So it is worth being precise about what survives a power cut:
| what it holds | how long | |
|---|---|---|
| detection history | 48 entries: address, class, label, signal, first and last seen | until a 49th replaces the oldest |
| mute rules | up to 128: whatever you chose to ignore — an address, a vendor prefix, a name, an advert shape | until you remove them |
| household census | 64 identities, as hashes — a 32-bit id and up to eight 16-bit address hashes | days fall out of a 16-day window |
| device table | everything currently nearby | RAM only; 30 minutes, gone on reboot |
| peer warnings | 8 warnings from other nodes | RAM only; 5 minutes |
| card log (1.85 and 1.43 only, off by default) | every detection, as CSV: time, address, class, label, signal, vendor | until you delete the file |
On most boards, now, not much. Everything in NVS — the Wi-Fi password, the
console password, the notifier tokens, the household mesh key, the mute
rules and the history — is encrypted at rest with keys derived from a
256-bit key the chip generates for itself and burns into its own eFuse on
first boot. It never leaves the chip and is different on every device.
esptool read_flash on a board that is lost, seized or sold second-hand
returns ciphertext.
Not on the three plain-ESP32 boards. The 2.8" CYD in both panel revisions and the 3.5" CYD have no HMAC peripheral, so there is nothing on the chip to anchor a key to and their stored secrets are readable. That is a property of those chips and not something a future release fixes. If the mesh key matters to you, do not put it on one of those three.
Be clear about the limit on the other eight, too: this stops somebody reading the flash. It does not stop somebody who can run their own code on the board, because that code can ask the same peripheral for the same keys. Closing that needs secure boot, which is a different and far more expensive trade — it permanently ends plaintext USB flashing on every board it touches. What is defended here is the recovered board, not the determined attacker with it on a bench.
Two things in that table are worth drawing out. The census keeps no addresses — only hashes, because the question it asks is how many a device has worn, never which, so there was nothing to gain by storing them. And the identifiable record is the history, which is 48 entries deep and overwrites itself.
Unless you give it a card. That sentence used to end "it is not a growing
log", and on nine of the eleven boards it still is not. The 1.85 and the 1.43
AMOLED have a MicroSD slot, and with OBSERVORE_SDLOG turned on they write
every detection to it as CSV and never overwrite anything. That is the point — 48 entries is a limit
of where the history was kept rather than of anything the detector knows — but
it is a different object from the rest of this table: a readable, growing,
removable record of who was near the device and when, on a card that fits in
a pocket and opens on any laptop.
It is off unless a board profile enables it. The reasoning for allowing it at all is that every byte in it was broadcast unencrypted by the devices themselves to anybody in range: the card records a public fact rather than creating a private one. A Wi-Fi password is a secret; a beacon is a shout. If that trade is not one you want, leave it off and the board behaves like the other nine.
The two CYDs have a slot too, and deliberately do not use it. It works — the card mounts and writes — but FATFS and the SPI driver want about 30 KB of internal RAM, and an ESP32 with no PSRAM driving those panels has not got it: the heap low-water mark falls from 11,772 bytes to 24 and the web console stops starting. A card that costs the console is a bad trade, and "it mounted" would have shipped it.
The console can show and clear all of it. POST /api/history empties the
history, /api/unmute?all=1 the rules.
The photograph in this README was redacted after the fact, because it showed two fixed public addresses — a body camera's and a drone's, with its Remote ID serial — belonging to other people. Rotating addresses identify nobody and were left alone.
A tool built to notice what is broadcast should not be the thing that publishes it. The same rule this project already had about never pasting real device output into documentation applies to other people's identifiers as much as to your own secrets.
Passive reception of broadcast frames is lawful in most jurisdictions, but "most" is not "all", and what you do with a log is a separate question from how you gathered it. Check your local law. This is a description of what the firmware does, not legal advice.
You need one of the eleven supported boards and a USB cable. The whole first run takes about ten minutes, most of it waiting.
The browser flasher detects the chip and offers the builds that fit it. There is usually one, and sometimes a choice: three of the boards are ESP32-C5s whose images are not interchangeable, and the 2.8" and 3.5" displays are both plain ESP32s with different panels. The flasher says which is which, and the wrong choice is recoverable — a mismatched display build shows a blank or garbled screen and is fixed by flashing the other one.
The console page is served gzipped, so a command-line client needs
curl --compressed (a browser needs nothing).
The quickest route is the browser flasher — any Chromium-based desktop browser (Chrome, Edge, Opera, or Brave 1.69 and later), no toolchain. Firefox and Safari do not implement Web Serial, and no mobile browser does. It writes the firmware without touching the stored configuration, so it upgrades an existing device without losing its mute rules, credentials or console password. Skip to step 2 below once it finishes.
To build it yourself instead you also need ESP-IDF v5.5.5, which is what CI is pinned to.
Not "v5.5 or later", and the difference is not cosmetic. Images built on the v5.5 line run perfectly when flashed over USB and hang during early startup when booted from the second OTA slot, somewhere between the PSRAM memory test and the first line of application code. Every over-the-air update rolled back and none ever took. The same source built on v5.5.5 boots from that slot and confirms itself. What actually differs inside ESP-IDF was never identified, only that it does, which is why the pin is exact rather than a floor.
1. Build and flash.
. ~/esp/esp-idf/export.sh
idf.py set-target esp32s3
idf.py build
idf.py -p /dev/ttyACM0 flash monitor
The XIAO uses the S3's native USB-Serial/JTAG, so it appears as
/dev/ttyACM0, not /dev/ttyUSB0.
2. Write down the console password. It is generated once, on this device, and printed at every boot:
console SoftAP: "console-XXXXXX" password: xxxxxxxxxxxx
You will need it in a moment, and serial is the only place it appears. See The console password.
3. Open the console. Hold the BOOT button for 1.5 s. The LED goes solid and
the log says the SoftAP is up. Join console-XXXXXX with the password from
step 2 and open http://192.168.4.1/.
If nothing happens, keep holding: the firmware prints how long it saw the button held, so a press slightly too short looks different from a dead button.
4. Give it your Wi-Fi. In the Network panel enter your SSID and password, and Save. Then hold the button for 1.5 s again — it joins, and the log prints the address the console now lives at:
uplink up at 192.168.1.42
From here the console is on your LAN and the SoftAP is no longer needed. Note that it is only on your network during its uplink window, so the address answers for about thirty seconds out of every two and a half minutes. That is deliberate.
5. Optionally, set up notifications. In the Notifications panel, put in a Gotify server URL and application token, Save, then Send test while the uplink is up.
6. Leave it where it will live, and take a baseline. Let it patrol for ten minutes or so, then open the console and press Set baseline. That marks everything currently in range as known and resets the score, so from then on it reports what is new rather than the whole neighbourhood.
Then expect quiet. After a good baseline Observore should say almost nothing. A silent device is the normal state, not a broken one. The heartbeat on the serial log tells you it is still watching.
Uplink mode puts the console on your LAN, so you can read the log without the SoftAP dance. It costs detection: while associated, the radio is pinned to your AP's channel and the Wi-Fi sniffer is suspended. BLE scanning and AP scans continue. If the network cannot be joined, Observore falls back to patrol rather than sitting associated to nothing.
Credentials never belong in a tracked file. There are three ways to set them, and the first is the one to prefer:
1. At runtime, from the console. Hold the button for 4 s to raise the SoftAP (1.5 s is enough before any network is configured), join it, and fill in the Network panel. Stored in NVS. Nothing touches this repo at all, and nothing needs rebuilding to re-point a device at a different network.
2. idf.py menuconfig → Observore → Wi-Fi uplink. This writes sdkconfig,
which is gitignored.
3. A gitignored credentials.conf, for reproducible or CI builds:
cp credentials.conf.example credentials.conf # gitignored
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;credentials.conf" build
A credential in NVS wins over one compiled in, so setting it at runtime is not silently reverted by reflashing the same firmware.
Leaving the password field blank keeps the stored password. The console
clears the field after every save and never receives the password back, so
always sending it would let a second Save replace a good password with an empty
one, which asks for an open network, and a WPA2 access point refuses that
with reason 210, no AP found with compatible security. That reads as though
the network were at fault. To genuinely configure an open network, tick open
network.
The panel shows whether a password is stored (password set / NO PASSWORD)
without ever revealing it.
The console's Network panel and the serial log both give the reason in words,
and every attempt is logged rather than only the last — logging only the last
reported reason 36, which is Observore's own disconnect in the timeout path and
says nothing about the real cause.
| Reason | Meaning |
|---|---|
| 210 | security mismatch — most often no password stored |
| 201 | network not found — check the SSID, and that the band is one your chip has |
| 202, 15, 204 | authentication or handshake failed — wrong password |
| 203 | association refused — MAC filtering? |
The ESP32-S3 and C6 have no 5 GHz radio, so a 5 GHz-only SSID can never be joined on those chips. A C5 can join either band.
The quickest way onto a network is to let the browser that just flashed the device also configure it, over the same USB cable, using Improv Serial. The flasher page offers it straight after installing.
That matters more than convenience. The console password is printed on the serial log and nowhere else, so a device you flash for somebody else is a device they cannot configure — they have no way to read the password, so they cannot join the setup SoftAP, so they cannot enter their Wi-Fi. Improv removes the password from the setup path entirely: it is still needed to open the console later, but no longer to get the device onto a network.
From a terminal, without a browser:
tools/improv_client.py --port /dev/ttyACM0 info
tools/improv_client.py --port /dev/ttyACM0 provision --ssid MyAP --password secret
Three things worth knowing:
The offered network list comes from the patrol sweep that is already running, rather than from a scan started on demand — this chip has one radio, and a scan requested here would fight the sweep for it. An unconfigured device patrols, so the list fills within a cycle of boot; before that first sweep completes the list is empty and the SSID can be typed instead.
Observore offers the name observore (configurable as
OBSERVORE_HOSTNAME) two ways: over mDNS as observore.local, and as the
hostname in its DHCP request, which is what a router registers in its own DNS
and shows in its client list.
The DHCP one is the more useful across subnets, because ordinary DNS routes and multicast does not.
Two caveats, both real:
.local name will not resolve unless your router reflects mDNS across both
networks — on UniFi that is the Multicast DNS setting, and it must be
enabled on each network, not just one..local collides with mDNS: RFC 6762 reserves
.local for multicast, so most resolvers send *.local to mDNS and never
ask your DNS server. If your LAN domain is something like house.local,
names under it are ambiguous for every client, not just this one. A DHCP
reservation plus a static record under a non-.local domain sidesteps the
whole problem..local at all: a Linux box with no Avahi
and systemd-resolved showing -mDNS has no multicast resolver, so the name
will fail there however the network is configured.The address is also reported on the serial log as uplink up at <ip>, in the
console's Network panel, and in your router's DHCP lease table. The Wi-Fi
station MAC is printed at boot.
Set OBSERVORE_WIFI_AUTOJOIN=n to keep full patrol coverage and reach the uplink
only on demand via the button.
For a first run, follow Getting started. This section is the reference for everything after that.
Every tagged release carries a bootloader, partition table and application
per supported chip, named with the target, plus a SHA256SUMS, an ESP Web
Tools manifest.json and the
per-target builds-<target>.json the manifest is assembled from.
The browser installer decides whether it is looking at an upgrade or a fresh install by asking the board what it is running. If it finds Observore, it offers an Update, which writes the firmware and touches nothing else. If it finds something else, or nothing that answers, it asks whether to erase first, and the box starts unticked.
Releases before v0.7.0 got this wrong, and if you ever set a device up twice this is why. The installer matches on an exact name, the manifests carried the board label in theirs, and the firmware did not, so every install looked like a fresh one. With the erase prompt turned off, on the belief that off meant safe, the installer erased without asking. Both settings were read from the installer's source before being changed, and the page says what it does now.
With esptool alone — note that the bootloader offset is not the same on
every chip, so these commands are not interchangeable:
# Seeed XIAO ESP32S3
esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
0x0 bootloader-xiao-esp32s3.bin \
0x8000 partition-table-xiao-esp32s3.bin \
0x10000 ota_data_initial-xiao-esp32s3.bin \
0x20000 observore-xiao-esp32s3.bin
# ESP32-C5-DevKitC-1 / Waveshare
esptool.py --chip esp32c5 -p /dev/ttyACM0 write_flash \
0x2000 bootloader-devkit-esp32c5.bin \
0x8000 partition-table-devkit-esp32c5.bin \
0x10000 ota_data_initial-devkit-esp32c5.bin \
0x20000 observore-devkit-esp32c5.bin
# Seeed XIAO ESP32-C5
esptool.py --chip esp32c5 -p /dev/ttyACM0 write_flash \
0x2000 bootloader-xiao-esp32c5.bin \
0x8000 partition-table-xiao-esp32c5.bin \
0x10000 ota_data_initial-xiao-esp32c5.bin \
0x20000 observore-xiao-esp32c5.bin
# Seeed XIAO ESP32C6
esptool.py --chip esp32c6 -p /dev/ttyACM0 write_flash \
0x0 bootloader-xiao-esp32c6.bin \
0x8000 partition-table-xiao-esp32c6.bin \
0x10000 ota_data_initial-xiao-esp32c6.bin \
0x20000 observore-xiao-esp32c6.bin
# ESP32-2432S028R (2.8" CYD, ST7789 revision) -- a CH340 bridge, so ttyUSB
esptool.py --chip esp32 -p /dev/ttyUSB0 write_flash \
0x1000 bootloader-cyd-2432s028r-st7789.bin \
0x8000 partition-table-cyd-2432s028r-st7789.bin \
0x10000 ota_data_initial-cyd-2432s028r-st7789.bin \
0x20000 observore-cyd-2432s028r-st7789.bin
# ESP32-2432S028R (2.8" CYD, ILI9341 revision) -- same board, other panel
esptool.py --chip esp32 -p /dev/ttyUSB0 write_flash \
0x1000 bootloader-cyd-2432s028r-ili9341.bin \
0x8000 partition-table-cyd-2432s028r-ili9341.bin \
0x10000 ota_data_initial-cyd-2432s028r-ili9341.bin \
0x20000 observore-cyd-2432s028r-ili9341.bin
# ESP32-3248S035R (3.5" CYD, resistive touch)
esptool.py --chip esp32 -p /dev/ttyUSB0 write_flash \
0x1000 bootloader-cyd-3248s035r-st7796.bin \
0x8000 partition-table-cyd-3248s035r-st7796.bin \
0x10000 ota_data_initial-cyd-3248s035r-st7796.bin \
0x20000 observore-cyd-3248s035r-st7796.bin
# RockBase NM-CYD-C5 (2.8" CYD on a C5) -- native USB, so ttyACM
esptool.py --chip esp32c5 -p /dev/ttyACM0 write_flash \
0x2000 bootloader-nm-cyd-c5.bin \
0x8000 partition-table-nm-cyd-c5.bin \
0x10000 ota_data_initial-nm-cyd-c5.bin \
0x20000 observore-nm-cyd-c5.bin
# Waveshare ESP32-S3-Touch-AMOLED-1.43 (round, capacitive)
esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
0x0 bootloader-waveshare-s3-amoled-143.bin \
0x8000 partition-table-waveshare-s3-amoled-143.bin \
0x10000 ota_data_initial-waveshare-s3-amoled-143.bin \
0x20000 observore-waveshare-s3-amoled-143.bin
# Waveshare ESP32-S3-Touch-LCD-1.85 (round, 360x360, capacitive)
esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
0x0 bootloader-waveshare-s3-lcd-185.bin \
0x8000 partition-table-waveshare-s3-lcd-185.bin \
0x10000 ota_data_initial-waveshare-s3-lcd-185.bin \
0x20000 observore-waveshare-s3-lcd-185.bin
# Elecrow CrowPanel 1.46" rotary (round, 360x360, capacitive, knob turns the page)
esptool.py --chip esp32s3 -p /dev/ttyACM0 write_flash \
0x0 bootloader-crowpanel-146-rotary.bin \
0x8000 partition-table-crowpanel-146-rotary.bin \
0x10000 ota_data_initial-crowpanel-146-rotary.bin \
0x20000 observore-crowpanel-146-rotary.bin
manifest.json in the release is the authoritative copy of those offsets: it
is generated from each build rather than written by hand, so if these ever
disagree, believe the manifest.
These are three separate files on purpose. A single merged image would
span 0x0 upward with the gaps padded, and the NVS partition sits at 0x9000
— inside that span. Flashing one would silently erase every mute rule, the
Wi-Fi credentials, the notifier token and the device's generated console
password. Writing the parts at their own offsets leaves NVS alone, so an
upgrade keeps everything it has learned and wiping is an explicit choice
(esptool.py erase_flash).
. ~/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/ttyACM0 flash monitor
idf.py set-target esp32s3, or esp32c5, or esp32c6 — is needed once in
a fresh checkout, and again whenever you change target.
The reference board is the XIAO ESP32S3 and every measurement here was taken on
it. The firmware also builds for esp32c5 and esp32c6, and CI builds all
three so portability breaks surface immediately rather than months later.
The ESP32-C5 is the interesting one, because it is dual-band: it is the only supported chip that can see 5 GHz at all. On a C5 the sniffer sweeps both bands (see Channels); on every other chip 5 GHz is simply invisible.
sdkconfig.defaults.<target> describes a chip. A board is a separate thing
and cannot be inferred from it — the ESP32-C5 appears three times here: as a
Waveshare kit with a WS2812 and a CH343 UART bridge, as a XIAO with a plain
LED and only native USB, and as a Cheap Yellow Display with a screen on it. Board files live in boards/:
idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;boards/xiao-esp32c5.defaults" \
--preview set-target esp32c5 build
| board | LED | button | notes |
|---|---|---|---|
| XIAO ESP32S3 (default) | GPIO21, plain | GPIO0 | the reference board |
| ESP32-C5 DevKitC / Waveshare (default for C5) | GPIO27, WS2812 | GPIO28 | two USB sockets, either works |
boards/xiao-esp32c5 | GPIO27, plain | GPIO28 | 8 MB PSRAM, native USB only |
boards/xiao-esp32c6 | GPIO15, plain | GPIO9 | no PSRAM, so TLS is tight |
boards/cyd-2432s028r-st7789 | GPIO16, plain (green of the RGB) | GPIO0 | original ESP32, no PSRAM, no notifier; the later 2.8" CYD, on-screen display |
boards/cyd-2432s028r-ili9341 | GPIO16, plain (green of the RGB) | GPIO0 | as above for the original 2.8" CYD and its 2.4"/3.2" siblings; not yet confirmed on hardware |
boards/cyd-3248s035r-st7796 | GPIO16, plain (green of the RGB) | GPIO0 | the 3.5" CYD, 480x320 with touch; resistive R model only |
boards/nm-cyd-c5 | GPIO27, WS2812 | GPIO28 | the 2.8" CYD on a C5: 8 MB PSRAM, dual-band, with touch. The only display board that sees 5 GHz |
boards/waveshare-s3-amoled-143 | none — the panel is the indicator | GPIO0 | round 466x466 AMOLED, capacitive touch, clock and accelerometer |
boards/waveshare-s3-lcd-185 | none | GPIO0 | round 1.85" 360x360 LCD with capacitive touch, clock and accelerometer. 31 columns, the narrowest here |
boards/crowpanel-146-rotary | GPIO48, WS2812 | GPIO41 (the knob's push) | round 1.46" 360x360 LCD, capacitive touch, and a rotary knob that turns the page. Octal PSRAM |
The XIAO C5 is the awkward one: its LED is on the same pin as the DevKitC's addressable pixel but is an ordinary LED, so getting the board wrong leaves it dark rather than obviously broken.
The 1.85 is the narrow one: 360x360 and round, so the text grid is the square that fits inside the circle and comes to 31 columns against the 40 every other board has. Pages truncate rather than wrap.
It is also the one that needs two I2C buses and an I/O expander. Neither the panel nor the touch controller has a reset pin — both are behind the expander — which makes bringing up a bus a precondition for bringing up a screen. See the display notes for what that cost.
No watch face on it yet. Nothing blocks one; the dial was tuned against the 1.43's 466 pixels and wants checking against 360 rather than assuming it scales.
The CrowPanel is the one with a knob, and it is the only input here that is not a finger or a single button: turning it changes page, in either direction, and its push is the ordinary board button. It is counted by the PCNT peripheral rather than polled, because a knob turned quickly outruns the UI loop and a missed edge does not merely lose travel — it counts a detent the wrong way.
Its touch panel arrives with x and y crossed and one of them reversed,
which no pair of calibration bounds can express; OBSERVORE_TOUCH_SWAP_XY
and OBSERVORE_TOUCH_FLIP_Y do. Three rounds of on-screen calibration each
made it worse before that was measured. The raw x axis also saturates,
covering screen rows 32 to 302 and pinning beyond them — the text area sits
wholly inside that, but anything drawn at the extreme top or bottom edge
would be out of reach.
The XIAO profiles are compile-tested only. Neither board has been run. docs/xiao-verification.md lists what is unverified and how to check it, ordered by how likely each is to be wrong.
The browser flasher ships a build per board and asks which one you have, because it cannot tell. ESP Web Tools matches on chip family, which separates an S3 from a C5 and cannot separate two C5 boards, so the picker handles boards that share a chip, and the chip check remains underneath as a backstop: choosing a profile for the wrong chip is refused rather than flashed.
Building without a board file gives the reference board for that chip.
Per-chip settings live in sdkconfig.defaults.<target>, which ESP-IDF loads on
top of the shared sdkconfig.defaults. What differs in practice:
| XIAO ESP32S3 | ESP32-C5 | ESP32-C6 | |
|---|---|---|---|
| Bands | 2.4 GHz | 2.4 + 5 GHz | 2.4 GHz |
| LED | one GPIO, active low | WS2812 pixel | board-dependent |
| LED / button GPIO | 21 / 0 | 27 / 28 | set them yourself |
| PSRAM | 8 MB | on R modules only | none |
A RISC-V build is about a fifth larger than Xtensa, which is why the application partition is sized the way it is.
The C5 image is built with SPIRAM_IGNORE_NOTFOUND, so one binary boots on
boards with PSRAM and without: the console sizes its scratch from what it can
actually allocate. That matters because the browser flasher cannot tell an
N16R8 from a module with no PSRAM at all.
On hardware verification: the S3 and the C5 are both verified on real hardware. C6 is compile-tested only.
The C5 was verified on a Waveshare ESP32-C5-WIFI6-KIT-N16R8, flashed with the
released artifacts rather than a local build, so the release pipeline is
covered too. Confirmed working: 8 MB PSRAM detected and tested, dual-band scan,
BLE passive scan, Wi-Fi uplink, mDNS, the web console including a 6 KB
/api/nearby response, and Set baseline.
The WS2812 and the button were checked directly rather than assumed. The pixel was driven through a known red-green-blue sequence and observed in that order, which rules out the failure actually worth worrying about: blue is the third byte in both RGB and GRB ordering, so a wrong colour format looks correct on blue while silently swapping red and green. An alert would show green. It does not. The BOOT button on GPIO28 reads high at rest and low when pressed.
The dual-band result, measured in an ordinary flat:
| 2.4 GHz APs | 5 GHz APs | total | |
|---|---|---|---|
| XIAO ESP32S3 | 15 | — | 15 |
| ESP32-C5 | 12–15 | 15–17 | 27–30 |
Slightly more than double, and the 2.4 GHz counts agree between the two chips, which is what makes the extra APs credible rather than an artefact. Everything in the 5 GHz column was previously invisible to this project.
The BLE/Wi-Fi coexistence table in The radio is shared, and it shows is still an S3 measurement. At the default 60/160 duty the C5 sniffed a comparable number of management frames per sweep, so the shape carries over, but the table has not been re-measured point-by-point on that chip.
The XIAO uses the S3's native USB-Serial/JTAG, so it enumerates as
/dev/ttyACM0, not /dev/ttyUSB0.
Everything tunable lives under idf.py menuconfig → Observore: the LED and
button pins, the hostname, the patrol and uplink windows, the BLE duty cycle,
and the credential overrides. Settings written there land in sdkconfig, which
is gitignored.
It is used twice: as the SoftAP's WPA2 key, and to unlock the console when the device is on your own network.
Each device generates its own on first boot, stores it in NVS and prints it on the serial log:
console SoftAP: "console-XXXXXX" password: xxxxxxxxxxxx
It is not derived from anything observable and it is not shipped in this repository, so no two devices share one. Deriving it from the MAC would be pointless. The SoftAP's BSSID is in every Wi-Fi scan and the derivation would be right here in the source. A build-time constant would be worse still: everyone who flashed the firmware would share it.
It is deliberately not exposed over the network. Serving the console's own password from inside the console would turn "someone was on the LAN once" into "someone can join the SoftAP in range, indefinitely". Read it from serial.
The password lives in the ap_pass NVS key and is regenerated whenever that
key is absent, so erasing NVS is what rotates it:
idf.py -p /dev/ttyACM0 erase-flash
idf.py -p /dev/ttyACM0 flash monitor
That erases everything else in NVS too — the uplink credentials, the notifier settings and your mute rules. The baseline is the cheapest of those to lose: it is regenerated by pressing Set baseline once, which is what it did the first time. Manually added mute rules are the ones worth writing down first.
Rotate if the password has been seen by anyone who should not have it — a screenshot, a pasted log, or a serial capture shared for debugging. Treat it like a router's Wi-Fi key, because that is exactly what it is.
OBSERVORE_AP_PASSWORD overrides it, and every device built from that firmware
then shares the password you chose. tools/check_no_secrets.sh fails if such a
value reaches a tracked file.
On your own network the console asks for that password once and exchanges it for a session cookie, so the password is not repeated on every request, which matters, because it is also the WPA2 key to the device's own access point. The session lasts two hours of use and Lock ends it.
The SoftAP is deliberately not challenged. WPA2 already authenticates that link with the same password, and first-time setup is the one moment the console is the only way in.
Everything except the page itself and the login endpoints requires a session: the detection log, the mute rules, and the network and notifier settings. The requirement lives in the route table and a single dispatcher enforces it, so a route added later cannot quietly forget to check.
Repeated wrong guesses are slowed by a delay that grows with consecutive
failures. Set OBSERVORE_CONSOLE_AUTH=n to turn the whole thing off, which is
reasonable only on a network you would be content to leave the detection log
readable on.
This is authentication, not encryption. Over plain HTTP the session cookie can be captured by anything sniffing the LAN.
Once a network is configured the console lives on your LAN, and the SoftAP is
only needed when you are away from it — hold the button for 4 s to raise it,
join console-XXXXXX, and open http://192.168.4.1/. The page lists every classified device with its class,
MAC, signal, evidence and how long ago it was last heard.
Score 23 across 27 devices: a body camera at −65 dBm, a Remote ID drone, four
trackers and two followers, with page, baseline and light under your thumb.
The body camera's and the drone's addresses are redacted below their vendor
prefix, and the drone's Remote ID serial with them: those two are fixed public
addresses that identify particular devices belonging to other people, where
every row marked random is a rotating address that identifies nobody. A tool
built to notice what is broadcast should not be the thing that publishes it.
The display boards — both 2.8" "Cheap Yellow Displays", the 3.5", the C5 one and the round AMOLED — all draw the same screen: the level as a coloured band with the score and device count, the top findings one per line in the same words a digest uses, and uptime with the version along the bottom. It is a status screen, redrawn every couple of seconds, and only the lines that changed are sent.
Two decisions about what the screen is. Nothing on it is gated: the glass on a desk is a personal display and the authentication belongs to the network-facing console, not to the thing in the room with you. And the console password is never drawn on it, for the same reason in reverse — a screen faces a room, and the device already prints the password to serial for whoever is setting it up. Touch does not change either rule. Someone who can press the glass is already standing in front of it.
The board carries an XPT2046 resistive controller on its own SPI pins, and
with CONFIG_OBSERVORE_TOUCH the bottom of the screen becomes a bar of three
buttons: page, baseline and light.
page moves between the watch page above and a system page — version, board, uptime, what has been seen, free memory, the console's address, what the last version check found, and how long the previous runs lasted with how each one ended.
The address is there because on a board like this there is nowhere else to read it: the device prints it to serial at boot and nothing else shows it, so a console you cannot find is a console you do not have. The password is a different matter and is still never drawn. clear ignores is on the system page, beside the update actions, and asks twice like the baseline that usually created them. It exists because undoing a baseline previously needed a console, and the board that most needed it — a screen in a pocket with no network configured — had none: getting out of an accidental baseline meant flashing a one-shot firmware twice. A device that can silence a room with one tap should be able to unsilence it from the same glass. The label carries the count, because "clear ignores" with nothing to clear should not look like the same words hiding fifty-one rules.
baseline asks twice — the first tap shows tap again to baseline, and it
forgets after five seconds. A baseline is the most destructive thing this
device does, silencing everything in range at once, and it was the one path
with no guard: dismissing a single finding already took two taps and the
console already asks for confirmation. It was hit twice by accident in one day
on a board with no case, the second time while leaving an office, which
silenced the population there and invalidated the journey the board was being
carried on. It does what holding the button
does, and says so on the screen; the work happens in the main loop, which can
be most of a minute away inside a scan. light steps the backlight through
four levels and remembers the choice, because a 2.8" panel at full brightness
is a beacon in a dark room and a detector that comes back from a power cut at
full brightness at three in the morning has told the room something.
Stacks are a budget too, and the only way to size one is to read it back.
Both the drawing task and the touch task report their remaining stack whenever
the mark moves, the way the heap watch does. That exists because the touch
task was trimmed to 1,536 bytes by eye during a memory fix and overflowed the
moment CONFIG_OBSERVORE_TOUCH_LOG_RAW was switched on — formatting one log
line costs about a kilobyte of stack — so the procedure documented above for
calibrating a panel panicked the device on every touch. Measured, drawing a
page of keyboard leaves about a kilobyte spare at 3,072 bytes and a logged
touch about 1,200.
Memory is the budget that governs this board. It has no PSRAM, and during an uplink window the console's scratch, the screen's buffers and the TLS handshake behind an update check all want internal RAM at once. v0.8.2 shipped with about twelve kilobytes less of it than v0.8.1 and the CYD could no longer check for its own updates: the handshake failed, the check reported that it could not connect, and since installing requires a successful check first, a device in that state could not be updated over the air at all. v0.8.3 gave the memory back — smaller task stacks, a shorter screen snapshot, and half the console scratch — and the failure log now carries the free heap, because "cannot connect" on its own points at the network rather than at the real cause. Anything added to a display build should be measured against the free heap reported during an uplink window, not during patrol, where there is twenty kilobytes more of it and nothing looks wrong.
A figure to measure against, from a 2.8" CYD in the field running v0.11.0: an update check takes the internal low-water from 26,580 bytes to 12,936, with the largest free block falling to 12,800. The check succeeds there, and the margin it succeeds by is about thirteen kilobytes. Patrol on the same board reports 48 KB free throughout and says nothing about any of this.
Free heap is not the whole story either. A later build had 31 KB free and
still could not check for updates, because the certificate check wanted a
single contiguous block of 4,437 bytes for an RSA signature and the heap was
too broken up to offer one. The device says so plainly — Dynamic Impl: alloc(4437 bytes) failed, then mbedtls_ssl_handshake returned -0x3000 — and
the answer was to stop holding the largest block for windows nobody uses: the
console's scratch is now taken on the first request of a window rather than
when the server starts. A console nobody opens costs nothing. A check that
falls due while somebody is reading the console may fail and retry in a later
window, which is the right way round.
Drawing moved to a task of its own when touch arrived. The main loop spends about thirty seconds of every patrol cycle inside a blocking scan, and a screen that only redrew there would ignore a finger for half a minute; now the loop publishes a status and the drawing task renders it on its own clock.
The network page lists what the last patrol scan saw — no scan is started for it, because the chip has one radio and a scan on demand fights the sweep — and tapping one opens a keyboard.
The password is typed blind. The key you press is shown, because a keyboard that does not say what it registered is unusable on a resistive panel, but the field itself shows dots. A show button reveals what has been typed for fifteen seconds and then hides it again, turning the field amber while it is legible. That is the same rule as everywhere else on this screen: it faces a room, so what appears on it is a deliberate choice rather than a default. The password is wiped from memory as soon as it is saved or abandoned.
An open network needs no keyboard at all, and joining takes effect at the next uplink window.
Three layers reach every character a passphrase may contain — lower case, upper case, and all thirty-two punctuation marks of printable ASCII, including the space, which earlier versions could not type at all.
Keys share the width of the row rather than sitting on a fixed grid, so the
zxcvbnm row gets wider keys than qwertyuiop, and a faint ruler above each
row marks where one key ends. This matters more than it sounds: a fingertip
covers six to eight millimetres, and on the round panels a key is a third of
that. The 1.43" fits 466 pixels across 36 millimetres of glass, nearly three
times the density of the 2.8" boards — so the same layout that is comfortable
on one is unusable on the other, and the layout has to give the smallest
panels every column it can rather than rounding them away.
If the keyboard is still too small for the board in front of you, the console is the easier path: bring up the SoftAP, join it from a phone, and type the passphrase on a real keyboard.
Two actions at the foot of the system page: check for updates, which asks straight away rather than waiting for the daily check, and install, which appears only when a check has found something. Install asks a second time before it starts, because it stops the detector for minutes and then reboots it — the same confirmation the console's button uses, for the same reason.
On the watch page, touching a finding offers to ignore it. The row turns amber and asks; a second tap writes the rule. It is the device's broadcast name where it has one and its address otherwise — deliberately not its advert fingerprint, which identifies a kind of device rather than an individual, so muting your own tracker that way would silence a stranger's. A baseline is allowed that trade for a follower on a rotating address because it is a statement about a whole room; one tap on one row is not.
Both confirmations lapse after five seconds and are cancelled by leaving the page, so a press nobody meant — a resistive panel under a sleeve, a board face-down on a desk — does nothing.
Only the Waveshare 1.85" round LCD has the parts: a PCM5101 I2S DAC into a power amplifier into an 8-ohm speaker on a header.
It is silent until you ask for it, and that is a considered position rather than a conservative default. This device's whole posture is to not announce itself — the watch face exists so that a thing glanced at in a pocket is less conspicuous than one showing a list of everybody's Bluetooth addresses — and a chirp when a threat appears tells the room you spotted them. But the posture is not universal: a detector left watching an empty flat has nobody to hide from, and a sound carries from the next room where a screen does not.
curl -X POST 'https://observore/api/audio?on=1&vol=20'
curl 'https://observore/api/audio' # {"sounding":true,"volume":20}
Volume runs 1 to 100, and 100 is not loud: the ceiling is a twelfth of full scale, because what it drives is a 2 W amplifier. The first version of this used a fifth of full scale, reasoning that a fifth sounds modest. It was loud enough that the speaker got pulled out of its socket. The number that matters is what the amplifier does with the sample, not the fraction of the range.
Two notes rising on the way to caution, three on the way to alert. Nothing on the way back down: a device that chirps when it relaxes chirps twice as often for no added information.
Some advertising and retail-analytics systems emit a short tone just above hearing — around 18 to 20 kHz — from a shop display, a television advert or a web page, and listen for it on a phone whose app holds the microphone permission. Hearing the same tone links the two: the phone in your pocket and the screen you walked past. It is a surveillance channel that works precisely because almost nobody can hear it.
Observore watches radios. This is the same job in a different medium, and on the one board here with a microphone it was a channel the hardware could already see and was not looking at.
curl 'https://observore/api/mic' # is this thing listening?
curl -X POST 'https://observore/api/mic?on=1'
What it does not do. It does not record. No audio is kept beyond the 20 millisecond block being analysed, none is written to flash, and none is sent anywhere; what survives a block is twenty band energies and a counter. There is no buffer to grow, no file to open and no API that returns a sample.
A counter-surveillance device that keeps a microphone open is a listening device, and "it means well" is not a property the person holding it can check. What they can check is that the code has nowhere to put the audio — and that the analysis starts at 17 kHz, above speech and above almost all music, so the frequencies a conversation lives in are never computed at all. That is structural, not a policy: the twenty filters are evaluated at twenty fixed frequencies and nothing else is ever calculated.
It is off at build time on every board but one, off again at runtime, and
GET /api/mic answers the question directly rather than making you infer it
from a build.
How it decides. A bank of twenty Goertzel filters at 250 Hz spacing from 17.0 to 21.75 kHz, one 960-sample block at a time. A bin has to stand eight times above the floor that room taught it, and stay there for ten consecutive blocks — 200 ms — before it counts. A beacon is emitted to be detected, so it lasts; a key, a coin or a camera shutter is a broadband click that is over within one block.
The floor learns only from blocks already judged quiet. Learning from every block would let a tone held long enough raise its own floor until it stopped being a tone, which is the exact failure a persistent emitter would exploit.
960 samples and not a round 1024, because a rectangular-window Goertzel is
sharp only where N·f/Fs is a whole number. At 1024 every target lands on a
fractional bin and the filters leak across the band — measured, a loud 8 kHz
tone was reported as a beacon. At 960 each 250 Hz step is exactly five bins
and that same tone leaks twelve orders of magnitude down.
Observore pushes to Gotify, ntfy or Pushover. Choose one in the console's Notifications panel, or:
GET /api/notify
POST /api/notify?provider=gotify&url=https://gotify.example.com&token=APP_TOKEN
POST /api/notify?provider=ntfy&url=https://ntfy.sh/your-topic[&token=TOKEN]
POST /api/notify?provider=pushover&token=APP_TOKEN&user=USER_KEY
POST /api/notify?test=1
POST /api/notify?clear=1
| Provider | URL | Credentials |
|---|---|---|
| Gotify | your server; /message is appended | application token |
| ntfy | the full topic URL, e.g. https://ntfy.sh/your-topic | optional bearer token |
| Pushover | none — it has one endpoint | application token and user key |
Urgency is chosen by what was found — a body camera or licence-plate reader is urgent, a follower or tracker is high, a drone or smart glasses normal, a camera low, and translated into each service's own scale. Pushover's urgent maps to high rather than emergency: emergency requires retry and expire parameters and keeps re-alerting until a human acknowledges, which is not a reasonable default for a device that can see a police car drive past.
Escalations of the overall threat level are pushed too; drops are not, because an alert that clears is not news.
Tokens and user keys are stored in NVS and are write-only — no endpoint returns them, exactly like the Wi-Fi password. Saving with the token left blank keeps the stored one, so changing a URL does not cost you a credential — but only for the same provider. Switching provider with nothing entered starts clean, because a token issued for one service must never be sent to another, and the console says which happened.
Sending needs the uplink. Detections happen during patrol, which has no network, so notices are queued and flushed the next time you are joined. The queue holds 24 and drops the oldest when full: a detector that stops noticing new things because its outbox is full would be worse than one that loses the oldest notice. A failed send stays queued and is retried.
Everything queued arrives as one message, with the findings listed in order
of how much they warrant attention: body cameras and ALPR first, then followers,
trackers, smart glasses and drones, then fleet telematics and cameras. Within a
class the closest is listed first. The title carries the level and a census, as
in alert: 6 findings (1 drone, 5 followers), and the whole message takes the
highest urgency present, so one body camera still arrives as urgent even when it
is listed behind quieter entries. Six lines fit; beyond that the message ends
with +N more rather than quietly losing the tail.
That ordering reflects how alarming a class is given how common it is, rather than how surveillance-like it sounds, which is why cameras come last — a Ring prefix is a doorbell far more often than it is aimed at you.
One message rather than one per finding is also what keeps the radio affordable. Each send is a TLS handshake with certificate verification, and six of those back to back in one thirty-second window drove the free internal heap to 184 bytes on a C5 — eight bytes above the level that once cost 10,019 consecutive delivery failures. Sending a single ranked list instead leaves that low-water mark at about 16 KB.
The console shows sent, queued, failed and dropped counts, plus the last
transport error — 401/403 is reported as a bad token or key rather than as
a bare number.
| Provider | Status |
|---|---|
| Gotify | sent end to end from the device to a live server over TLS |
| ntfy | the exact request the firmware builds was accepted by ntfy.sh, with the title, multi-line body and priority arriving intact |
| Pushover | the request shape was accepted by api.pushover.net, which parsed the form body and rejected only the deliberately invalid token — delivery itself is unverified, since that needs an account |
| Webhook | sent end to end from the device to a listener on another VLAN, over plain HTTP: bearer header, JSON content type and body all arrived as built |
| Telegram | the request is built to the Bot API's documented shape and host tested, including the bot token in the path and disable_notification for low urgency — delivery itself is unverified, since that needs a bot |
A JSON POST to whatever URL you give it, unchanged, with Authorization: Bearer <token> when a token is set. The body is:
{"source":"observore","urgency":"high","title":"alert: 2 findings","message":"..."}
urgency is one of low, normal, high, urgent. The body names its own
fields rather than pretending to be any one service's shape: a Home Assistant
webhook trigger reads it directly, and anything that wants Discord's content
or Slack's text maps it in a line of template. A body that tried to be all of
them at once would carry the message three times and not fit.
This is the one provider that can skip TLS. Pointing it at http:// on your
own network costs no handshake at all, which on this device is the entire
expense of sending a notification. A Gotify or ntfy on the LAN over http://
gets the same saving.
Home Assistant accepts webhooks with no authentication beyond the id itself, which makes it the simplest thing to point Observore at and also means the id is a secret: make it long and random. Create an automation with a webhook trigger, either in the UI or as YAML:
alias: Observore findings
triggers:
- trigger: webhook
webhook_id: observore-7f3a9c2e51b8d4
allowed_methods:
- POST
local_only: true
actions:
- action: notify.mobile_app_your_phone
data:
title: "{{ trigger.json.title }}"
message: "{{ trigger.json.message }}"
data:
priority: "{{ 'high' if trigger.json.urgency in ['high', 'urgent'] else 'normal' }}"
local_only refuses the webhook from outside your network, which is where the
device is anyway. Then in Observore's console choose Webhook and enter:
http://192.168.1.10:8123/api/webhook/observore-7f3a9c2e51b8d4
Use the address, not homeassistant.local: mDNS is link-local and often does
not cross from an IoT VLAN. Leave the token blank; Home Assistant ignores it.
Press Send test while the device is on the uplink and the automation
should fire once with the title Observore test.
The four fields are all available as trigger.json.<name>. urgency is
low, normal, high or urgent, so a condition on it can route a
bodycam digest to a different notifier than a quiet follower one, or
switch on a light, or anything else an automation can do — which is the
point of sending to Home Assistant rather than to a phone directly.
Expect a POST at most once every few minutes and only when there is something to say. The device is off the network while patrolling, and findings are combined into one message per uplink window.
The same body works for n8n and Node-RED webhook nodes as-is, and for
Apprise through its API, which can then fan out to whatever it supports.
Discord and Slack want a specific field name — content and text
respectively — so put a small relay in between, or use one of the above to
forward. Observore does not send the message under three names at once
because the request would not fit.
Token is the bot token, the second credential is the chat id. The token forms part of the request path, which is how the Bot API is shaped, so the notifier logs only the host of whatever it is configured with — a webhook URL is often the credential too, and serial logs end up in bug reports.
All three wire formats are pinned by host tests: URL construction including
trailing slashes, header names, body encoding, and the priority mapping. The
Pushover body is form-encoded, so an advertised device name containing &
cannot inject a field. There is a test for exactly that.
The sections above are what you need to run it. These are the longer write-ups: why each decision was made, and what went wrong on hardware before it worked.
Built by Forged in Feathers Technology, alongside WarDeck — the same interest in what the radio spectrum around you is actually doing, pointed in the opposite direction.
The concept — passive BLE plus Wi-Fi surveillance detection with a decaying threat score on a pocket-sized ESP32. Comes from simeononsecurity/eye-spy (Apache-2.0). Observore is an independent implementation for different hardware: ESP-IDF rather than Arduino, a web console rather than an RGB LED, and vendor tables generated from the IEEE registry rather than maintained by hand. No code was taken from it.
MIT. See LICENSE.