BLE Mesh Chat
Archived
Experiment · Mobile

BLE Mesh Chat

2025

An offline messenger relaying chats peer-to-peer over Bluetooth LE with no internet and no server. It got a working mesh: a 7-byte frame packed into the manufacturer-data field carrying two magic bytes, a four-byte message id and a TTL, leaving 13 bytes of text; duplicate suppression over a bounded 500-entry id set so a flood terminates instead of ringing forever; and a TTL relay that rewrites the hop byte of a received frame and re-broadcasts it. Flutter runs both roles at once — flutter_reactive_ble scanning against flutter_ble_peripheral advertising — with a Python bleak scanner used off-device to confirm frames were really going out. Abandoned at reassembly: chunked messages send but every chunk carries the same id, so the receiver's own dedupe discards everything after the first.

Built with
FlutterFlutter
DartDart
Bluetooth LEBluetooth LE
PythonPython
bleakbleak
Project Details

STATUS
Archived
YEAR

2025

TYPE

Experiment · Mobile

TAGS
Bluetooth
Mesh Networking
Abandoned

The premise was a messenger for places where the network is not an option — a festival ground with saturated cell towers, or anywhere with no infrastructure at all. Every phone running the app both advertises and scans over Bluetooth LE, so a message hops device to device until it reaches someone. No server, no accounts, no internet.

The obvious way to do this is GATT: discover a peer, connect, open a characteristic, write to it. That fails at exactly the scale the idea needs. A connection is a negotiated, stateful, one-to-one thing, an Android radio will hold only a handful at once, and a phone in your pocket with the screen off is not going to complete a discovery handshake with a stranger walking past. Advertisements are the opposite: connectionless, broadcast to everyone in range, and cheap enough to send continuously. The cost is that an advertisement is a fixed, tiny frame with no acknowledgement and no ordering, and every design problem in this project comes out of that trade.

The wire protocol

Messages ride in the BLE advertisement's manufacturer-data field under manufacturer ID 0x1337. The header is seven bytes: two magic bytes (0x13, 0x37), a four-byte message id, and a one-byte TTL, with UTF-8 text filling the rest. An advertisement payload only budgets 20 bytes, which leaves 13 bytes of actual text per frame, so anything longer gets chopped into chunks — each one advertised for 900 ms, then a 200 ms gap, up to 255 chunks per message.

The message id is derived once per message, not per chunk: the current epoch milliseconds XORed with 20 bits from Random(), hashed and masked to 32 bits, then written big-endian into bytes two through five. Every chunk of a message therefore carries the same id, which is the detail that turns out to matter later. TTL sits in byte six; the send function defaults it to 30, but every call site in the UI passes 3.

Flooding without a routing table

Relaying is a straight flood. The scanner runs in ScanMode.lowLatency, reads the id out of every manufacturer-data blob it sees, and checks it against a rolling set of the last 500 message ids. New id, TTL still above zero: decrement the TTL byte in place and re-advertise the same frame. Seen it before: drop it. That is the whole mesh — no routing table, no neighbour discovery, no acknowledgements.

Decrementing in place rather than re-encoding is deliberate. The received bytes are copied verbatim, byte six is overwritten, and the array goes straight back out, so the id survives the hop unchanged and every device downstream computes the same dedupe key. The bounded set is a plain Set<int> with an eviction of its oldest entry once it passes 500, which is a rough FIFO with no timestamps and no expiry.

The scan itself filters on nothing. withServices is an empty list, so there is no service UUID filter and no manufacturer-ID filter at the radio; the only gates are that the manufacturer data exists, that it is at least seven bytes, and that the id is unseen. A chatServiceUuid constant is declared in every copy of the file and referenced by none of them.

What got built

  • A dual-role BLE stackflutter_reactive_ble for continuous scanning, flutter_ble_peripheral for peripheral-mode advertising, running in the same app.
  • The 7-byte framing with pack and unpack helpers for id, TTL and payload.
  • Chunked transmission that splits a message across advertisement frames under a fixed 13-byte text budget.
  • Duplicate suppression via a bounded 500-entry id set, so a flood terminates instead of ringing forever.
  • TTL-based relay that rewrites byte 6 of a received frame and re-broadcasts it.
  • Runtime permissions for bluetoothScan, bluetoothAdvertise, bluetoothConnect and location, with the manifest declaring neverForLocation on scan and capping the legacy Bluetooth permissions at API 30.
  • Foreground service and wake-lock declarations plus RECEIVE_BOOT_COMPLETED, so scanning and advertising could survive the screen going off.
  • A debug console tab keeping the last 200 timestamped events — advertise errors, parse failures, accepted messages with their id and TTL.
  • A Python bleak scanner used off-device to confirm that advertisements were actually going out with the right manufacturer data and readable RSSI.

The UI is two tabs on one screen, chat and debug, with everything living in a single stateful widget. An inbox row is a formatted string rather than a model, carrying the advertiser's device id, the RSSI, the decoded text and the received TTL. There is no nickname and no peer list; a UUID is minted per launch, shown on screen, and never put on the wire, so attribution is whatever address the scanner reports.

Three copies of one file

There is no git history in either repository, so the version control is three side-by-side copies of main.dart and the differences between them are the changelog.

The first is fire-and-forget: the chunk loop awaits one 900 ms advertisement at a time, and relays are launched from inside the scan callback without being awaited. Overlapping advertisements are the predictable result, and the platform's complaint about them is what the second copy is built around. That one adds an advertise queue drained one item at a time with a 300 ms spacing, an explicit stop and 100 ms settle before every start, and a three-attempt retry whose backoff branches on string-matching the error text for "too many advertisers" and "Platform exception 2". It also drops the device name from the advertisement and cuts the payload budget from 20 bytes to 10, which shrinks the text budget to three bytes per frame. A third copy predates the framing entirely, writes a five-byte header while still reading the id at offset two and the TTL at offset six, and has the relay call commented out.

Why it stopped

The chunking is where it fell apart. Splitting a message into 13-byte frames with no sequence number, no reassembly buffer and a 900 ms advertise window per frame means a two-line message takes several seconds to transmit and arrives out of order, if at all. There is a second copy of main.dart in the repo that adds an advertise queue to serialise broadcasts, which was the start of a fix and did not finish.

It is worse than out of order, in fact. Because every chunk carries the same message id, the receiver's own duplicate suppression discards chunks two onward as already seen. Chunking is write-only: the sender splits, and nothing on the other end could ever put it back together.

Two other things show it was still a bench experiment: the magic-byte check on receive is commented out, so the scanner tries to parse every advertisement in range, and every outgoing message is prefixed with the literal string sat2025 and filtered on it, standing in for a real channel identifier. The prefix costs seven of the first chunk's thirteen bytes, is matched with contains rather than as a prefix, and is never stripped before display. And because the relay fires before that check, foreign advertisements from unrelated devices get their sixth byte decremented and rebroadcast too.

The background story is also thinner than the manifest suggests. The permissions are declared, but there is no service class and no foregroundServiceType, and the three dependencies that would have carried it — the foreground-task plugin, the wakelock plugin and flutter_blue_plus — are in pubspec.yaml and imported nowhere. The single test in the repository is the untouched Flutter counter template pointed at the wrong file, so it cannot pass. The Python side never learned to decode the format the app emits; it prints every advertiser it sees and leaves the manufacturer bytes unparsed, which was enough to answer the only question it was written for. Abandoned before any of that hardened.

Project Details

STATUS
Archived
YEAR

2025

TYPE

Experiment · Mobile

TAGS
Bluetooth
Mesh Networking
Abandoned