AirSensors

I wanted a sensor you could drop somewhere inconvenient — a hallway, a basement, a bike rack — and still get some telemetry back without Wi‑Fi, without a SIM, and without setting up a gateway. That’s where AirSensors came from: a family of small, battery-powered sensors that use Apple’s Find MyOffline Finding” network as a best‑effort uplink.

This post is the story of how I built it: starting from a normal Find My beacon, then turning the “public key” field into a 1‑bit carrier, and finally building a gateway that reconstructs sensor readings from report presence/absence. Along the way, the biggest surprise was a crypto constraint that looks like “random missing bits” until you understand it.

0:00 / 0:00
AirSensors hardware and enclosure photo

AirSensor prototypes.

An AirSensor is a sensor (button, light, gas, thermistor) that encodes a 3‑byte message:


[type:1B][value:2B]

It then transmits those 24 bits by repeatedly advertising Find My–compatible BLE frames. Nearby Apple devices opportunistically upload “reports” keyed by the advertised identifier. The gateway asks Apple for reports for a set of candidate IDs and recovers the message by observing which candidates had any reports.

  • Latency: minutes to hours
  • Throughput (observed): ~3 bytes/min

How Find My works

Apple’s Find My network uses each BLE advertisement to encode a 28‑byte x‑coordinate (from secp224r1). Apple devices that see it upload encrypted reports keyed by:


SHA256(pubKeyX)

That means Apple’s backend behaves like a strange public key/value store: if you query for a particular key ID and any reports exist, you learn a 1‑bit signal — seen vs not seen.

AirSensors turns that into a modem by mapping each payload bit to a pair of candidate key IDs (bit=0 vs bit=1) and checking which one shows up.

Step 0: prove the plumbing with a normal Find My beacon

Before attempting a modem, I built a baseline that simply broadcasts a valid Find My advertisement continuously. This checks that:

  • BLE payload formatting is correct
  • Apple devices are nearby
  • The backend can fetch reports for an ID

Healthy boot log:

airsensor: === AirSensor Beacon ===
airsensor: Initializing...
airsensor: Configuring static advertisement payload...
airsensor: Advertisement data configured, starting advertising...
airsensor: Advertising started successfully!
airsensor: MAC Address: F2:EC:A5:F6:79:72
airsensor: Payload (first 16 bytes): 1E FF 4C 00 12 19 00 3A D8 9C 76 6A A7 C0 33 BD

Turning it into a modem: the end-to-end flow

Once the static beacon worked, the modem path became an encoding + retrieval problem.

sequenceDiagram
participant AirSensors as AirSensors(ESP32)
participant AppleDev as AppleDevice
participant Apple as AppleServer
participant Gateway as Gateway
 
AirSensors->>AppleDev: BLE_Adv(FindMy_Format, pubKeyX(bit i))
AppleDev->>AppleDev: EncryptLocationTo(pubKey)
AppleDev->>Apple: UploadEncryptedReport(keyHash)
Gateway->>Apple: FetchReports(ids=[keyHash...])
Gateway->>Gateway: InferBits + Reconstruct(3B_payload)
Gateway->>Gateway: DecodeSensorValue

Encoding: 3 bytes → 24 bits → 24 advertisements

AirSensors messages are intentionally tiny: one byte for sensor type, two bytes for sensor value (big-endian).

void send_sensor_data(uint8_t type, uint16_t value, uint32_t msg_id) {
    uint8_t data[3];
    data[0] = type;
    data[1] = (value >> 8) & 0xFF;
    data[2] = value & 0xFF;
    // ... loop over bits and advertise each bit ...
}

Bit-to-key encoding: “public key X” as a structured carrier

The Find My advertisement contains a 28‑byte x‑coordinate. In modem mode, AirSensors uses that slot as a structured carrier:

void set_addr_and_payload_for_bit(uint32_t bit_index, uint32_t msg_id, uint8_t bit) {
    public_key[0] = 0xBA;
    public_key[1] = 0xBE;
 
    copy_4b_big_endian(&public_key[2], bit_index);
    copy_4b_big_endian(&public_key[6], msg_id);
    copy_4b_big_endian(&public_key[10], modem_id);
 
    public_key[27] = bit & 0x01;
 
    do {
        copy_4b_big_endian(&public_key[14], valid_key_counter);
        valid_key_counter++;
    } while (!is_valid_pubkey(public_key));
}

The confusing part: why “random” x-coordinates break

Find My treats the 28 bytes as an x‑coordinate on secp224r1. Not every x has a valid y satisfying: y2=x3+ax+b(modp)y^2 = x^3 + ax + b \pmod p

Invalid x‑coordinates produce unreliable behavior.

int is_valid_pubkey(uint8_t *pub_key_compressed) {
    uint8_t with_sign_byte[29];
    uint8_t pub_key_uncompressed[128];
    const struct uECC_Curve_t *curve = uECC_secp224r1();
 
    with_sign_byte[0] = 0x02;
    memcpy(&with_sign_byte[1], pub_key_compressed, 28);
    uECC_decompress(with_sign_byte, pub_key_uncompressed, curve);
 
    return uECC_valid_public_key(pub_key_uncompressed, curve);
}

Backend mirror (Euler’s criterion):

def is_valid_public_key_x(x_bytes: bytes) -> bool:
    x = int.from_bytes(x_bytes, byteorder="big")
    y_squared = (pow(x, 3, SECP224R1_P) + SECP224R1_A * x + SECP224R1_B) % SECP224R1_P
    legendre = pow(y_squared, (SECP224R1_P - 1) // 2, SECP224R1_P)
    return legendre == 1

Retrieval: how the gateway reconstructs bits

Candidate generation:

def generate_key_candidates(modem_id: int, msg_id: int, num_bits: int):
    candidates = []
    for bit_index in range(num_bits):
        for bit_value in (0, 1):
            public_key, _ = generate_public_key_for_bit(
                bit_index, msg_id, modem_id, bit_value
            )
            hashed_key = public_key_to_hashed_adv_key(public_key)
            candidates.append((hashed_key, bit_index, bit_value))
    return candidates

Apple query payload:

data = {
    "search": [
        {
            "startDate": start_date * 1000,
            "endDate": unix_epoch * 1000,
            "ids": advertisement_keys
        }
    ]
}

Hardware

Two phases:

Phase 1: ESP32 DevKitC

Loading 3D model…

ESP32 DevKitC carrier.

Loading 3D model…

ESP32 DevKitC cover.

Phase 2: XIAO ESP32-C3 + LiPo

LiPo battery integration

LiPo battery integration diagram

Internals top view

Internals top view

Internals side view

Internals side view

Loading 3D model…

Phase 2 enclosure case.

Sensor packaging

Wire format stays constant:

[type:1B][value:2B]
Thermistor internals

Thermistor internals

Antenna experiments

Throughput was limited by Find My relay latency, not BLE range.

Antenna testing

Antenna testing

Throughput

  • Radio rate: ESP32 advertising
  • Network rate: Find My report latency

Observed throughput stayed ~3 bytes/min.

Debugging checklist

  • Static beacon first
  • Magic bytes must be BA BE
  • IDs must match
  • Suspect curve validity before RF

Threat model and defensive framing

AirSensors exploits a seen / not-seen side channel inherent to Find My’s privacy design.

Mitigations include:

  • Bulk query rate limits
  • Anomaly detection
  • Stronger ID binding

Appendix: message and key layout

Sensor payload

[type:1B][value:2B]

Modem x-coordinate layout (28 bytes)

[0..1]   = BA BE
[2..5]   = bit_index
[6..9]   = msg_id
[10..13] = modem_id
[14..17] = valid_key_counter
[18..26] = padding
[27]     = bit_value