# 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`](https://github.com/dotintent/react-native-ble-plx) (version 3) and the [`buffer`](https://www.npmjs.com/package/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 | ```ts 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 prebuild` or EAS) and add the plugin to `app.json`: ```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 `NSBluetoothAlwaysUsageDescription` to `Info.plist`. - **Android** (bare React Native): declare `BLUETOOTH_SCAN` (with `android:usesPermissionFlags="neverForLocation"`) and `BLUETOOTH_CONNECT` for Android 12+, and `ACCESS_FINE_LOCATION` with `android:maxSdkVersion="30"` for older versions. - On Android, request those permissions at runtime before scanning: ```ts import { PermissionsAndroid, Platform } from 'react-native'; export async function requestBlePermissions(): Promise { 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** `BleManager` for the whole app and keep it for the app's lifetime. Wait for `manager.onStateChange` to 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: ```ts 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`, where `XXXX` is the last two bytes of the hub's MAC address in hex. Use `device.localName`. `device.name` may 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`. ```ts 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: 1. Stop scanning, then connect. On Android, ask for a large MTU while connecting. iOS negotiates the MTU itself and ignores the option. ```ts let device = await manager.connectToDevice(id, { requestMTU: 255 }); device = await device.discoverAllServicesAndCharacteristics(); ``` 2. Optionally read **Info**. This works without pairing, so the app can show the hub's name, firmware version and node count first: ```ts const c = await device.readCharacteristicForService(SERVICE, INFO); const info = JSON.parse(Buffer.from(c.value!, 'base64').toString('utf8')); ``` 3. 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. 4. Send the `info` command (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 - 4` bytes of JSON (section 6). - FW Data writes carry a 4-byte offset, so each holds up to `device.mtu - 7` bytes 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. ```json { "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):** 1. Encode the JSON object to UTF-8 bytes. 2. Split it into fragments of `device.mtu - 4` bytes. 3. Pick a sequence number (increment and wrap at 64). 4. 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. ```ts 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 | 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`: ```json {"id": 12, "cmd": "node", "serial": "3D8DF1FC"} ``` - Use a new positive `id` for each request (for example, a counter). The response carries the same `id`. - 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": "", "message": ""}`. - 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` ```json {"id": 1, "cmd": "info"} ``` ```json {"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. ```json {"id": 2, "cmd": "nodes"} ``` ```json {"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` ```json {"id": 3, "cmd": "node", "serial": "3D8DF1FC"} ``` ```json {"id": 3, "ok": true, "node": { ...node object... }} ``` Errors: `bad_request`, `not_found`. ### 7.4 `valve` ```json {"id": 4, "cmd": "valve", "serial": "3D8DF1FC", "state": "open"} ``` ```json {"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: ```json {"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. ```json {"id": 7, "cmd": "identify", "serial": "3D8DF1FC"} ``` ```json {"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: ```json {"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` ```json {"id": 9, "cmd": "reboot"} ``` ```json {"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` ```json {"id": 5, "cmd": "cal_get", "serial": "3D8DF1FC"} ``` ```json {"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. ```json {"id": 6, "cmd": "cal_set", "serial": "3D8DF1FC", "dry_mv": 2750, "wet_mv": 1180} ``` ```json {"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. ```json {"id": 8, "cmd": "cal_reset", "serial": "3D8DF1FC"} ``` ```json {"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. ```json {"id": 10, "cmd": "cal_start", "serial": "3D8DF1FC"} ``` ```json {"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`: ```json {"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_start` again with another serial switches to that node. Samples for the old node can still arrive briefly, so check `serial` on 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_mv` in `nodes` stays current. - Errors: `bad_request`, `not_found`, `offline`, and `busy` (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"` | ```json {"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. ```json {"id": 11, "cmd": "cal_stop"} ``` ```json {"id": 11, "ok": true} ``` Send it whenever the calibration screen closes, including on cancel and unmount. ### 8.6 Calibration flow 1. **Start**: send `cal_start` for the node. Show the live `mv` and `moisture` from each `cal_sample`, ideally with a small chart of the last 30 seconds, so the user can see when the reading is steady. 2. **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`. 3. **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`. 4. **Check**: - If `|dry - wet| < 200` mV, 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. 5. **Save**: send `cal_set` with `serial`, `dry_mv: dry` and `wet_mv: wet`. Show the stored values from the response. 6. **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 `mv` is 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_stopped` arrives during the window, fail the step and show its `message`. ```ts 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 { 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). ```ts 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` ```json {"id": 20, "cmd": "fw_begin", "target": "hub", "size": 584029, "crc32": 3141592653} {"id": 21, "cmd": "fw_begin", "target": "node", "serial": "3D8DF1FC", "size": 5120, "crc32": 271828182} ``` ```json {"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: ```json {"event": "fw_ack", "next": 16384} {"event": "fw_ack", "next": 12288, "resend": true} ``` - `next` is the number of bytes received, written and checked so far. It never decreases within one upload. - `"resend": true` means the hub dropped data (a gap or a full buffer) and the app must continue sending from `next`. The hub ignores chunks whose offset is below `next`, and chunks after a gap, until the data at `next` arrives. Rules for the sender: - Never have more than `window` bytes past the last acknowledged `next` in 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_ack` arrives for 3 seconds while data is in flight, ask with `fw_status` and continue from its `next`. - Once `next` equals the file size, send `fw_end`. ```ts 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 { 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((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: ```json {"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` ```json {"id": 22, "cmd": "fw_status"} ``` ```json {"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`. ```json {"id": 23, "cmd": "fw_end"} ``` For the **hub**: ```json {"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: 1. Wait about 5 seconds, then scan again. The new firmware takes a few seconds to boot. 2. Reconnect. The bond survives, so there is no passkey prompt. 3. Read Info and check that `fw_version` is 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**: ```json {"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: ```json {"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` ```json {"id": 24, "cmd": "fw_abort"} ``` ```json {"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_request` with `"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. ```ts import { Buffer } from 'buffer'; import type { BleManager, Device, Subscription } from 'react-native-ble-plx'; type Message = Record; 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 void; reject: (e: Error) => void; timer: ReturnType }>(); private listeners = new Set(); private chain: Promise = 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 { 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 { const id = this.nextId++; const response = new Promise((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 the `HubConnection`, tracks the state (connecting, pairing, ready, disconnected), and reconnects after a hub reboot. - `firmware.ts`: `crc32`, `sendFirmware`, and an `updateFirmware(target, serial, file)` that runs `fw_begin`, `sendFirmware` and `fw_end`, then follows the node flash events or the hub reboot. It sends `fw_abort` if anything throws. - `useCalibration(serial)` hook: sends `cal_start` on mount and `cal_stop` on unmount, keeps the latest samples for the chart, and exposes `measureAverage` for 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` ```json {"id": 30, "cmd": "tank"} ``` ```json {"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) ```json {"id": 31, "cmd": "tank_history"} ``` ```json {"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` ```json {"id": 32, "cmd": "power"} ``` ```json {"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. ```json {"id": 33, "cmd": "patches"} ``` ```json {"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. ```json {"id": 34, "cmd": "node_set", "serial": "3D8DF1FC", "patches": ["patch-1", "patch-3"]} ``` ```json {"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 `, 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": ""}` 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.