1383 lines
49 KiB
Markdown
1383 lines
49 KiB
Markdown
# 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<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** `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<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`:
|
|
|
|
```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": "<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`
|
|
|
|
```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<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).
|
|
|
|
```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<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:
|
|
|
|
```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<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 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 <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.
|