Browse Source

FindMy Beaconing support

pull/2731/head
Stefan Alfredsson 4 days ago
committed by Stefan Alfredsson
parent
commit
4a6f78edcf
  1. 113
      docs/findmy.md
  2. 8
      examples/simple_repeater/MyMesh.cpp
  3. 9
      examples/simple_repeater/main.cpp
  4. 33
      src/helpers/NRF52Board.cpp
  5. 5
      src/helpers/NRF52Board.h
  6. 138
      src/helpers/nrf52/FindMyBeacon.cpp
  7. 41
      src/helpers/nrf52/FindMyBeacon.h
  8. 19
      variants/lilygo_techo/platformio.ini
  9. 21
      variants/rak4631/platformio.ini

113
docs/findmy.md

@ -0,0 +1,113 @@
# FindMy / OpenHaystack Beacon
This document describes the experimental FindMy locator beacon for nRF52 MeshCore nodes.
## Purpose
When enabled, a node advertises an Apple [FindMy](https://support.apple.com/find-my) /
[OpenHaystack](https://github.com/seemoo-lab/openhaystack) "offline finding" beacon alongside
its normal mesh duties. Nearby iPhones anonymously relay its encrypted location to Apple's
servers, letting you locate a deployed node through the global Find My network — no extra
infrastructure, GPS, or cellular needed.
This is particularly useful for repeater nodes. To maximise coverage they are often deployed in
exposed, unattended, hard-to-reach spots — rooftops, towers, hilltops, remote solar sites — which
makes them especially prone to going missing, whether through theft, weather, a failed mount, or
simply being forgotten. A FindMy beacon gives you a way to locate (or recover) such a node, or at
least get a last-known position, without having to physically visit the site.
The node only ever broadcasts a **public** advertising key. Decrypting the location reports
requires the matching **private** key, which you keep off-device on your own machine.
## Supported hardware
nRF52840 boards only (Adafruit Bluefruit BLE stack),
but the implementation is easily ported to ESP32 and other BLE capable chips.
The challenge is with concurrent BLE usage, ie. companion nodes. **TODO/Future work** check if
ble adverisements can be sent when the companion is disconnected from the phone,
and disable when reconnected.
Pre-defined build environments:
| Environment | Board |
|-------------|-------|
| `RAK_4631_repeater_findmy` | RAK4631 / RAK WisMesh (repeater, incl. Solar Repeater Mini) |
| `LilyGo_T-Echo_repeater_findmy` | LilyGo T-Echo |
The feature is gated behind the `WITH_FINDMY_BEACON` build flag, so it adds nothing to other
builds. It is intended for always-on roles (repeater/sensor) where BLE is otherwise unused. It
is **not** for the phone-companion firmware, which needs BLE for its own link — a node can be a
FindMy beacon or a phone companion, not both at once.
## Generating keys
Keys are generated off-device with the OpenHaystack / macless-haystack tooling — see
[macless-haystack](https://github.com/dchristl/macless-haystack) (or the original
[OpenHaystack](https://github.com/seemoo-lab/openhaystack)). Its `generate_keys.py` produces
three values per key; use them as follows:
| Value | Where it goes | Purpose |
|-------|---------------|---------|
| **Advertisement key** (public, 28 bytes) | the **node** (`set findmy.key`) | broadcast in the BLE advert + MAC |
| **Private key** | your **server only** | decrypts the location reports |
| **Hashed adv key** | your **server** (lookup id) | used to query Apple for reports |
Only the advertisement (public) key goes on the node. Never load the private key onto the
device — it would be broadcast publicly and reports would not decrypt. (Note: the private key is
also 28 bytes, so the firmware cannot tell them apart by length — pick the value labelled
*advertisement*.)
## Node configuration
Configure over the local USB serial console (115200 baud) or remotely from the MeshCore app's
Command Line tab when logged in as admin. Configuration is stored in `/findmy` on the node's
internal filesystem (independent of the normal node preferences).
```
set findmy.key <base64-advertisement-key> -> OK - reboot to apply
set findmy on -> OK - on, reboot to apply
get findmy -> > on, mac DF:41:B5:D7:F3:BF
reboot
```
- `set findmy.key` must decode to exactly 28 bytes, otherwise it reports
`Error: decoded N bytes, expected 28`.
- `get findmy` shows the enabled state and the derived BLE MAC (it never echoes the key). The
MAC is `key[0]|0xC0 : key[1] : key[2] : key[3] : key[4] : key[5]`.
- The beacon starts only at boot, so `reboot` (or a power cycle) is required to apply changes.
On boot the node prints `FindMy beacon started`.
- `set findmy off` then `reboot` stops advertising (the stored key is kept).
### Verifying
1. **On air:** scan with a BLE tool (e.g. nRF Connect). You should see a non-connectable
advertiser at the address `get findmy` reported (address type *Random*) carrying Apple
manufacturer data beginning `4C 00 12 19 …`.
2. **Key sanity:** run heystack's `tools/showmac.py` on the advertisement key you loaded — it
must print the same MAC as `get findmy`. A mismatch means the wrong key was loaded.
## Retrieving location data
Use your own server with the **private** key — the node cannot retrieve anything itself. With
macless-haystack, query and decrypt reports using its scripts, e.g. `fetch_reports.py` (a
`findmy.py`-style query), which authenticates to Apple (via Anisette), pulls the encrypted
reports for the hashed adv key, and decrypts them with the private key. See the
[macless-haystack](https://github.com/dchristl/macless-haystack) README for setup
(Anisette/Apple-ID requirements) and exact invocation.
## Notes and caveats
- **Latency:** Find My is deliberately latency-tolerant. Reports appear only after the node is
near a passing iPhone and can take minutes to a few hours.
- **Power:** continuous BLE advertising keeps the SoftDevice awake, so idle current is higher
than a node with BLE off. The advertising interval defaults to ~2 s; override with
`-D FINDMY_ADV_INTERVAL=<0.625ms-units>`.
- **Privacy:** the key is static (no rotation), so anyone holding it can track the node.
- **Static key may be rate-limited / blocked (TODO):** genuine AirTags rotate their advertising
key roughly daily, and Apple's network is tuned for that. A single never-changing key can be
treated as anomalous and may have its reports throttled or dropped over time, so long-term
reliability is not guaranteed. **Future work:** add key rotation — store a sequence of keys and
advance the index on a daily schedule (matching AirTag behaviour), and extend the key generator
to emit a batch of keys with daily-incrementing indices that the server side can resolve.
- **OTA:** the beacon coexists with `start ota` — triggering a firmware update reuses the
running BLE stack and switches to the DFU advertiser automatically.

8
examples/simple_repeater/MyMesh.cpp

@ -1,6 +1,10 @@
#include "MyMesh.h" #include "MyMesh.h"
#include <algorithm> #include <algorithm>
#ifdef WITH_FINDMY_BEACON
#include <helpers/nrf52/FindMyBeacon.h>
#endif
/* ------------------------------ Config -------------------------------- */ /* ------------------------------ Config -------------------------------- */
#ifndef LORA_FREQ #ifndef LORA_FREQ
@ -1257,6 +1261,10 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
sendNodeDiscoverReq(); sendNodeDiscoverReq();
strcpy(reply, "OK - Discover sent"); strcpy(reply, "OK - Discover sent");
} }
#ifdef WITH_FINDMY_BEACON
} else if (findmy_beacon.handleCommand(command, reply)) {
// FindMy beacon command handled (set/get findmy ...)
#endif
} else{ } else{
_cli.handleCommand(sender_timestamp, command, reply); // common CLI commands _cli.handleCommand(sender_timestamp, command, reply); // common CLI commands
} }

9
examples/simple_repeater/main.cpp

@ -8,6 +8,10 @@
static UITask ui_task(display); static UITask ui_task(display);
#endif #endif
#ifdef WITH_FINDMY_BEACON
#include <helpers/nrf52/FindMyBeacon.h>
#endif
StdRNG fast_rng; StdRNG fast_rng;
SimpleMeshTables tables; SimpleMeshTables tables;
@ -91,6 +95,11 @@ void setup() {
the_mesh.begin(fs); the_mesh.begin(fs);
#ifdef WITH_FINDMY_BEACON
findmy_beacon.begin();
if (findmy_beacon.isRunning()) Serial.println("FindMy beacon started");
#endif
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION);
#endif #endif

33
src/helpers/NRF52Board.cpp

@ -6,6 +6,16 @@
static BLEDfu bledfu; static BLEDfu bledfu;
// Tracks whether Bluefruit.begin() has already run (it is not idempotent).
static bool _bluefruit_begun = false;
bool NRF52Board::beginBluefruitOnce() {
if (!_bluefruit_begun) {
_bluefruit_begun = Bluefruit.begin();
}
return _bluefruit_begun;
}
static void connect_callback(uint16_t conn_handle) { static void connect_callback(uint16_t conn_handle) {
(void)conn_handle; (void)conn_handle;
MESH_DEBUG_PRINTLN("BLE client connected"); MESH_DEBUG_PRINTLN("BLE client connected");
@ -317,13 +327,22 @@ bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) {
} }
bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { bool NRF52Board::startOTAUpdate(const char *id, char reply[]) {
// Config the peripheral connection with maximum bandwidth if (!_bluefruit_begun) {
// more SRAM required by SoftDevice // Config the peripheral connection with maximum bandwidth
// Note: All config***() function must be called before begin() // more SRAM required by SoftDevice
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); // Note: All config***() function must be called before begin()
Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);
Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16);
Bluefruit.begin(1, 0);
_bluefruit_begun = Bluefruit.begin(1, 0);
} else {
// Stack already up (e.g. FindMy beacon). Reuse it: stop the beacon advert and
// reset the advertising payload/type so we can advertise the DFU service instead.
Bluefruit.Advertising.stop();
Bluefruit.Advertising.clearData();
Bluefruit.ScanResponse.clearData();
Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_CONNECTABLE_SCANNABLE_UNDIRECTED);
}
// Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4
Bluefruit.setTxPower(4); Bluefruit.setTxPower(4);
// Set the BLE device name // Set the BLE device name

5
src/helpers/NRF52Board.h

@ -52,6 +52,11 @@ public:
virtual void reboot() override { NVIC_SystemReset(); } virtual void reboot() override { NVIC_SystemReset(); }
virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool getBootloaderVersion(char* version, size_t max_len) override;
virtual bool startOTAUpdate(const char *id, char reply[]) override; virtual bool startOTAUpdate(const char *id, char reply[]) override;
// Bring up the Bluefruit/SoftDevice stack exactly once. Bluefruit.begin() has no
// double-init guard, so any feature that needs BLE (FindMy beacon, OTA DFU) must
// route through here instead of calling Bluefruit.begin() directly.
static bool beginBluefruitOnce();
virtual void sleep(uint32_t secs) override; virtual void sleep(uint32_t secs) override;
bool isExternalPowered() override; bool isExternalPowered() override;

138
src/helpers/nrf52/FindMyBeacon.cpp

@ -0,0 +1,138 @@
#include "FindMyBeacon.h"
// Gated on the opt-in feature flag so the unit stays inert even if a variant's build_src_filter
// happens to glob helpers/nrf52/*.cpp.
#ifdef WITH_FINDMY_BEACON
#include <string.h>
#include <stdio.h>
#include <bluefruit.h>
#include <InternalFileSystem.h>
#include <base64.hpp>
#include "ble_gap.h"
#include "../NRF52Board.h"
using namespace Adafruit_LittleFS_Namespace;
// Advertising interval in units of 0.625 ms. ~2s by default to keep idle current low;
// override per-build with -D FINDMY_ADV_INTERVAL=<units>.
#ifndef FINDMY_ADV_INTERVAL
#define FINDMY_ADV_INTERVAL 3200
#endif
#define FINDMY_FILE "/findmy"
FindMyBeacon findmy_beacon;
bool FindMyBeacon::load() {
_enabled = 0;
memset(_key, 0, sizeof(_key));
if (!InternalFS.exists(FINDMY_FILE)) return false;
File f = InternalFS.open(FINDMY_FILE);
if (!f) return false;
f.read((uint8_t *)&_enabled, sizeof(_enabled));
f.read(_key, sizeof(_key));
f.close();
return true;
}
void FindMyBeacon::save() {
InternalFS.remove(FINDMY_FILE);
File f = InternalFS.open(FINDMY_FILE, FILE_O_WRITE);
if (!f) return;
f.write((uint8_t *)&_enabled, sizeof(_enabled));
f.write(_key, sizeof(_key));
f.close();
}
void FindMyBeacon::begin(int8_t tx_dbm) {
if (_started) return;
load();
if (!_enabled) return;
bool key_set = false;
for (size_t i = 0; i < sizeof(_key); i++) { if (_key[i]) { key_set = true; break; } }
if (!key_set) return;
// Bring up the SoftDevice/Bluefruit stack (shared one-shot guard - see NRF52Board).
if (!NRF52Board::beginBluefruitOnce()) return;
Bluefruit.setTxPower(tx_dbm);
// Static-random BLE address derived from the first 6 key bytes. ble_gap_addr_t.addr is
// little-endian (addr[0] = LSB); the MSB's top two bits mark a static random address.
ble_gap_addr_t addr;
memset(&addr, 0, sizeof(addr));
addr.addr_type = BLE_GAP_ADDR_TYPE_RANDOM_STATIC;
addr.addr[5] = _key[0] | 0xC0;
addr.addr[4] = _key[1];
addr.addr[3] = _key[2];
addr.addr[2] = _key[3];
addr.addr[1] = _key[4];
addr.addr[0] = _key[5];
sd_ble_gap_addr_set(&addr);
// 31-byte OpenHaystack advertisement payload.
uint8_t adv[31];
adv[0] = 0x1E; // length: 30 bytes follow
adv[1] = 0xFF; // AD type: manufacturer specific data
adv[2] = 0x4C; // company id: Apple (0x004C), little-endian
adv[3] = 0x00;
adv[4] = 0x12; // Apple payload type: offline finding
adv[5] = 0x19; // length of remaining offline-finding payload (25)
adv[6] = 0x00; // status byte
memcpy(&adv[7], &_key[6], 22); // public key bytes 6..27
adv[29] = _key[0] >> 6; // top two bits of key[0]
adv[30] = 0x00; // hint
Bluefruit.Advertising.stop();
Bluefruit.Advertising.clearData();
Bluefruit.ScanResponse.clearData();
Bluefruit.Advertising.setType(BLE_GAP_ADV_TYPE_NONCONNECTABLE_NONSCANNABLE_UNDIRECTED);
Bluefruit.Advertising.setData(adv, sizeof(adv));
Bluefruit.Advertising.restartOnDisconnect(false);
Bluefruit.Advertising.setInterval(FINDMY_ADV_INTERVAL, FINDMY_ADV_INTERVAL);
Bluefruit.Advertising.setFastTimeout(0);
_started = Bluefruit.Advertising.start(0); // 0 = advertise forever
}
void FindMyBeacon::stop() {
if (_started) {
Bluefruit.Advertising.stop();
_started = false;
}
}
bool FindMyBeacon::handleCommand(const char* command, char* reply) {
if (memcmp(command, "set findmy.key ", 15) == 0) {
const char* b64 = &command[15];
uint8_t decoded[40]; // 28-byte key encodes to 40 base64 chars
unsigned int len = decode_base64((unsigned char *)b64, strlen(b64), (unsigned char *)decoded);
if (len == sizeof(_key)) {
memcpy(_key, decoded, sizeof(_key));
save();
strcpy(reply, "OK - reboot to apply");
} else {
sprintf(reply, "Error: decoded %u bytes, expected 28", len);
}
return true;
}
if (memcmp(command, "set findmy ", 11) == 0) {
_enabled = memcmp(&command[11], "on", 2) == 0;
save();
strcpy(reply, _enabled ? "OK - on, reboot to apply" : "OK - off, reboot to apply");
return true;
}
if (memcmp(command, "get findmy", 10) == 0) {
// derived static-random MAC is key[0]|0xC0 : key[1] : ... : key[5]
sprintf(reply, "> %s, mac %02X:%02X:%02X:%02X:%02X:%02X",
_enabled ? "on" : "off",
_key[0] | 0xC0, _key[1], _key[2], _key[3], _key[4], _key[5]);
return true;
}
return false;
}
#endif

41
src/helpers/nrf52/FindMyBeacon.h

@ -0,0 +1,41 @@
#pragma once
#include <stdint.h>
// Apple FindMy / OpenHaystack locator beacon for nRF52 (Adafruit Bluefruit).
//
// Advertises a static, non-connectable OpenHaystack payload derived from a 28-byte
// advertising public key. The matching private key is held off-device (in the user's
// OpenHaystack / macless-haystack setup) and is required to actually locate the device.
//
// The algorithm is ported from https://github.com/pix/heystack-nrf5x (nRF5 SDK) and
// reimplemented here on the Bluefruit advertising API used by MeshCore.
//
// Self-contained: it persists its own config to "/findmy" on the internal filesystem and
// parses its own "set/get findmy" CLI commands, so it needs no changes to the shared
// NodePrefs/CommonCLI code. Provisioning: `set findmy.key <base64>`, `set findmy on`, reboot.
//
// Intended for always-on roles (repeater/sensor) where BLE is otherwise unused. It is not
// meant to run alongside the phone-companion firmware, which needs BLE for its own link.
class FindMyBeacon {
bool _started = false;
uint8_t _enabled = 0;
uint8_t _key[28] = {0}; // OpenHaystack advertising public key
bool load(); // read "/findmy" into _enabled/_key
void save(); // write "/findmy"
public:
// Load persisted config and, if enabled with a key set, start advertising. Call once at
// boot after the internal filesystem is mounted. tx_dbm is the advertising TX power.
void begin(int8_t tx_dbm = 4);
void stop();
bool isRunning() const { return _started; }
// Handle "set findmy.key <b64>", "set findmy on|off", "get findmy".
// Returns true if the command was recognised (and reply filled), false otherwise.
bool handleCommand(const char* command, char* reply);
};
// Single shared instance (defined in FindMyBeacon.cpp).
extern FindMyBeacon findmy_beacon;

19
variants/lilygo_techo/platformio.ini

@ -64,6 +64,25 @@ build_flags =
; -D MESH_PACKET_LOGGING=1 ; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1 ; -D MESH_DEBUG=1
[env:LilyGo_T-Echo_repeater_findmy]
extends = LilyGo_T-Echo
build_src_filter = ${LilyGo_T-Echo.build_src_filter}
+<helpers/nrf52/FindMyBeacon.cpp>
+<../examples/simple_repeater>
build_flags =
${LilyGo_T-Echo.build_flags}
-D ADVERT_NAME='"T-Echo Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_FINDMY_BEACON=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
lib_deps =
${LilyGo_T-Echo.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:LilyGo_T-Echo_room_server] [env:LilyGo_T-Echo_room_server]
extends = LilyGo_T-Echo extends = LilyGo_T-Echo
build_src_filter = ${LilyGo_T-Echo.build_src_filter} build_src_filter = ${LilyGo_T-Echo.build_src_filter}

21
variants/rak4631/platformio.ini

@ -53,6 +53,27 @@ build_src_filter = ${rak4631.build_src_filter}
+<helpers/ui/SSD1306Display.cpp> +<helpers/ui/SSD1306Display.cpp>
+<../examples/simple_repeater> +<../examples/simple_repeater>
[env:RAK_4631_repeater_findmy]
extends = rak4631
build_flags =
${rak4631.build_flags}
-D DISPLAY_CLASS=SSD1306Display
-D ADVERT_NAME='"RAK4631 Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D WITH_FINDMY_BEACON=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${rak4631.build_src_filter}
+<helpers/ui/SSD1306Display.cpp>
+<helpers/nrf52/FindMyBeacon.cpp>
+<../examples/simple_repeater>
lib_deps =
${rak4631.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:RAK_4631_repeater_bridge_rs232_serial1] [env:RAK_4631_repeater_bridge_rs232_serial1]
extends = rak4631 extends = rak4631
build_flags = build_flags =

Loading…
Cancel
Save