49 KiB
Allotment Hub BLE Protocol (version 1)
This document is the full contract between the Allotment hub firmware and a mobile app. It is written so that an engineer or an LLM can build a React Native app (TypeScript, iOS and Android) from it alone. Anything not described here is not supported.
The hub is an ESP32-S3 that manages up to 32 sensor nodes on a wired RS485 bus. Each node reports soil temperature, soil moisture (as a raw sensor voltage) and the state of its water valve. Over BLE the app can:
- discover hubs nearby and show basic details before pairing;
- list nodes and read their readings;
- open and close node valves;
- identify a node by flashing a rainbow on its LED;
- calibrate each node's moisture sensor from live readings;
- update the hub firmware and node firmware by sending the file over BLE;
- reboot the hub.
Setting node LED colours is deliberately not part of this protocol. The
only LED command is identify (section 7.5).
The examples use react-native-ble-plx
(version 3) and the buffer package.
react-native-ble-plx passes every characteristic value and every advertised
byte field as a base64 string, so the app converts with
Buffer.from(value, 'base64') and buffer.toString('base64').
1. Quick reference
| Item | Value |
|---|---|
| Protocol version | 1 |
| Service UUID | 8e7f0001-3c1a-4b6e-9a51-a110c7ed0000 |
| Info characteristic | 8e7f0002-3c1a-4b6e-9a51-a110c7ed0000 (read, no pairing needed) |
| Command characteristic | 8e7f0003-3c1a-4b6e-9a51-a110c7ed0000 (write with response, paired) |
| Events characteristic | 8e7f0004-3c1a-4b6e-9a51-a110c7ed0000 (notify, paired) |
| FW Data characteristic | 8e7f0005-3c1a-4b6e-9a51-a110c7ed0000 (write without response, paired) |
| Advertised name | Allotment-XXXX (XXXX = 4 upper-case hex digits) |
| Manufacturer company ID | 0xFFFF |
| Pairing | Passkey entry, 6 digits shown on the hub's screen, bonded |
| Connections | One at a time |
| Text encoding | UTF-8 JSON objects, framed (section 5) |
| Byte order | Little-endian for all binary fields |
export const SERVICE = '8e7f0001-3c1a-4b6e-9a51-a110c7ed0000';
export const INFO = '8e7f0002-3c1a-4b6e-9a51-a110c7ed0000';
export const COMMAND = '8e7f0003-3c1a-4b6e-9a51-a110c7ed0000';
export const EVENTS = '8e7f0004-3c1a-4b6e-9a51-a110c7ed0000';
export const FW_DATA = '8e7f0005-3c1a-4b6e-9a51-a110c7ed0000';
export const PROTOCOL_VERSION = 1;
Compare UUIDs case-insensitively. iOS reports them in upper case and Android in lower case.
2. Project setup
-
Install:
npm install react-native-ble-plx buffer. -
Expo: BLE needs native code, so Expo Go cannot run the app. Use a development build (
npx expo prebuildor EAS) and add the plugin toapp.json:{ "expo": { "plugins": [ ["react-native-ble-plx", { "isBackgroundEnabled": false, "neverForLocation": true, "bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to your allotment hub" }] ] } } -
iOS (bare React Native): add
NSBluetoothAlwaysUsageDescriptiontoInfo.plist. -
Android (bare React Native): declare
BLUETOOTH_SCAN(withandroid:usesPermissionFlags="neverForLocation") andBLUETOOTH_CONNECTfor Android 12+, andACCESS_FINE_LOCATIONwithandroid:maxSdkVersion="30"for older versions. -
On Android, request those permissions at runtime before scanning:
import { PermissionsAndroid, Platform } from 'react-native'; export async function requestBlePermissions(): Promise<boolean> { if (Platform.OS !== 'android') return true; if (Platform.Version >= 31) { const result = await PermissionsAndroid.requestMultiple([ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, ]); return Object.values(result).every((r) => r === PermissionsAndroid.RESULTS.GRANTED); } const result = await PermissionsAndroid.request( PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, ); return result === PermissionsAndroid.RESULTS.GRANTED; } -
Create one
BleManagerfor the whole app and keep it for the app's lifetime. Wait formanager.onStateChangeto report'PoweredOn'before scanning.
3. Discovery
The hub advertises continuously while no phone is connected. It stops advertising while a phone is connected and starts again when it disconnects.
3.1 Advertising packet
- Flags: LE General Discoverable, BR/EDR not supported.
- Complete list of 128-bit service UUIDs: the service UUID above.
Scan with a service filter so that only hubs are reported:
import { BleManager, ScanMode } from 'react-native-ble-plx';
manager.startDeviceScan(
[SERVICE],
{ allowDuplicates: true, scanMode: ScanMode.LowLatency },
(error, device) => {
if (error || !device) return;
const advert = parseAdvert(device.manufacturerData);
// device.id, device.localName, device.rssi, advert
},
);
// Later: manager.stopDeviceScan();
3.2 Scan response
The hub answers active scans with:
- Complete local name:
Allotment-XXXX, whereXXXXis the last two bytes of the hub's MAC address in hex. Usedevice.localName.device.namemay be a name the phone cached earlier. - Manufacturer specific data, 8 bytes, in
device.manufacturerData(base64, including the company ID):
| Byte | Meaning |
|---|---|
| 0-1 | Company ID 0xFFFF, little-endian (FF FF) |
| 2 | Protocol version (1). Refuse to connect to versions you do not know. |
| 3 | Hub firmware major version |
| 4 | Hub firmware minor version |
| 5 | Hub firmware patch version |
| 6 | Number of nodes currently online (0-32) |
| 7 | Flags: bit 0 = busy (an update or node flash is running, or the hub is about to reboot); bit 1 = the hub has at least one bonded phone. Other bits are reserved and must be ignored. |
The manufacturer data is refreshed about every 5 seconds. A device can be
reported before its scan response arrives, with no name or manufacturer data
yet; keep the last values seen for each device.id.
import { Buffer } from 'buffer';
export interface HubAdvert {
protocolVersion: number;
firmware: string;
nodesOnline: number;
busy: boolean;
hasBonds: boolean;
}
export function parseAdvert(manufacturerData: string | null): HubAdvert | null {
if (!manufacturerData) return null;
const b = Buffer.from(manufacturerData, 'base64');
if (b.length < 8 || b[0] !== 0xff || b[1] !== 0xff) return null;
return {
protocolVersion: b[2],
firmware: `${b[3]}.${b[4]}.${b[5]}`,
nodesOnline: b[6],
busy: (b[7] & 0x01) !== 0,
hasBonds: (b[7] & 0x02) !== 0,
};
}
3.3 Identifying a hub
device.id is a MAC address on Android and a per-phone UUID on iOS. Use it to
reconnect, but use hub_id from the Info characteristic (section 5.1) as the
stable identity to show or sync.
4. Connecting and pairing
Follow these steps in this order:
-
Stop scanning, then connect. On Android, ask for a large MTU while connecting. iOS negotiates the MTU itself and ignores the option.
let device = await manager.connectToDevice(id, { requestMTU: 255 }); device = await device.discoverAllServicesAndCharacteristics(); -
Optionally read Info. This works without pairing, so the app can show the hub's name, firmware version and node count first:
const c = await device.readCharacteristicForService(SERVICE, INFO); const info = JSON.parse(Buffer.from(c.value!, 'base64').toString('utf8')); -
Subscribe to Events with
device.monitorCharacteristicForService(SERVICE, EVENTS, listener). The hub responds by asking for security:- A new phone: the phone shows its system pairing prompt with a code entry field. On Android this can appear as a notification. At the same moment the hub's screen shows "Bluetooth pairing" with a 6-digit code. The user types that code on the phone. The pair is then bonded; later connections encrypt silently.
- A bonded phone: the link is encrypted with no prompt.
Before subscribing, tell the user a code will appear on the hub's screen.
-
Send the
infocommand (section 7.1), with a timeout of at least 60 seconds for this first command, since the user may still be typing the code. A response proves that pairing is complete and that the whole path works. The hub sends nothing on Events until the link is secure.
Notes:
- The Command, Events and FW Data characteristics need an encrypted, authenticated (MITM) and bonded link. A write before pairing completes may fail with an authentication error. iOS and Android normally pair and retry by themselves. If a write fails, wait for pairing and retry.
- A phone that connects but does not pair within 60 seconds is disconnected, so it cannot block other phones.
- If the user cancels the pairing prompt or types the wrong code, the hub drops the connection. Reconnect to try again.
- Watch for disconnects with
manager.onDeviceDisconnected(id, listener)and fail every pending command. - The hub keeps up to 8 bonds and forgets the oldest when it needs room.
- Clearing bonds on the hub: on the hub's BLE screen, holding the PRG button for 3 seconds forgets every phone. The phone still has its half of the old bond, so the next connection fails to encrypt and drops. Tell the user to open the phone's Bluetooth settings, choose "Forget This Device" (iOS) or "Unpair" (Android) for the hub, and then connect again to re-pair.
4.1 MTU and write sizes
The hub supports an ATT MTU of up to 255. After connecting, read the
negotiated value from device.mtu (use the Device object returned by
connectToDevice). The largest write payload is device.mtu - 3:
- Command writes carry a 1-byte frame header, so each fragment holds
device.mtu - 4bytes of JSON (section 6). - FW Data writes carry a 4-byte offset, so each holds up to
device.mtu - 7bytes of file (section 9).
If device.mtu is 23 (the default), everything still works, only slower.
The hub asks for a connection interval of 15-30 ms. On Android, calling
device.requestConnectionPriority(ConnectionPriority.High) before a firmware
upload also helps.
5. Characteristics
5.1 Info - read
Readable without pairing. The value is a single JSON object, not framed, of at most 400 bytes. Both platforms read long values automatically.
{
"name": "Allotment-3A7C",
"hub_id": "F412FA3A7C10",
"fw_version": "1.0.0",
"protocol": 1,
"uptime_s": 5231,
"nodes_online": 3,
"nodes_total": 4,
"busy": false,
"wifi": {"connected": true, "ip": "192.168.68.188", "rssi": -61},
"lora": {"joined": true},
"gps": {"fix": true, "sats": 7}
}
| Field | Type | Meaning |
|---|---|---|
name |
string | Advertised name |
hub_id |
string | 12 hex digits, stable for the life of the hub |
fw_version |
string | Hub firmware version, major.minor.patch |
protocol |
number | This protocol's version |
uptime_s |
number | Seconds since the hub booted. A drop means it rebooted. |
nodes_online |
number | Nodes answering right now |
nodes_total |
number | Nodes the hub is tracking, online or recently offline |
busy |
boolean | An upload, node flash or reboot is in progress |
wifi.connected |
boolean | Hub is on WiFi |
wifi.ip |
string | Empty when not connected |
wifi.rssi |
number | dBm, 0 when not connected |
lora.joined |
boolean | LoRaWAN network joined |
gps.fix |
boolean | GNSS has a position fix |
gps.sats |
number | Satellites in use |
Apps must ignore unknown fields in this and every other JSON object.
5.2 Command - write with response
The app writes framed JSON requests (section 6). One request is at most 1024 bytes of JSON.
5.3 Events - notify
The hub sends framed JSON messages (section 6). Each message is either:
- a response to a command: it has an
"id"field; or - an event: it has an
"event"field and no"id".
5.4 FW Data - write without response
Raw firmware bytes during an upload (section 9).
6. Message framing
JSON messages on Command and Events can be longer than one BLE packet, so every write and every notification starts with a 1-byte header:
| Bits | Meaning |
|---|---|
bit 0 (0x01) |
FINAL: this is the last fragment of the message |
bit 1 (0x02) |
FIRST: this is the first fragment of the message |
| bits 2-7 | Message sequence number, 0-63, the same on every fragment of one message |
The rest of the packet is the next slice of the UTF-8 JSON text. A message that fits in one packet has both FIRST and FINAL set.
Sending (app to hub):
- Encode the JSON object to UTF-8 bytes.
- Split it into fragments of
device.mtu - 4bytes. - Pick a sequence number (increment and wrap at 64).
- Write each fragment to Command with
writeCharacteristicWithResponseForService, in order, awaiting each write before starting the next.
Receiving (hub to app):
- On FIRST: clear the buffer and remember the sequence number.
- If a fragment without FIRST carries a different sequence number, or arrives with no message in progress, discard the partial message.
- Append the payload. On FINAL, decode the buffer as a JSON object.
import { Buffer } from 'buffer';
const FINAL = 0x01;
const FIRST = 0x02;
export class Framer {
private nextSeq = 0;
private rxSeq: number | null = null;
private parts: Buffer[] = [];
/** Base64 packets for one message, to be written in order. */
frames(message: object, mtu: number): string[] {
const json = Buffer.from(JSON.stringify(message), 'utf8');
const seq = this.nextSeq;
this.nextSeq = (this.nextSeq + 1) & 0x3f;
const size = Math.max(1, mtu - 4);
const packets: string[] = [];
let offset = 0;
do {
const end = Math.min(offset + size, json.length);
let header = seq << 2;
if (offset === 0) header |= FIRST;
if (end === json.length) header |= FINAL;
packets.push(Buffer.concat([Buffer.from([header]), json.slice(offset, end)]).toString('base64'));
offset = end;
} while (offset < json.length);
return packets;
}
/** Returns the parsed message when `value` (base64) completes one. */
receive(value: string): Record<string, any> | null {
const packet = Buffer.from(value, 'base64');
if (packet.length === 0) return null;
const header = packet[0];
const seq = header >> 2;
if (header & FIRST) {
this.parts = [];
this.rxSeq = seq;
} else if (this.rxSeq !== seq) {
this.rxSeq = null;
this.parts = [];
return null;
}
this.parts.push(packet.slice(1));
if (!(header & FINAL)) return null;
const text = Buffer.concat(this.parts).toString('utf8');
this.rxSeq = null;
this.parts = [];
try {
return JSON.parse(text);
} catch {
return null;
}
}
}
7. Commands
Every request is a JSON object with an integer id and a string cmd:
{"id": 12, "cmd": "node", "serial": "3D8DF1FC"}
- Use a new positive
idfor each request (for example, a counter). The response carries the sameid. - The hub handles commands one at a time, in order. It queues at most 4. Keep one command outstanding at a time, with a 10 second timeout (60 seconds for the first command after connecting, section 4).
- A successful response is
{"id": 12, "ok": true, ...fields}. - A failed response is
{"id": 12, "ok": false, "error": "<code>", "message": "<human text>"}. - If the request could not be parsed, the response has
"id": 0.
Error codes:
| Code | Meaning |
|---|---|
bad_request |
Missing or invalid field, or wrong state (for example fw_end with no upload) |
unknown_command |
cmd is not in this document |
not_found |
The hub is not tracking a node with that serial |
offline |
The node is known but not answering |
busy |
Another upload, node flash or calibration holds the resource. Retry later. |
storage_error |
The hub could not save the setting |
transfer_error |
The firmware upload failed. See message. |
internal |
Out of memory or another hub fault |
Show message to the user when an action fails. Its wording may change
between firmware versions, so do not parse it.
Serials: nodes are identified by serial, always 8 upper-case hex digits
such as "3D8DF1FC". The hub accepts upper or lower case.
7.1 info
{"id": 1, "cmd": "info"}
{"id": 1, "ok": true, "info": { ...same object as the Info characteristic... }}
7.2 nodes
Lists every node the hub is tracking. Nodes that have been offline for 5 minutes drop off the list.
{"id": 2, "cmd": "nodes"}
{"id": 2, "ok": true, "nodes": [
{"serial": "3D8DF1FC", "online": true, "age_ms": 840, "temp_c": 14.25,
"moisture": 43, "moisture_mv": 2112, "valve": "closed",
"cal_min_mv": 2800, "cal_max_mv": 1200, "cal_custom": false}
]}
Node object:
| Field | Type | Meaning |
|---|---|---|
serial |
string | 8 hex digits |
online |
boolean | Answered the most recent poll |
age_ms |
number | Milliseconds since the hub last heard from it |
temp_c |
number or null | Soil temperature in °C; null when there is no sensor or reading |
moisture |
number or null | 0-100 %, calculated with this node's calibration; null when there is no reading |
moisture_mv |
number or null | Raw sensor voltage in millivolts; null when there is no reading |
valve |
"open", "closed" or null |
null when the node has no valve support |
cal_min_mv |
number | Calibration dry point: the voltage that reads as 0 % |
cal_max_mv |
number | Calibration wet point: the voltage that reads as 100 % |
cal_custom |
boolean | true if this node has its own calibration, false if it uses the hub-wide default |
The hub polls nodes every 2 seconds. Poll nodes or node every 2-5
seconds while a screen shows live data. For faster readings during
calibration, use cal_start (section 8). Node online and offline changes
also arrive as events (section 10).
7.3 node
{"id": 3, "cmd": "node", "serial": "3D8DF1FC"}
{"id": 3, "ok": true, "node": { ...node object... }}
Errors: bad_request, not_found.
7.4 valve
{"id": 4, "cmd": "valve", "serial": "3D8DF1FC", "state": "open"}
{"id": 4, "ok": true, "queued": true}
state is "open" or "closed". The response only means the command was
queued. The result arrives as an event:
{"event": "complete", "target": "valve", "serial": "3D8DF1FC", "message": "valve open"}
{"event": "error", "target": "valve", "serial": "3D8DF1FC", "message": "no reply from node"}
Errors: bad_request, not_found, offline, and busy (a valve command is
already queued, or a node flash is running).
7.5 identify
Makes the node cycle its RGB LED through a rainbow for 3 seconds, so the user can find which physical node has this serial. The LED then goes back to what it showed before.
{"id": 7, "cmd": "identify", "serial": "3D8DF1FC"}
{"id": 7, "ok": true, "queued": true}
The response only means the command was queued. The result arrives as an event once the node has acknowledged it, at which point the rainbow has started:
{"event": "complete", "target": "identify", "serial": "3D8DF1FC", "message": "identifying"}
{"event": "error", "target": "identify", "serial": "3D8DF1FC", "message": "no reply from node"}
"no reply from node" for a node that is online usually means its firmware is
older than identify. Suggest a node firmware update (section 9).
Errors: bad_request, not_found, offline, and busy (an identify command
is already queued, or a node flash is running).
7.6 reboot
{"id": 9, "cmd": "reboot"}
{"id": 9, "ok": true}
The hub reboots about 1 second later and the link drops. Errors: busy
while an update is running.
7.7 Other commands
The calibration commands (cal_get, cal_set, cal_reset, cal_start,
cal_stop) are in section 8. The firmware commands (fw_begin,
fw_status, fw_end, fw_abort) are in section 9.
8. Moisture calibration
A capacitive sensor gives a higher voltage in dry soil and a lower voltage in wet soil. The hub maps the voltage onto a straight line: the dry point reads 0 % and the wet point reads 100 %, clamped to 0-100. Values outside 0-6000 mV are rejected, and the two points must differ.
There is one hub-wide default pair (initially dry 2800 mV, wet 1200 mV). Any node can have its own pair, which replaces the default for that node. The hub stores up to 32 per-node pairs, and they survive reboots and node firmware updates.
In calibration commands the points are dry_mv and wet_mv. In the node
object they are cal_min_mv (dry) and cal_max_mv (wet).
The app calibrates a node by streaming live readings with cal_start,
averaging 3 seconds of readings in dry soil and again in dampened soil, and
storing both averages with cal_set (section 8.6).
8.1 cal_get
{"id": 5, "cmd": "cal_get", "serial": "3D8DF1FC"}
{"id": 5, "ok": true, "serial": "3D8DF1FC", "dry_mv": 2800, "wet_mv": 1200, "custom": false}
Leave out serial to get the hub-wide default:
{"id": 5, "ok": true, "dry_mv": 2800, "wet_mv": 1200, "custom": false}.
8.2 cal_set
Stores both points.
{"id": 6, "cmd": "cal_set", "serial": "3D8DF1FC", "dry_mv": 2750, "wet_mv": 1180}
{"id": 6, "ok": true, "serial": "3D8DF1FC", "dry_mv": 2750, "wet_mv": 1180, "custom": true}
Both values must be whole numbers. Leave out serial to set the hub-wide
default. Errors: bad_request, storage_error (including when 32 nodes
already have their own calibration).
8.3 cal_reset
Removes the node's own pair so it uses the hub-wide default again.
{"id": 8, "cmd": "cal_reset", "serial": "3D8DF1FC"}
{"id": 8, "ok": true, "serial": "3D8DF1FC", "dry_mv": 2800, "wet_mv": 1200, "custom": false}
8.4 cal_start
Starts streaming live readings from one node. The hub asks the node for a
fresh reading every 500 ms over the wired bus and sends each one as a
cal_sample event.
{"id": 10, "cmd": "cal_start", "serial": "3D8DF1FC"}
{"id": 10, "ok": true, "serial": "3D8DF1FC", "dry_mv": 2800, "wet_mv": 1200,
"custom": false, "interval_ms": 500, "max_ms": 300000}
The response includes the node's current calibration (dry_mv, wet_mv,
custom), the sample interval, and the longest a session runs (max_ms,
5 minutes).
Then, about every interval_ms:
{"event": "cal_sample", "target": "cal", "serial": "3D8DF1FC", "mv": 2112, "moisture": 43, "t_ms": 5231840}
| Field | Type | Meaning |
|---|---|---|
serial |
string | The node being sampled |
mv |
number or null | Sensor voltage in millivolts; null when the node has no sensor reading |
moisture |
number or null | 0-100 % with the node's current calibration; null when mv is null |
t_ms |
number | Hub clock in milliseconds when the reading was taken. Use it, not the phone's clock, to time averaging windows. |
Rules:
- One session at a time. Sending
cal_startagain with another serial switches to that node. Samples for the old node can still arrive briefly, so checkserialon every sample. - Samples are usually 500 ms apart. They can be up to about a second apart while the hub polls the other nodes, and a reading the node does not answer is skipped, with no event.
- Readings also reach the normal node object, so
moisture_mvinnodesstays current. - Errors:
bad_request,not_found,offline, andbusy(a node flash is running).
The session ends when:
| Cause | What the app sees |
|---|---|
The app sends cal_stop |
The cal_stop response |
| The phone disconnects | Nothing (the link is gone) |
5 minutes (max_ms) after cal_start |
cal_stopped, message: "timed out" |
| A node firmware flash starts | cal_stopped, message: "node flash started" |
| The node misses 4 readings in a row (about 2 seconds) | cal_stopped, message: "no reply from node" |
{"event": "cal_stopped", "target": "cal", "serial": "3D8DF1FC", "message": "timed out"}
If the calibration screen is still open after "timed out", send
cal_start again. "no reply from node" straight after cal_start usually
means the node's firmware is older than live readings. Suggest a node
firmware update (section 9).
8.5 cal_stop
Stops the session. It always succeeds, even when no session is running.
{"id": 11, "cmd": "cal_stop"}
{"id": 11, "ok": true}
Send it whenever the calibration screen closes, including on cancel and unmount.
8.6 Calibration flow
- Start: send
cal_startfor the node. Show the livemvandmoisturefrom eachcal_sample, ideally with a small chart of the last 30 seconds, so the user can see when the reading is steady. - Dry point: "Push the sensor fully into the soil as it is now. Wait
until the reading is steady, then tap Measure." On Measure, collect a
3-second average (below). Store it as
dry. - Wet point: "Now water the soil around the sensor until it is damp all
the way down. Wait until the reading is steady again, then tap Measure."
Collect another 3-second average. Store it as
wet. - Check:
- If
|dry - wet| < 200mV, the soil probably did not change enough. Ask the user to water more and measure the wet point again. - If
dry < wet, the voltage went up when the soil got wetter, which usually means the steps were swapped. Warn the user, and offer to measure again or to save anyway.
- If
- Save: send
cal_setwithserial,dry_mv: dryandwet_mv: wet. Show the stored values from the response. - Finish: send
cal_stop.
Also offer "Reset to default" (cal_reset) and a manual editor (cal_get
and cal_set).
3-second average: use the first sample that arrives after the user taps
Measure as the start. Keep collecting samples for that node while
t_ms - start.t_ms < 3000, then stop at the first sample past that point.
That gives about 6 samples.
- Ignore samples whose
mvis null. If fewer than 4 samples have a voltage, fail the step with "The sensor is not giving readings". - Average with
Math.round(sum / count). - If the spread (
max - min) is over 150 mV, the reading was still moving. Show the result, but offer "Measure again". - If
cal_stoppedarrives during the window, fail the step and show itsmessage.
export interface CalSample { serial: string; mv: number | null; moisture: number | null; t_ms: number }
export interface Average { mv: number; spread: number; count: number }
/** Resolves with the 3-second average of the next samples for `serial`. */
export function measureAverage(conn: HubConnection, serial: string, windowMs = 3000): Promise<Average> {
return new Promise((resolve, reject) => {
let start: number | null = null;
const values: number[] = [];
const timeout = setTimeout(() => finish(new Error('No readings from the hub')), windowMs + 5000);
const off = conn.onEvent((event) => {
if (event.event === 'cal_stopped') {
finish(new Error(event.message ?? 'Calibration stopped'));
return;
}
if (event.event !== 'cal_sample' || event.serial !== serial) return;
const sample = event as CalSample;
if (start === null) start = sample.t_ms;
if (sample.t_ms - start >= windowMs) {
finish(null);
return;
}
if (sample.mv !== null) values.push(sample.mv);
});
function finish(error: Error | null) {
clearTimeout(timeout);
off();
if (error) return reject(error);
if (values.length < 4) return reject(new Error('The sensor is not giving readings'));
const sum = values.reduce((a, b) => a + b, 0);
resolve({
mv: Math.round(sum / values.length),
spread: Math.max(...values) - Math.min(...values),
count: values.length,
});
}
});
}
9. Firmware updates
The same mechanism updates the hub and any node. Only one upload can run at a time across BLE and the hub's WiFi API.
9.1 Files
| Target | Accepted files | Size limit |
|---|---|---|
hub |
ESP32 app image firmware.bin, or the same gzip-compressed as firmware.bin.gz (preferred, about 35 % smaller). The gzip file must hold exactly one member. |
3,342,336 bytes (the OTA slot) |
node |
Intel HEX (firmware.hex), raw binary (firmware.bin), or either gzip-compressed (.gz) |
24,576 bytes as sent; the decoded program must fit in 7,168 bytes |
The hub detects gzip from the file contents, so the file name does not matter. Send the file bytes exactly as stored.
To let the user pick a file, use expo-document-picker and read it with
expo-file-system (readAsStringAsync(uri, { encoding: 'base64' })), or use
@react-native-documents/picker with react-native-fs
(readFile(path, 'base64')). Then Buffer.from(base64, 'base64') gives the
bytes. A hub image is under 1 MB, so holding it in memory is fine.
9.2 CRC-32
fw_begin needs the CRC-32 of the file bytes exactly as sent. This is the
standard zlib/PNG/gzip CRC-32: reflected polynomial 0xEDB88320, initial
value 0xFFFFFFFF, final XOR 0xFFFFFFFF. The CRC-32 of the ASCII bytes
123456789 is 0xCBF43926 (3421780262).
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
export function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
The >>> 0 keeps the result unsigned. Send it as a JSON number, never
negative.
9.3 fw_begin
{"id": 20, "cmd": "fw_begin", "target": "hub", "size": 584029, "crc32": 3141592653}
{"id": 21, "cmd": "fw_begin", "target": "node", "serial": "3D8DF1FC", "size": 5120, "crc32": 271828182}
{"id": 20, "ok": true, "window": 8192, "ack_every": 2048, "max_chunk": 248}
| Request field | Meaning |
|---|---|
target |
"hub" or "node" |
serial |
Required for node. The node should normally be online, but this is not checked, so a node broken by a failed flash can be recovered (section 9.6). |
size |
File size in bytes |
crc32 |
CRC-32 of the file as a JSON number (unsigned 32-bit) |
| Response field | Meaning |
|---|---|
window |
Most unacknowledged bytes the app may have in flight |
ack_every |
The hub sends fw_ack about this often, and at the end |
max_chunk |
Largest data payload per FW Data write at the current MTU |
Sending fw_begin again from the same phone drops the earlier upload and
starts over. Errors: bad_request, and busy (another upload is running, a
node flash is running, or the hub is about to reboot).
9.4 Sending data
Each write to FW Data, using writeCharacteristicWithoutResponseForService,
is:
| Bytes | Meaning |
|---|---|
| 0-3 | offset of this chunk in the file, u32 little-endian |
| 4… | File bytes [offset, offset + n) |
The chunk payload size is min(max_chunk, device.mtu - 7).
The hub acknowledges on Events:
{"event": "fw_ack", "next": 16384}
{"event": "fw_ack", "next": 12288, "resend": true}
nextis the number of bytes received, written and checked so far. It never decreases within one upload."resend": truemeans the hub dropped data (a gap or a full buffer) and the app must continue sending fromnext. The hub ignores chunks whose offset is belownext, and chunks after a gap, until the data atnextarrives.
Rules for the sender:
- Never have more than
windowbytes past the last acknowledgednextin flight. The hub's buffer is sized on that rule. - Await each write before starting the next. A write without response can still be dropped by the phone's Bluetooth stack; the hub notices the gap and asks for a resend, so no extra handling is needed.
- If no
fw_ackarrives for 3 seconds while data is in flight, ask withfw_statusand continue from itsnext. - Once
nextequals the file size, sendfw_end.
import { Buffer } from 'buffer';
import type { Device } from 'react-native-ble-plx';
export async function sendFirmware(
conn: HubConnection,
device: Device,
file: Buffer,
begin: { window: number; max_chunk: number },
onProgress: (fraction: number) => void,
): Promise<void> {
const size = file.length;
const chunk = Math.max(1, Math.min(begin.max_chunk, device.mtu - 7));
let acked = 0;
let sendPos = 0;
let lastAck = Date.now();
let failure: string | null = null;
let wake: (() => void) | null = null;
const off = conn.onEvent((event) => {
if (event.event === 'fw_ack') {
acked = Math.max(acked, event.next);
if (event.resend) sendPos = event.next;
lastAck = Date.now();
onProgress(acked / size);
} else if (event.event === 'fw_error') {
failure = event.message ?? 'transfer failed';
} else {
return;
}
wake?.();
});
const waitForAck = (ms: number) =>
new Promise<void>((resolve) => {
const timer = setTimeout(() => { wake = null; resolve(); }, ms);
wake = () => { clearTimeout(timer); wake = null; resolve(); };
});
try {
while (acked < size) {
if (failure) throw new Error(failure);
if (sendPos < size && sendPos - acked < begin.window) {
const n = Math.min(chunk, size - sendPos, begin.window - (sendPos - acked));
const packet = Buffer.alloc(4 + n);
packet.writeUInt32LE(sendPos, 0);
file.copy(packet, 4, sendPos, sendPos + n);
sendPos += n;
await device.writeCharacteristicWithoutResponseForService(SERVICE, FW_DATA, packet.toString('base64'));
continue;
}
await waitForAck(1000);
if (!failure && acked < size && Date.now() - lastAck >= 3000) {
const status = await conn.send({ cmd: 'fw_status' });
if (!status.active) throw new Error('The hub dropped the upload');
acked = Math.max(acked, status.next);
sendPos = status.next;
lastAck = Date.now();
}
}
} finally {
off();
}
}
Errors during the transfer arrive as an event, after which the upload is over:
{"event": "fw_error", "message": "ota write failed"}
For the hub target the hub also sends {"event":"error","target":"hub",...}
(section 10). If no data arrives for 30 seconds the hub drops the upload and
sends {"event":"error","target":"hub"|"node","message":"upload timed out"}.
Disconnecting also drops the upload. Start again with fw_begin; there is no
resume across fw_begin calls.
9.5 fw_status
{"id": 22, "cmd": "fw_status"}
{"id": 22, "ok": true, "active": true, "next": 16384, "total": 584029}
When no upload is running, active is false and next and total are 0.
9.6 fw_end
Send fw_end once fw_ack.next == size.
{"id": 23, "cmd": "fw_end"}
For the hub:
{"id": 23, "ok": true, "rebooting": true}
The hub has checked the CRC and the image, marked it to boot, and sends
{"event":"complete","target":"hub","percent":100,"message":"rebooting"}. It
reboots about 1 second later and the connection drops. The app should then:
- Wait about 5 seconds, then scan again. The new firmware takes a few seconds to boot.
- Reconnect. The bond survives, so there is no passkey prompt.
- Read Info and check that
fw_versionis the expected new version.
The hub checks the image before switching to it, so a corrupt or truncated
file is refused at fw_end and the running firmware is kept. The hub does
not roll back an image that boots and then misbehaves. Flash a known-good
file to recover.
For a node:
{"id": 23, "ok": true, "flashing": true}
The hub now programs the node over the wired bus, which takes about 10-30 seconds. Progress arrives as events:
{"event":"progress","target":"node","serial":"3D8DF1FC","percent":0,"message":"muting bus"}
{"event":"progress","target":"node","serial":"3D8DF1FC","percent":2,"message":"syncing with bootloader"}
{"event":"progress","target":"node","serial":"3D8DF1FC","percent":57,"message":"programming page"}
{"event":"complete","target":"node","serial":"3D8DF1FC","percent":100,"message":"done"}
Or, on failure:
{"event":"error","target":"node","serial":"3D8DF1FC","percent":0,"message":"page verify failed"}.
While a node is being flashed, all nodes pause, valve and cal_start
return busy, a running calibration ends, and node readings go stale. If the
flash fails part-way through programming, the node may have no working
program and drops offline. Its bootloader still works, so run the update
again with the same serial. fw_begin accepts any serial, even one the hub
no longer lists, so the app should remember the serial of a node it is
updating and offer "Retry" after a failure.
fw_end errors (all transfer_error unless noted):
"upload incomplete, have X of Y bytes": keep sending."crc32 mismatch": the file was corrupted in transit or the CRC was wrong. The upload is dropped.- Image errors, such as
"truncated gzip file","ota end failed", or a node image decode error. The upload is dropped. bad_request"no upload in progress".
9.7 fw_abort
{"id": 24, "cmd": "fw_abort"}
{"id": 24, "ok": true}
Drops the upload. A hub image that was partly written is discarded, and the running firmware is untouched. Send it when the user cancels, or when the sender throws.
10. Events
Events have an event field and never an id. Apps must ignore event types
and fields they do not recognise.
event |
target |
Other fields | Meaning |
|---|---|---|---|
online |
node |
serial, message |
A node started answering |
offline |
node |
serial, message |
A node stopped answering |
progress |
hub |
percent, message |
Hub image upload progress, every 10 % |
progress |
node |
serial, percent, message |
Node flash progress |
complete |
hub |
percent, message |
Hub update installed; rebooting |
complete |
node |
serial, percent, message |
Node flashed |
complete |
valve |
serial, message |
Valve command acknowledged by the node |
complete |
identify |
serial, message |
The node has started its rainbow |
error |
hub, node, valve or identify |
serial (node, valve and identify), percent (hub and node), message |
The operation failed |
cal_sample |
cal |
serial, mv, moisture, t_ms |
Live calibration reading (section 8.4) |
cal_stopped |
cal |
serial, message |
The hub ended the calibration session itself (section 8.4) |
fw_ack |
none | next, optional resend |
Firmware transfer acknowledgement (section 9.4) |
fw_error |
none | message |
Firmware transfer failed (section 9.4) |
Events can come from uploads started through the hub's WiFi API as well, so
the app may see a progress for an update it did not start.
11. Limits and behaviour summary
- One BLE connection at a time. While a phone is connected, the hub does not advertise.
- Keep one command outstanding at a time. The hub queues at most 4, and drops extra commands without a response.
- Commands are at most 1024 bytes of JSON; larger ones get
bad_requestwith"id": 0. - A phone that does not pair within 60 seconds is disconnected.
- A firmware upload that goes quiet for 30 seconds is dropped.
- A calibration session lasts at most 5 minutes and ends on disconnect.
- Firmware uploads, node flashing, valve and identify commands are also available over the hub's WiFi HTTP API. Both interfaces share the same busy rules.
- The protocol version in the advertisement and Info changes only when this document changes in a way that breaks existing apps. New fields and events can be added without a version change.
12. Suggested app structure
12.1 HubConnection
One class owns the connected Device, the Framer, command matching and the
event listeners. Everything else talks to the hub through it.
import { Buffer } from 'buffer';
import type { BleManager, Device, Subscription } from 'react-native-ble-plx';
type Message = Record<string, any>;
type Listener = (event: Message) => void;
export class HubError extends Error {
constructor(public code: string, message: string) {
super(message);
}
}
export class HubConnection {
private framer = new Framer();
private nextId = 1;
private pending = new Map<number, { resolve: (m: Message) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
private listeners = new Set<Listener>();
private chain: Promise<unknown> = Promise.resolve();
private subscriptions: Subscription[] = [];
constructor(private manager: BleManager, public device: Device) {}
/** Subscribes to Events, which starts pairing on a new phone. */
start(onDisconnect: () => void) {
this.subscriptions.push(
this.device.monitorCharacteristicForService(SERVICE, EVENTS, (error, characteristic) => {
if (error || !characteristic?.value) return;
const message = this.framer.receive(characteristic.value);
if (!message) return;
if (typeof message.id === 'number' && message.event === undefined) {
this.settle(message);
} else {
this.listeners.forEach((listener) => listener(message));
}
}),
this.manager.onDeviceDisconnected(this.device.id, () => {
this.failAll(new Error('Disconnected'));
onDisconnect();
}),
);
}
onEvent(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Sends one command after the previous one has finished. Rejects with HubError when ok is false. */
send(command: Message, timeoutMs = 10000): Promise<Message> {
const run = () => this.sendNow(command, timeoutMs);
const result = this.chain.then(run, run);
this.chain = result.catch(() => undefined);
return result;
}
private async sendNow(command: Message, timeoutMs: number): Promise<Message> {
const id = this.nextId++;
const response = new Promise<Message>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`${command.cmd} timed out`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
for (const packet of this.framer.frames({ ...command, id }, this.device.mtu)) {
await this.device.writeCharacteristicWithResponseForService(SERVICE, COMMAND, packet);
}
const message = await response;
if (message.ok !== true) throw new HubError(message.error ?? 'internal', message.message ?? 'Failed');
return message;
}
private settle(message: Message) {
const entry = this.pending.get(message.id);
if (!entry) return;
clearTimeout(entry.timer);
this.pending.delete(message.id);
entry.resolve(message);
}
private failAll(error: Error) {
this.pending.forEach((entry) => {
clearTimeout(entry.timer);
entry.reject(error);
});
this.pending.clear();
}
async close() {
this.subscriptions.forEach((s) => s.remove());
this.subscriptions = [];
this.failAll(new Error('Closed'));
await this.manager.cancelDeviceConnection(this.device.id).catch(() => undefined);
}
}
A response with "id": 0 means the hub could not parse a request; it cannot
be matched, so the command it belonged to times out.
12.2 Other modules
useHubScanner()hook: scans with the service filter and returns a list of discovered hubs (id,localName,HubAdvert,rssi, last seen). Drop hubs not seen for 10 seconds.useHub()context: connects, reads Info, creates theHubConnection, tracks the state (connecting, pairing, ready, disconnected), and reconnects after a hub reboot.firmware.ts:crc32,sendFirmware, and anupdateFirmware(target, serial, file)that runsfw_begin,sendFirmwareandfw_end, then follows the node flash events or the hub reboot. It sendsfw_abortif anything throws.useCalibration(serial)hook: sendscal_starton mount andcal_stopon unmount, keeps the latest samples for the chart, and exposesmeasureAveragefor the two steps.- Screens:
- Hub list.
- Hub dashboard: Info plus the node list.
- Node detail: readings, valve toggle, "Identify", "Calibrate", "Update firmware".
- Calibration wizard: the flow in section 8.6.
- Firmware update: pick a file, choose the target, show upload progress and then node flash progress or "Hub rebooting".
- Keep the screen awake during a firmware update (for example with
expo-keep-awake). Background BLE is not needed for this protocol.
13. Extensions
These commands and fields were added after the first release of this
document. They do not change the protocol version (section 11): a hub that
does not implement them answers unknown_command, and apps must treat that as
"needs a newer hub firmware" rather than as a failure.
The data behind these commands comes from the allotment server. The hub already sends tank and power readings over LoRaWAN, and it keeps a cached copy of the server's patch list and node assignments (Appendix A), so the app never needs to talk to the server directly.
13.1 tank
{"id": 30, "cmd": "tank"}
{"id": 30, "ok": true, "tank": {"level": 62, "age_s": 41}}
| Field | Type | Meaning |
|---|---|---|
level |
number or null | Water tank level, 0-100 %; null when the hub has no reading yet |
age_s |
number or null | Seconds since the reading was taken |
13.2 tank_history (optional)
{"id": 31, "cmd": "tank_history"}
{"id": 31, "ok": true, "samples": [{"age_s": 86400, "level": 70}, {"age_s": 84600, "level": 69}]}
Up to 48 samples, oldest first. A hub may implement tank without
tank_history; apps should hide the chart on unknown_command.
13.3 power
{"id": 32, "cmd": "power"}
{"id": 32, "ok": true, "power": {
"battery": {"voltage": 12.81, "current": 0.42, "percent": 87},
"solar": {"voltage": 18.2, "current": 0.61},
"load": {"voltage": 12.7, "current": 0.19},
"age_s": 12
}}
Every number may be null when the hub has no reading. battery.current is
positive while charging.
13.4 patches
The patches defined on the server, as last synced by the hub.
{"id": 33, "cmd": "patches"}
{"id": 33, "ok": true, "patches": [
{"id": "patch-1", "name": "Tomatoes", "icon": "tomato"},
{"id": "patch-2", "name": "", "icon": "carrot"}
]}
| Field | Type | Meaning |
|---|---|---|
id |
string | Logical patch id, stable |
name |
string | Display name; may be empty, in which case show id |
icon |
string | Produce icon id |
13.5 node_set
Assigns a node to zero or more patches. The list replaces the previous one.
{"id": 34, "cmd": "node_set", "serial": "3D8DF1FC", "patches": ["patch-1", "patch-3"]}
{"id": 34, "ok": true, "serial": "3D8DF1FC", "patches": ["patch-1", "patch-3"]}
The hub stores the list straight away and relays it to the server in the
background (Appendix A), retrying until the server accepts it. Errors:
bad_request (unknown patch id or not a list), not_found, storage_error.
13.6 Node object: patches
The node object (section 7.2) gains an optional field:
| Field | Type | Meaning |
|---|---|---|
patches |
string[] | Logical patch ids this node is assigned to. Missing on older firmware. |
Appendix A. Hub to server relay
The hub talks to the allotment server over WiFi with plain HTTPS and JSON.
Every request carries Authorization: Bearer <HUB_API_KEY>, where the key is
configured on the server (HUB_API_KEY) and on the hub. A missing or wrong
key returns 401.
| Method and path | Body | Response |
|---|---|---|
GET /api/hub/patches |
none | {"status": "ok", "data": {"patches": [{"id", "name", "icon"}]}} |
GET /api/hub/nodes |
none | {"status": "ok", "data": {"nodes": [{"serial", "patches"}]}} |
POST /api/hub/nodes |
{"serials": ["3D8DF1FC"]} |
Registers nodes the hub has seen. {"status": "ok", "data": {"nodes": [...]}} |
PUT /api/hub/nodes/:serial |
{"patches": ["patch-1"]} |
Replaces the node's patch list. {"status": "ok", "data": {"node": {"serial", "patches"}}} |
Errors are {"status": "error", "error": "<message>"} with status 400,
401 or 404. Unknown patch ids in PUT are rejected with 400.
The hub should refresh patches and nodes from the server every few
minutes and after reconnecting to WiFi, and register new serials as soon as it
sees them on the bus. Patch assignments can also change from the web client,
so the server's list wins when the hub has no unsent change for that node.