A Bluetooth Low Energy (BLE) advertisement can arrive repeatedly while its application data stops changing. Build an ESP32 broadcaster and observer that display two different ages: time since the latest accepted receiver callback and time since the latest advancing sequence. Then freeze the sequence while the radio keeps advertising. The difference becomes visible without pretending that either age is a sensor timestamp.
Verification: this is a documented software and radio-topology experiment with synthetic values. Host tests exercise the complete provided code using controlled radio adapters. Neither ESP32 target execution, firmware compilation, physical radio reception nor packet-delivery performance has been tested. The diagrams are original topology and calculated examples, not photographs or measured traces.
Two clocks answer two questions
BLE advertising broadcasts short data without making a connection. Our broadcaster requests a 100 millisecond (ms) advertising interval. Its application changes a sequence and a synthetic value roughly once per second, then stops changing them while the controller continues repeating the same advertisement. Receiving another copy tells the observer that another callback occurred; it does not tell it that a new application observation was created.
The first metric is callback_age_ms: receiver elapsed time minus the callback timestamp of the last accepted packet from the selected source. The second is sequence_change_age_ms: receiver elapsed time minus the callback timestamp of the last accepted forward sequence change. They use the observer’s clock. They are not hardware radio-arrival timestamps, do not include an authenticated source time, and cannot establish when a sensor was measured. The payload in this lesson is deliberately synthetic.
The electronics course provides the signal and timing foundations. The related ESP32 BLE swipe article concerns connected input devices; this experiment instead examines connectionless advertisements, parser boundaries and receiver-side freshness.
Exact board, firmware and power topology
Use two Espressif ESP32-DevKitC V4 / ESP32-DevKitC-32E boards populated with ESP32-WROOM-32E-N4 modules: original ESP32 family, 4 megabytes of flash and an onboard printed-circuit antenna. The current module sheet is version 2.1. Do not assume an ESP32-C3, S3, S2, WROVER or an unidentified clone has the same radio and firmware behavior. The module uses an ESP32 system-on-chip (SoC); verify the actual board/module marking rather than identifying hardware from a shop title.
Pin the official MicroPython 1.26.0 ESP32_GENERIC firmware and its low-level bluetooth application programming interface (API). This module is documented as evolving; a newer version needs its own check. The official ESP32_GENERIC download supports appropriate ESP32/WROOM boards with at least 4 mebibytes (MiB) of flash. No additional Python package or floating library version is needed.
Each board receives power and its console through its own Universal Serial Bus (USB) cable connected to the board’s Micro-B port. Leave all general-purpose input/output (GPIO), 5V and 3V3 header pins unconnected. The DevKitC guide requires exactly one power-source option per board; do not add a header supply while USB is connected. The module’s 3.0–3.6 V operating supply is provided by the board; its 3.3 V logic domain is not permission to put USB’s 5 V on a GPIO. No external component is attached in this experiment.
The visible cables terminate at the named USB ports in the drawing. Radio frequency (RF) signals carry the packets; the dashed link is radio propagation, not a wire or a shared positive supply. Both cables can connect to one computer; that may join grounds through the host, so this is not an electrical-isolation experiment. Keep the antenna areas clear of metal and use only your two deliberate test broadcasters. Signal strength is not a calibrated distance measurement.

tricky-circuit-batch009-campaigns81-86/images/esp32-ble-observer-age.svg).A private 27-byte protocol, not a company identifier
Advertising data (AD) is a sequence of length/type/value fields. A field’s length byte counts its type and value, but not the length byte itself. Bluetooth’s assigned type 0x21 means service data beginning with a 128-bit universally unique identifier (UUID). We choose a private UUID for this experiment, then put a small versioned format after it. This does not invent a Bluetooth manufacturer/company identifier, register a public service or provide authentication.
The human-readable UUID is 9e917b32-d3a8-4faa-896b-643f2c1e0701. Its 16 wire bytes are 01 07 1e 2c 3f 64 6b 89 aa 4f a8 d3 32 7b 91 9e: the complete UUID is in least-significant-byte-first order. Do not substitute a platform’s mixed-endian UUID representation. The provided encoder defines the exact bytes, and the host test compares them with an independent canonical-byte reversal.
| Byte offsets | Content | Meaning |
|---|---|---|
| 0–2 | 02 01 06 |
Flags field. |
| 3–4 | 17 21 |
Length 23 and type 0x21. |
| 5–20 | 16 UUID wire bytes | Identifies this data format. |
| 21 | 01 |
Application format version. |
| 22–23 | Unsigned 16-bit run ID | Operator-selected experiment label, little endian. |
| 24–25 | Unsigned 16-bit sequence | Application progression marker, little endian. |
| 26 | Unsigned 8-bit value | Synthetic demonstration value, not a sensor reading. |
Flags = 3 bytes
Service-data value = 16 UUID + 1 version + 2 run + 2 sequence + 1 value = 22
Service-data AD field = 1 length + 1 type + 22 value = 24 bytes
Total legacy payload = 3 + 24 = 27 bytes, within the 31-byte limit
Requested scan duty = 20000 µs / 50000 µs = 0.40 (40%)
A microsecond (µs) is one millionth of a second. The 40% figure is only the requested scan-window ratio; it is not a probability of receiving a particular packet. Radio scheduling, interference, advertising-channel changes, stack buffering and software load all matter. Non-connectable advertisements and passive scanning need no scan-response payload, pairing or connection. The code explicitly clears the response data instead of accidentally reusing an old buffer.
Sequence progression and receiver states
The observer is configured for one run ID, initially 1001. Its first accepted packet binds the source tuple: address type plus six address bytes. A different address is ignored, including an address change that might belong to the same physical board. Matching that tuple and UUID is a filter, not proof of identity: another broadcaster can copy them. Only the first matching source wins; do not use this experiment for presence, access or security decisions.
The first packet establishes BASELINE. It has not yet demonstrated progression. A later sequence is forward if its modular difference is from 1 through 32767; zero is a duplicate. A difference of 32768 or more is ambiguous or backward under our short-run contract and invalidates the run. The exact half-range boundary cannot determine ordering. A legitimate large jump, a restarted sender and replay can look alike without stronger session/sequence rules.
delta = (new_sequence - previous_sequence) & 65535
0 → duplicate: refresh callback age only
1 .. 32767 → accepted forward change: refresh both ages
32768 .. 65535 → ambiguous: invalidate this run
callback age = observer elapsed time - last accepted callback time
change age = observer elapsed time - last forward-change callback time
After a forward change the display is ADVANCING while both ages stay below the application-selected 3000 ms threshold. If copies keep arriving but the sequence stops, the state becomes SEQUENCE_STALE. If no accepted callback has arrived for 3000 ms, NO_RECENT_CALLBACK takes precedence. None of these labels diagnoses why a callback is missing. Transmitter power loss, scan gaps, radio interference, receiver overload and distance are competing explanations.
A duplicate sequence carrying a different value violates this format’s “one sequence, one value” rule and invalidates the history. An invalid history never silently becomes valid on the next advertisement. Stop, investigate and begin a new observer instance for a deliberately new run ID after any intentional transmitter restart. An operator-chosen run ID is neither a cryptographic nonce nor an authentication mechanism; reusing one can make a restart indistinguishable from valid modular progression.

tricky-circuit-batch009-campaigns81-86/images/esp32-ble-observer-age-logic.svg).Copy temporary buffers and preserve their time
The API calls this hook an interrupt request (IRQ) handler, although the exact backend execution context matters. The MicroPython scan-result callback includes borrowed memory views into temporary driver-provided storage. They are valid only during the callback. Saving that view and reading it later risks observing different bytes. The provided callback copies the address and advertising bytes before returning, records ticks_ms() at callback entry, validates the complete AD structure, and queues only packets matching the private UUID and expected run ID. Malformed lengths, unsupported versions and duplicate matching fields never refresh either age.
The timestamp is deliberately recorded before the main-loop queue delay. If a packet waits 500 ms before processing, those 500 ms must remain part of its age. A timestamp taken only when draining the queue would make a backlog look new. Received-event time and display time have separate monotonic checks, because an old queued event can legitimately be processed after the previous display update.
The application uses an eight-entry, single-producer/single-consumer ring. A full ring latches QUEUE_OVERFLOW; draining it does not erase the loss. A callback exception similarly latches CALLBACK_FAULT so the main loop can report a detected fault without depending on backend exception handling. Packet processing and drain work are bounded. The pinned ESP32 port invokes callbacks from the NimBLE task with a global interpreter lock (GIL) interlock, which serializes Python execution. This is neither the generic scheduled-ring-buffer branch nor a user-configured hard interrupt. The program adds no Python threads; do not move this allocating parser into an unrelated hard interrupt or add a second queue consumer.
This only accounts for detected faults in our application queue. The exact ESP32 1.26.0 port selects synchronous/interlocked events; the generic documentation’s rxbuf configuration belongs to another compile branch and is not called here. Radio/controller/host processing can still lose observations before the application sees them; this program has no complete loss counter for those layers. Repeated advertisements being enabled in the selected backend does not guarantee that every repetition reaches your callback. Missing sequence values are unobserved progression, not a reliable count of lost radio packets.
Complete program for both roles
Save this entire Python file (ZIP; file: tricky-circuit-batch009-campaigns81-86/code/esp32-ble-observer-age.py) as esp32-ble-observer-age.py on each board. Use the USB console’s read–evaluate–print loop (REPL). The program defines both roles but starts neither on loading. On the observer enter the following block, finish the with block with a blank line, then run the last line. Repeat on the broadcaster with "broadcast" instead of "observe". Use the same deliberately selected run ID on both.
namespace = {}
with open("esp32-ble-observer-age.py") as f:
exec(f.read(), namespace)
namespace["main"]("observe", 1001)
Start the observer first. It requests a 60-second passive scan with a 50 ms interval and 20 ms window. The broadcaster requests sequence values 0 through 9 at one-second application steps. There are ten payloads but only nine forward transitions; the last update is at nominal 9 seconds. Its final one-second wait plus an additional ten-second wait keep payload 9 unchanged until nominal 20 seconds: about eleven seconds without an update. All these times are requested software timing, not measured transmission schedules.
"""Two ESP32-DevKitC V4/WROOM-32E boards; MicroPython 1.26.0.
Synthetic observations only. Receipt/sequence ages use receiver callback time,
not radio hardware timestamps, source measurement times or authentication.
"""
UUID_WIRE = b'\x01\x07\x1e\x2c\x3f\x64\x6b\x89\xaa\x4f\xa8\xd3\x32\x7b\x91\x9e'
IRQ_SCAN_RESULT = 5
IRQ_SCAN_DONE = 6
def u16(value):
if type(value) is not int or not 0 <= value <= 65535:
raise ValueError('uint16 integer required')
return bytes((value & 255, value >> 8))
def encode_adv(run_id, sequence, value):
if type(value) is not int or not 0 <= value <= 255:
raise ValueError('synthetic value must be uint8')
body = UUID_WIRE + b'\x01' + u16(run_id) + u16(sequence) + bytes((value,))
return b'\x02\x01\x06' + bytes((len(body) + 1, 0x21)) + body
def parse_adv(data):
"""Strict complete AD walk; accept exactly one matching service-data field.
Other well-formed fields are allowed. Reject truncated fields, duplicate
matching UUIDs, wrong version/size and nonzero data after a zero terminator.
Return None for irrelevant/invalid input; never update freshness from it.
"""
if len(data) > 31:
return None
i, result = 0, None
while i < len(data):
size = data[i]
if size == 0:
return result if all(x == 0 for x in data[i:]) else None
end = i + 1 + size
if end > len(data):
return None
if data[i + 1] == 0x21:
if size < 17:
return None
field = data[i + 2:end]
if bytes(field[:16]) == UUID_WIRE:
if result is not None or len(field) != 22 or field[16] != 1:
return None
result = (field[17] | field[18] << 8,
field[19] | field[20] << 8, field[21])
i = end
return result
class CallbackQueue:
"""Single producer (BLE callback), single consumer (main loop), 8 entries.
Fixed nine-slot ring leaves one slot empty. Pinned ESP32 NimBLE callback runs with the runtime global interpreter lock
(GIL) interlock. No extra Python threads; not a user hard interrupt.
Copy borrowed views before return. fault is sticky; never auto-clear it.
"""
def __init__(self, clock, run_id):
u16(run_id)
self.clock, self.run_id = clock, run_id
self.slots = [None] * 9
self.head = self.tail = 0
self.fault = None
self.done = False
def irq(self, event, data):
try:
if event == IRQ_SCAN_DONE:
self.done = True
return
if event != IRQ_SCAN_RESULT or self.fault is not None:
return
addr_type, addr, adv_type, rssi, borrowed = data
stamp = self.clock() # callback time, before parsing/queue delay
if type(stamp) is not int:
raise ValueError('bad clock')
if adv_type != 3 or len(borrowed) > 31:
return
advertisement = bytes(borrowed)
parsed = parse_adv(advertisement)
if parsed is None or parsed[0] != self.run_id:
return
address = bytes(addr)
if addr_type not in (0, 1) or len(address) != 6:
raise ValueError('bad source address')
nxt = (self.head + 1) % len(self.slots)
if nxt == self.tail:
self.fault = 'QUEUE_OVERFLOW'
return
self.slots[self.head] = (stamp, (addr_type, address), advertisement, rssi)
self.head = nxt
except BaseException:
self.fault = 'CALLBACK_FAULT'
def pop(self):
if self.tail == self.head:
return None
item = self.slots[self.tail]
self.slots[self.tail] = None
self.tail = (self.tail + 1) % len(self.slots)
return item
class Observation:
"""Relative receiver milliseconds, not raw wrapping ticks. Sticky gaps."""
def __init__(self, run_id, stale_ms=3000):
u16(run_id)
if type(stale_ms) is not int or not 0 < stale_ms <= 10000:
raise ValueError('bounded positive stale threshold required')
self.run_id, self.stale_ms = run_id, stale_ms
self.source = self.sequence = self.value = None
self.last_rx = self.last_change = None
self.advanced = False
self.gap = None
self.received_now = self.display_now = 0
self.closed_snapshot = None
def invalidate(self, reason):
if self.gap is None:
self.gap = reason
def _time(self, now, display=False):
prior = self.display_now if display else self.received_now
if type(now) is not int or now < prior:
self.invalidate('CLOCK_FAULT')
return False
if display:
self.display_now = now
else:
self.received_now = now
return True
def accept(self, parsed, source, received_ms):
if self.gap is not None:
return 'INVALID_RUN'
if not self._time(received_ms):
return 'CLOCK_FAULT'
run_id, sequence, value = parsed
if run_id != self.run_id:
return 'OTHER_RUN'
if self.source is not None and source != self.source:
return 'OTHER_SOURCE'
if self.source is None:
self.source, self.sequence, self.value = source, sequence, value
self.last_rx = self.last_change = received_ms
return 'BASELINE'
delta = (sequence - self.sequence) & 65535
if delta == 0:
if value != self.value:
self.invalidate('SAME_SEQUENCE_CHANGED_VALUE')
return 'INVALID_RUN'
self.last_rx = received_ms
return 'DUPLICATE'
if delta >= 32768:
self.invalidate('AMBIGUOUS_SEQUENCE')
return 'INVALID_RUN'
self.sequence, self.value = sequence, value
self.last_rx = self.last_change = received_ms
self.advanced = True
return 'ADVANCED'
def close(self, now):
final = self.snapshot(now)
final['closed'] = True
final['not_live_after_stop'] = True
self.closed_snapshot = final
def snapshot(self, now):
if self.closed_snapshot is not None:
return dict(self.closed_snapshot)
if not self._time(now, display=True) or now < self.received_now:
self.invalidate('CLOCK_FAULT')
return {'state': 'INVALID', 'reason': self.gap}
if self.gap is not None:
return {'state': 'INVALID', 'reason': self.gap}
if self.last_rx is None:
return {'state': 'WAITING'}
rx_age, progress_age = now - self.last_rx, now - self.last_change
state = ('NO_RECENT_CALLBACK' if rx_age >= self.stale_ms else
'SEQUENCE_STALE' if progress_age >= self.stale_ms else
'ADVANCING' if self.advanced else 'BASELINE')
return {'state': state, 'callback_age_ms': rx_age,
'sequence_change_age_ms': progress_age,
'sequence': self.sequence, 'synthetic_value': self.value}
def observe(ble, run_id, ticks_ms, ticks_diff, sleep_ms, emit=print):
"""Finite nominal60s scan; service clock/queue at least every1000ms.
ticks_diff is valid only within its half-period. A complete-wrap pause can
be undetectable: no indefinite age guarantee. Queued callback age includes
main-loop lag and must be <=1000ms; timestamps are not RF hardware time.
"""
queue = CallbackQueue(ticks_ms, run_id)
state = Observation(run_id)
cleanup = {'scan_stopped_command': False, 'irq_detached': False}
elapsed, last_tick, last_output = 0, None, -1000
def advance():
nonlocal elapsed, last_tick
tick = ticks_ms()
delta = ticks_diff(tick, last_tick)
if type(delta) is not int or not 0 <= delta <= 1000:
raise ValueError('CLOCK_OR_SERVICE_GAP')
elapsed += delta
last_tick = tick
return elapsed, tick
try:
ble.active(True)
last_tick = ticks_ms() # establish origin before installing callback
ble.irq(queue.irq)
ble.gap_scan(60000, 50000, 20000, False)
while True:
try:
advance()
if queue.fault:
state.invalidate(queue.fault)
break
for _ in range(8): # bounded drain: never starve the main clock
item = queue.pop()
if item is None:
break
stamp, source, advertisement, rssi = item
current, tick = advance() # AFTER pop, not before callback
lag = ticks_diff(tick, stamp)
if type(lag) is not int or not 0 <= lag <= 1000:
state.invalidate('QUEUE_OR_CLOCK_GAP')
break
state.accept(parse_adv(advertisement), source, current - lag)
current, tick = advance()
except (ValueError, TypeError):
state.invalidate('CLOCK_OR_SERVICE_GAP')
break
if state.gap or queue.fault:
state.invalidate(queue.fault or state.gap)
break
if current - last_output >= 1000:
emit(state.snapshot(current))
last_output = current
if current >= 60000 or queue.done:
break
sleep_ms(50)
return state
finally:
try:
ble.gap_scan(None)
cleanup['scan_stopped_command'] = True
finally:
try:
ble.irq(None)
cleanup['irq_detached'] = True
finally:
# Last display/stop can dispatch callbacks. Reconcile sticky
# fault AFTER detaching, before the pending return is accepted.
if queue.fault:
state.invalidate(queue.fault)
state.close(elapsed)
if state.gap:
emit({'state': 'INVALID', 'reason': state.gap})
emit({'closed_run': state.snapshot(elapsed)})
emit({'cleanup_commands': cleanup, 'not_radio_measurements': True})
def broadcast(ble, run_id, sleep_ms, emit=print):
"""Synthetic seq0..9 at1s steps; final payload held11s total; stop in finally."""
u16(run_id)
try:
ble.active(True)
emit('Synthetic seq0..9 at requested1s steps; final payload held11s; not sensor data')
for sequence in range(10):
ble.gap_advertise(100000, encode_adv(run_id, sequence, sequence),
resp_data=b'', connectable=False)
sleep_ms(1000)
sleep_ms(10000) # no new gap_advertise call: controller repeats payload
finally:
ble.gap_advertise(None)
def main(mode='observe', run_id=1001):
import bluetooth
from time import ticks_ms, ticks_diff, sleep_ms
ble = bluetooth.BLE()
if mode == 'observe':
return observe(ble, run_id, ticks_ms, ticks_diff, sleep_ms)
if mode == 'broadcast':
return broadcast(ble, run_id, sleep_ms)
raise ValueError('mode must be observe or broadcast')
The observer services its clock and queue at intervals no longer than 1000 ms under this experiment’s contract. A detected longer service gap, invalid tick difference, stale queued callback, sequence ambiguity or queue fault invalidates the run. Raw tick values wrap; adjacent ticks_diff operations are valid only within the clock’s half-period. A pause spanning a complete wrap can alias into a small difference and remain undetectable. The bounded run and frequent service do not justify an indefinite or sleep-resilient age guarantee.
Cleanup attempts to stop scanning and detach the callback independently through nested finally blocks. It checks a final sticky callback fault after detaching, so an overflow delivered during the last display or stop is not lost at return. Detaching does not claim to join an already executing backend callback. The returned closed_run is a frozen session-end snapshot, not continuing live validity after scanning stops. Advertising is stopped in the broadcaster’s finally. A completed stop call is an API-command acknowledgement, not an independent measurement that a radio emitted nothing afterward. Stop/detach failures raise an exception; do not treat that run as successful.
Bench procedure and fault experiments
- Identify both board/module markings and firmware version. Label their USB consoles A and B so the sender does not accidentally run in the observer window. Leave every header unwired; use only each board’s USB supply.
- Save the exact program on both, choose a run ID and start
observeon B. Startbroadcaston A. Record when each application was launched and which states actually appeared, without claiming host clocks were synchronized. - Look for BASELINE followed by ADVANCING, then SEQUENCE_STALE while repeated payload 9 is still observed. When callbacks cease after advertising stops, look for NO_RECENT_CALLBACK. If the environment produces no callbacks, record that outcome and investigate configuration and radio conditions instead of inventing an expected trace.
- For a separate repeat, use a new run ID on both. Place the boards differently while keeping software and power unchanged. Record missing/late observations and the received signal strength indicator (RSSI) if you deliberately add logging; the supplied queue carries RSSI but does not convert it to distance.
- Use the host fault tests to fill the application queue with nine valid callbacks before draining. Confirm sticky invalidation even after the eight retained entries are removed, and after a terminal-display overflow. Do not claim this synthetic injection measures the ESP32 controller buffer.
- Pause receiver servicing in a separate instrumented copy only within the documented clock assumptions. A detected gap should produce INVALID, not a fresh age. A long debugger or console pause can also stop Python callbacks; that is a changed acquisition condition.
- Restart the broadcaster intentionally. First observe the failure possibilities under a reused run ID in the host model. Then restart both roles with a new run ID for a clean physical run. Do not let the observer automatically accept a reset sequence as continuous history.
The blank worksheet (ZIP; file: tricky-circuit-batch009-campaigns81-86/code/esp32-ble-observer-age-observations-blank.csv) separates callback age, sequence-change age, state and actual test conditions. Leave unmeasured fields blank. The supplied host suite tests the real encoder/parser, wrap rules, duplicate behavior, borrowed-buffer mutation, bounded queues, clock gaps and cleanup. It does not build firmware, run on either board or establish a radio-delivery rate.
Challenge: packets arrive, but is the observation new?
At receiver time 15 seconds you process another valid callback whose sequence last advanced at receiver time 9 seconds. The previous valid callback occurred at 14.9 seconds. What do the two ages mean, and does a newer radio callback prove a fresh sensor value?
Show the answer
The just-accepted callback age is approximately zero at processing time only if queue delay is negligible; otherwise include that delay from its original callback timestamp. Sequence-change age is six seconds, so the 3000 ms policy reports SEQUENCE_STALE while callbacks remain recent. Neither metric proves sensor age: no authenticated sensor timestamp exists here, and our value is synthetic. A copied or replayed sequence can also mislead an unauthenticated observer.
Primary sources and verification limits
- Espressif ESP32-DevKitC V4 guide and ESP32-WROOM-32E/32UE datasheet v2.1: exact board/module family, USB supply choice, antenna and operating supply. The board guide’s earlier-C15 boot note is not a measured diagnosis of your board.
- Official ESP32_GENERIC firmware, MicroPython 1.26.0 Bluetooth, time and ESP32 reference: pinned radio API, callback lifetime, requested intervals and wrapping clock rules.
- Bluetooth Special Interest Group (SIG) Assigned Numbers, version date 2026-10-22 and Core Specification Supplement, Part A §1.11: type 0x21 and the UUID-prefixed service-data structure.
- Pinned ESP32 port configuration, Pinned MicroPython callback implementation and NimBLE backend: exact ESP32 synchronous/interlocked callback selection, borrowed-buffer handling, generic-versus-port configuration and duplicate filtering. The linked ESP32 port header selects the compile branch; generic code alone is insufficient. This article supplies original application code; it does not redistribute those implementations.
- Current ESP32 SoC errata: identify the actual silicon revision and applicable issues before hardware qualification. No low-level register workaround, clock-frequency switching or GPIO0 clock output is used here; no blanket “errata-free board” claim follows.
Download the reader files (ZIP): complete source code, the blank observation worksheet and original editable SVG/PNG diagrams for esp32-ble-observer-age are inside tricky-circuit-batch009-campaigns81-86/. All inline programs are complete; the download is supplementary.





