diff --git a/.env.example b/.env.example index 40049b3..853cd74 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ OIDC_CLIENT_SECRET= # In dev, use Vite proxy so the callback is same-site with the UI: OIDC_REDIRECT_URI=http://localhost:5173/auth/callback +# iOS app sign-in: after OIDC, /auth/login?app=1 redirects here with ?token=. +APP_REDIRECT_URI=allotment://auth +# OIDC callback used for app sign-in only. The phone must reach it, so in dev set it to the +# server's LAN address (and allow it in the IdP client). Defaults to OIDC_REDIRECT_URI. +# OIDC_APP_CALLBACK_URI=http://192.168.1.10:3001/auth/callback + +# Shared secret the hub firmware sends as "Authorization: Bearer " to /api/hub/* (see packages/app/BLE_PROTOCOL.md Appendix A) +HUB_API_KEY= + # MQTT (optional — copy config/mqtt.example.json to config/mqtt.json) MQTT_CONFIG_PATH= MQTT_PASSWORD= diff --git a/packages/app/.env.example b/packages/app/.env.example new file mode 100644 index 0000000..e6adebd --- /dev/null +++ b/packages/app/.env.example @@ -0,0 +1,4 @@ +# Base URL of packages/server as reached from the phone (no trailing slash). +# For local testing use the Mac's LAN address, and set the server's +# OIDC_REDIRECT_URI to http://:3001/auth/callback. +EXPO_PUBLIC_SERVER_URL=http://192.168.1.10:3001 diff --git a/packages/app/.gitignore b/packages/app/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/packages/app/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md new file mode 100644 index 0000000..be05e77 --- /dev/null +++ b/packages/app/AGENTS.md @@ -0,0 +1,41 @@ +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. + +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/packages/app/BLE_PROTOCOL.md b/packages/app/BLE_PROTOCOL.md new file mode 100644 index 0000000..1557250 --- /dev/null +++ b/packages/app/BLE_PROTOCOL.md @@ -0,0 +1,1382 @@ +# 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. diff --git a/packages/app/app.json b/packages/app/app.json new file mode 100644 index 0000000..cb1889d --- /dev/null +++ b/packages/app/app.json @@ -0,0 +1,53 @@ +{ + "expo": { + "name": "Allotment App", + "slug": "allotment-app", + "scheme": "allotment", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "backgroundColor": "#0f1419", + "ios": { + "bundleIdentifier": "com.tombutcher.allotment", + "appleTeamId": "B832M34SD9", + "supportsTablet": false, + "infoPlist": { + "CFBundleDisplayName": "Allotment", + "NSLocalNetworkUsageDescription": "Allotment connects to your allotment server on this network.", + "NSAppTransportSecurity": { + "NSAllowsLocalNetworking": true + }, + "ITSAppUsesNonExemptEncryption": false + } + }, + "plugins": [ + "expo-router", + "expo-font", + "expo-web-browser", + "expo-secure-store", + [ + "react-native-ble-plx", + { + "isBackgroundEnabled": false, + "neverForLocation": true, + "bluetoothAlwaysPermission": "Allow Allotment to connect to your allotment hub" + } + ], + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "imageWidth": 160, + "resizeMode": "contain", + "backgroundColor": "#0f1419" + } + ], + "expo-image", + "./plugins/withSceneLifecycle" + ], + "experiments": { + "typedRoutes": false + } + } +} diff --git a/packages/app/assets/icon.png b/packages/app/assets/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/packages/app/assets/icon.png differ diff --git a/packages/app/assets/images/pcb-board.png b/packages/app/assets/images/pcb-board.png new file mode 100644 index 0000000..d3c7611 Binary files /dev/null and b/packages/app/assets/images/pcb-board.png differ diff --git a/packages/app/assets/images/solar-panel.png b/packages/app/assets/images/solar-panel.png new file mode 100644 index 0000000..560a0d0 Binary files /dev/null and b/packages/app/assets/images/solar-panel.png differ diff --git a/packages/app/assets/lottie/water-animation.json b/packages/app/assets/lottie/water-animation.json new file mode 100644 index 0000000..525b7e5 --- /dev/null +++ b/packages/app/assets/lottie/water-animation.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":200,"w":1920,"meta":{"g":"@lottiefiles/creator 1.84.0"},"layers":[{"ty":4,"nm":"Rectangle 1","sr":1,"st":0,"op":61,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[-0.000010174902513426787,15.996278075781959]},"s":{"a":0,"k":[127.2,87]},"sk":{"a":0,"k":0},"p":{"a":0,"k":[959.999875262902,199.99996557591544]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"rc","bm":0,"hd":false,"nm":"Rect Shape 1","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"s":{"a":0,"k":[1510.684015890517,31.992579712062604]}},{"ty":"fl","bm":0,"hd":false,"nm":"Fill","c":{"a":0,"k":[0.2353,0.8275,0.9961]},"r":1,"o":{"a":0,"k":100}}],"ind":1},{"ty":3,"nm":"Null 1","sr":1,"st":0,"op":450,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,52,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[960,100],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":0,"ix":11}},"ind":2},{"ty":4,"nm":"Shape Layer 1","sr":1,"st":0,"op":450,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,20,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":0},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":20},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":40},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":60},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":80},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":100},{"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":120}],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.2353,0.8275,0.9961],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3,"parent":2},{"ty":4,"nm":"Shape Layer 2","sr":1,"st":-10,"op":440,"ip":-10,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,0,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":-10},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":10},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":30},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":50},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":70},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":90},{"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":110}],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.2353,0.8275,0.9961],"ix":4},"r":1,"o":{"a":0,"k":25,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4,"parent":2},{"ty":4,"nm":"Shape Layer 4","sr":1,"st":-20,"op":430,"ip":-20,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,-20,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":-20},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":0},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":20},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":40},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":60},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":80},{"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":100}],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.2353,0.8275,0.9961],"ix":4},"r":1,"o":{"a":0,"k":40,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":5,"parent":2},{"ty":4,"nm":"Shape Layer 5","sr":1,"st":-30,"op":420,"ip":-30,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,-40,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":-30},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":-10},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":10},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":30},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":50},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":70},{"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":90}],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.2353,0.8275,0.9961],"ix":4},"r":1,"o":{"a":0,"k":10,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":6,"parent":2},{"ty":4,"nm":"Shape Layer 3","sr":1,"st":-40,"op":410,"ip":-40,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[0,-40,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Shape 1","ix":1,"cix":2,"np":3,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":-40},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":-20},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":0},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":20},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-610.5,55],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,-10],[339,67],[-131,-8]]}],"t":40},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[{"c":true,"i":[[122,-84],[48.5,0],[0,0],[0,0],[0,0],[67,0],[140.945,54.409],[163.819,-52.5],[126.181,60.682]],"o":[[-125.674,86.529],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-136,-52.5],[-168.5,54],[-131,-63]],"v":[[-590,28],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,24.5],[336.5,30],[-133,29]]}],"t":60},{"s":[{"c":true,"i":[[152.5,-5],[48.5,0],[0,0],[0,0],[0,0],[67,0],[151,-5],[172,3],[140,2]],"o":[[-152.5,5],[-48.5,0],[0,0],[0,0],[0,0],[-67,0],[-151,5],[-172,-3],[-140,-2]],"v":[[-625.5,-15],[-959.5,28],[-1000.5,62],[-1018,220],[1067.5,118.5],[958.5,28],[722,59],[334,-7],[-135,66]]}],"t":80}],"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.2353,0.8275,0.9961],"ix":4},"r":1,"o":{"a":0,"k":50,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[0,-20],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":7,"parent":2}],"v":"5.7.0","fr":30,"op":61,"ip":0,"assets":[]} \ No newline at end of file diff --git a/packages/app/assets/produce/apple.svg b/packages/app/assets/produce/apple.svg new file mode 100644 index 0000000..182b8e4 --- /dev/null +++ b/packages/app/assets/produce/apple.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/beet.svg b/packages/app/assets/produce/beet.svg new file mode 100644 index 0000000..3af1764 --- /dev/null +++ b/packages/app/assets/produce/beet.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/cabbage.svg b/packages/app/assets/produce/cabbage.svg new file mode 100644 index 0000000..f84b33c --- /dev/null +++ b/packages/app/assets/produce/cabbage.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/carrot.svg b/packages/app/assets/produce/carrot.svg new file mode 100644 index 0000000..9088b0c --- /dev/null +++ b/packages/app/assets/produce/carrot.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/assets/produce/cherry.svg b/packages/app/assets/produce/cherry.svg new file mode 100644 index 0000000..5348537 --- /dev/null +++ b/packages/app/assets/produce/cherry.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/corn.svg b/packages/app/assets/produce/corn.svg new file mode 100644 index 0000000..6ee0a30 --- /dev/null +++ b/packages/app/assets/produce/corn.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/cucumber.svg b/packages/app/assets/produce/cucumber.svg new file mode 100644 index 0000000..918e1ba --- /dev/null +++ b/packages/app/assets/produce/cucumber.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/kiwi.svg b/packages/app/assets/produce/kiwi.svg new file mode 100644 index 0000000..786dd79 --- /dev/null +++ b/packages/app/assets/produce/kiwi.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/leek.svg b/packages/app/assets/produce/leek.svg new file mode 100644 index 0000000..51bb03d --- /dev/null +++ b/packages/app/assets/produce/leek.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/lettuce.svg b/packages/app/assets/produce/lettuce.svg new file mode 100644 index 0000000..5f3cf69 --- /dev/null +++ b/packages/app/assets/produce/lettuce.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/peas.svg b/packages/app/assets/produce/peas.svg new file mode 100644 index 0000000..524c6c4 --- /dev/null +++ b/packages/app/assets/produce/peas.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/plum.svg b/packages/app/assets/produce/plum.svg new file mode 100644 index 0000000..8e1af85 --- /dev/null +++ b/packages/app/assets/produce/plum.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/potato.svg b/packages/app/assets/produce/potato.svg new file mode 100644 index 0000000..47632b5 --- /dev/null +++ b/packages/app/assets/produce/potato.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/radish.svg b/packages/app/assets/produce/radish.svg new file mode 100644 index 0000000..c7bd98e --- /dev/null +++ b/packages/app/assets/produce/radish.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/raspberry.svg b/packages/app/assets/produce/raspberry.svg new file mode 100644 index 0000000..ddeefd2 --- /dev/null +++ b/packages/app/assets/produce/raspberry.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/strawberry.svg b/packages/app/assets/produce/strawberry.svg new file mode 100644 index 0000000..2db7831 --- /dev/null +++ b/packages/app/assets/produce/strawberry.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/produce/tomato.svg b/packages/app/assets/produce/tomato.svg new file mode 100644 index 0000000..9cee538 --- /dev/null +++ b/packages/app/assets/produce/tomato.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/assets/splash-icon.png b/packages/app/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/packages/app/assets/splash-icon.png differ diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js new file mode 100644 index 0000000..7a3315b --- /dev/null +++ b/packages/app/metro.config.js @@ -0,0 +1,8 @@ +const { getDefaultConfig } = require('expo/metro-config'); + +const config = getDefaultConfig(__dirname); + +// Produce icons are SVG files rendered by expo-image. +config.resolver.assetExts.push('svg'); + +module.exports = config; diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..638863b --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,46 @@ +{ + "name": "@toms-allotment/app", + "version": "1.0.0", + "main": "expo-router/entry", + "dependencies": { + "@expo-google-fonts/jetbrains-mono": "^0.4.1", + "@expo-google-fonts/libre-baskerville": "^0.4.3", + "@expo-google-fonts/rubik": "^0.4.2", + "buffer": "^6.0.3", + "expo": "57.0.26", + "expo-constants": "~57.0.20", + "expo-document-picker": "~57.0.3", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-image": "~57.0.5", + "expo-keep-awake": "~57.0.2", + "expo-linear-gradient": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-router": "~57.0.24", + "expo-secure-store": "~57.0.4", + "expo-splash-screen": "~57.0.9", + "expo-status-bar": "~57.0.1", + "expo-symbols": "~57.0.3", + "expo-system-ui": "~57.0.4", + "expo-web-browser": "~57.0.3", + "lottie-react-native": "~7.3.8", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-ble-plx": "^3.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.2", + "react-native-svg": "15.15.4" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "expo start", + "ios": "expo run:ios", + "ios:device": "expo run:ios --device", + "typecheck": "tsc --noEmit" + }, + "private": true +} diff --git a/packages/app/plugins/withSceneLifecycle.js b/packages/app/plugins/withSceneLifecycle.js new file mode 100644 index 0000000..5b5069b --- /dev/null +++ b/packages/app/plugins/withSceneLifecycle.js @@ -0,0 +1,55 @@ +const { withAppDelegate, withInfoPlist } = require('expo/config-plugins'); + +/** + * iOS 27 aborts at launch unless the app adopts the UIScene life cycle. Expo + * ships `ExpoAppSceneDelegate` for this, but the SDK 57 template still starts + * React Native from the app delegate, so register the scene delegate and let it + * own the window. + */ +function withSceneManifest(config) { + return withInfoPlist(config, (cfg) => { + cfg.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: 'EXExpoAppSceneDelegate', + }, + ], + }, + }; + return cfg; + }); +} + +function withSceneAppDelegate(config) { + return withAppDelegate(config, (cfg) => { + if (cfg.modResults.language !== 'swift') { + throw new Error('withSceneLifecycle only supports a Swift AppDelegate'); + } + let src = cfg.modResults.contents; + + if (!src.includes('ExpoReactNativeFactoryProvider')) { + src = src.replace( + /class AppDelegate: ExpoAppDelegate \{/, + 'class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {', + ); + } + + src = src.replace( + /\n#if os\(iOS\) \|\| os\(tvOS\)\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\n\s*factory\.startReactNative\([\s\S]*?\)\n#endif\n/, + '', + ); + + if (!src.includes('ExpoReactNativeFactoryProvider') || src.includes('UIWindow(frame: UIScreen.main.bounds)')) { + throw new Error('withSceneLifecycle could not patch AppDelegate.swift; the Expo template has changed'); + } + cfg.modResults.contents = src; + return cfg; + }); +} + +module.exports = function withSceneLifecycle(config) { + return withSceneAppDelegate(withSceneManifest(config)); +}; diff --git a/packages/app/src/app/(tabs)/_layout.tsx b/packages/app/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..bf1eff0 --- /dev/null +++ b/packages/app/src/app/(tabs)/_layout.tsx @@ -0,0 +1,27 @@ +import { NativeTabs } from 'expo-router/unstable-native-tabs'; +import { useTheme } from '../../theme/ThemeProvider'; + +/** System tab bar, which iOS 26+ draws with Liquid Glass. */ +export default function TabsLayout() { + const theme = useTheme(); + return ( + + + Nodes + + + + Tank + + + + Power + + + + Settings + + + + ); +} diff --git a/packages/app/src/app/(tabs)/nodes/[serial].tsx b/packages/app/src/app/(tabs)/nodes/[serial].tsx new file mode 100644 index 0000000..693ff6a --- /dev/null +++ b/packages/app/src/app/(tabs)/nodes/[serial].tsx @@ -0,0 +1,369 @@ +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; +import { useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { isUnsupported } from '../../../ble/HubConnection'; +import type { HubNode, HubPatch, ValveState } from '../../../ble/types'; +import { Badge } from '../../../components/Badge'; +import { Button } from '../../../components/Button'; +import { FirmwareUploader } from '../../../components/FirmwareUploader'; +import { HubBanner } from '../../../components/HubBanner'; +import { Input } from '../../../components/Input'; +import { ButtonRow, Metric, Metrics, Notice, Screen, Section } from '../../../components/Layout'; +import { ProduceIcon } from '../../../components/ProduceIcon'; +import { Segmented } from '../../../components/Segmented'; +import { MultiSelectSheet } from '../../../components/SelectSheet'; +import { Text } from '../../../components/Text'; +import { useToast } from '../../../components/Toast'; +import { waitForResult } from '../../../hub/events'; +import { useHub, useHubEvent } from '../../../hub/HubProvider'; +import { useHubQuery } from '../../../hub/useHubQuery'; +import { FONT } from '../../../theme/palette'; +import { useTheme } from '../../../theme/ThemeProvider'; +import { errorMessage, formatAge } from '../../../utils/format'; +import { useNow } from '../../../utils/useNow'; + +const MAX_MV = 6000; + +export default function NodeDetail() { + const { serial: rawSerial } = useLocalSearchParams<{ serial: string }>(); + const serial = String(rawSerial ?? '').toUpperCase(); + const theme = useTheme(); + const toast = useToast(); + const router = useRouter(); + const { send, onEvent, status } = useHub(); + const now = useNow(1000); + + const nodeQuery = useHubQuery({ cmd: 'node', serial }, (r) => r.node, 3000); + const patchesQuery = useHubQuery({ cmd: 'patches' }, (r) => r.patches ?? [], 0); + const node = nodeQuery.data; + const notTracked = nodeQuery.error != null && /not.?found|not tracking/i.test(nodeQuery.error); + + useHubEvent((event) => { + if (event.serial?.toUpperCase() !== serial) return; + if (event.event === 'online' || event.event === 'offline') nodeQuery.refresh(); + }); + + const [identifying, setIdentifying] = useState(false); + const [valveBusy, setValveBusy] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [savingPatches, setSavingPatches] = useState(false); + const [patchError, setPatchError] = useState(null); + const [patchesUnsupported, setPatchesUnsupported] = useState(false); + + const isOnline = node ? node.online !== false : false; + const age = node && nodeQuery.fetchedAt != null ? node.age_ms + (now - nodeQuery.fetchedAt) : node?.age_ms; + + const identify = async () => { + setIdentifying(true); + try { + const result = waitForResult(onEvent, 'identify', serial); + await send({ cmd: 'identify', serial }); + await result; + toast('success', `Identifying ${serial}`, 'The node LED shows a rainbow for 3 seconds'); + } catch (e) { + const message = errorMessage(e); + toast( + 'error', + 'Identify failed', + /no reply/i.test(message) && isOnline ? `${message}. The node firmware may be too old for identify.` : message, + ); + } finally { + setIdentifying(false); + } + }; + + const setValve = async (state: 'open' | 'closed') => { + if (node?.valve === state) return; + setValveBusy(state); + try { + const result = waitForResult(onEvent, 'valve', serial); + await send({ cmd: 'valve', serial, state }); + await result; + toast('success', state === 'open' ? 'Valve open' : 'Valve closed', serial); + } catch (e) { + toast('error', 'Valve command failed', errorMessage(e)); + } finally { + setValveBusy(null); + nodeQuery.refresh(); + } + }; + + const patchById = useMemo(() => new Map((patchesQuery.data ?? []).map((p) => [p.id, p])), [patchesQuery.data]); + const assigned = node?.patches ?? []; + + const savePatches = async (values: string[]) => { + setSavingPatches(true); + setPatchError(null); + try { + await send({ cmd: 'node_set', serial, patches: values }); + setSheetOpen(false); + toast('success', 'Patches saved', values.length ? values.map((id) => patchById.get(id)?.name || id).join(', ') : 'No patches'); + nodeQuery.refresh(); + } catch (e) { + if (isUnsupported(e)) { + setPatchesUnsupported(true); + setSheetOpen(false); + } else { + setPatchError(errorMessage(e)); + } + } finally { + setSavingPatches(false); + } + }; + + const patchSupport = !patchesQuery.unsupported && !patchesUnsupported; + + return ( + <> + + + +
+ + {serial} + {node ? ( + + ) : notTracked ? ( + + ) : null} + + {notTracked ? ( + + ) : null} + {node ? ( + <> + + {node.temp_c == null ? '--' : `${node.temp_c.toFixed(2)} °C`} + + {node.moisture == null ? '--' : `${node.moisture}%`} + + {node.valve == null ? '--' : node.valve === 'open' ? 'Open' : 'Closed'} + {formatAge(age)} + + {!isOnline ? : null} + + ) : null} +
+ +
+ +
+ +
+ {node && node.valve == null ? ( + No valve + ) : ( + + )} +
+ +
{ + setPatchError(null); + setSheetOpen(true); + }} + /> + ) : null + } + > + {!patchSupport ? ( + + ) : assigned.length === 0 ? ( + Not assigned to any patch + ) : ( + + {assigned.map((id) => { + const p = patchById.get(id); + return ( + + + + {p?.name || id} + {p?.name ? {id} : null} + + + ); + })} + + )} +
+ + {node ? router.push(`/nodes/calibrate/${serial}`)} /> : null} + +
+ +
+ + {node ? ( +
+ + {JSON.stringify(node, null, 2)} + +
+ ) : null} +
+ + setSheetOpen(false)} + onDone={savePatches} + emptyMessage="The hub has no patches yet" + options={(patchesQuery.data ?? []).map((p) => ({ + value: p.id, + label: p.name || p.id, + detail: p.name ? p.id : undefined, + leading: , + }))} + /> + + ); +} + +function CalibrationSection({ node, onSaved, onGuide }: { node: HubNode; onSaved: () => void; onGuide: () => void }) { + const theme = useTheme(); + const toast = useToast(); + const { send, status } = useHub(); + const [dry, setDry] = useState(String(node.cal_min_mv)); + const [wet, setWet] = useState(String(node.cal_max_mv)); + const [defaults, setDefaults] = useState<{ dry_mv: number; wet_mv: number } | null>(null); + const [saving, setSaving] = useState(false); + const [resetting, setResetting] = useState(false); + + useEffect(() => { + setDry(String(node.cal_min_mv)); + setWet(String(node.cal_max_mv)); + }, [node.cal_min_mv, node.cal_max_mv]); + + useEffect(() => { + if (status !== 'ready') return; + send({ cmd: 'cal_get' }) + .then((r) => setDefaults({ dry_mv: r.dry_mv, wet_mv: r.wet_mv })) + .catch(() => undefined); + }, [send, status]); + + const valid = (v: string) => /^\d{1,4}$/.test(v) && Number(v) <= MAX_MV; + const d = Number(dry); + const w = Number(wet); + const error = !valid(dry) || !valid(wet) ? `Use whole millivolts, 0-${MAX_MV}` : d === w ? 'Dry and wet must differ' : null; + const changed = d !== node.cal_min_mv || w !== node.cal_max_mv; + + const save = async () => { + setSaving(true); + try { + const r = await send({ cmd: 'cal_set', serial: node.serial, dry_mv: d, wet_mv: w }); + toast('success', 'Calibration saved', `dry ${r.dry_mv} mV, wet ${r.wet_mv} mV`); + onSaved(); + } catch (e) { + toast('error', 'Could not save calibration', errorMessage(e)); + } finally { + setSaving(false); + } + }; + + const reset = async () => { + setResetting(true); + try { + const r = await send({ cmd: 'cal_reset', serial: node.serial }); + toast('success', 'Calibration reset', `Using hub default ${r.dry_mv}-${r.wet_mv} mV`); + onSaved(); + } catch (e) { + toast('error', 'Could not reset calibration', errorMessage(e)); + } finally { + setResetting(false); + } + }; + + return ( +
} + > + + + Dry, 0% + + + + Wet, 100% + + + + + + Live{' '} + + {node.moisture_mv == null ? '--' : `${node.moisture_mv} mV`} + + + {defaults ? ( + + Default {defaults.dry_mv}-{defaults.wet_mv} mV + + ) : null} + + {error ? ( + + {error} + + ) : null} + +
+ ); +} + +const styles = StyleSheet.create({ + titleRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap' }, + patchList: { gap: 12 }, + patchItem: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + calGrid: { flexDirection: 'row', gap: 12 }, + liveRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, +}); diff --git a/packages/app/src/app/(tabs)/nodes/_layout.tsx b/packages/app/src/app/(tabs)/nodes/_layout.tsx new file mode 100644 index 0000000..7a2c6ac --- /dev/null +++ b/packages/app/src/app/(tabs)/nodes/_layout.tsx @@ -0,0 +1,13 @@ +import { Stack } from 'expo-router'; +import { useStackScreenOptions } from '../../../navigation/stackOptions'; + +export default function NodesLayout() { + const options = useStackScreenOptions(); + return ( + + + + + + ); +} diff --git a/packages/app/src/app/(tabs)/nodes/calibrate/[serial].tsx b/packages/app/src/app/(tabs)/nodes/calibrate/[serial].tsx new file mode 100644 index 0000000..e955f15 --- /dev/null +++ b/packages/app/src/app/(tabs)/nodes/calibrate/[serial].tsx @@ -0,0 +1,278 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useState } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { useCalibration, type Average } from '../../../../ble/calibration'; +import { Button } from '../../../../components/Button'; +import { HubBanner } from '../../../../components/HubBanner'; +import { ButtonRow, Metric, Metrics, Notice, Screen, Section } from '../../../../components/Layout'; +import { LiveChart } from '../../../../components/LiveChart'; +import { Text } from '../../../../components/Text'; +import { useToast } from '../../../../components/Toast'; +import { useHub } from '../../../../hub/HubProvider'; +import { FONT } from '../../../../theme/palette'; +import { useTheme } from '../../../../theme/ThemeProvider'; +import { errorMessage } from '../../../../utils/format'; + +type Step = 'dry' | 'wet' | 'check' | 'saved'; + +const STEPS: { key: Step; label: string }[] = [ + { key: 'dry', label: 'Dry' }, + { key: 'wet', label: 'Wet' }, + { key: 'check', label: 'Save' }, +]; + +const STEADY_SPREAD_MV = 150; +const MIN_CHANGE_MV = 200; + +export default function CalibrateScreen() { + const { serial: rawSerial } = useLocalSearchParams<{ serial: string }>(); + const serial = String(rawSerial ?? '').toUpperCase(); + const theme = useTheme(); + const router = useRouter(); + const toast = useToast(); + const { send } = useHub(); + const cal = useCalibration(serial); + + const [step, setStep] = useState('dry'); + const [dry, setDry] = useState(null); + const [wet, setWet] = useState(null); + const [measuring, setMeasuring] = useState(false); + const [measureError, setMeasureError] = useState(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState<{ dry_mv: number; wet_mv: number } | null>(null); + const [resetting, setResetting] = useState(false); + + const live = cal.samples.filter((s) => s.mv != null).map((s) => s.mv as number); + const latest = cal.latest; + + const measure = async () => { + setMeasuring(true); + setMeasureError(null); + try { + const avg = await cal.measure(); + if (step === 'dry') setDry(avg); + else setWet(avg); + } catch (e) { + setMeasureError(errorMessage(e)); + } finally { + setMeasuring(false); + } + }; + + const save = async () => { + if (!dry || !wet) return; + setSaving(true); + try { + const r = await send({ cmd: 'cal_set', serial, dry_mv: dry.mv, wet_mv: wet.mv }); + setSaved({ dry_mv: r.dry_mv, wet_mv: r.wet_mv }); + setStep('saved'); + toast('success', 'Calibration saved', serial); + } catch (e) { + toast('error', 'Could not save calibration', errorMessage(e)); + } finally { + setSaving(false); + } + }; + + const reset = async () => { + setResetting(true); + try { + const r = await send({ cmd: 'cal_reset', serial }); + toast('success', 'Using the hub default', `dry ${r.dry_mv} mV, wet ${r.wet_mv} mV`); + router.back(); + } catch (e) { + toast('error', 'Could not reset calibration', errorMessage(e)); + } finally { + setResetting(false); + } + }; + + const current = step === 'dry' ? dry : step === 'wet' ? wet : null; + const tooSmall = dry && wet ? Math.abs(dry.mv - wet.mv) < MIN_CHANGE_MV : false; + const swapped = dry && wet ? dry.mv < wet.mv : false; + const stepIndex = STEPS.findIndex((s) => s.key === step); + + const markers = [ + ...(dry ? [{ value: dry.mv, label: `Dry ${dry.mv} mV`, color: theme.warn }] : []), + ...(wet ? [{ value: wet.mv, label: `Wet ${wet.mv} mV`, color: theme.info }] : []), + ]; + + return ( + + +
+ + {STEPS.map((s, i) => { + const done = step === 'saved' || i < stepIndex; + const active = i === stepIndex; + const color = active || done ? theme.accent : theme.faint; + return ( + + + {i + 1} + + {s.label} + + ); + })} + + + {serial} + {cal.session ? ` · now ${cal.session.dry_mv}-${cal.session.wet_mv} mV${cal.session.custom ? ' custom' : ' default'}` : ''} + +
+ +
+ {cal.error ? ( + <> + + +
+ + {step === 'dry' || step === 'wet' ? ( +
+ + {step === 'dry' + ? 'Push the sensor fully into the soil as it is now. Wait until the reading is steady, then tap Measure.' + : '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.'} + + {current ? ( + + + Average{' '} + + {current.mv} mV + {' '} + from {current.count} readings + + {current.spread > STEADY_SPREAD_MV ? ( + + ) : null} + + ) : null} + {measureError ? : null} + + {current ? ( + <> +
+ ) : null} + + {step === 'check' && dry && wet ? ( +
+ + {`${dry.mv} mV`} + {`${wet.mv} mV`} + + {tooSmall ? ( + + ) : swapped ? ( + + ) : ( + Looks good. The node will read 0% at the dry point and 100% at the wet point. + )} + + {tooSmall ? ( +
+ ) : null} + + {step === 'saved' && saved ? ( +
+ + {`${saved.dry_mv} mV`} + {`${saved.wet_mv} mV`} + + +
+ ) : null} + + {step !== 'saved' ? ( +
+ Drop this node's own calibration and use the hub-wide default again. + +
+ ) : null} +
+ ); +} + +const styles = StyleSheet.create({ + steps: { flexDirection: 'row', gap: 18 }, + stepItem: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + stepDot: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + waiting: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 20 }, +}); diff --git a/packages/app/src/app/(tabs)/nodes/index.tsx b/packages/app/src/app/(tabs)/nodes/index.tsx new file mode 100644 index 0000000..bddc5ef --- /dev/null +++ b/packages/app/src/app/(tabs)/nodes/index.tsx @@ -0,0 +1,202 @@ +import { Stack, useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'; +import type { HubNode } from '../../../ble/types'; +import { Badge, StatusDot } from '../../../components/Badge'; +import { MoistureBar } from '../../../components/Bars'; +import { Button } from '../../../components/Button'; +import { HubBanner } from '../../../components/HubBanner'; +import { Icon } from '../../../components/Icon'; +import { ButtonRow, Notice, Screen, Section } from '../../../components/Layout'; +import { Text } from '../../../components/Text'; +import { useToast } from '../../../components/Toast'; +import { useHub, useHubEvent } from '../../../hub/HubProvider'; +import { useHubQuery } from '../../../hub/useHubQuery'; +import { FONT } from '../../../theme/palette'; +import { useTheme } from '../../../theme/ThemeProvider'; +import { errorMessage, formatAge } from '../../../utils/format'; +import { useNow } from '../../../utils/useNow'; + +type BulkAction = 'identify' | 'open' | 'closed'; + +export default function NodesScreen() { + const theme = useTheme(); + const router = useRouter(); + const toast = useToast(); + const { send, status } = useHub(); + const nodes = useHubQuery({ cmd: 'nodes' }, (r) => r.nodes ?? [], 3000); + const [query, setQuery] = useState(''); + const [bulk, setBulk] = useState(null); + const now = useNow(1000); + + useHubEvent((event) => { + if (event.event === 'online' || event.event === 'offline' || (event.target === 'valve' && event.event === 'complete')) { + nodes.refresh(); + } + }); + + const list = nodes.data ?? []; + const online = list.filter((n) => n.online !== false); + const filtered = useMemo(() => { + const q = query.trim().toUpperCase(); + return q ? list.filter((n) => n.serial.includes(q)) : list; + }, [list, query]); + + const runBulk = async (action: BulkAction) => { + setBulk(action); + const failures: string[] = []; + for (const n of online) { + try { + if (action === 'identify') await send({ cmd: 'identify', serial: n.serial }); + else await send({ cmd: 'valve', serial: n.serial, state: action }); + } catch (e) { + failures.push(`${n.serial}: ${errorMessage(e)}`); + } + } + setBulk(null); + const label = action === 'identify' ? 'Identify' : action === 'open' ? 'Open valves' : 'Close valves'; + if (failures.length === 0) { + toast('success', `${label} on ${online.length} node${online.length === 1 ? '' : 's'}`); + } else { + toast('error', `${failures.length} of ${online.length} nodes did not answer`, failures.join('\n')); + } + nodes.refresh(); + }; + + const bulkDisabled = online.length === 0 || status !== 'ready' || bulk !== null; + + return ( + <> + setQuery(e.nativeEvent.text), + onCancelButtonPress: () => setQuery(''), + }, + }} + /> + + +
+ + {nodes.data ? `${online.length} of ${list.length} online` : 'Loading nodes…'} + + +
+ +
+ {!nodes.data ? ( + + + {status === 'ready' ? 'Loading' : 'Waiting for hub'} + + ) : list.length === 0 ? ( + + No nodes on the bus + + ) : filtered.length === 0 ? ( + + No matches + + ) : ( + + + + Serial + + + Temp + + + Moisture + + + {filtered.map((n, i) => { + const isOnline = n.online !== false; + const age = nodes.fetchedAt != null ? n.age_ms + (now - nodes.fetchedAt) : n.age_ms; + return ( + router.push(`/nodes/${n.serial}`)} + style={({ pressed }) => [ + styles.nodeRow, + i < filtered.length - 1 ? { borderBottomWidth: 2, borderBottomColor: theme.borderUi } : null, + pressed ? { backgroundColor: theme.wash } : null, + ]} + > + + + + + {n.serial} + + + + {n.temp_c == null ? '--' : `${n.temp_c.toFixed(1)}°`} + + + + + + + + + {n.valve ? ( + + ) : null} + + {formatAge(age)} + + + + ); + })} + + )} +
+
+ + ); +} + +const styles = StyleSheet.create({ + empty: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 20 }, + emptyText: { paddingVertical: 20 }, + headerRow: { flexDirection: 'row', paddingBottom: 8, borderBottomWidth: 2, gap: 10 }, + nodeRow: { paddingVertical: 12, gap: 8, marginHorizontal: -10, paddingHorizontal: 10, borderRadius: 12 }, + line: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + serialCell: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + colTemp: { width: 56, textAlign: 'right' }, + colMoisture: { width: 104, alignItems: 'flex-end' }, +}); diff --git a/packages/app/src/app/(tabs)/power/_layout.tsx b/packages/app/src/app/(tabs)/power/_layout.tsx new file mode 100644 index 0000000..092e9f8 --- /dev/null +++ b/packages/app/src/app/(tabs)/power/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; +import { useStackScreenOptions } from '../../../navigation/stackOptions'; + +export default function PowerLayout() { + const options = useStackScreenOptions(); + return ( + + + + ); +} diff --git a/packages/app/src/app/(tabs)/power/index.tsx b/packages/app/src/app/(tabs)/power/index.tsx new file mode 100644 index 0000000..9063177 --- /dev/null +++ b/packages/app/src/app/(tabs)/power/index.tsx @@ -0,0 +1,75 @@ +import { Image } from 'expo-image'; +import { StyleSheet, View } from 'react-native'; +import type { PowerReading } from '../../../ble/types'; +import { FlowLine } from '../../../components/FlowLine'; +import { HubBanner } from '../../../components/HubBanner'; +import { Metric, Metrics, Notice, Screen } from '../../../components/Layout'; +import { Text } from '../../../components/Text'; +import { Vessel } from '../../../components/Vessel'; +import { useHubQuery } from '../../../hub/useHubQuery'; +import { withAlpha } from '../../../theme/palette'; +import { useTheme } from '../../../theme/ThemeProvider'; +import { formatAge } from '../../../utils/format'; +import { useNow } from '../../../utils/useNow'; + +const SOLAR = require('../../../../assets/images/solar-panel.png'); +const PCB = require('../../../../assets/images/pcb-board.png'); + +function flowWatts(power: PowerReading | null): number { + const v = Number(power?.battery?.voltage); + const i = Number(power?.battery?.current); + return Number.isFinite(v) && Number.isFinite(i) ? Math.abs(v * i) : 0; +} + +export default function PowerScreen() { + const theme = useTheme(); + const power = useHubQuery({ cmd: 'power' }, (r) => r.power, 10_000); + const now = useNow(1000); + const data = power.data; + const watts = flowWatts(data); + const age = data?.age_s != null && power.fetchedAt != null ? data.age_s * 1000 + (now - power.fetchedAt) : null; + + return ( + + + {power.unsupported ? ( + + ) : ( + <> + + + + + + + + + + {data?.battery?.percent != null ? `${Math.round(data.battery.percent)}%` : '—'} + + + {data?.battery?.voltage != null ? `${data.battery.voltage.toFixed(2)} V` : '—'} + + + {data ? ( + + {watts > 0 ? `${watts.toFixed(1)} W · ` : ''}Updated {formatAge(age)} + + ) : null} + {power.error ? : null} + + )} + + ); +} + +const styles = StyleSheet.create({ + diagram: { alignItems: 'center', gap: 10, paddingTop: 4, paddingBottom: 12 }, + solar: { width: 220, height: 160, marginVertical: -5 }, + pcb: { width: 130, height: 110 }, +}); diff --git a/packages/app/src/app/(tabs)/settings/_layout.tsx b/packages/app/src/app/(tabs)/settings/_layout.tsx new file mode 100644 index 0000000..9469174 --- /dev/null +++ b/packages/app/src/app/(tabs)/settings/_layout.tsx @@ -0,0 +1,13 @@ +import { Stack } from 'expo-router'; +import { useStackScreenOptions } from '../../../navigation/stackOptions'; + +export default function SettingsLayout() { + const options = useStackScreenOptions(); + return ( + + + + + + ); +} diff --git a/packages/app/src/app/(tabs)/settings/about.tsx b/packages/app/src/app/(tabs)/settings/about.tsx new file mode 100644 index 0000000..df121a9 --- /dev/null +++ b/packages/app/src/app/(tabs)/settings/about.tsx @@ -0,0 +1,81 @@ +import { useIsFocused } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import { Badge } from '../../../components/Badge'; +import { HubBanner } from '../../../components/HubBanner'; +import { Notice, Row, Screen, Section } from '../../../components/Layout'; +import { useHub } from '../../../hub/HubProvider'; +import { errorMessage, formatUptime, signalQuality } from '../../../utils/format'; + +function YesNo({ yes, yesLabel, noLabel }: { yes: boolean | undefined; yesLabel: string; noLabel: string }) { + if (yes == null) return ; + return ; +} + +export default function AboutHub() { + const { info, hub, status, refreshInfo } = useHub(); + const focused = useIsFocused(); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + const refresh = useCallback(async () => { + setRefreshing(true); + try { + await refreshInfo(); + setError(null); + } catch (e) { + setError(errorMessage(e)); + } finally { + setRefreshing(false); + } + }, [refreshInfo]); + + useEffect(() => { + if (!focused || status !== 'ready') return; + refresh(); + const id = setInterval(refresh, 10_000); + return () => clearInterval(id); + }, [focused, status, refresh]); + + return ( + + + {error ? : null} +
+ + + + + + } last /> +
+ +
+ + +
+ +
+ } + /> + + +
+ +
+ } /> + } /> + +
+ +
+ +
+
+ ); +} diff --git a/packages/app/src/app/(tabs)/settings/firmware.tsx b/packages/app/src/app/(tabs)/settings/firmware.tsx new file mode 100644 index 0000000..c50e2aa --- /dev/null +++ b/packages/app/src/app/(tabs)/settings/firmware.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import type { HubNode } from '../../../ble/types'; +import { StatusDot } from '../../../components/Badge'; +import { FirmwareUploader } from '../../../components/FirmwareUploader'; +import { HubBanner } from '../../../components/HubBanner'; +import { Icon } from '../../../components/Icon'; +import { Row, Screen, Section } from '../../../components/Layout'; +import { Segmented } from '../../../components/Segmented'; +import { Text } from '../../../components/Text'; +import { useHub } from '../../../hub/HubProvider'; +import { useHubQuery } from '../../../hub/useHubQuery'; +import { useTheme } from '../../../theme/ThemeProvider'; + +type Target = 'hub' | 'node'; + +export default function FirmwareScreen() { + const theme = useTheme(); + const { info } = useHub(); + const [target, setTarget] = useState('hub'); + const [serial, setSerial] = useState(null); + const nodes = useHubQuery(target === 'node' ? { cmd: 'nodes' } : null, (r) => r.nodes ?? [], 10_000); + const selected = nodes.data?.find((n) => n.serial === serial) ?? null; + + return ( + + +
+ + Choose a firmware file on this iPhone, for example from Files or iCloud Drive, and send it to the hub over + Bluetooth. Keep the phone near the hub until it finishes. + + + value={target} + onChange={setTarget} + options={[ + { value: 'hub', label: 'Hub', icon: 'antenna.radiowaves.left.and.right' }, + { value: 'node', label: 'Node', icon: 'leaf' }, + ]} + /> +
+ + {target === 'hub' ? ( +
+ + +
+ ) : ( + <> +
+ {!nodes.data ? ( + Loading nodes… + ) : nodes.data.length === 0 ? ( + No nodes on the bus + ) : ( + + {nodes.data.map((n, i) => ( + setSerial(n.serial)} + value={ + + + {serial === n.serial ? ( + + ) : ( + + )} + + } + /> + ))} + + )} +
+ {selected ? ( +
+ +
+ ) : null} + + )} +
+ ); +} diff --git a/packages/app/src/app/(tabs)/settings/index.tsx b/packages/app/src/app/(tabs)/settings/index.tsx new file mode 100644 index 0000000..4d41113 --- /dev/null +++ b/packages/app/src/app/(tabs)/settings/index.tsx @@ -0,0 +1,112 @@ +import Constants from 'expo-constants'; +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { ActivityIndicator, Alert } from 'react-native'; +import { SERVER_URL, useAuth } from '../../../auth/AuthProvider'; +import { Badge } from '../../../components/Badge'; +import { HubBanner } from '../../../components/HubBanner'; +import { Row, Screen, Section } from '../../../components/Layout'; +import { useToast } from '../../../components/Toast'; +import { useHub } from '../../../hub/HubProvider'; +import { useTheme } from '../../../theme/ThemeProvider'; +import { errorMessage } from '../../../utils/format'; + +export default function SettingsScreen() { + const theme = useTheme(); + const router = useRouter(); + const toast = useToast(); + const { user, signOut } = useAuth(); + const { hub, info, status, send, disconnect } = useHub(); + const [rebooting, setRebooting] = useState(false); + + const reboot = () => { + Alert.alert('Reboot the hub?', 'The connection drops for a few seconds and reconnects by itself.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reboot', + style: 'destructive', + onPress: async () => { + setRebooting(true); + try { + await send({ cmd: 'reboot' }); + toast('info', 'Hub rebooting', 'Reconnecting in a few seconds'); + } catch (e) { + toast('error', 'Could not reboot the hub', errorMessage(e)); + } finally { + setRebooting(false); + } + }, + }, + ]); + }; + + const switchHub = () => { + Alert.alert('Switch hub?', `Disconnect from ${hub?.name ?? 'this hub'} and choose another one.`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Switch', onPress: () => disconnect() }, + ]); + }; + + const confirmSignOut = () => { + Alert.alert('Sign out?', undefined, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Sign out', + style: 'destructive', + onPress: async () => { + await disconnect(); + await signOut(); + }, + }, + ]); + }; + + return ( + + +
+ + } + /> + router.push('/settings/about')} /> + router.push('/settings/firmware')} + /> + : undefined} + /> + +
+ +
+ + + +
+ +
+ +
+
+ ); +} diff --git a/packages/app/src/app/(tabs)/tank/_layout.tsx b/packages/app/src/app/(tabs)/tank/_layout.tsx new file mode 100644 index 0000000..a111bac --- /dev/null +++ b/packages/app/src/app/(tabs)/tank/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'expo-router'; +import { useStackScreenOptions } from '../../../navigation/stackOptions'; + +export default function TankLayout() { + const options = useStackScreenOptions(); + return ( + + + + ); +} diff --git a/packages/app/src/app/(tabs)/tank/index.tsx b/packages/app/src/app/(tabs)/tank/index.tsx new file mode 100644 index 0000000..37f4c57 --- /dev/null +++ b/packages/app/src/app/(tabs)/tank/index.tsx @@ -0,0 +1,66 @@ +import { StyleSheet, View } from 'react-native'; +import type { TankReading, TankSample } from '../../../ble/types'; +import { HubBanner } from '../../../components/HubBanner'; +import { Metric, Metrics, Notice, Screen, Section } from '../../../components/Layout'; +import { LiveChart } from '../../../components/LiveChart'; +import { Text } from '../../../components/Text'; +import { Vessel } from '../../../components/Vessel'; +import { useHubQuery } from '../../../hub/useHubQuery'; +import { FIXED } from '../../../theme/palette'; +import { formatAge } from '../../../utils/format'; +import { useNow } from '../../../utils/useNow'; + +export default function TankScreen() { + const tank = useHubQuery({ cmd: 'tank' }, (r) => r.tank, 15_000); + const history = useHubQuery({ cmd: 'tank_history' }, (r) => r.samples ?? [], 60_000); + const now = useNow(1000); + + const reading = tank.data; + const age = + reading?.age_s != null && tank.fetchedAt != null ? reading.age_s * 1000 + (now - tank.fetchedAt) : null; + const samples = [...(history.data ?? [])].sort((a, b) => b.age_s - a.age_s).map((s) => s.level); + + const refresh = () => { + tank.refresh(); + history.refresh(); + }; + + return ( + + + {tank.unsupported ? ( + + ) : ( + <> + + + + + {reading?.level != null ? `${Math.round(reading.level)}%` : '—'} + + + {reading?.level == null ? '—' : formatAge(age)} + + + + + + {tank.error ? : null} + {!history.unsupported && samples.length > 1 ? ( +
+ + + + + +
+ ) : null} + + )} +
+ ); +} + +const styles = StyleSheet.create({ + top: { flexDirection: 'row', gap: 32, alignItems: 'center', paddingTop: 8 }, +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx new file mode 100644 index 0000000..9d594be --- /dev/null +++ b/packages/app/src/app/_layout.tsx @@ -0,0 +1,99 @@ +import { JetBrainsMono_400Regular, JetBrainsMono_500Medium } from '@expo-google-fonts/jetbrains-mono'; +import { LibreBaskerville_700Bold } from '@expo-google-fonts/libre-baskerville'; +import { + Rubik_400Regular, + Rubik_500Medium, + Rubik_600SemiBold, + Rubik_700Bold, + Rubik_800ExtraBold, +} from '@expo-google-fonts/rubik'; +import { useFonts } from 'expo-font'; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider as NavigationThemeProvider } from 'expo-router'; +import * as SplashScreen from 'expo-splash-screen'; +import { StatusBar } from 'expo-status-bar'; +import * as SystemUI from 'expo-system-ui'; +import { useEffect, useMemo } from 'react'; +import { AuthProvider, useAuth } from '../auth/AuthProvider'; +import { ToastProvider } from '../components/Toast'; +import { HubProvider, useHub } from '../hub/HubProvider'; +import { useStackScreenOptions } from '../navigation/stackOptions'; +import { ThemeProvider, useTheme } from '../theme/ThemeProvider'; + +SplashScreen.preventAutoHideAsync().catch(() => undefined); + +function RootNavigator() { + const theme = useTheme(); + const { user } = useAuth(); + const { entered } = useHub(); + const stackOptions = useStackScreenOptions(); + + useEffect(() => { + SystemUI.setBackgroundColorAsync(theme.bg).catch(() => undefined); + }, [theme.bg]); + + useEffect(() => { + if (user !== undefined) SplashScreen.hideAsync().catch(() => undefined); + }, [user]); + + const navTheme = useMemo(() => { + const base = theme.isDay ? DefaultTheme : DarkTheme; + return { + ...base, + colors: { + ...base.colors, + primary: theme.accent, + background: theme.bg, + card: theme.bg, + text: theme.text, + border: theme.borderUi, + }, + }; + }, [theme]); + + if (user === undefined) return null; + const signedIn = user !== null; + + return ( + + + + + + + + + + + + + + + + ); +} + +export default function RootLayout() { + const [fontsLoaded] = useFonts({ + Rubik_400Regular, + Rubik_500Medium, + Rubik_600SemiBold, + Rubik_700Bold, + Rubik_800ExtraBold, + LibreBaskerville_700Bold, + JetBrainsMono_400Regular, + JetBrainsMono_500Medium, + }); + if (!fontsLoaded) return null; + + return ( + + + + + + + + + + ); +} diff --git a/packages/app/src/app/hubs.tsx b/packages/app/src/app/hubs.tsx new file mode 100644 index 0000000..b3dd99e --- /dev/null +++ b/packages/app/src/app/hubs.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { useAuth } from '../auth/AuthProvider'; +import { PROTOCOL_VERSION } from '../ble/constants'; +import { Badge } from '../components/Badge'; +import { Button } from '../components/Button'; +import { Icon } from '../components/Icon'; +import { ButtonRow, Notice, Row, Screen, Section } from '../components/Layout'; +import { Text } from '../components/Text'; +import { useHub } from '../hub/HubProvider'; +import { useHubScanner, type ScannedHub } from '../hub/useHubScanner'; +import { useTheme } from '../theme/ThemeProvider'; + +function signalLabel(rssi: number | null) { + if (rssi == null) return ''; + if (rssi >= -60) return 'Strong'; + if (rssi >= -75) return 'Good'; + return 'Weak'; +} + +function signalIcon(rssi: number | null) { + if (rssi == null || rssi < -75) return 'wifi.exclamationmark' as const; + return 'wifi' as const; +} + +export default function Hubs() { + const theme = useTheme(); + const { user, signOut } = useAuth(); + const { status, hub, error, connect, disconnect, lastHub } = useHub(); + const busy = status === 'connecting' || status === 'pairing'; + const { hubs, error: scanError } = useHubScanner(!busy); + const autoTried = useRef(false); + + useEffect(() => { + if (autoTried.current || !lastHub || status !== 'idle') return; + autoTried.current = true; + connect(lastHub); + }, [lastHub, status, connect]); + + const choose = (h: ScannedHub) => connect({ id: h.id, name: h.name ?? 'Allotment hub' }); + + return ( + + {busy ? ( +
+ + + + {status === 'pairing' ? 'Pairing with' : 'Connecting to'} {hub?.name} + + + {status === 'pairing' ? ( + + If this is the first time, a 6-digit code appears on the hub's screen. Type it into the prompt on + your iPhone when it asks. + + ) : ( + Make sure the hub is powered on and nearby. + )} +
+ ) : ( + <> + {status === 'failed' && hub ? ( +
+ + +
+ ) : null} + +
} + > + {scanError ? : null} + {hubs.length === 0 ? ( + + Looking for hubs. The hub only shows up while no other phone is connected to it. + + ) : ( + + {hubs.map((h, i) => { + const unsupported = h.advert != null && h.advert.protocolVersion !== PROTOCOL_VERSION; + const detail = h.advert + ? `v${h.advert.firmware} · ${h.advert.nodesOnline} node${h.advert.nodesOnline === 1 ? '' : 's'} online` + : 'Reading details…'; + return ( + choose(h)} + chevron={!unsupported} + last={i === hubs.length - 1} + value={ + + {h.advert?.busy ? : null} + {unsupported ? : null} + + {signalLabel(h.rssi)} + + } + /> + ); + })} + + )} +
+ +
+ + If the hub's pairings were cleared, open iPhone Settings, then Bluetooth, choose "Forget This + Device" for the hub, and connect again. + +
+ +
+ } + /> +
+ + )} +
+ ); +} + +const styles = StyleSheet.create({ + connecting: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + hubMeta: { flexDirection: 'row', alignItems: 'center', gap: 6 }, +}); diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx new file mode 100644 index 0000000..beb56eb --- /dev/null +++ b/packages/app/src/app/index.tsx @@ -0,0 +1,11 @@ +import { Redirect } from 'expo-router'; +import { useAuth } from '../auth/AuthProvider'; +import { useHub } from '../hub/HubProvider'; + +export default function Index() { + const { user } = useAuth(); + const { entered } = useHub(); + if (!user) return ; + if (!entered) return ; + return ; +} diff --git a/packages/app/src/app/login.tsx b/packages/app/src/app/login.tsx new file mode 100644 index 0000000..1a6ec69 --- /dev/null +++ b/packages/app/src/app/login.tsx @@ -0,0 +1,55 @@ +import { StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SERVER_URL, useAuth } from '../auth/AuthProvider'; +import { Button } from '../components/Button'; +import { Icon } from '../components/Icon'; +import { Text } from '../components/Text'; +import { FONT } from '../theme/palette'; +import { useTheme } from '../theme/ThemeProvider'; + +export default function Login() { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const { signIn, signingIn, error } = useAuth(); + + return ( + + + + Allotment + + Welcome back + + + Sign in with your allotment account, then connect to your hub over Bluetooth. + + + + + {error ? ( + + {error} + + ) : null} +