Add devtools and app configuration files, including CLI, color parsing, and hub communication. Update dependencies in pnpm-lock.yaml and add environment variables for app redirection and server URL. Include .gitignore for app-specific files and initial documentation for BLE protocol.

This commit is contained in:
Tom Butcher 2026-10-04 21:21:31 +01:00
parent 6e33faa5fd
commit e5eee9a269
101 changed files with 13031 additions and 1836 deletions

View File

@ -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=<session 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 <key>" 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=

View File

@ -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://<lan-ip>:3001/auth/callback.
EXPO_PUBLIC_SERVER_URL=http://192.168.1.10:3001

41
packages/app/.gitignore vendored Normal file
View File

@ -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

41
packages/app/AGENTS.md Normal file
View File

@ -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<major>.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 <package> # 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 <command>` in Bun projects, or `npx eas-cli@latest <command>` 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

1382
packages/app/BLE_PROTOCOL.md Normal file

File diff suppressed because it is too large Load Diff

53
packages/app/app.json Normal file
View File

@ -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
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M576 106.666667s-64 65.493333-64 192.746666" fill="#FFFFFF" /><path d="M512 299.413333S485.333333 32 213.333333 128c29.333333 278.229333 298.666667 171.413333 298.666667 171.413333z" fill="#4CAF50" /><path d="M134.08 553.258667C94.293333 338.474667 253.418667 123.669333 512 299.413333c258.581333-175.744 417.706667 39.061333 377.92 253.824C850.154667 768.042667 631.338667 969.685333 512 826.624c-119.338667 143.061333-338.154667-58.581333-377.92-273.365333z" fill="#D32F2F" /><path d="M690.816 277.333333c-46.72 0-98.816 19.306667-154.837333 57.365334L512 350.997333v409.024l32.768 39.274667c18.346667 22.016 39.189333 32.725333 63.68 32.725333 84.458667 0 210.133333-127.808 239.509333-286.549333 15.018667-81.024-2.325333-163.413333-45.248-215.04C773.802667 295.701333 735.104 277.333333 690.816 277.333333z" fill="#FFF3E0" /><path d="M672 559.082667a42.666667 42.666667 0 0 1-85.333333 0c0-23.573333 42.666667-72.832 42.666666-72.832s42.666667 49.28 42.666667 72.832z" fill="#5D4037" /><path d="M512 299.413333C253.418667 123.669333 94.293333 338.474667 134.08 553.258667 173.845333 768.042667 392.661333 969.685333 512 826.624V299.413333z" fill="#F44336" /></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M598.08 426.666667c-36.757333 0-64 12.117333-85.333333 12.117333s-53.098667-12.117333-85.333334-12.117333c-117.098667 0-192 85.12-192 194.005333 0 108.885333 85.333333 211.328 179.242667 211.328h-8.576c85.333333 0 106.666667 85.333333 106.666667 85.333333s21.333333-85.333333 118.186666-85.781333h-8.554666C704.746667 832 789.333333 729.557333 789.333333 620.672S720.170667 426.773333 598.08 426.666667z" fill="#D81B60" /><path d="M444.437333 467.562667a67.562667 67.562667 0 0 0 135.125334 0c0-14.293333-4.736-27.306667-12.352-38.208-22.08 3.562667-40.213333 9.429333-55.210667 9.429333-14.634667 0-34.282667-5.568-55.509333-9.152a66.666667 66.666667 0 0 0-12.053334 37.930667z" fill="#AD1457" /><path d="M362.666667 576h65.792M597.333333 597.333333h85.333334M350.208 682.666667H490.666667M526.677333 768H618.666667" fill="#AD1457" /><path d="M811.946667 157.184c-8.512-8.512 0-4.266667 2.154666-10.666667 4.266667-8.512-12.821333-10.666667-21.333333-10.666666h-4.266667c-23.466667-2.133333 2.133333-6.4 0-17.066667s-27.733333-8.533333-34.133333-8.533333c-12.821333 2.154667-27.754667 6.421333-38.421333 12.821333 4.266667-2.154667 10.666667-10.666667 6.421333-14.933333-4.266667-4.266667-21.333333 2.133333-27.754667 2.133333-12.8 4.266667-23.466667 6.421333-36.245333 12.821333-8.533333 4.266667-14.933333 12.8-23.488 19.178667 0-4.245333 2.154667-8.512 2.154667-12.8-19.2-4.266667-51.2 36.266667-49.088 55.466667 0-8.512 0-23.466667-10.666667-14.912-4.245333 8.512-66.133333 74.666667-66.133333 213.333333v65.557333c0 3.264 0.362667 6.122667 0.853333 8.768V490.666667c10.645333 0 19.733333-8.149333 21.333333-18.325334 0.213333-0.106667-0.213333 0.106667 0 0 4.330667-29.994667 11.946667-61.269333 24.746667-97.536 8.512-29.866667 21.333333-64 55.466667-70.4 8.533333-2.154667 21.333333 2.133333 29.866666-6.4-2.154667-2.154667-10.666667-25.6-12.821333-27.754666 10.666667 2.154667 21.333333 19.2 34.154667 21.333333 17.066667 2.154667 19.178667-8.512 21.333333-21.333333 6.4 10.666667 21.333333 19.2 32 21.333333 12.8 2.154667 23.466667-8.512 14.933333-21.333333 12.8 8.533333 59.733333 34.154667 59.733334 0 0-19.178667-17.066667-46.912-34.154667-53.333334 12.821333 0 32 8.533333 46.933333 6.421334 12.821333 0 21.333333-8.533333 25.6-21.333334 4.288-19.221333-8.512-32-19.178666-44.821333z" fill="#4CAF50" /><path d="M512 383.338667c0-138.666667-61.866667-204.821333-66.133333-213.333334-10.666667-8.533333-10.666667 6.4-10.666667 14.912 2.133333-19.178667-29.866667-59.733333-49.088-55.466666 0 4.266667 2.154667 8.533333 2.154667 12.8-8.533333-6.4-14.933333-14.912-23.488-19.178667-12.8-6.4-23.466667-8.533333-36.245334-12.821333-6.421333 0-23.488-6.4-27.754666-2.133334-4.245333 4.266667 2.154667 12.8 6.421333 14.933334-10.666667-6.4-25.6-10.666667-38.421333-12.821334-6.4 0-32-2.133333-34.133334 8.533334s23.466667 14.933333 0 17.066666h-4.266666c-8.512 0-25.6 2.154667-21.333334 10.666667 2.154667 6.4 10.666667 2.154667 2.154667 10.666667-10.666667 12.842667-23.466667 25.621333-19.2 44.842666 4.266667 12.8 12.8 21.333333 25.6 21.333334 14.933333 2.133333 34.133333-6.421333 46.933333-6.421334-17.088 6.421333-34.154667 34.154667-34.154666 53.333334 0 34.154667 46.933333 8.533333 59.733333 0-8.512 12.821333 2.154667 23.488 14.933333 21.333333 10.666667-2.133333 25.6-10.666667 32-21.333333 2.154667 12.821333 4.266667 23.488 21.333334 21.333333 12.821333-2.133333 23.488-19.178667 34.154666-21.333333-2.154667 2.154667-10.666667 25.6-12.821333 27.754666 8.533333 8.512 21.333333 4.245333 29.866667 6.4 34.133333 6.4 46.933333 40.512 55.466666 70.4 13.44 38.016 21.610667 70.208 25.6 94.549334a21.333333 21.333333 0 0 0 21.333334 21.333333v-42.666667L512 383.338667z" fill="#388E3C" /></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 800 800" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Layer_3" transform="matrix(25,0,0,25,0,0)">
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
<path d="M14.419,21.806L10.394,16.767C9.726,15.931 9.73,14.741 10.404,13.909C10.404,13.909 14.211,11.216 15.952,10.018C16.912,9.362 18.212,9.534 18.969,10.417C20.513,12.218 22.843,14.937 22.843,14.937L22.824,14.953C22.99,15.162 23.152,15.374 23.31,15.59L23.48,15.83C27.19,20.93 26.45,27.73 26.44,27.99C26.421,28.125 26.349,28.247 26.24,28.33C26.152,28.393 26.048,28.427 25.94,28.43L25.86,28.43C25.623,28.38 22.09,27.809 18.657,25.672C17.734,25.193 16.789,24.453 15.825,23.452C15.7,23.321 15.578,23.189 15.457,23.055C15.263,22.844 15.074,22.625 14.89,22.398C14.73,22.203 14.573,22.006 14.419,21.806Z" style="fill:rgb(255,196,0);"/>
</g>
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
<path d="M13.132,20.195L13.308,20.012C13.388,19.924 13.499,19.87 13.618,19.862C13.848,19.862 19.459,19.189 21.179,14.179C21.226,14.058 21.314,13.959 21.429,13.899L21.783,13.701C22.405,14.426 22.843,14.937 22.843,14.937L22.824,14.953C22.99,15.162 23.152,15.374 23.31,15.59L23.48,15.83C23.518,15.882 23.555,15.935 23.593,15.987C23.576,16.051 23.531,16.117 23.499,16.189C21.599,21.699 15.739,22.399 15.269,22.449L15.012,22.546C14.971,22.497 14.931,22.448 14.89,22.398C14.73,22.203 14.573,22.006 14.419,21.806L13.132,20.195Z" style="fill:rgb(255,171,0);"/>
</g>
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,9.362665,13.283662)">
<path d="M16.961,18.696C17.485,18.429 18.024,18.093 18.542,17.674C18.523,18.128 18.495,18.407 18.493,18.456C18.474,18.591 18.402,18.713 18.293,18.796C18.205,18.858 18.101,18.893 17.993,18.896L17.913,18.896C17.845,18.881 17.5,18.823 16.961,18.696Z" style="fill:rgb(255,171,0);"/>
</g>
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,5.36948,9.341635)">
<path d="M13.307,19.933L13.376,19.861C13.456,19.773 13.568,19.719 13.686,19.711C13.916,19.711 19.34,19.08 21.06,14.07C21.067,14.053 21.074,14.036 21.082,14.02C21.679,15.524 22.015,17.035 22.198,18.379C20.66,20.516 18.446,21.512 16.905,21.97C16.143,21.63 15.344,21.22 14.55,20.726C14.14,20.513 13.726,20.249 13.307,19.933Z" style="fill:rgb(255,171,0);"/>
</g>
<g transform="matrix(0.933663,-0.08891,0.08891,0.933663,1.095304,5.088483)">
<path d="M11.093,17.642L10.394,16.767C9.979,16.247 9.823,15.591 9.927,14.969C10.689,14.885 15.385,14.075 17.098,9.625C17.79,9.582 18.489,9.856 18.969,10.417C19.171,10.653 19.387,10.905 19.61,11.164C19.59,11.222 19.551,11.281 19.523,11.345C17.623,16.855 11.763,17.555 11.293,17.605L11.093,17.642Z" style="fill:rgb(255,171,0);"/>
</g>
<g transform="matrix(-0.024981,-0.001144,-0.001144,0.024981,27.145456,1.349857)">
<path d="M640,426.667C640,426.667 740.267,341.333 814.933,341.333C864,341.333 917.333,373.333 917.333,430.933C917.333,477.867 904.533,533.333 846.933,533.333C791.467,533.333 791.467,490.667 787.2,473.6C774.4,488.533 714.667,507.733 697.6,458.667C697.6,458.667 605.867,486.4 539.733,535.467L640,426.667ZM593.067,448C659.2,371.2 603.733,192 503.467,192C426.667,192 405.333,230.4 405.333,264.533C405.333,294.4 435.2,330.667 477.867,324.267C469.333,377.6 518.4,401.067 554.667,373.333C576,379.733 550.4,494.933 492.8,548.267L593.067,448Z" style="fill:rgb(76,175,80);fill-rule:nonzero;"/>
</g>
<g transform="matrix(-0.024981,-0.001144,-0.001144,0.024981,27.145456,1.349857)">
<path d="M706.133,85.333C659.2,85.333 618.667,110.933 618.667,149.333C618.667,200.533 676.267,194.133 676.267,194.133C676.267,194.133 627.2,253.867 712.533,270.933C708.267,292.267 676.267,332.8 631.467,381.867L433.067,580.267C424.533,588.8 424.533,601.6 433.067,610.133C437.333,614.4 443.733,616.533 448,616.533C452.267,616.533 458.667,614.4 462.933,610.133L516.267,556.8C563.2,514.133 620.8,454.4 689.067,384C689.067,384 750.933,320 765.867,302.933C870.4,198.4 853.333,85.333 706.133,85.333Z" style="fill:rgb(56,142,60);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M883.2 629.333333c-87.466667-264.533333-288-70.4-288-70.4s-27.733333 2.133333-59.733333 12.8c-68.266667-66.133333-196.266667-25.6-196.266667-25.6s-236.8-78.933333-209.066667 185.6c12.8 113.066667 100.266667 185.6 221.866667 185.6 61.866667 0 115.2-19.2 153.6-51.2 64 57.6 157.866667 68.266667 251.733333 25.6 115.2-51.2 162.133333-153.6 125.866667-262.4z" fill="#FF1744" /><path d="M633.6 753.066667c-8.533333-83.2 57.6-234.666667 113.066667-262.4-76.8-2.133333-151.466667 68.266667-151.466667 68.266666s-25.6 2.133333-57.6 12.8c-2.133333-2.133333-4.266667-2.133333-6.4-2.133333 29.866667 27.733333 49.066667 78.933333 40.533333 162.133333-6.4 55.466667-29.866667 100.266667-66.133333 132.266667 32 29.866667 108.8 85.333333 251.733333 27.733333-66.133333-12.8-115.2-55.466667-123.733333-138.666666z" fill="#D50000" /><path d="M215.466667 744.533333c-8.533333 0-19.2-6.4-21.333334-17.066666-21.333333-87.466667 29.866667-123.733333 51.2-136.533334 10.666667-6.4 23.466667-2.133333 27.733334 8.533334 6.4 10.666667 2.133333 23.466667-8.533334 27.733333-21.333333 10.666667-42.666667 34.133333-29.866666 87.466667 2.133333 10.666667-4.266667 23.466667-14.933334 25.6-2.133333 4.266667-2.133333 4.266667-4.266666 4.266666z" fill="#FFCDD2" /><path d="M614.4 518.4c-2.133333-6.4-4.266667-10.666667-6.4-17.066667 0-2.133333-2.133333-4.266667-2.133333-6.4-2.133333-4.266667-2.133333-8.533333-4.266667-10.666666-2.133333-4.266667-2.133333-6.4-4.266667-10.666667s-2.133333-6.4-2.133333-10.666667c-2.133333-4.266667-2.133333-8.533333-4.266667-10.666666-2.133333-4.266667-2.133333-8.533333-4.266666-12.8 0-2.133333-2.133333-4.266667-2.133334-6.4-2.133333-4.266667-2.133333-8.533333-2.133333-12.8 0-2.133333 0-4.266667-2.133333-6.4 0-4.266667-2.133333-8.533333-2.133334-12.8 0-2.133333 0-4.266667-2.133333-6.4 0-4.266667-2.133333-8.533333-2.133333-12.8v-4.266667-12.8-6.4-12.8-4.266667-12.8-4.266666-10.666667-6.4-8.533333-6.4-6.4c0-2.133333 0-4.266667 2.133333-8.533334v-4.266666c0-2.133333 2.133333-6.4 2.133333-8.533334v-4.266666c0-4.266667 2.133333-6.4 2.133334-10.666667v-2.133333c0-4.266667 2.133333-6.4 2.133333-10.666667 12.8-44.8 34.133333-74.666667 42.666667-85.333333 2.133333 0 4.266667-2.133333 4.266666-2.133334 10.666667-4.266667 14.933333-17.066667 10.666667-27.733333s-10.666667-19.2-21.333333-14.933333c0 0-2.133333 0-2.133334 2.133333-2.133333 0-4.266667 0-6.4 2.133333-2.133333 0-2.133333 2.133333-4.266666 4.266667C309.333333 249.6 320 573.866667 320 576c0 10.666667 10.666667 21.333333 21.333333 21.333333 12.8 0 21.333333-10.666667 21.333334-21.333333 0-2.133333-8.533333-249.6 196.266666-388.266667-32 78.933333-57.6 215.466667 40.533334 398.933334 4.266667 6.4 10.666667 10.666667 19.2 10.666666 4.266667 0 6.4 0 10.666666-2.133333 8.533333-4.266667 12.8-12.8 10.666667-21.333333 0-2.133333-2.133333-6.4-2.133333-8.533334-4.266667-8.533333-8.533333-17.066667-12.8-23.466666v-2.133334c-4.266667-6.4-8.533333-14.933333-10.666667-21.333333z" fill="#795548" /><path d="M810.666667 384c-38.4-40.533333 61.866667-277.333333-192-277.333333-145.066667 281.6 192 277.333333 192 277.333333z" fill="#4CAF50" /></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M405.333333 938.666667c-106.666667 0-128-146.133333-128-405.333334s85.952-469.333333 192-469.333333 192 210.133333 192 469.333333-21.333333 405.333333-128 405.333334" fill="#EA8711" /><path d="M533.333333 789.333333a42.666667 42.666667 0 0 1-42.666666 42.666667h-42.666667a42.666667 42.666667 0 0 1 0-85.333333h42.666667a42.666667 42.666667 0 0 1 42.666666 42.666666zM533.333333 682.666667a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666666zM533.333333 576a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM533.333333 469.333333a42.666667 42.666667 0 0 0-42.666666-42.666666h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM533.333333 362.666667a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666666zM533.333333 256a42.666667 42.666667 0 0 0-42.666666-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666666-42.666667zM682.666667 682.666667a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666666zM682.666667 576a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333334h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666667zM682.666667 469.333333a42.666667 42.666667 0 0 0-42.666667-42.666666h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666667zM682.666667 362.666667a42.666667 42.666667 0 0 0-42.666667-42.666667h-42.666667a42.666667 42.666667 0 0 0 0 85.333333h42.666667a42.666667 42.666667 0 0 0 42.666667-42.666666zM661.333333 256a42.666667 42.666667 0 0 0-42.666666-42.666667h-21.333334a42.666667 42.666667 0 0 0 0 85.333334h21.333334a42.666667 42.666667 0 0 0 42.666666-42.666667zM320 149.333333a42.666667 42.666667 0 0 0 85.333333 0 42.666667 42.666667 0 0 0-85.333333 0zM426.666667 149.333333a42.666667 42.666667 0 0 0 85.333333 0 42.666667 42.666667 0 0 0-85.333333 0zM533.333333 149.333333a42.666667 42.666667 0 0 0 85.333334 0 42.666667 42.666667 0 0 0-85.333334 0zM277.333333 256a42.666667 42.666667 0 0 0 42.666667 42.666667h21.333333a42.666667 42.666667 0 0 0 0-85.333334h-21.333333a42.666667 42.666667 0 0 0-42.666667 42.666667zM256 362.666667a42.666667 42.666667 0 0 0 42.666667 42.666666h42.666666a42.666667 42.666667 0 0 0 0-85.333333h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666667zM256 469.333333a42.666667 42.666667 0 0 0 42.666667 42.666667h42.666666a42.666667 42.666667 0 0 0 0-85.333333h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666666zM256 576a42.666667 42.666667 0 0 0 42.666667 42.666667h42.666666a42.666667 42.666667 0 0 0 0-85.333334h-42.666666a42.666667 42.666667 0 0 0-42.666667 42.666667z" fill="#FFC107" /><path d="M874.666667 719.04C617.770667 463.061333 490.666667 832 490.666667 832S400.192 589.546667 149.333333 448c166.229333 159.616 3.029333 512 332.437334 512C784 960 587.562667 601.6 874.666667 719.04z" fill="#4CAF50" /><path d="M874.666667 719.04C617.770667 463.061333 490.666667 960 490.666667 960S277.333333 512 149.333333 448c166.229333 159.616 149.333333 512 332.437334 512C682.666667 960 597.333333 554.666667 874.666667 719.04z" fill="#388E3C" /></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M938.666667 290.133333C938.666667 157.866667 910.933333 85.333333 846.933333 85.333333c-61.866667 0-70.4 68.266667-113.066666 149.333334-12.8 2.133333-23.466667 12.8-23.466667 27.733333 0 4.266667 0 8.533333 2.133333 10.666667-14.933333 25.6-29.866667 49.066667-44.8 72.533333h-4.266666c-14.933333 0-27.733333 12.8-27.733334 27.733333 0 6.4 2.133333 10.666667 4.266667 14.933334-27.733333 38.4-57.6 74.666667-85.333333 106.666666h-4.266667c-14.933333 0-27.733333 12.8-27.733333 27.733334v6.4c-29.866667 29.866667-57.6 55.466667-85.333334 76.8-12.8 0-25.6 8.533333-27.733333 21.333333-27.733333 19.2-53.333333 38.4-78.933333 53.333333-2.133333 0-4.266667-2.133333-6.4-2.133333-12.8 0-25.6 8.533333-27.733334 21.333333-25.6 14.933333-49.066667 27.733333-70.4 38.4-4.266667-2.133333-10.666667-4.266667-14.933333-4.266666-14.933333 0-27.733333 12.8-27.733333 27.733333C121.6 793.6 85.333333 817.066667 85.333333 855.466667c0 27.733333 19.2 53.333333 59.733334 68.266666 6.4 8.533333 14.933333 14.933333 25.6 14.933334 6.4 0 10.666667-2.133333 14.933333-4.266667 17.066667 2.133333 36.266667 4.266667 57.6 4.266667 29.866667 0 64-4.266667 96-8.533334 4.266667 6.4 12.8 8.533333 21.333333 8.533334 12.8 0 23.466667-8.533333 27.733334-19.2 36.266667-10.666667 74.666667-23.466667 110.933333-38.4 4.266667 2.133333 8.533333 2.133333 12.8 2.133333 12.8 0 25.6-8.533333 27.733333-21.333333 23.466667-10.666667 46.933333-25.6 68.266667-38.4 4.266667 4.266667 10.666667 4.266667 17.066667 4.266666 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c19.2-12.8 36.266667-27.733333 55.466667-42.666667 4.266667 2.133333 8.533333 2.133333 12.8 2.133334 14.933333 0 27.733333-12.8 27.733333-27.733334 0-4.266667 0-6.4-2.133333-10.666666 17.066667-17.066667 34.133333-36.266667 51.2-55.466667 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c17.066667-25.6 34.133333-51.2 49.066667-76.8 14.933333 0 27.733333-12.8 27.733333-27.733334 0-6.4-2.133333-12.8-6.4-17.066666 12.8-27.733333 21.333333-57.6 29.866667-87.466667 8.533333-4.266667 14.933333-12.8 14.933333-23.466667 0-6.4-2.133333-12.8-6.4-17.066666 2.133333-27.733333 4.266667-55.466667 4.266667-85.333334z" fill="#4CAF50" /><path d="M938.666667 290.133333C710.4 881.066667 211.2 889.6 85.333333 855.466667c0 27.733333 19.2 53.333333 59.733334 68.266666 6.4 8.533333 14.933333 14.933333 25.6 14.933334 6.4 0 10.666667-2.133333 14.933333-4.266667 17.066667 2.133333 36.266667 4.266667 57.6 4.266667 29.866667 0 64-4.266667 96-8.533334 4.266667 6.4 12.8 8.533333 21.333333 8.533334 12.8 0 23.466667-8.533333 27.733334-19.2 36.266667-10.666667 74.666667-23.466667 110.933333-38.4 4.266667 2.133333 8.533333 2.133333 12.8 2.133333 12.8 0 25.6-8.533333 27.733333-21.333333 23.466667-10.666667 46.933333-25.6 68.266667-38.4 4.266667 4.266667 10.666667 4.266667 17.066667 4.266666 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c19.2-12.8 36.266667-27.733333 55.466667-42.666667 4.266667 2.133333 8.533333 2.133333 12.8 2.133334 14.933333 0 27.733333-12.8 27.733333-27.733334 0-4.266667 0-6.4-2.133333-10.666666 17.066667-17.066667 34.133333-36.266667 51.2-55.466667 14.933333 0 27.733333-12.8 27.733333-27.733333v-6.4c17.066667-25.6 34.133333-51.2 49.066667-76.8 14.933333 0 27.733333-12.8 27.733333-27.733334 0-6.4-2.133333-12.8-6.4-17.066666 12.8-27.733333 21.333333-57.6 29.866667-87.466667 8.533333-4.266667 14.933333-12.8 14.933333-23.466667 0-6.4-2.133333-12.8-6.4-17.066666 2.133333-27.733333 4.266667-55.466667 4.266667-85.333334z" fill="#388E3C" /><path d="M810.666667 256c12.8 0 21.333333 8.533333 21.333333 21.333333s-10.666667 21.333333-21.333333 21.333334c-12.8 0-21.333333-8.533333-21.333334-21.333334s8.533333-21.333333 21.333334-21.333333z m-42.666667 234.666667c12.8 0 21.333333 8.533333 21.333333 21.333333s-8.533333 21.333333-21.333333 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333333z m-64-64c12.8 0 21.333333 8.533333 21.333333 21.333333s-10.666667 21.333333-21.333333 21.333333c-12.8 0-21.333333-8.533333-21.333333-21.333333s8.533333-21.333333 21.333333-21.333333z m-85.333333 213.333333c12.8 0 21.333333 8.533333 21.333333 21.333333s-8.533333 21.333333-21.333333 21.333334-21.333333-8.533333-21.333334-21.333334 8.533333-21.333333 21.333334-21.333333z m-85.333334 21.333333c12.8 0 21.333333 8.533333 21.333334 21.333334s-8.533333 21.333333-21.333334 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z m-128 106.666667c12.8 0 21.333333 8.533333 21.333334 21.333333s-8.533333 21.333333-21.333334 21.333334-21.333333-8.533333-21.333333-21.333334 8.533333-21.333333 21.333333-21.333333z m-149.333333 21.333333c12.8 0 21.333333 8.533333 21.333333 21.333334s-8.533333 21.333333-21.333333 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z" fill="#AED581" /></svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M221.866667 851.2c-6.4-2.133333-12.8-2.133333-17.066667 2.133333 0-8.533333-4.266667-14.933333-12.8-19.2-4.266667-2.133333-8.533333-2.133333-12.8-2.133333 0-4.266667 0-6.4-2.133333-10.666667-4.266667-10.666667-17.066667-14.933333-27.733334-10.666666 0 0-2.133333 2.133333-6.4 4.266666-4.266667 2.133333-8.533333 6.4-14.933333 10.666667-10.666667 10.666667-25.6 23.466667-36.266667 40.533333-10.666667 14.933333-19.2 34.133333-23.466666 46.933334-2.133333 6.4-4.266667 12.8-4.266667 17.066666v8.533334s0-2.133333 2.133333-6.4c2.133333-4.266667 4.266667-8.533333 8.533334-14.933334 6.4-10.666667 19.2-25.6 32-36.266666s29.866667-19.2 40.533333-25.6c4.266667-2.133333 6.4-2.133333 10.666667-4.266667-2.133333 2.133333-4.266667 6.4-6.4 10.666667-6.4 12.8-17.066667 29.866667-29.866667 44.8-10.666667 17.066667-23.466667 32-36.266667 40.533333-2.133333 2.133333-6.4 4.266667-8.533333 6.4-2.133333 2.133333-4.266667 2.133333-6.4 4.266667-4.266667 0-6.4 2.133333-6.4 2.133333s2.133333 0 6.4-2.133333c2.133333 0 4.266667 0 8.533333-2.133334 2.133333 0 6.4-2.133333 10.666667-4.266666 14.933333-6.4 32-17.066667 49.066667-29.866667 17.066667-12.8 32-25.6 42.666666-38.4l8.533334-8.533333c0 2.133333-2.133333 6.4-4.266667 8.533333-6.4 12.8-12.8 29.866667-23.466667 44.8-10.666667 19.2-23.466667 32-34.133333 40.533333-6.4 4.266667-10.666667 8.533333-14.933333 8.533334-4.266667 2.133333-6.4 4.266667-6.4 4.266666s2.133333 0 6.4-2.133333c4.266667 0 10.666667-2.133333 17.066666-6.4 14.933333-6.4 32-14.933333 46.933334-29.866667 14.933333-12.8 29.866667-27.733333 38.4-40.533333 4.266667-6.4 8.533333-10.666667 10.666666-14.933333 2.133333-4.266667 4.266667-6.4 4.266667-6.4 0-2.133333 2.133333-4.266667 2.133333-6.4 6.4-6.4 0-19.2-10.666666-23.466667z" fill="#A1887F" /><path d="M234.666667 874.666667c-21.333333 0-42.666667-8.533333-59.733334-25.6-34.133333-34.133333-34.133333-87.466667 0-121.6L405.333333 533.333333c34.133333-34.133333 134.4-115.2 166.4-81.066666 34.133333 34.133333-27.733333 113.066667-59.733333 145.066666L294.4 849.066667C277.333333 866.133333 256 874.666667 234.666667 874.666667z" fill="#DCEDC8" /><path d="M981.333333 42.666667L533.333333 533.333333l-106.666666-42.666666L832 42.666667z" fill="#33691E" /><path d="M938.666667 85.333333v128L554.666667 533.333333l-128-42.666666z" fill="#558B2F" /><path d="M682.666667 149.333333h128L597.333333 469.333333h-128z" fill="#689F38" /><path d="M853.333333 341.333333L554.666667 554.666667l-85.333334-64L853.333333 192z" fill="#7CB342" /><path d="M533.333333 277.333333h128l-128 213.333334-128 42.666666z" fill="#9CCC65" /><path d="M746.666667 469.333333v-149.333333L405.333333 533.333333c108.8 0 106.666667 64 106.666667 64l234.666667-128z" fill="#AED581" /></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M903.68 84.629333c-39.232 0-155.093333 14.272-242.346667 171.370667-106.666667 192-128 213.333333-170.666666 256S234.666667 725.333333 234.666667 725.333333s-106.666667 85.333333-128 170.666667c0 0 41.450667 42.666667 127.402666 42.666667S490.666667 938.666667 725.333333 704c170.666667-192 170.666667-405.333333 170.666667-405.333333V170.666667c0-67.456 21.333333-85.333333 21.333333-85.333334s-4.928-0.704-13.653333-0.704z" fill="#7CB342" /><path d="M698.624 276.714667c-109.568 197.226667-132.352 220.032-177.792 265.450666-43.84 43.84-250.730667 209.450667-259.52 216.469334-22.570667 18.154667-78.144 70.144-103.210667 121.088 15.914667 7.872 41.045333 16.277333 75.968 16.277333 83.392 0 238.506667 0 460.202667-221.269333 156.16-176.384 159.04-374.208 159.061333-376.170667V170.666667c0-13.354667 0.725333-25.301333 2.005334-36.010667-44.586667 13.290667-105.045333 49.066667-156.714667 142.058667z" fill="#388E3C" /><path d="M736 437.333333m-53.333333 0a53.333333 53.333333 0 1 0 106.666666 0 53.333333 53.333333 0 1 0-106.666666 0Z" fill="#C5E1A5" /><path d="M789.333333 277.333333m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M640 576m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" fill="#C5E1A5" /><path d="M522.666667 693.333333m-53.333334 0a53.333333 53.333333 0 1 0 106.666667 0 53.333333 53.333333 0 1 0-106.666667 0Z" fill="#C5E1A5" /><path d="M405.333333 789.333333m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M277.333333 832m-42.666666 0a42.666667 42.666667 0 1 0 85.333333 0 42.666667 42.666667 0 1 0-85.333333 0Z" fill="#C5E1A5" /><path d="M917.333333 85.333333s-149.333333-21.333333-256 170.666667-128 213.333333-170.666666 256-256 213.333333-256 213.333333-106.666667 85.333333-128 170.666667c0 0 213.333333-64 448-298.666667 170.666667-192 149.333333-277.333333 277.333333-426.666666 43.904-51.221333 85.333333-85.333333 85.333333-85.333334z" fill="#8BC34A" /></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M597.333333 85.333333s-64 66.133333-64 192" fill="#FFFFFF" /><path d="M832 554.666667c0 177.066667-142.933333 405.333333-320 405.333333S192 731.733333 192 554.666667c0-209.066667 93.866667-362.666667 341.333333-277.333334 200.533333-87.466667 298.666667 100.266667 298.666667 277.333334z" fill="#9C27B0" /><path d="M701.866667 610.133333c4.266667-10.666667 4.266667-23.466667 4.266666-34.133333l4.266667-34.133333v-36.266667c0-12.8 0-23.466667-2.133333-34.133333s-4.266667-23.466667-6.4-34.133334c-2.133333-10.666667-6.4-21.333333-8.533334-32-6.4-21.333333-14.933333-40.533333-23.466666-61.866666-10.666667-17.066667-19.2-36.266667-29.866667-51.2-8.533333-12.8-17.066667-25.6-27.733333-36.266667-25.6 2.133333-51.2 8.533333-81.066667 21.333333 110.933333 85.333333 134.4 253.866667 134.4 264.533334v32c0 10.666667 0 21.333333-2.133333 32s-2.133333 21.333333-4.266667 32c0 10.666667-2.133333 21.333333-4.266667 29.866666-4.266667 19.2-8.533333 38.4-14.933333 57.6-6.4 17.066667-12.8 34.133333-19.2 49.066667-2.133333 8.533333-6.4 14.933333-8.533333 21.333333-4.266667 6.4-6.4 12.8-10.666667 19.2-6.4 12.8-10.666667 23.466667-14.933333 32-6.4 17.066667-10.666667 27.733333-10.666667 27.733334s6.4-8.533333 17.066667-25.6c6.4-8.533333 12.8-17.066667 19.2-29.866667 4.266667-6.4 8.533333-12.8 12.8-17.066667 4.266667-6.4 6.4-12.8 10.666666-21.333333 8.533333-14.933333 17.066667-32 25.6-49.066667 8.533333-17.066667 14.933333-38.4 21.333334-57.6 4.266667-10.666667 6.4-21.333333 8.533333-32 4.266667-10.666667 8.533333-21.333333 10.666667-32z" fill="#4A148C" /><path d="M533.333333 277.333333S505.6 10.666667 234.666667 106.666667c29.866667 277.333333 298.666667 170.666667 298.666666 170.666666z" fill="#4CAF50" /></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M422.4 917.333333c-14.933333 0-29.866667 0-44.8-2.133333-98.133333-10.666667-179.2-57.6-224-132.266667-108.8-177.066667 2.133333-277.333333 108.8-371.2 27.733333-25.6 57.6-51.2 83.2-78.933333 8.533333-8.533333 17.066667-19.2 25.6-27.733333 115.2-128 243.2-273.066667 422.4-149.333334 72.533333 51.2 115.2 128 125.866667 224 12.8 132.266667-42.666667 283.733333-142.933334 388.266667-98.133333 93.866667-228.266667 149.333333-354.133333 149.333333z" fill="#8D6E63" /><path d="M422.4 917.333333c-14.933333 0-29.866667 0-44.8-2.133333-98.133333-10.666667-179.2-57.6-224-132.266667-29.866667-49.066667-42.666667-89.6-44.8-128 206.933333 187.733333 716.8 46.933333 808.533333-277.333333 12.8 132.266667-42.666667 283.733333-142.933333 388.266667-96 96-226.133333 151.466667-352 151.466666z" fill="#795548" /><path d="M480 821.333333m-32 0a32 32 0 1 0 64 0 32 32 0 1 0-64 0Z" fill="#3E2723" /><path d="M725.333333 277.333333c12.8 0 21.333333 8.533333 21.333334 21.333334s-8.533333 21.333333-21.333334 21.333333-21.333333-8.533333-21.333333-21.333333 8.533333-21.333333 21.333333-21.333334z m0 234.666667c0 12.8 8.533333 21.333333 21.333334 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333333-21.333333 8.533333-21.333334 21.333333z m-170.666666-149.333333c0 12.8 8.533333 21.333333 21.333333 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333334-21.333333 8.533333-21.333333 21.333334zM298.666667 682.666667c0 12.8 8.533333 21.333333 21.333333 21.333333s21.333333-8.533333 21.333333-21.333333-8.533333-21.333333-21.333333-21.333334-21.333333 8.533333-21.333333 21.333334z m128-74.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32-14.933333-32-32-32-32 14.933333-32 32z" fill="#5D4037" /></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M524.8 605.866667s-19.2-27.733333-38.4-46.933334c-19.2-19.2-44.8-36.266667-46.933333-38.4-110.933333-83.2-206.933333-53.333333-256 0-38.4 42.666667-55.466667 85.333333-55.466667 128 0 32 12.8 64 17.066667 93.866667 12.8 59.733333 17.066667 98.133333-6.4 132.266667l-25.6 25.6c-8.533333 8.533333-8.533333 21.333333 0 29.866666 4.266667 6.4 8.533333 8.533333 14.933333 8.533334s10.666667-2.133333 14.933333-6.4l34.133334-34.133334c27.733333-23.466667 72.533333-12.8 123.733333-2.133333 34.133333 6.4 70.4 21.333333 108.8 21.333333 40.533333 0 83.2-19.2 119.466667-53.333333 55.466667-51.2 70.4-162.133333-4.266667-258.133333z" fill="#FF4081" /><path d="M531.2 701.866667c12.8-12.8 21.333333-29.866667 23.466667-49.066667-6.4-14.933333-17.066667-29.866667-27.733334-46.933333 0 0-19.2-27.733333-38.4-46.933334-19.2-19.2-44.8-36.266667-46.933333-38.4-17.066667-12.8-32-21.333333-46.933333-29.866666-19.2 2.133333-36.266667 8.533333-49.066667 21.333333-38.4 38.4-27.733333 113.066667 23.466667 164.266667s123.733333 64 162.133333 25.6z" fill="#F8BBD0" /><path d="M640 426.666667s100.266667-85.333333 174.933333-85.333334c49.066667 0 102.4 32 102.4 89.6 0 46.933333-12.8 102.4-70.4 102.4-55.466667 0-55.466667-42.666667-59.733333-59.733333-12.8 14.933333-72.533333 34.133333-89.6-14.933333 0 0-91.733333 27.733333-157.866667 76.8L640 426.666667z m-46.933333 21.333333c66.133333-76.8 10.666667-256-89.6-256C426.666667 192 405.333333 230.4 405.333333 264.533333c0 29.866667 29.866667 66.133333 72.533334 59.733334-8.533333 53.333333 40.533333 76.8 76.8 49.066666 21.333333 6.4-4.266667 121.6-61.866667 174.933334l100.266667-100.266667z" fill="#4CAF50" /><path d="M706.133333 85.333333C659.2 85.333333 618.666667 110.933333 618.666667 149.333333c0 51.2 57.6 44.8 57.6 44.8s-49.066667 59.733333 36.266666 76.8c-4.266667 21.333333-36.266667 61.866667-81.066666 110.933334l-198.4 198.4c-8.533333 8.533333-8.533333 21.333333 0 29.866666 4.266667 4.266667 10.666667 6.4 14.933333 6.4s10.666667-2.133333 14.933333-6.4l53.333334-53.333333c46.933333-42.666667 104.533333-102.4 172.8-172.8 0 0 61.866667-64 76.8-81.066667C870.4 198.4 853.333333 85.333333 706.133333 85.333333z" fill="#388E3C" /></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M806.677333 337.322667c-60.010667 60.010667-165.034667 45.013333-165.034666 45.013333s-14.954667-105.045333 45.013333-165.013333C746.666667 157.333333 851.669333 172.330667 851.669333 172.330667s15.018667 104.96-44.992 164.992z" fill="#689F38" /><path d="M651.285333 410.069333C787.072 425.152 937.92 576 937.92 576s15.082667-105.6-90.517333-181.013333c-105.6-75.434667-196.117333 15.082667-196.117334 15.082666zM616.234667 372.714667c-15.082667-135.765333-165.930667-286.634667-165.930667-286.634667s105.6-15.082667 181.013333 90.496c75.413333 105.621333-15.082667 196.138667-15.082666 196.138667z" fill="#8BC34A" /><path d="M750.997333 755.2c-114.517333 114.56-441.173333 234.837333-568.448 107.562667-127.296-127.274667-7.018667-453.909333 107.541334-568.448 114.56-114.56 256.832-50.453333 384.106666 76.842666 127.253333 127.232 191.36 269.482667 76.8 384.042667z" fill="#FF1744" /><path d="M346.282667 324.949333a53.312 53.312 0 1 1 75.413333 75.413334c-20.842667 20.821333-59.52 25.813333-80.362667 4.970666s-15.872-59.541333 4.949334-80.384zM521.856 448.96a53.312 53.312 0 1 1 75.413333 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.541333 4.970667-80.362667zM244.181333 725.738667a53.312 53.312 0 1 1 75.413334 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949333s-15.850667-59.541333 4.970666-80.362666zM646.314667 622.272a53.312 53.312 0 1 1 75.413333 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.52 4.970667-80.362667zM286.912 512.341333a53.312 53.312 0 1 1 75.413333 75.413334c-20.842667 20.821333-59.541333 25.792-80.384 4.949333s-15.850667-59.541333 4.970667-80.362667zM432.981333 686.272a53.312 53.312 0 1 1 75.413334 75.413333c-20.842667 20.821333-59.541333 25.792-80.384 4.949334s-15.872-59.52 4.970666-80.362667z" fill="#D50000" /><path d="M399.082667 377.749333c-8.341333 8.341333-49.408 35.925333-57.749334 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165334 30.165333zM574.656 501.76c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM296.981333 778.517333c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM699.114667 675.072c-8.341333 8.341333-49.408 35.925333-57.749334 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165334 30.165333zM339.712 565.12c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333zM485.781333 739.072c-8.341333 8.341333-49.408 35.925333-57.749333 27.584s19.242667-49.408 27.584-57.749333a21.312 21.312 0 1 1 30.165333 30.165333z" fill="#FFC107" /></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M488.938667 242.368S135.445333 171.114667 90.389333 461.994667C45.418667 752.917333 312.576 843.968 389.76 861.397333c77.141333 17.408 423.808 65.322667 496.533333-249.898666 60.288-261.205333-172.309333-418.581333-371.605333-363.285334l-25.749333-5.845333z" fill="#FF3D00" /><path d="M514.688 248.213333l-25.749333-5.824S231.168 190.592 127.872 356.693333c34.048 20.778667 89.877333 45.162667 89.877333 45.162667 38.485333 16.597333 75.477333 25.045333 109.952 25.045333 34.048 0 65.237333-8.085333 92.373334-23.424 8.938667 43.157333 33.6 88.554667 91.008 123.626667l41.706666 25.472 19.626667-44.778667c16.448-37.546667 24.64-68.842667 26.645333-97.002666 22.08 11.114667 46.656 18.218667 73.493334 18.218666 32.192 0 64.746667-10.176 96.746666-30.186666 0 0 42.005333-24.256 68.928-44.757334-73.194667-95.850667-204.288-138.944-323.541333-105.856z" fill="#DD2C00" /><path d="M533.333333 490.688c40.896-93.333333 23.658667-135.701333-16.192-213.354667 54.528 71.04 127.296 149.354667 229.525334 85.354667-77.781333-20.672-63.701333-80.469333-160.298667-97.429333 72.448 3.029333 110.762667 25.685333 160.298667-73.237334-77.994667 41.045333-123.562667-48.896-224.917334 43.776-17.109333 10.624-35.072-16.341333-48.789333-1.728-73.173333-121.621333-160.128-12.928-195.626667-106.048 0 143.189333 137.429333 104.234667 170.666667 128-129.365333-10.901333-119.402667 87.552-213.333333 106.666667 123.562667 53.333333 203.754667 1.344 236.202666-87.274667-5.077333 34.624-48.768 147.328 62.464 215.274667z" fill="#4CAF50" /><path d="M469.354667 257.28c-0.554667 4.202667 8.362667 20.053333 37.290666 20.053333 34.624 0 21.354667-17.258667 21.354667-17.258666s-3.178667-99.626667 69.333333-165.461334c-18.410667-15.253333-37.781333-7.082667-37.781333-7.082666S482.133333 167.466667 469.354667 257.28z" fill="#4CAF50" /></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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;

46
packages/app/package.json Normal file
View File

@ -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
}

View File

@ -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));
};

View File

@ -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 (
<NativeTabs tintColor={theme.accent} minimizeBehavior="onScrollDown">
<NativeTabs.Trigger name="nodes" contentStyle={{ backgroundColor: theme.bg }}>
<NativeTabs.Trigger.Label>Nodes</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={{ default: 'leaf', selected: 'leaf.fill' }} md="eco" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="tank" contentStyle={{ backgroundColor: theme.bg }}>
<NativeTabs.Trigger.Label>Tank</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={{ default: 'drop', selected: 'drop.fill' }} md="water_drop" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="power" contentStyle={{ backgroundColor: theme.bg }}>
<NativeTabs.Trigger.Label>Power</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={{ default: 'bolt', selected: 'bolt.fill' }} md="bolt" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" contentStyle={{ backgroundColor: theme.bg }}>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf={{ default: 'gearshape', selected: 'gearshape.fill' }} md="settings" />
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@ -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<HubNode>({ cmd: 'node', serial }, (r) => r.node, 3000);
const patchesQuery = useHubQuery<HubPatch[]>({ 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<ValveState>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [savingPatches, setSavingPatches] = useState(false);
const [patchError, setPatchError] = useState<string | null>(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 (
<>
<Stack.Screen options={{ title: serial, headerLargeTitle: false }} />
<Screen onRefresh={nodeQuery.refresh}>
<HubBanner />
<Section first>
<View style={styles.titleRow}>
<Text style={{ fontFamily: FONT.monoMedium, fontSize: 26, color: theme.text }}>{serial}</Text>
{node ? (
<Badge label={isOnline ? 'Online' : 'Offline'} tone={isOnline ? 'accent' : 'warn'} dot />
) : notTracked ? (
<Badge label="Not tracked" tone="muted" />
) : null}
</View>
{notTracked ? (
<Notice message="The hub is not tracking this node. Nodes drop off the list after 5 minutes offline." />
) : null}
{node ? (
<>
<Metrics>
<Metric title="Temperature">{node.temp_c == null ? '--' : `${node.temp_c.toFixed(2)} °C`}</Metric>
<Metric
title="Moisture"
sub={node.moisture_mv == null ? 'no reading' : `${node.moisture_mv} mV`}
>
{node.moisture == null ? '--' : `${node.moisture}%`}
</Metric>
<Metric title="Valve">{node.valve == null ? '--' : node.valve === 'open' ? 'Open' : 'Closed'}</Metric>
<Metric title="Last seen">{formatAge(age)}</Metric>
</Metrics>
{!isOnline ? <Notice tone="warn" message="Offline: commands will likely fail" /> : null}
</>
) : null}
</Section>
<Section title="Actions">
<ButtonRow>
<Button
title="Identify"
icon="sparkles"
loading={identifying}
disabled={!node || status !== 'ready'}
onPress={identify}
/>
</ButtonRow>
</Section>
<Section title="Valve">
{node && node.valve == null ? (
<Text variant="muted">No valve</Text>
) : (
<Segmented
value={valveBusy ?? node?.valve ?? null}
disabled={!node || status !== 'ready' || valveBusy !== null}
onChange={setValve}
options={[
{ value: 'open', label: 'Open', icon: 'lock.open' },
{ value: 'closed', label: 'Closed', icon: 'lock' },
]}
/>
)}
</Section>
<Section
title="Patches"
right={
patchSupport ? (
<Button
title="Edit"
small
icon="square.and.pencil"
disabled={!node || status !== 'ready' || !patchesQuery.data}
onPress={() => {
setPatchError(null);
setSheetOpen(true);
}}
/>
) : null
}
>
{!patchSupport ? (
<Notice tone="warn" message="Needs a newer hub firmware to assign patches." />
) : assigned.length === 0 ? (
<Text variant="muted">Not assigned to any patch</Text>
) : (
<View style={styles.patchList}>
{assigned.map((id) => {
const p = patchById.get(id);
return (
<View key={id} style={styles.patchItem}>
<ProduceIcon icon={p?.icon} size={26} />
<View style={{ flex: 1 }}>
<Text weight="medium">{p?.name || id}</Text>
{p?.name ? <Text variant="faint">{id}</Text> : null}
</View>
</View>
);
})}
</View>
)}
</Section>
{node ? <CalibrationSection node={node} onSaved={nodeQuery.refresh} onGuide={() => router.push(`/nodes/calibrate/${serial}`)} /> : null}
<Section title="Firmware">
<FirmwareUploader target="node" serial={serial} offline={!isOnline} />
</Section>
{node ? (
<Section title="Raw">
<Text variant="mono" selectable style={{ fontSize: 12, color: theme.muted }}>
{JSON.stringify(node, null, 2)}
</Text>
</Section>
) : null}
</Screen>
<MultiSelectSheet
visible={sheetOpen}
title="Patches"
selected={assigned}
saving={savingPatches}
error={patchError}
onCancel={() => 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: <ProduceIcon icon={p.icon} size={24} />,
}))}
/>
</>
);
}
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 (
<Section
title="Moisture calibration"
right={<Badge label={node.cal_custom ? 'Custom' : 'Hub default'} tone={node.cal_custom ? 'info' : 'muted'} />}
>
<View style={styles.calGrid}>
<View style={{ flex: 1, gap: 6 }}>
<Text variant="label">Dry, 0%</Text>
<Input value={dry} onChangeText={setDry} keyboardType="number-pad" mono suffix="mV" invalid={!valid(dry)} />
</View>
<View style={{ flex: 1, gap: 6 }}>
<Text variant="label">Wet, 100%</Text>
<Input value={wet} onChangeText={setWet} keyboardType="number-pad" mono suffix="mV" invalid={!valid(wet) || d === w} />
</View>
</View>
<View style={styles.liveRow}>
<Text variant="muted">
Live{' '}
<Text variant="mono" weight="medium">
{node.moisture_mv == null ? '--' : `${node.moisture_mv} mV`}
</Text>
</Text>
{defaults ? (
<Text variant="faint">
Default {defaults.dry_mv}-{defaults.wet_mv} mV
</Text>
) : null}
</View>
{error ? (
<Text variant="small" style={{ color: theme.danger }}>
{error}
</Text>
) : null}
<ButtonRow>
<Button
title="Use as dry"
small
disabled={node.moisture_mv == null}
onPress={() => setDry(String(node.moisture_mv))}
/>
<Button
title="Use as wet"
small
disabled={node.moisture_mv == null}
onPress={() => setWet(String(node.moisture_mv))}
/>
</ButtonRow>
<ButtonRow>
<Button title="Save" variant="primary" loading={saving} disabled={!changed || error != null || status !== 'ready'} onPress={save} />
<Button title="Reset" loading={resetting} disabled={!node.cal_custom || status !== 'ready'} onPress={reset} />
<Button title="Guided calibration" icon="wand.and.stars" disabled={status !== 'ready'} onPress={onGuide} />
</ButtonRow>
</Section>
);
}
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' },
});

View File

@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
import { useStackScreenOptions } from '../../../navigation/stackOptions';
export default function NodesLayout() {
const options = useStackScreenOptions();
return (
<Stack screenOptions={options}>
<Stack.Screen name="index" options={{ title: 'Nodes' }} />
<Stack.Screen name="[serial]" options={{ title: '' }} />
<Stack.Screen name="calibrate/[serial]" options={{ title: 'Calibrate', headerLargeTitle: false }} />
</Stack>
);
}

View File

@ -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<Step>('dry');
const [dry, setDry] = useState<Average | null>(null);
const [wet, setWet] = useState<Average | null>(null);
const [measuring, setMeasuring] = useState(false);
const [measureError, setMeasureError] = useState<string | null>(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 (
<Screen>
<HubBanner />
<Section first>
<View style={styles.steps}>
{STEPS.map((s, i) => {
const done = step === 'saved' || i < stepIndex;
const active = i === stepIndex;
const color = active || done ? theme.accent : theme.faint;
return (
<View key={s.key} style={styles.stepItem}>
<View style={[styles.stepDot, { borderColor: color, backgroundColor: done ? theme.accent : 'transparent' }]}>
<Text style={{ fontFamily: FONT.semibold, fontSize: 12, color: done ? theme.onAccent : color }}>{i + 1}</Text>
</View>
<Text style={{ fontFamily: FONT.medium, fontSize: 14, color: active ? theme.text : theme.muted }}>{s.label}</Text>
</View>
);
})}
</View>
<Text variant="mono" style={{ color: theme.muted }}>
{serial}
{cal.session ? ` · now ${cal.session.dry_mv}-${cal.session.wet_mv} mV${cal.session.custom ? ' custom' : ' default'}` : ''}
</Text>
</Section>
<Section title="Live reading">
{cal.error ? (
<>
<Notice tone="danger" icon="exclamationmark.triangle" message={cal.error} />
<ButtonRow>
<Button title="Try again" small onPress={cal.restart} />
</ButtonRow>
</>
) : null}
{cal.stopped ? (
<>
<Notice tone="warn" message={`Streaming stopped: ${cal.stopped}`} />
<ButtonRow>
<Button title="Restart" small onPress={cal.restart} />
</ButtonRow>
</>
) : null}
<Metrics>
<Metric title="Voltage">{latest?.mv == null ? '--' : `${latest.mv} mV`}</Metric>
<Metric title="Moisture" sub="with current calibration">
{latest?.moisture == null ? '--' : `${latest.moisture}%`}
</Metric>
</Metrics>
{live.length === 0 && !cal.error ? (
<View style={styles.waiting}>
<ActivityIndicator color={theme.faint} />
<Text variant="muted">Waiting for readings</Text>
</View>
) : (
<LiveChart values={live} markers={markers} />
)}
</Section>
{step === 'dry' || step === 'wet' ? (
<Section title={step === 'dry' ? 'Dry point' : 'Wet point'}>
<Text>
{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.'}
</Text>
{current ? (
<View style={{ gap: 8 }}>
<Text variant="muted">
Average{' '}
<Text variant="mono" weight="medium">
{current.mv} mV
</Text>{' '}
from {current.count} readings
</Text>
{current.spread > STEADY_SPREAD_MV ? (
<Notice tone="warn" message={`The reading moved by ${current.spread} mV while measuring. Measure again once it settles.`} />
) : null}
</View>
) : null}
{measureError ? <Notice tone="danger" icon="exclamationmark.triangle" message={measureError} /> : null}
<ButtonRow>
{current ? (
<>
<Button
title="Continue"
variant="primary"
onPress={() => setStep(step === 'dry' ? 'wet' : 'check')}
/>
<Button title="Measure again" loading={measuring} onPress={measure} />
</>
) : (
<Button
title={measuring ? 'Measuring…' : 'Measure'}
variant="primary"
icon="scope"
loading={measuring}
disabled={!cal.session || live.length === 0}
onPress={measure}
/>
)}
{step === 'wet' ? <Button title="Back" variant="ghost" onPress={() => setStep('dry')} /> : null}
</ButtonRow>
</Section>
) : null}
{step === 'check' && dry && wet ? (
<Section title="Check and save">
<Metrics>
<Metric title="Dry, 0%">{`${dry.mv} mV`}</Metric>
<Metric title="Wet, 100%">{`${wet.mv} mV`}</Metric>
</Metrics>
{tooSmall ? (
<Notice
tone="warn"
message={`The reading only changed by ${Math.abs(dry.mv - wet.mv)} mV, so the soil probably did not get wet enough. Water a little more and measure the wet point again.`}
/>
) : swapped ? (
<Notice
tone="warn"
message="The voltage went up when the soil got wetter. That usually means the steps were swapped. Measure again, or save anyway."
/>
) : (
<Text variant="muted">Looks good. The node will read 0% at the dry point and 100% at the wet point.</Text>
)}
<ButtonRow>
{tooSmall ? (
<Button
title="Measure wet again"
variant="primary"
onPress={() => {
setWet(null);
setStep('wet');
}}
/>
) : (
<Button title={swapped ? 'Save anyway' : 'Save'} variant="primary" loading={saving} onPress={save} />
)}
<Button
title="Start over"
onPress={() => {
setDry(null);
setWet(null);
setStep('dry');
}}
/>
</ButtonRow>
</Section>
) : null}
{step === 'saved' && saved ? (
<Section title="Saved">
<Metrics>
<Metric title="Dry, 0%">{`${saved.dry_mv} mV`}</Metric>
<Metric title="Wet, 100%">{`${saved.wet_mv} mV`}</Metric>
</Metrics>
<ButtonRow>
<Button title="Done" variant="primary" onPress={() => router.back()} />
</ButtonRow>
</Section>
) : null}
{step !== 'saved' ? (
<Section title="Hub default">
<Text variant="muted">Drop this node's own calibration and use the hub-wide default again.</Text>
<ButtonRow>
<Button title="Reset to default" variant="danger" small loading={resetting} onPress={reset} />
</ButtonRow>
</Section>
) : null}
</Screen>
);
}
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 },
});

View File

@ -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<HubNode[]>({ cmd: 'nodes' }, (r) => r.nodes ?? [], 3000);
const [query, setQuery] = useState('');
const [bulk, setBulk] = useState<BulkAction | null>(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 (
<>
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: 'Filter by serial',
autoCapitalize: 'characters',
hideWhenScrolling: true,
onChangeText: (e) => setQuery(e.nativeEvent.text),
onCancelButtonPress: () => setQuery(''),
},
}}
/>
<Screen onRefresh={nodes.refresh} refreshing={false}>
<HubBanner />
<Section first>
<Text variant="muted">
{nodes.data ? `${online.length} of ${list.length} online` : 'Loading nodes…'}
</Text>
<ButtonRow>
<Button
title="Identify all"
icon="sparkles"
small
loading={bulk === 'identify'}
disabled={bulkDisabled}
onPress={() => runBulk('identify')}
/>
<Button
title="Open all valves"
icon="drop"
small
loading={bulk === 'open'}
disabled={bulkDisabled}
onPress={() => runBulk('open')}
/>
<Button
title="Close all valves"
icon="drop.halffull"
small
loading={bulk === 'closed'}
disabled={bulkDisabled}
onPress={() => runBulk('closed')}
/>
</ButtonRow>
{nodes.error && nodes.data ? (
<Notice tone="danger" icon="exclamationmark.triangle" message={nodes.error} />
) : null}
</Section>
<Section>
{!nodes.data ? (
<View style={styles.empty}>
<ActivityIndicator color={theme.faint} />
<Text variant="muted">{status === 'ready' ? 'Loading' : 'Waiting for hub'}</Text>
</View>
) : list.length === 0 ? (
<Text variant="muted" style={styles.emptyText}>
No nodes on the bus
</Text>
) : filtered.length === 0 ? (
<Text variant="muted" style={styles.emptyText}>
No matches
</Text>
) : (
<View>
<View style={[styles.headerRow, { borderBottomColor: theme.borderUi }]}>
<Text variant="label" style={{ flex: 1 }}>
Serial
</Text>
<Text variant="label" style={styles.colTemp}>
Temp
</Text>
<Text variant="label" style={styles.colMoisture}>
Moisture
</Text>
</View>
{filtered.map((n, i) => {
const isOnline = n.online !== false;
const age = nodes.fetchedAt != null ? n.age_ms + (now - nodes.fetchedAt) : n.age_ms;
return (
<Pressable
key={n.serial}
onPress={() => router.push(`/nodes/${n.serial}`)}
style={({ pressed }) => [
styles.nodeRow,
i < filtered.length - 1 ? { borderBottomWidth: 2, borderBottomColor: theme.borderUi } : null,
pressed ? { backgroundColor: theme.wash } : null,
]}
>
<View style={styles.line}>
<View style={[styles.serialCell, { flex: 1 }]}>
<StatusDot online={isOnline} />
<Text variant="mono" style={{ fontFamily: FONT.monoMedium, fontSize: 15 }}>
{n.serial}
</Text>
</View>
<Text variant="mono" style={styles.colTemp}>
{n.temp_c == null ? '--' : `${n.temp_c.toFixed(1)}°`}
</Text>
<View style={styles.colMoisture}>
<MoistureBar moisture={n.moisture} mv={n.moisture_mv} compact />
</View>
</View>
<View style={[styles.line, { paddingLeft: 16 }]}>
<View style={{ flexDirection: 'row', gap: 6, flex: 1, alignItems: 'center' }}>
<Badge label={isOnline ? 'Online' : 'Offline'} tone={isOnline ? 'accent' : 'warn'} dot />
{n.valve ? (
<Badge label={n.valve === 'open' ? 'Valve open' : 'Valve closed'} tone={n.valve === 'open' ? 'info' : 'muted'} />
) : null}
</View>
<Text variant="faint">{formatAge(age)}</Text>
<Icon name="chevron.right" size={12} color={theme.faint} />
</View>
</Pressable>
);
})}
</View>
)}
</Section>
</Screen>
</>
);
}
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' },
});

View File

@ -0,0 +1,11 @@
import { Stack } from 'expo-router';
import { useStackScreenOptions } from '../../../navigation/stackOptions';
export default function PowerLayout() {
const options = useStackScreenOptions();
return (
<Stack screenOptions={options}>
<Stack.Screen name="index" options={{ title: 'Power' }} />
</Stack>
);
}

View File

@ -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<PowerReading>({ 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 (
<Screen onRefresh={power.refresh}>
<HubBanner />
{power.unsupported ? (
<Notice tone="warn" message="Needs a newer hub firmware to show power." />
) : (
<>
<View style={styles.diagram}>
<Image source={SOLAR} style={styles.solar} contentFit="contain" accessibilityLabel="Solar panel" />
<FlowLine watts={watts} />
<Vessel
level={data?.battery?.percent ?? 0}
width={110}
top={theme.accent}
deep={withAlpha(theme.accent, 0.45)}
/>
<FlowLine watts={watts} />
<Image source={PCB} style={styles.pcb} contentFit="contain" accessibilityLabel="Controller board" />
</View>
<Metrics>
<Metric title="Battery">
{data?.battery?.percent != null ? `${Math.round(data.battery.percent)}%` : '—'}
</Metric>
<Metric title="Charge voltage">
{data?.battery?.voltage != null ? `${data.battery.voltage.toFixed(2)} V` : '—'}
</Metric>
</Metrics>
{data ? (
<Text variant="faint">
{watts > 0 ? `${watts.toFixed(1)} W · ` : ''}Updated {formatAge(age)}
</Text>
) : null}
{power.error ? <Notice tone="danger" icon="exclamationmark.triangle" message={power.error} /> : null}
</>
)}
</Screen>
);
}
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 },
});

View File

@ -0,0 +1,13 @@
import { Stack } from 'expo-router';
import { useStackScreenOptions } from '../../../navigation/stackOptions';
export default function SettingsLayout() {
const options = useStackScreenOptions();
return (
<Stack screenOptions={options}>
<Stack.Screen name="index" options={{ title: 'Settings' }} />
<Stack.Screen name="about" options={{ title: 'About hub' }} />
<Stack.Screen name="firmware" options={{ title: 'Firmware' }} />
</Stack>
);
}

View File

@ -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 <Badge label="Unknown" />;
return <Badge label={yes ? yesLabel : noLabel} tone={yes ? 'accent' : 'warn'} dot />;
}
export default function AboutHub() {
const { info, hub, status, refreshInfo } = useHub();
const focused = useIsFocused();
const [error, setError] = useState<string | null>(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 (
<Screen onRefresh={refresh} refreshing={refreshing}>
<HubBanner />
{error ? <Notice tone="danger" icon="exclamationmark.triangle" message={error} /> : null}
<Section first title="Hub">
<Row label="Name" value={info?.name ?? hub?.name ?? '--'} />
<Row label="Hub ID" value={info?.hub_id ?? '--'} mono />
<Row label="Firmware" value={info ? `v${info.fw_version}` : '--'} />
<Row label="Protocol" value={info ? String(info.protocol) : '--'} />
<Row label="Uptime" value={formatUptime(info?.uptime_s)} />
<Row label="Busy" value={<YesNo yes={info ? !info.busy : undefined} yesLabel="Idle" noLabel="Busy" />} last />
</Section>
<Section title="Nodes">
<Row label="Online" value={info ? String(info.nodes_online) : '--'} />
<Row label="Tracked" value={info ? String(info.nodes_total) : '--'} last />
</Section>
<Section title="WiFi">
<Row
label="Status"
value={<YesNo yes={info?.wifi?.connected} yesLabel="Connected" noLabel="Not connected" />}
/>
<Row label="IP address" value={info?.wifi?.ip || '--'} mono />
<Row
label="Signal"
value={info?.wifi?.connected ? `${info.wifi.rssi} dBm, ${signalQuality(info.wifi.rssi)}` : '--'}
last
/>
</Section>
<Section title="Radio">
<Row label="LoRaWAN" value={<YesNo yes={info?.lora?.joined} yesLabel="Joined" noLabel="Not joined" />} />
<Row label="GPS" value={<YesNo yes={info?.gps?.fix} yesLabel="Fix" noLabel="No fix" />} />
<Row label="Satellites" value={info?.gps ? String(info.gps.sats) : '--'} last />
</Section>
<Section title="Bluetooth">
<Row label="Device ID" value={hub?.id ?? '--'} mono last />
</Section>
</Screen>
);
}

View File

@ -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<Target>('hub');
const [serial, setSerial] = useState<string | null>(null);
const nodes = useHubQuery<HubNode[]>(target === 'node' ? { cmd: 'nodes' } : null, (r) => r.nodes ?? [], 10_000);
const selected = nodes.data?.find((n) => n.serial === serial) ?? null;
return (
<Screen>
<HubBanner />
<Section first>
<Text variant="muted">
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.
</Text>
<Segmented<Target>
value={target}
onChange={setTarget}
options={[
{ value: 'hub', label: 'Hub', icon: 'antenna.radiowaves.left.and.right' },
{ value: 'node', label: 'Node', icon: 'leaf' },
]}
/>
</Section>
{target === 'hub' ? (
<Section title="Hub firmware">
<Row label="Installed" value={info ? `v${info.fw_version}` : '--'} last />
<FirmwareUploader key="hub" target="hub" />
</Section>
) : (
<>
<Section title="Node">
{!nodes.data ? (
<Text variant="muted">Loading nodes…</Text>
) : nodes.data.length === 0 ? (
<Text variant="muted">No nodes on the bus</Text>
) : (
<View>
{nodes.data.map((n, i) => (
<Row
key={n.serial}
label={n.serial}
mono
last={i === nodes.data!.length - 1}
onPress={() => setSerial(n.serial)}
value={
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<StatusDot online={n.online !== false} />
{serial === n.serial ? (
<Icon name="checkmark" size={15} color={theme.accent} />
) : (
<View style={{ width: 15 }} />
)}
</View>
}
/>
))}
</View>
)}
</Section>
{selected ? (
<Section title={`Flash ${selected.serial}`}>
<FirmwareUploader key={selected.serial} target="node" serial={selected.serial} offline={selected.online === false} />
</Section>
) : null}
</>
)}
</Screen>
);
}

View File

@ -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 (
<Screen>
<HubBanner />
<Section first title="Hub">
<Row
label={hub?.name ?? 'Hub'}
icon="antenna.radiowaves.left.and.right"
detail={info ? `${info.nodes_online} of ${info.nodes_total} nodes online` : undefined}
value={
<Badge
label={status === 'ready' ? 'Connected' : 'Reconnecting'}
tone={status === 'ready' ? 'accent' : 'warn'}
dot
/>
}
/>
<Row label="About hub" icon="info.circle" chevron onPress={() => router.push('/settings/about')} />
<Row
label="Firmware"
icon="arrow.up.circle"
value={info ? `v${info.fw_version}` : undefined}
chevron
onPress={() => router.push('/settings/firmware')}
/>
<Row
label="Reboot hub"
icon="arrow.clockwise"
onPress={status === 'ready' && !rebooting ? reboot : undefined}
value={rebooting ? <ActivityIndicator size="small" color={theme.faint} /> : undefined}
/>
<Row label="Switch hub" icon="arrow.left.arrow.right" chevron onPress={switchHub} last />
</Section>
<Section title="Account">
<Row
label={user?.name || 'Signed in'}
detail={user?.email}
icon="person.crop.circle"
/>
<Row label="Server" icon="server.rack" value={SERVER_URL.replace(/^https?:\/\//, '')} />
<Row label="Sign out" icon="rectangle.portrait.and.arrow.right" destructive onPress={confirmSignOut} last />
</Section>
<Section title="App">
<Row label="Version" icon="app.badge" value={Constants.expoConfig?.version ?? '1.0.0'} last />
</Section>
</Screen>
);
}

View File

@ -0,0 +1,11 @@
import { Stack } from 'expo-router';
import { useStackScreenOptions } from '../../../navigation/stackOptions';
export default function TankLayout() {
const options = useStackScreenOptions();
return (
<Stack screenOptions={options}>
<Stack.Screen name="index" options={{ title: 'Water tank' }} />
</Stack>
);
}

View File

@ -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<TankReading>({ cmd: 'tank' }, (r) => r.tank, 15_000);
const history = useHubQuery<TankSample[]>({ 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 (
<Screen onRefresh={refresh}>
<HubBanner />
{tank.unsupported ? (
<Notice tone="warn" message="Needs a newer hub firmware to show the water tank." />
) : (
<>
<View style={styles.top}>
<Vessel level={reading?.level ?? 0} width={150} top={FIXED.water} deep={FIXED.waterDeep} ripple />
<View style={{ flex: 1 }}>
<Metrics vertical>
<Metric title="Level">{reading?.level != null ? `${Math.round(reading.level)}%` : '—'}</Metric>
<Metric title="Updated">
<Text variant="value" style={{ fontSize: 17 }}>
{reading?.level == null ? '—' : formatAge(age)}
</Text>
</Metric>
</Metrics>
</View>
</View>
{tank.error ? <Notice tone="danger" icon="exclamationmark.triangle" message={tank.error} /> : null}
{!history.unsupported && samples.length > 1 ? (
<Section>
<Metric title="History">
<View style={{ marginTop: 10 }}>
<LiveChart values={samples} color={FIXED.water} minSpan={10} height={150} />
</View>
</Metric>
</Section>
) : null}
</>
)}
</Screen>
);
}
const styles = StyleSheet.create({
top: { flexDirection: 'row', gap: 32, alignItems: 'center', paddingTop: 8 },
});

View File

@ -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 (
<NavigationThemeProvider value={navTheme}>
<StatusBar style={theme.isDay ? 'dark' : 'light'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.bg } }}>
<Stack.Screen name="index" />
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={signedIn && !entered}>
<Stack.Screen name="hubs" options={{ ...stackOptions, headerShown: true, title: 'Choose a hub' }} />
</Stack.Protected>
<Stack.Protected guard={signedIn && entered}>
<Stack.Screen name="(tabs)" />
</Stack.Protected>
</Stack>
</NavigationThemeProvider>
);
}
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 (
<ThemeProvider>
<AuthProvider>
<HubProvider>
<ToastProvider>
<RootNavigator />
</ToastProvider>
</HubProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@ -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 (
<Screen>
{busy ? (
<Section first>
<View style={styles.connecting}>
<ActivityIndicator color={theme.accent} />
<Text variant="heading">
{status === 'pairing' ? 'Pairing with' : 'Connecting to'} {hub?.name}
</Text>
</View>
{status === 'pairing' ? (
<Text variant="muted">
If this is the first time, a 6-digit code appears on the hub&apos;s screen. Type it into the prompt on
your iPhone when it asks.
</Text>
) : (
<Text variant="muted">Make sure the hub is powered on and nearby.</Text>
)}
<Button title="Cancel" onPress={disconnect} />
</Section>
) : (
<>
{status === 'failed' && hub ? (
<Section first>
<Notice tone="danger" icon="exclamationmark.triangle" message={error ?? 'Could not connect to the hub.'} />
<ButtonRow>
<Button title="Try again" variant="primary" onPress={() => connect(hub)} />
<Button title="Choose another hub" onPress={disconnect} />
</ButtonRow>
</Section>
) : null}
<Section
first={status !== 'failed'}
title="Hubs nearby"
right={<ActivityIndicator size="small" color={theme.faint} />}
>
{scanError ? <Notice tone="warn" message={scanError} /> : null}
{hubs.length === 0 ? (
<Text variant="muted">
Looking for hubs. The hub only shows up while no other phone is connected to it.
</Text>
) : (
<View>
{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 (
<Row
key={h.id}
label={h.name ?? 'Allotment hub'}
detail={detail}
onPress={unsupported ? undefined : () => choose(h)}
chevron={!unsupported}
last={i === hubs.length - 1}
value={
<View style={styles.hubMeta}>
{h.advert?.busy ? <Badge label="Busy" tone="warn" /> : null}
{unsupported ? <Badge label="Update the app" tone="danger" /> : null}
<Icon name={signalIcon(h.rssi)} size={14} color={theme.faint} />
<Text variant="faint">{signalLabel(h.rssi)}</Text>
</View>
}
/>
);
})}
</View>
)}
</Section>
<Section title="Pairing help">
<Text variant="muted">
If the hub&apos;s pairings were cleared, open iPhone Settings, then Bluetooth, choose &quot;Forget This
Device&quot; for the hub, and connect again.
</Text>
</Section>
<Section>
<Row
label={user?.name || user?.email || 'Signed in'}
icon="person.crop.circle"
last
value={<Button title="Sign out" small onPress={signOut} />}
/>
</Section>
</>
)}
</Screen>
);
}
const styles = StyleSheet.create({
connecting: { flexDirection: 'row', alignItems: 'center', gap: 12 },
hubMeta: { flexDirection: 'row', alignItems: 'center', gap: 6 },
});

View File

@ -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 <Redirect href="/login" />;
if (!entered) return <Redirect href="/hubs" />;
return <Redirect href="/nodes" />;
}

View File

@ -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 (
<View style={[styles.page, { backgroundColor: theme.bg, paddingTop: insets.top + 80, paddingBottom: insets.bottom + 32 }]}>
<View style={styles.top}>
<Icon name="leaf.fill" size={34} color={theme.accent} />
<Text style={[styles.brand, { color: theme.text }]}>Allotment</Text>
<Text variant="title" style={{ marginTop: 28 }}>
Welcome back
</Text>
<Text variant="muted" style={{ marginTop: 10, maxWidth: 320 }}>
Sign in with your allotment account, then connect to your hub over Bluetooth.
</Text>
</View>
<View style={styles.bottom}>
{error ? (
<Text variant="small" style={{ color: theme.danger }}>
{error}
</Text>
) : null}
<Button
title={signingIn ? 'Signing in…' : 'Sign in'}
variant="primary"
icon="person.crop.circle"
loading={signingIn}
onPress={signIn}
style={{ alignSelf: 'stretch', minHeight: 50 }}
/>
<Text variant="faint" style={{ textAlign: 'center' }} numberOfLines={1}>
{SERVER_URL ? SERVER_URL.replace(/^https?:\/\//, '') : 'No server configured'}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
page: { flex: 1, paddingHorizontal: 28, justifyContent: 'space-between' },
top: { gap: 4 },
brand: { fontFamily: FONT.extrabold, fontSize: 26, letterSpacing: 1, textTransform: 'uppercase', marginTop: 12 },
bottom: { gap: 14 },
});

View File

@ -0,0 +1,117 @@
import * as Linking from 'expo-linking';
import * as SecureStore from 'expo-secure-store';
import * as WebBrowser from 'expo-web-browser';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
const TOKEN_KEY = 'allotment.session';
const USER_KEY = 'allotment.user';
const APP_REDIRECT = 'allotment://auth';
export const SERVER_URL = (process.env.EXPO_PUBLIC_SERVER_URL ?? '').replace(/\/+$/, '');
export interface User {
sub: string;
email?: string;
name?: string;
}
interface AuthContextValue {
/** `undefined` while the stored session is loading. */
user: User | null | undefined;
signIn: () => Promise<void>;
signOut: () => Promise<void>;
signingIn: boolean;
error: string | null;
}
const AuthContext = createContext<AuthContextValue | null>(null);
async function fetchMe(token: string): Promise<User | null | 'offline'> {
try {
const res = await fetch(`${SERVER_URL}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) return null;
if (!res.ok) return 'offline';
const body = await res.json();
return body?.user ?? null;
} catch {
return 'offline';
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null | undefined>(undefined);
const [signingIn, setSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const clear = useCallback(async () => {
await SecureStore.deleteItemAsync(TOKEN_KEY).catch(() => undefined);
await SecureStore.deleteItemAsync(USER_KEY).catch(() => undefined);
setUser(null);
}, []);
useEffect(() => {
(async () => {
const token = await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null);
if (!token) {
setUser(null);
return;
}
const cached = await SecureStore.getItemAsync(USER_KEY).catch(() => null);
// Everything after sign-in goes over BLE, so a server that is out of reach keeps the stored session.
setUser(cached ? (JSON.parse(cached) as User) : { sub: 'unknown' });
const me = await fetchMe(token);
if (me === null) await clear();
else if (me !== 'offline') {
setUser(me);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(me));
}
})();
}, [clear]);
const signIn = useCallback(async () => {
if (!SERVER_URL) {
setError('No server configured. Set EXPO_PUBLIC_SERVER_URL and rebuild the app.');
return;
}
setSigningIn(true);
setError(null);
try {
const result = await WebBrowser.openAuthSessionAsync(`${SERVER_URL}/auth/login?app=1`, APP_REDIRECT);
if (result.type !== 'success') return;
const token = Linking.parse(result.url).queryParams?.token;
if (typeof token !== 'string' || !token) throw new Error('The server did not return a session.');
const me = await fetchMe(token);
if (me === null) throw new Error('The server did not accept the session.');
if (me === 'offline') throw new Error('Could not reach the server to finish signing in.');
await SecureStore.setItemAsync(TOKEN_KEY, token);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(me));
setUser(me);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSigningIn(false);
}
}, []);
const signOut = useCallback(async () => {
const token = await SecureStore.getItemAsync(TOKEN_KEY).catch(() => null);
if (token && SERVER_URL) {
fetch(`${SERVER_URL}/auth/logout`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }).catch(
() => undefined,
);
}
await clear();
}, [clear]);
const value = useMemo(
() => ({ user, signIn, signOut, signingIn, error }),
[user, signIn, signOut, signingIn, error],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth requires AuthProvider');
return ctx;
}

View File

@ -0,0 +1,132 @@
import type { BleManager, Device, Subscription } from 'react-native-ble-plx';
import { COMMAND, COMMAND_TIMEOUT_MS, EVENTS, SERVICE } from './constants';
import { Framer } from './framer';
export type Message = Record<string, any>;
export type EventListener = (event: Message) => void;
export class HubError extends Error {
constructor(
public code: string,
message: string,
) {
super(message);
}
}
export function isUnsupported(error: unknown): boolean {
return error instanceof HubError && error.code === 'unknown_command';
}
interface Pending {
resolve: (m: Message) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
/** Owns one connected hub: framing, command matching and event fan-out (BLE_PROTOCOL.md section 12.1). */
export class HubConnection {
private framer = new Framer();
private nextId = 1;
private pending = new Map<number, Pending>();
private listeners = new Set<EventListener>();
private chain: Promise<unknown> = Promise.resolve();
private subscriptions: Subscription[] = [];
private closed = false;
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 from the hub'));
if (!this.closed) onDisconnect();
}),
);
}
onEvent(listener: EventListener): () => 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 = COMMAND_TIMEOUT_MS): Promise<Message> {
const run = () => this.sendNow(command, timeoutMs);
const result = this.chain.then(run, run);
this.chain = result.catch(() => undefined);
return result;
}
get mtu(): number {
return this.device.mtu || 23;
}
private async sendNow(command: Message, timeoutMs: number): Promise<Message> {
if (this.closed) throw new Error('Not connected');
const id = this.nextId++;
const response = new Promise<Message>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`The hub did not answer (${command.cmd})`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
try {
for (const packet of this.framer.frames({ ...command, id }, this.mtu)) {
await this.device.writeCharacteristicWithResponseForService(SERVICE, COMMAND, packet);
}
} catch (e) {
const entry = this.pending.get(id);
if (entry) {
clearTimeout(entry.timer);
this.pending.delete(id);
}
throw e;
}
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.closed = true;
this.subscriptions.forEach((s) => s.remove());
this.subscriptions = [];
this.listeners.clear();
this.failAll(new Error('Closed'));
await this.manager.cancelDeviceConnection(this.device.id).catch(() => undefined);
}
}

View File

@ -0,0 +1,22 @@
import { Buffer } from 'buffer';
export interface HubAdvert {
protocolVersion: number;
firmware: string;
nodesOnline: number;
busy: boolean;
hasBonds: boolean;
}
export function parseAdvert(manufacturerData: string | null | undefined): 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,
};
}

View File

@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { EventListener, Message } from './HubConnection';
import { useHub } from '../hub/HubProvider';
export interface CalSample {
serial: string;
mv: number | null;
moisture: number | null;
t_ms: number;
}
export interface Average {
mv: number;
spread: number;
count: number;
}
export interface CalSession {
dry_mv: number;
wet_mv: number;
custom: boolean;
}
type OnEvent = (listener: EventListener) => () => void;
/** Resolves with the 3-second average of the next samples for `serial` (BLE_PROTOCOL.md section 8.6). */
export function measureAverage(onEvent: OnEvent, serial: string, windowMs = 3000): Promise<Average> {
return new Promise((resolve, reject) => {
let start: number | null = null;
const values: number[] = [];
const timeout = setTimeout(() => finish(new Error('No readings from the hub')), windowMs + 5000);
const off = onEvent((event: Message) => {
if (event.event === 'cal_stopped') {
finish(new Error(event.message ?? 'Calibration stopped'));
return;
}
if (event.event !== 'cal_sample' || event.serial?.toUpperCase() !== serial.toUpperCase()) 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,
});
}
});
}
const MAX_SAMPLES = 60;
/** Streams live readings for `serial` while mounted: `cal_start` on mount, `cal_stop` on unmount. */
export function useCalibration(serial: string) {
const { send, onEvent, status } = useHub();
const [samples, setSamples] = useState<CalSample[]>([]);
const [session, setSession] = useState<CalSession | null>(null);
const [error, setError] = useState<string | null>(null);
const [stopped, setStopped] = useState<string | null>(null);
const mounted = useRef(true);
const start = useCallback(async () => {
setError(null);
setStopped(null);
try {
const r = await send({ cmd: 'cal_start', serial });
if (!mounted.current) return;
setSession({ dry_mv: r.dry_mv, wet_mv: r.wet_mv, custom: r.custom });
} catch (e) {
if (mounted.current) setError(e instanceof Error ? e.message : String(e));
}
}, [send, serial]);
useEffect(() => {
mounted.current = true;
const off = onEvent((event) => {
if (event.serial?.toUpperCase() !== serial.toUpperCase()) return;
if (event.event === 'cal_sample') {
setSamples((prev) => [...prev.slice(-(MAX_SAMPLES - 1)), event as CalSample]);
} else if (event.event === 'cal_stopped') {
if (event.message === 'timed out' && mounted.current) {
start();
return;
}
setStopped(event.message ?? 'Calibration stopped');
}
});
return () => {
mounted.current = false;
off();
send({ cmd: 'cal_stop' }).catch(() => undefined);
};
}, [onEvent, send, serial, start]);
useEffect(() => {
if (status === 'ready') start();
}, [status, start]);
const measure = useCallback(() => measureAverage(onEvent, serial), [onEvent, serial]);
return { samples, session, error, stopped, restart: start, measure, latest: samples[samples.length - 1] ?? null };
}

View File

@ -0,0 +1,10 @@
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;
export const COMMAND_TIMEOUT_MS = 10_000;
/** The first command after connecting waits for the user to type the pairing code. */
export const PAIRING_TIMEOUT_MS = 60_000;

View File

@ -0,0 +1,191 @@
import { Buffer } from 'buffer';
import { FW_DATA, SERVICE } from './constants';
import type { HubConnection, Message } from './HubConnection';
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;
}
export const HUB_MAX_BYTES = 3_342_336;
export const NODE_MAX_BYTES = 24_576;
export type FirmwareTarget = 'hub' | 'node';
export interface FirmwareProgress {
stage: 'starting' | 'uploading' | 'installing' | 'flashing' | 'rebooting' | 'done';
/** 0-1 for the current stage. */
fraction: number;
message?: string;
}
async function sendFirmware(
conn: HubConnection,
file: Buffer,
begin: { window: number; max_chunk: number },
onProgress: (fraction: number) => void,
signal: { cancelled: boolean },
): Promise<void> {
const size = file.length;
const chunk = Math.max(1, Math.min(begin.max_chunk, conn.mtu - 7));
let acked = 0;
let sendPos = 0;
let lastAck = Date.now();
let failure: string | null = null;
let wake: (() => void) | null = null;
const off = conn.onEvent((event) => {
if (event.event === 'fw_ack') {
acked = Math.max(acked, event.next);
if (event.resend) sendPos = event.next;
lastAck = Date.now();
onProgress(acked / size);
} else if (event.event === 'fw_error') {
failure = event.message ?? 'transfer failed';
} else {
return;
}
wake?.();
});
const waitForAck = (ms: number) =>
new Promise<void>((resolve) => {
const timer = setTimeout(() => {
wake = null;
resolve();
}, ms);
wake = () => {
clearTimeout(timer);
wake = null;
resolve();
};
});
try {
while (acked < size) {
if (signal.cancelled) throw new Error('Cancelled');
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 conn.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();
}
}
/**
* Uploads a firmware file and follows it to completion: for a node, until the
* hub has flashed it; for the hub, until it reports that it is rebooting.
* Sends `fw_abort` if anything fails.
*/
export async function updateFirmware(
conn: HubConnection,
target: FirmwareTarget,
bytes: Uint8Array,
options: {
serial?: string;
onProgress: (p: FirmwareProgress) => void;
signal?: { cancelled: boolean };
},
): Promise<void> {
const { serial, onProgress } = options;
const signal = options.signal ?? { cancelled: false };
const file = Buffer.from(bytes);
const limit = target === 'hub' ? HUB_MAX_BYTES : NODE_MAX_BYTES;
if (file.length === 0) throw new Error('The file is empty');
if (file.length > limit) throw new Error(`The file is too large (limit ${limit.toLocaleString()} bytes)`);
onProgress({ stage: 'starting', fraction: 0 });
const begin = await conn.send({
cmd: 'fw_begin',
target,
...(target === 'node' ? { serial } : {}),
size: file.length,
crc32: crc32(file),
});
// Node flash events can arrive straight after fw_end, so listen first.
let flashResult: Promise<void> | null = null;
let stopFlashListener: (() => void) | null = null;
if (target === 'node') {
flashResult = new Promise<void>((resolve, reject) => {
stopFlashListener = conn.onEvent((event: Message) => {
if (event.target !== 'node' || (event.serial && event.serial.toUpperCase() !== serial?.toUpperCase())) return;
if (event.event === 'progress') {
onProgress({ stage: 'flashing', fraction: (event.percent ?? 0) / 100, message: event.message });
} else if (event.event === 'complete') {
resolve();
} else if (event.event === 'error') {
reject(new Error(event.message ?? 'Node flash failed'));
}
});
});
flashResult.catch(() => undefined);
}
try {
await sendFirmware(
conn,
file,
{ window: begin.window, max_chunk: begin.max_chunk },
(fraction) => onProgress({ stage: 'uploading', fraction }),
signal,
);
onProgress({ stage: 'installing', fraction: 1 });
const end = await conn.send({ cmd: 'fw_end' }, 30_000);
if (target === 'hub') {
onProgress({ stage: 'rebooting', fraction: 1, message: end.rebooting ? 'rebooting' : undefined });
return;
}
onProgress({ stage: 'flashing', fraction: 0 });
await withTimeout(flashResult!, 90_000, 'The node flash did not finish');
onProgress({ stage: 'done', fraction: 1 });
} catch (e) {
await conn.send({ cmd: 'fw_abort' }).catch(() => undefined);
throw e;
} finally {
(stopFlashListener as (() => void) | null)?.();
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e) => {
clearTimeout(timer);
reject(e);
},
);
});
}

View File

@ -0,0 +1,56 @@
import { Buffer } from 'buffer';
const FINAL = 0x01;
const FIRST = 0x02;
/** Message framing for the Command and Events characteristics (BLE_PROTOCOL.md section 6). */
export class Framer {
private nextSeq = 0;
private rxSeq: number | null = null;
private parts: Uint8Array[] = [];
/** 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.subarray(offset, end)]).toString('base64'));
offset = end;
} while (offset < json.length);
return packets;
}
/** Returns the parsed message when `value` (base64) completes one. */
receive(value: string): Record<string, any> | null {
const packet = Buffer.from(value, 'base64');
if (packet.length === 0) return null;
const header = packet[0];
const seq = header >> 2;
if (header & FIRST) {
this.parts = [];
this.rxSeq = seq;
} else if (this.rxSeq !== seq) {
this.rxSeq = null;
this.parts = [];
return null;
}
this.parts.push(packet.subarray(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;
}
}
}

View File

@ -0,0 +1,27 @@
import { BleManager, State, type Subscription } from 'react-native-ble-plx';
/** One BleManager for the app's lifetime (BLE_PROTOCOL.md section 2). */
export const bleManager = new BleManager();
export function waitForPoweredOn(timeoutMs = 10_000): Promise<void> {
return new Promise((resolve, reject) => {
let done = false;
let sub: Subscription | null = null;
const finish = (error: Error | null) => {
if (done) return;
done = true;
clearTimeout(timer);
sub?.remove();
if (error) reject(error);
else resolve();
};
const timer = setTimeout(
() => finish(new Error('Bluetooth is not available. Check it is switched on and allowed for Allotment.')),
timeoutMs,
);
sub = bleManager.onStateChange((state) => {
if (state === State.PoweredOn) finish(null);
}, true);
if (done) sub.remove();
});
}

View File

@ -0,0 +1,61 @@
export interface HubInfo {
name: string;
hub_id: string;
fw_version: string;
protocol: number;
uptime_s: number;
nodes_online: number;
nodes_total: number;
busy: boolean;
wifi?: { connected: boolean; ip: string; rssi: number };
lora?: { joined: boolean };
gps?: { fix: boolean; sats: number };
}
export type ValveState = 'open' | 'closed' | null;
export interface HubNode {
serial: string;
online: boolean;
age_ms: number;
temp_c: number | null;
moisture: number | null;
moisture_mv: number | null;
valve: ValveState;
cal_min_mv: number;
cal_max_mv: number;
cal_custom: boolean;
patches?: string[];
}
export interface TankReading {
level: number | null;
age_s: number | null;
}
export interface TankSample {
age_s: number;
level: number;
}
export interface PowerReading {
battery: { voltage: number | null; current: number | null; percent: number | null };
solar: { voltage: number | null; current: number | null };
load: { voltage: number | null; current: number | null };
age_s: number | null;
}
export interface HubPatch {
id: string;
name: string;
icon: string;
}
export interface HubEvent {
event: string;
target?: string;
serial?: string;
percent?: number;
message?: string;
[key: string]: any;
}

View File

@ -0,0 +1,57 @@
import { StyleSheet, View } from 'react-native';
import { FONT } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Text } from './Text';
export type Tone = 'accent' | 'warn' | 'info' | 'danger' | 'muted';
export function toneColor(tone: Tone, theme: ReturnType<typeof useTheme>) {
switch (tone) {
case 'accent':
return theme.accent;
case 'warn':
return theme.warn;
case 'info':
return theme.info;
case 'danger':
return theme.danger;
default:
return theme.muted;
}
}
/** Pill badge from the devtools: 2px outline, text in the tone colour. */
export function Badge({ label, tone = 'muted', dot }: { label: string; tone?: Tone; dot?: boolean }) {
const theme = useTheme();
const color = toneColor(tone, theme);
return (
<View style={[styles.badge, { borderColor: theme.borderUi }]}>
{dot ? <View style={[styles.dot, { backgroundColor: color }]} /> : null}
<Text style={[styles.text, { color }]}>{label}</Text>
</View>
);
}
export function StatusDot({ online, size = 8 }: { online: boolean; size?: number }) {
const theme = useTheme();
return (
<View
style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: online ? theme.accent : theme.warn }}
/>
);
}
const styles = StyleSheet.create({
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
borderWidth: 2,
borderRadius: 999,
paddingHorizontal: 9,
paddingVertical: 2,
alignSelf: 'flex-start',
},
dot: { width: 6, height: 6, borderRadius: 3 },
text: { fontFamily: FONT.medium, fontSize: 12 },
});

View File

@ -0,0 +1,63 @@
import { useEffect, useRef } from 'react';
import { Animated, Easing, StyleSheet, View } from 'react-native';
import { FIXED, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Text } from './Text';
export function ProgressBar({ fraction, color }: { fraction: number; color?: string }) {
const theme = useTheme();
const width = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(width, {
toValue: Math.max(0, Math.min(1, fraction)),
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}, [fraction, width]);
return (
<View style={[styles.track, { backgroundColor: withAlpha(theme.text, 0.14) }]}>
<Animated.View
style={[
styles.fill,
{
backgroundColor: color ?? theme.accent,
width: width.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] }),
},
]}
/>
</View>
);
}
/** Devtools moisture cell: a 64x6 cyan bar, the percentage, then the raw millivolts. */
export function MoistureBar({ moisture, mv, compact }: { moisture: number | null; mv: number | null; compact?: boolean }) {
const theme = useTheme();
if (moisture == null) {
return <Text variant={mv == null ? 'faint' : 'mono'}>{mv == null ? '--' : `${mv} mV`}</Text>;
}
const pct = Math.max(0, Math.min(100, moisture));
return (
<View style={styles.moisture}>
<View style={[styles.moistureTrack, { backgroundColor: withAlpha(theme.text, 0.14) }]}>
<View style={[styles.moistureFill, { width: `${pct}%` }]} />
</View>
<Text variant="mono" style={{ minWidth: 34 }}>
{Math.round(pct)}%
</Text>
{!compact && mv != null ? (
<Text variant="mono" style={{ color: theme.faint, fontSize: 12 }}>
{mv} mV
</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
track: { height: 6, borderRadius: 3, overflow: 'hidden' },
fill: { height: '100%', borderRadius: 3 },
moisture: { flexDirection: 'row', alignItems: 'center', gap: 8 },
moistureTrack: { width: 64, height: 6, borderRadius: 3, overflow: 'hidden' },
moistureFill: { height: '100%', borderRadius: 3, backgroundColor: FIXED.info },
});

View File

@ -0,0 +1,97 @@
import type { SFSymbol } from 'expo-symbols';
import { ActivityIndicator, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { FONT, RADIUS } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Icon } from './Icon';
import { Text } from './Text';
export type ButtonVariant = 'default' | 'primary' | 'ghost' | 'danger';
interface Props {
title: string;
onPress?: () => void;
variant?: ButtonVariant;
small?: boolean;
icon?: SFSymbol;
disabled?: boolean;
loading?: boolean;
style?: StyleProp<ViewStyle>;
}
/** Pill button from the web client: 2px outline, inverts while pressed; `primary` is filled with the accent. */
export function Button({ title, onPress, variant = 'default', small, icon, disabled, loading, style }: Props) {
const theme = useTheme();
const isDisabled = disabled || loading;
return (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: isDisabled }}
disabled={isDisabled}
onPress={onPress}
style={({ pressed }) => {
const primary = variant === 'primary';
let backgroundColor = primary ? theme.accent : 'transparent';
let borderColor = primary ? 'transparent' : theme.borderUi;
if (pressed && !primary) {
backgroundColor = variant === 'danger' ? theme.danger : theme.text;
borderColor = backgroundColor;
}
return [
styles.base,
small ? styles.small : null,
{ backgroundColor, borderColor, opacity: isDisabled ? 0.5 : 1 },
pressed ? { transform: [{ scale: 0.975 }], opacity: primary ? 0.85 : 1 } : null,
style,
];
}}
>
{({ pressed }) => {
let color = variant === 'primary' ? theme.onAccent : variant === 'danger' ? theme.danger : theme.text;
if (pressed && variant !== 'primary') color = variant === 'danger' ? '#fff' : theme.bg;
return (
<View style={styles.content}>
{loading ? (
<ActivityIndicator size="small" color={color} />
) : icon ? (
<Icon name={icon} size={small ? 13 : 15} color={color} />
) : null}
<Text
style={{
fontFamily: variant === 'primary' ? FONT.semibold : FONT.medium,
fontSize: small ? 13 : 14.5,
color,
}}
numberOfLines={1}
>
{title}
</Text>
</View>
);
}}
</Pressable>
);
}
const styles = StyleSheet.create({
base: {
minHeight: 40,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: RADIUS.control,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'flex-start',
},
small: {
minHeight: 32,
paddingHorizontal: 12,
paddingVertical: 5,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
});

View File

@ -0,0 +1,206 @@
import * as DocumentPicker from 'expo-document-picker';
import { File } from 'expo-file-system';
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import { useEffect, useRef, useState } from 'react';
import { Alert, StyleSheet, View } from 'react-native';
import { HUB_MAX_BYTES, NODE_MAX_BYTES, updateFirmware, type FirmwareProgress, type FirmwareTarget } from '../ble/firmware';
import { useHub } from '../hub/HubProvider';
import { useTheme } from '../theme/ThemeProvider';
import { errorMessage, formatBytes } from '../utils/format';
import { ProgressBar } from './Bars';
import { Button } from './Button';
import { Icon } from './Icon';
import { ButtonRow, Notice } from './Layout';
import { Text } from './Text';
import { useToast } from './Toast';
interface PickedFile {
name: string;
size: number;
bytes: Uint8Array;
}
type Phase = 'idle' | 'running' | 'rebooting' | 'done' | 'error';
const KEEP_AWAKE_TAG = 'firmware-update';
const STAGE_LABEL: Record<FirmwareProgress['stage'], string> = {
starting: 'Starting upload',
uploading: 'Uploading',
installing: 'Checking the image',
flashing: 'Flashing over the bus',
rebooting: 'Hub rebooting',
done: 'Done',
};
/**
* Pick a firmware file from the device and send it to the hub or a node over BLE
* (BLE_PROTOCOL.md section 9), with upload and flash progress.
*/
export function FirmwareUploader({ target, serial, offline }: { target: FirmwareTarget; serial?: string; offline?: boolean }) {
const theme = useTheme();
const toast = useToast();
const { connection, status, info } = useHub();
const [file, setFile] = useState<PickedFile | null>(null);
const [phase, setPhase] = useState<Phase>('idle');
const [progress, setProgress] = useState<FirmwareProgress | null>(null);
const [uploadFraction, setUploadFraction] = useState(0);
const [error, setError] = useState<string | null>(null);
const [previousVersion, setPreviousVersion] = useState<string | null>(null);
const signal = useRef({ cancelled: false });
const limit = target === 'hub' ? HUB_MAX_BYTES : NODE_MAX_BYTES;
const tooLarge = file != null && file.size > limit;
useEffect(() => {
if (phase === 'rebooting' && status === 'ready' && info) {
setPhase('done');
deactivateKeepAwake(KEEP_AWAKE_TAG);
const changed = previousVersion && info.fw_version !== previousVersion;
toast('success', 'Hub updated', changed ? `Now running v${info.fw_version}` : `Running v${info.fw_version}`);
}
}, [phase, status, info, previousVersion, toast]);
useEffect(
() => () => {
deactivateKeepAwake(KEEP_AWAKE_TAG);
},
[],
);
const pick = async () => {
setError(null);
try {
const result = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true, multiple: false });
if (result.canceled || !result.assets?.[0]) return;
const asset = result.assets[0];
const bytes = await new File(asset.uri).bytes();
setFile({ name: asset.name, size: bytes.length, bytes });
setPhase('idle');
setProgress(null);
setUploadFraction(0);
} catch (e) {
setError(`Could not read the file: ${errorMessage(e)}`);
}
};
const run = async () => {
if (!file || !connection) return;
signal.current = { cancelled: false };
setPhase('running');
setError(null);
setUploadFraction(0);
setPreviousVersion(info?.fw_version ?? null);
await activateKeepAwakeAsync(KEEP_AWAKE_TAG).catch(() => undefined);
try {
await updateFirmware(connection, target, file.bytes, {
serial,
signal: signal.current,
onProgress: (p) => {
setProgress(p);
if (p.stage === 'uploading') setUploadFraction(p.fraction);
if (p.stage !== 'starting' && p.stage !== 'uploading') setUploadFraction(1);
},
});
if (target === 'hub') {
setPhase('rebooting');
} else {
setPhase('done');
deactivateKeepAwake(KEEP_AWAKE_TAG);
toast('success', 'Node updated', serial);
}
} catch (e) {
deactivateKeepAwake(KEEP_AWAKE_TAG);
setPhase('error');
setError(errorMessage(e));
}
};
const confirmAndRun = () => {
if (target === 'hub') {
Alert.alert('Update hub firmware?', 'The hub reboots after flashing and reconnects by itself.', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Flash and reboot', style: 'destructive', onPress: run },
]);
} else {
run();
}
};
const running = phase === 'running';
const flashing = progress?.stage === 'flashing' || (target === 'node' && phase === 'done');
return (
<View style={styles.wrap}>
<View style={styles.fileRow}>
<Icon name="doc" size={18} color={file ? theme.text : theme.faint} />
<View style={{ flex: 1 }}>
<Text numberOfLines={1}>{file ? file.name : 'No file chosen'}</Text>
<Text variant="faint">
{file
? formatBytes(file.size)
: target === 'hub'
? 'firmware.bin or firmware.bin.gz'
: 'firmware.hex, .bin or .gz, up to 24 KB'}
</Text>
</View>
<Button title={file ? 'Change' : 'Choose file'} small onPress={pick} disabled={running || phase === 'rebooting'} />
</View>
{tooLarge ? <Notice tone="danger" message={`This file is larger than the ${formatBytes(limit)} limit.`} /> : null}
{target === 'node' && offline ? (
<Notice tone="warn" message="The node is offline. Flashing can still recover a node broken by a failed update." />
) : null}
{phase !== 'idle' && phase !== 'error' ? (
<View style={styles.progress}>
<Text variant="label">Upload</Text>
<ProgressBar fraction={uploadFraction} />
{target === 'node' ? (
<>
<Text variant="label" style={{ marginTop: 8 }}>
RS485
</Text>
<ProgressBar fraction={phase === 'done' ? 1 : flashing ? progress?.fraction ?? 0 : 0} color={theme.info} />
</>
) : null}
<Text variant="muted">
{phase === 'rebooting'
? status === 'ready'
? 'Reconnected'
: 'Hub rebooting, reconnecting…'
: phase === 'done'
? 'Done'
: progress
? `${STAGE_LABEL[progress.stage]}${progress.message ? `: ${progress.message}` : ''}${
progress.stage === 'uploading' ? ` ${Math.round(progress.fraction * 100)}%` : ''
}`
: 'Starting'}
</Text>
</View>
) : null}
{error ? <Notice tone="danger" icon="exclamationmark.triangle" message={error} /> : null}
<ButtonRow>
{running ? (
<Button title="Cancel" variant="danger" onPress={() => (signal.current.cancelled = true)} />
) : (
<Button
title={phase === 'error' ? 'Retry' : target === 'hub' ? 'Flash hub' : 'Flash node'}
variant="primary"
icon="arrow.up.circle"
onPress={confirmAndRun}
disabled={!file || tooLarge || !connection || status !== 'ready' || phase === 'rebooting'}
/>
)}
</ButtonRow>
</View>
);
}
const styles = StyleSheet.create({
wrap: { gap: 14 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
progress: { gap: 6 },
});

View File

@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, StyleSheet, View } from 'react-native';
import { withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
const BALL_COUNT = 4;
const BALL = 8;
/** Higher power means a faster flow, as in the client's PowerFlowLine. */
function durationForWatts(watts: number): number | null {
if (!Number.isFinite(watts) || watts <= 0) return null;
return Math.max(0.35, 5.5 / (1 + watts / 18)) * 1000;
}
/** Vertical track with glowing accent balls that flow downwards. */
export function FlowLine({ watts, length = 56 }: { watts: number; length?: number }) {
const theme = useTheme();
const duration = durationForWatts(watts);
const progress = useRef(new Animated.Value(0)).current;
const [trackLength, setTrackLength] = useState(length);
useEffect(() => {
if (duration == null) {
progress.stopAnimation();
return;
}
progress.setValue(0);
const loop = Animated.loop(
Animated.timing(progress, { toValue: 1, duration, easing: Easing.linear, useNativeDriver: true }),
);
loop.start();
return () => loop.stop();
}, [duration, progress]);
return (
<View style={[styles.wrap, { height: length }]}>
<View
style={[styles.track, { backgroundColor: withAlpha(theme.text, 0.14) }]}
onLayout={(e) => setTrackLength(e.nativeEvent.layout.height)}
>
{duration != null
? Array.from({ length: BALL_COUNT }, (_, i) => {
const offset = Animated.modulo(Animated.add(progress, i / BALL_COUNT), 1);
const translateY = offset.interpolate({
inputRange: [0, 1],
outputRange: [-BALL, trackLength],
});
return (
<Animated.View
key={i}
style={[
styles.ball,
{
backgroundColor: theme.accent,
shadowColor: theme.accent,
transform: [{ translateY }],
},
]}
/>
);
})
: null}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrap: { alignItems: 'center', justifyContent: 'center' },
track: { width: 4, height: '100%', borderRadius: 2, overflow: 'hidden' },
ball: {
position: 'absolute',
left: -2,
top: 0,
width: BALL,
height: BALL,
borderRadius: BALL / 2,
shadowOpacity: 0.55,
shadowRadius: 4,
shadowOffset: { width: 0, height: 0 },
},
});

View File

@ -0,0 +1,23 @@
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useHub } from '../hub/HubProvider';
import { useTheme } from '../theme/ThemeProvider';
import { Text } from './Text';
/** Shown at the top of tab pages while the hub link is down, like the client's "Reconnecting…" line. */
export function HubBanner() {
const theme = useTheme();
const { status, hub } = useHub();
if (status === 'ready') return null;
return (
<View style={styles.wrap}>
<ActivityIndicator size="small" color={theme.warn} />
<Text variant="muted" style={{ color: theme.warn, flex: 1 }}>
Reconnecting to {hub?.name ?? 'the hub'}…
</Text>
</View>
);
}
const styles = StyleSheet.create({
wrap: { flexDirection: 'row', alignItems: 'center', gap: 10 },
});

View File

@ -0,0 +1,26 @@
import { SymbolView, type SFSymbol } from 'expo-symbols';
import type { StyleProp, ViewStyle } from 'react-native';
import { useTheme } from '../theme/ThemeProvider';
export function Icon({
name,
size = 16,
color,
style,
}: {
name: SFSymbol;
size?: number;
color?: string;
style?: StyleProp<ViewStyle>;
}) {
const theme = useTheme();
return (
<SymbolView
name={name}
size={size}
tintColor={color ?? theme.text}
weight="medium"
style={[{ width: size, height: size }, style]}
/>
);
}

View File

@ -0,0 +1,78 @@
import { forwardRef, useState, type ReactNode } from 'react';
import { StyleSheet, TextInput, View, type TextInputProps } from 'react-native';
import { FONT, RADIUS, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Text } from './Text';
interface Props extends TextInputProps {
prefix?: ReactNode;
suffix?: string;
mono?: boolean;
invalid?: boolean;
}
/** Text field from the web client: 18px radius, 2px outline, accent ring when focused. */
export const Input = forwardRef<TextInput, Props>(function Input(
{ prefix, suffix, mono, invalid, style, onFocus, onBlur, editable = true, ...rest },
ref,
) {
const theme = useTheme();
const [focused, setFocused] = useState(false);
const borderColor = invalid ? theme.danger : focused ? theme.accent : theme.borderUi;
return (
<View
style={[
styles.wrap,
{
backgroundColor: theme.bg,
borderColor,
opacity: editable ? 1 : 0.55,
shadowColor: theme.accent,
shadowOpacity: focused ? 0.25 : 0,
},
]}
>
{prefix}
<TextInput
ref={ref}
editable={editable}
placeholderTextColor={withAlpha(theme.text, 0.45)}
selectionColor={theme.accent}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
style={[
styles.input,
{ color: theme.text, fontFamily: mono ? FONT.mono : FONT.medium },
style,
]}
{...rest}
/>
{suffix ? <Text variant="faint">{suffix}</Text> : null}
</View>
);
});
const styles = StyleSheet.create({
wrap: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: 42,
paddingHorizontal: 14,
borderRadius: RADIUS.control,
borderWidth: 2,
shadowRadius: 2,
shadowOffset: { width: 0, height: 0 },
},
input: {
flex: 1,
fontSize: 15,
paddingVertical: 9,
},
});

View File

@ -0,0 +1,229 @@
import type { ReactNode } from 'react';
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
View,
type ScrollViewProps,
type StyleProp,
type ViewStyle,
} from 'react-native';
import type { SFSymbol } from 'expo-symbols';
import { useTheme } from '../theme/ThemeProvider';
import { Icon } from './Icon';
import { Text } from './Text';
export const PAGE_PADDING = 20;
/**
* Scrolling page body. It must be the first child of a screen so the native
* large title and the liquid glass tab bar can track its scroll position.
*/
export function Screen({
children,
onRefresh,
refreshing = false,
contentStyle,
...rest
}: ScrollViewProps & {
children: ReactNode;
onRefresh?: () => void;
refreshing?: boolean;
contentStyle?: StyleProp<ViewStyle>;
}) {
const theme = useTheme();
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={{ flex: 1, backgroundColor: theme.bg }}
contentContainerStyle={[styles.content, contentStyle]}
keyboardDismissMode="interactive"
refreshControl={
onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.muted} /> : undefined
}
{...rest}
>
{children}
</ScrollView>
);
}
/** A flat page section: a heading, then content, separated from the next section by a 2px rule. */
export function Section({
title,
right,
children,
first,
style,
}: {
title?: string;
right?: ReactNode;
children: ReactNode;
first?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const theme = useTheme();
return (
<View
style={[
styles.section,
first ? null : { borderTopWidth: 2, borderTopColor: theme.borderUi, paddingTop: 22 },
style,
]}
>
{title || right ? (
<View style={styles.sectionHeader}>
{title ? <Text variant="heading">{title}</Text> : <View />}
{right}
</View>
) : null}
{children}
</View>
);
}
/** A label/value line in a list, optionally tappable with a chevron. Rows are separated by 2px rules, not cards. */
export function Row({
label,
detail,
value,
icon,
onPress,
chevron,
destructive,
last,
mono,
children,
}: {
label: string;
detail?: string;
value?: ReactNode;
icon?: SFSymbol;
onPress?: () => void;
chevron?: boolean;
destructive?: boolean;
last?: boolean;
mono?: boolean;
children?: ReactNode;
}) {
const theme = useTheme();
const color = destructive ? theme.danger : theme.text;
const content = (
<View style={[styles.row, last ? null : { borderBottomWidth: 2, borderBottomColor: theme.borderUi }]}>
{icon ? <Icon name={icon} size={17} color={destructive ? theme.danger : theme.muted} /> : null}
{detail ? (
<View style={{ flex: 1 }}>
<Text style={{ color }}>{label}</Text>
<Text variant="faint">{detail}</Text>
</View>
) : (
<Text style={{ flex: 1, color }}>{label}</Text>
)}
{typeof value === 'string' || typeof value === 'number' ? (
<Text variant={mono ? 'mono' : 'muted'} style={mono ? { color: theme.muted } : null} selectable>
{value}
</Text>
) : (
value
)}
{children}
{chevron ? <Icon name="chevron.right" size={13} color={theme.faint} /> : null}
</View>
);
if (!onPress) return content;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.rowPressable, pressed ? { backgroundColor: theme.wash } : null]}
>
{content}
</Pressable>
);
}
export function Metric({ title, children, sub }: { title: string; children: ReactNode; sub?: ReactNode }) {
return (
<View style={styles.metric}>
<Text variant="label" style={{ marginBottom: 4 }}>
{title}
</Text>
{typeof children === 'string' || typeof children === 'number' ? (
<Text variant="value">{children}</Text>
) : (
children
)}
{sub ? (
typeof sub === 'string' ? (
<Text variant="faint" style={{ marginTop: 2 }}>
{sub}
</Text>
) : (
sub
)
) : null}
</View>
);
}
export function Metrics({ children, vertical }: { children: ReactNode; vertical?: boolean }) {
return <View style={vertical ? styles.metricsVertical : styles.metrics}>{children}</View>;
}
export function Notice({
message,
tone = 'muted',
icon = 'info.circle',
}: {
message: string;
tone?: 'muted' | 'warn' | 'danger';
icon?: SFSymbol;
}) {
const theme = useTheme();
const color = tone === 'warn' ? theme.warn : tone === 'danger' ? theme.danger : theme.muted;
return (
<View style={styles.notice}>
<Icon name={icon} size={16} color={color} style={{ marginTop: 2 }} />
<Text variant="muted" style={{ flex: 1, color }}>
{message}
</Text>
</View>
);
}
export function ButtonRow({ children }: { children: ReactNode }) {
return <View style={styles.buttonRow}>{children}</View>;
}
const styles = StyleSheet.create({
content: {
paddingHorizontal: PAGE_PADDING,
paddingTop: 8,
paddingBottom: 48,
gap: 22,
},
section: { gap: 14 },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
rowPressable: {
marginHorizontal: -10,
paddingHorizontal: 10,
borderRadius: 12,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
minHeight: 50,
paddingVertical: 12,
},
metric: { flexGrow: 1, flexBasis: 140 },
metrics: { flexDirection: 'row', flexWrap: 'wrap', rowGap: 18, columnGap: 16 },
metricsVertical: { gap: 28 },
notice: { flexDirection: 'row', gap: 10, alignItems: 'flex-start' },
buttonRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
});

View File

@ -0,0 +1,94 @@
import { useState } from 'react';
import { View } from 'react-native';
import Svg, { Defs, Line, LinearGradient, Path, Stop, Text as SvgText } from 'react-native-svg';
import { FONT, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
export interface ChartMarker {
value: number;
label: string;
color: string;
}
/** Line chart of the latest values, with optional dashed reference lines. */
export function LiveChart({
values,
markers = [],
height = 140,
color,
minSpan = 200,
}: {
values: number[];
markers?: ChartMarker[];
height?: number;
color?: string;
minSpan?: number;
}) {
const theme = useTheme();
const [width, setWidth] = useState(0);
const stroke = color ?? theme.accent;
const all = [...values, ...markers.map((m) => m.value)];
let min = all.length ? Math.min(...all) : 0;
let max = all.length ? Math.max(...all) : 1;
if (max - min < minSpan) {
const mid = (max + min) / 2;
min = mid - minSpan / 2;
max = mid + minSpan / 2;
}
const pad = (max - min) * 0.12;
min -= pad;
max += pad;
const top = 8;
const bottom = height - 8;
const y = (v: number) => bottom - ((v - min) / (max - min)) * (bottom - top);
const step = values.length > 1 ? width / (values.length - 1) : 0;
const points = values.map((v, i) => `${(i * step).toFixed(1)},${y(v).toFixed(1)}`);
const line = points.length > 1 ? `M${points.join(' L')}` : '';
const area = line ? `${line} L${width},${bottom} L0,${bottom} Z` : '';
return (
<View
style={{ height, borderRadius: 14, borderWidth: 2, borderColor: theme.borderUi, overflow: 'hidden' }}
onLayout={(e) => setWidth(e.nativeEvent.layout.width - 4)}
>
{width > 0 ? (
<Svg width={width} height={height - 4}>
<Defs>
<LinearGradient id="fill" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor={stroke} stopOpacity={0.28} />
<Stop offset="1" stopColor={stroke} stopOpacity={0} />
</LinearGradient>
</Defs>
{markers.map((m) => (
<Line
key={m.label}
x1={0}
x2={width}
y1={y(m.value)}
y2={y(m.value)}
stroke={withAlpha(m.color, 0.8)}
strokeWidth={1.5}
strokeDasharray="5 5"
/>
))}
{markers.map((m) => (
<SvgText
key={`${m.label}-text`}
x={8}
y={y(m.value) - 5}
fill={m.color}
fontSize={11}
fontFamily={FONT.medium}
>
{m.label}
</SvgText>
))}
{area ? <Path d={area} fill="url(#fill)" /> : null}
{line ? <Path d={line} stroke={stroke} strokeWidth={2.5} fill="none" strokeLinejoin="round" strokeLinecap="round" /> : null}
</Svg>
) : null}
</View>
);
}

View File

@ -0,0 +1,26 @@
import { Image } from 'expo-image';
const ICONS: Record<string, number> = {
apple: require('../../assets/produce/apple.svg'),
beet: require('../../assets/produce/beet.svg'),
cabbage: require('../../assets/produce/cabbage.svg'),
carrot: require('../../assets/produce/carrot.svg'),
cherry: require('../../assets/produce/cherry.svg'),
corn: require('../../assets/produce/corn.svg'),
cucumber: require('../../assets/produce/cucumber.svg'),
kiwi: require('../../assets/produce/kiwi.svg'),
leek: require('../../assets/produce/leek.svg'),
lettuce: require('../../assets/produce/lettuce.svg'),
peas: require('../../assets/produce/peas.svg'),
plum: require('../../assets/produce/plum.svg'),
potato: require('../../assets/produce/potato.svg'),
radish: require('../../assets/produce/radish.svg'),
raspberry: require('../../assets/produce/raspberry.svg'),
strawberry: require('../../assets/produce/strawberry.svg'),
tomato: require('../../assets/produce/tomato.svg'),
};
export function ProduceIcon({ icon, size = 24 }: { icon?: string; size?: number }) {
const source = ICONS[icon ?? ''] ?? ICONS.tomato;
return <Image source={source} style={{ width: size, height: size }} contentFit="contain" />;
}

View File

@ -0,0 +1,68 @@
import type { SFSymbol } from 'expo-symbols';
import { Pressable, StyleSheet, View } from 'react-native';
import { FONT, RADIUS } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Icon } from './Icon';
import { Text } from './Text';
export interface SegmentOption<T extends string> {
value: T;
label: string;
icon?: SFSymbol;
}
/** Devtools segmented control: the active option gets an accent wash and accent text. */
export function Segmented<T extends string>({
options,
value,
onChange,
disabled,
}: {
options: SegmentOption<T>[];
value: T | null;
onChange: (value: T) => void;
disabled?: boolean;
}) {
const theme = useTheme();
return (
<View style={[styles.wrap, { borderColor: theme.borderUi, opacity: disabled ? 0.5 : 1 }]}>
{options.map((o) => {
const active = o.value === value;
const color = active ? theme.accent : theme.text;
return (
<Pressable
key={o.value}
disabled={disabled}
onPress={() => onChange(o.value)}
style={({ pressed }) => [
styles.option,
{ backgroundColor: active ? theme.accentWash : pressed ? theme.wash : 'transparent' },
]}
>
{o.icon ? <Icon name={o.icon} size={14} color={color} /> : null}
<Text style={{ color, fontFamily: FONT.medium, fontSize: 14 }}>{o.label}</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
flexDirection: 'row',
borderWidth: 2,
borderRadius: RADIUS.control,
padding: 3,
gap: 3,
alignSelf: 'flex-start',
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 14,
},
});

View File

@ -0,0 +1,141 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { FONT, RADIUS, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Button } from './Button';
import { Icon } from './Icon';
import { Input } from './Input';
import { Text } from './Text';
export interface SheetOption {
value: string;
label: string;
detail?: string;
leading?: ReactNode;
}
/**
* Native page sheet with a searchable list of options that toggle on tap,
* styled like the web client's multi-select dropdown.
*/
export function MultiSelectSheet({
visible,
title,
options,
selected,
onCancel,
onDone,
saving,
error,
emptyMessage = 'Nothing to choose from',
}: {
visible: boolean;
title: string;
options: SheetOption[];
selected: string[];
onCancel: () => void;
onDone: (values: string[]) => void;
saving?: boolean;
error?: string | null;
emptyMessage?: string;
}) {
const theme = useTheme();
const [values, setValues] = useState<string[]>(selected);
const [query, setQuery] = useState('');
useEffect(() => {
if (visible) {
setValues(selected);
setQuery('');
}
}, [visible, selected]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q));
}, [options, query]);
const toggle = (v: string) => setValues((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onCancel}>
<View style={[styles.sheet, { backgroundColor: theme.surface }]}>
<View style={styles.header}>
<Pressable onPress={onCancel} hitSlop={12}>
<Text style={{ color: theme.muted, fontFamily: FONT.medium }}>Cancel</Text>
</Pressable>
<Text variant="heading">{title}</Text>
<Button title="Save" variant="primary" small loading={saving} onPress={() => onDone(values)} />
</View>
<View style={{ paddingHorizontal: 20 }}>
<Input
placeholder="Search"
value={query}
onChangeText={setQuery}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
prefix={<Icon name="magnifyingglass" size={15} color={theme.muted} />}
/>
{error ? (
<Text variant="small" style={{ color: theme.danger, marginTop: 10 }}>
{error}
</Text>
) : null}
</View>
<ScrollView contentContainerStyle={styles.list} keyboardShouldPersistTaps="handled">
{filtered.length === 0 ? (
<Text variant="muted" style={{ textAlign: 'center', paddingVertical: 24 }}>
{options.length === 0 ? emptyMessage : 'No matches'}
</Text>
) : (
filtered.map((o) => {
const on = values.includes(o.value);
return (
<Pressable
key={o.value}
onPress={() => toggle(o.value)}
style={({ pressed }) => [
styles.option,
{
backgroundColor: on ? withAlpha(theme.accent, 0.18) : pressed ? theme.wash : 'transparent',
},
]}
>
{o.leading}
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: FONT.medium }}>{o.label}</Text>
{o.detail ? <Text variant="faint">{o.detail}</Text> : null}
</View>
{on ? <Icon name="checkmark" size={16} color={theme.accent} /> : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
sheet: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 16,
},
list: { padding: 14, paddingBottom: 48, gap: 4 },
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 12,
paddingVertical: 11,
borderRadius: RADIUS.option,
},
});

View File

@ -0,0 +1,37 @@
import { Text as RNText, type TextProps, type TextStyle } from 'react-native';
import { FONT } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
export type TextVariant = 'body' | 'muted' | 'faint' | 'label' | 'value' | 'mono' | 'heading' | 'title' | 'small';
interface Props extends TextProps {
variant?: TextVariant;
color?: string;
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
}
/** Typography from the web client: Rubik body, uppercase metric labels, Libre Baskerville titles, JetBrains Mono data. */
export function Text({ variant = 'body', color, weight, style, ...rest }: Props) {
const theme = useTheme();
const base: Record<TextVariant, TextStyle> = {
body: { fontFamily: FONT.regular, fontSize: 15, lineHeight: 22, color: theme.text },
small: { fontFamily: FONT.regular, fontSize: 13, lineHeight: 18, color: theme.muted },
muted: { fontFamily: FONT.regular, fontSize: 14, lineHeight: 20, color: theme.muted },
faint: { fontFamily: FONT.regular, fontSize: 13, lineHeight: 18, color: theme.faint },
label: {
fontFamily: FONT.medium,
fontSize: 12,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: theme.muted,
},
value: { fontFamily: FONT.semibold, fontSize: 21, lineHeight: 26, color: theme.text },
mono: { fontFamily: FONT.mono, fontSize: 14, color: theme.text },
heading: { fontFamily: FONT.semibold, fontSize: 17, lineHeight: 22, color: theme.text },
title: { fontFamily: FONT.serif, fontSize: 30, lineHeight: 36, color: theme.text },
};
const weightStyle: TextStyle | undefined = weight
? { fontFamily: variant === 'mono' ? FONT.monoMedium : FONT[weight] }
: undefined;
return <RNText style={[base[variant], weightStyle, color ? { color } : null, style]} {...rest} />;
}

View File

@ -0,0 +1,93 @@
import * as Haptics from 'expo-haptics';
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { Animated, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { RADIUS } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
import { Icon } from './Icon';
import { Text } from './Text';
type Kind = 'success' | 'error' | 'info';
interface ToastState {
id: number;
kind: Kind;
title: string;
detail?: string;
}
const ToastContext = createContext<(kind: Kind, title: string, detail?: string) => void>(() => undefined);
/** Short confirmations like the devtools toasts: success, error, info. */
export function ToastProvider({ children }: { children: ReactNode }) {
const theme = useTheme();
const insets = useSafeAreaInsets();
const [toast, setToast] = useState<ToastState | null>(null);
const opacity = useRef(new Animated.Value(0)).current;
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const nextId = useRef(1);
const show = useCallback(
(kind: Kind, title: string, detail?: string) => {
if (timer.current) clearTimeout(timer.current);
setToast({ id: nextId.current++, kind, title, detail });
Haptics.notificationAsync(
kind === 'error' ? Haptics.NotificationFeedbackType.Error : Haptics.NotificationFeedbackType.Success,
).catch(() => undefined);
Animated.timing(opacity, { toValue: 1, duration: 180, useNativeDriver: true }).start();
timer.current = setTimeout(
() => {
Animated.timing(opacity, { toValue: 0, duration: 220, useNativeDriver: true }).start(() => setToast(null));
},
kind === 'error' ? 6000 : 3000,
);
},
[opacity],
);
const color = toast?.kind === 'error' ? theme.danger : toast?.kind === 'info' ? theme.info : theme.accent;
return (
<ToastContext.Provider value={show}>
{children}
{toast ? (
<Animated.View
pointerEvents="none"
style={[styles.wrap, { top: insets.top + 6, opacity }]}
>
<View style={[styles.toast, { backgroundColor: theme.surface, borderColor: theme.borderUi }]}>
<Icon
name={toast.kind === 'error' ? 'exclamationmark.circle.fill' : toast.kind === 'info' ? 'info.circle.fill' : 'checkmark.circle.fill'}
size={18}
color={color}
/>
<View style={{ flex: 1 }}>
<Text weight="medium">{toast.title}</Text>
{toast.detail ? <Text variant="small">{toast.detail}</Text> : null}
</View>
</View>
</Animated.View>
) : null}
</ToastContext.Provider>
);
}
export function useToast() {
return useContext(ToastContext);
}
const styles = StyleSheet.create({
wrap: { position: 'absolute', left: 16, right: 16, zIndex: 100 },
toast: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: RADIUS.control,
borderWidth: 2,
shadowColor: '#000',
shadowOpacity: 0.22,
shadowRadius: 18,
shadowOffset: { width: 0, height: 8 },
},
});

View File

@ -0,0 +1,116 @@
import { LinearGradient } from 'expo-linear-gradient';
import LottieView from 'lottie-react-native';
import { useEffect, useMemo, useRef } from 'react';
import { Animated, Easing, StyleSheet, View } from 'react-native';
import { RADIUS, withAlpha } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
const NOTCH_COUNT = 10;
const WATER_ANIMATION = require('../../assets/lottie/water-animation.json');
const LOTTIE_ASPECT = 200 / 1920;
function clamp(level: number | null | undefined) {
const n = Number(level);
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0;
}
/**
* The client's tank/battery vessel: a 7:11 outlined shell with a gradient fill,
* a diagonal shine and level notches. `ripple` adds the water-surface Lottie.
*/
export function Vessel({
level,
width,
top,
deep,
ripple,
}: {
level: number | null | undefined;
width: number;
top: string;
deep: string;
ripple?: boolean;
}) {
const theme = useTheme();
const pct = clamp(level);
const height = (width * 11) / 7;
const inner = height - 4;
const target = pct <= 0 ? 0 : Math.max(0, (inner * pct) / 100 - 10);
const fill = useRef(new Animated.Value(target)).current;
const notches = useMemo(() => Array.from({ length: NOTCH_COUNT }, (_, i) => i), []);
useEffect(() => {
Animated.timing(fill, {
toValue: target,
duration: 650,
easing: Easing.bezier(0.33, 1, 0.68, 1),
useNativeDriver: false,
}).start();
}, [fill, target]);
const lottieHeight = (width - 4) * LOTTIE_ASPECT;
return (
<View
style={{ width, height }}
accessible
accessibilityRole="image"
accessibilityLabel={`${Math.round(pct)} percent`}
>
<View style={[styles.shell, { borderColor: theme.borderUi }]}>
<View style={styles.fillWrap}>
<Animated.View style={{ height: fill, width: '100%' }}>
{pct > 0 ? (
<>
<LinearGradient colors={[top, deep]} style={StyleSheet.absoluteFill} />
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.14)', 'transparent']}
locations={[0, 0.42, 0.58]}
start={{ x: 0, y: 0.37 }}
end={{ x: 1, y: 0.63 }}
style={[StyleSheet.absoluteFill, { opacity: 0.35 }]}
/>
{ripple ? (
<View
pointerEvents="none"
style={[styles.ripple, { height: lottieHeight, top: -10 - lottieHeight * 0.48 }]}
>
<LottieView source={WATER_ANIMATION} autoPlay loop style={{ width: '100%', height: '100%' }} />
</View>
) : null}
</>
) : null}
</Animated.View>
</View>
<View style={styles.notches} pointerEvents="none">
{notches.map((i) => (
<View key={i} style={[styles.notch, { backgroundColor: withAlpha(theme.text, 0.12) }]} />
))}
</View>
</View>
</View>
);
}
const FILL = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } as const;
const styles = StyleSheet.create({
shell: {
...FILL,
borderRadius: RADIUS.tank,
borderWidth: 2,
overflow: 'hidden',
},
fillWrap: { ...FILL, justifyContent: 'flex-end' },
ripple: { position: 'absolute', left: 0, right: 0 },
notches: {
position: 'absolute',
top: 20,
bottom: 20,
left: 15,
width: 10,
justifyContent: 'space-between',
alignItems: 'flex-end',
},
notch: { width: 7, height: 3, borderRadius: 1 },
});

View File

@ -0,0 +1,274 @@
import { Buffer } from 'buffer';
import * as SecureStore from 'expo-secure-store';
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { BleError, BleErrorCode, type Device } from 'react-native-ble-plx';
import { INFO, PAIRING_TIMEOUT_MS, SERVICE } from '../ble/constants';
import { HubConnection, type EventListener, type Message } from '../ble/HubConnection';
import { bleManager, waitForPoweredOn } from '../ble/manager';
import type { HubInfo } from '../ble/types';
const LAST_HUB_KEY = 'allotment.lastHub';
const RECONNECT_DELAYS_MS = [4000, 5000, 8000, 10000];
/**
* - `idle`: no hub chosen.
* - `connecting` / `pairing`: first connection to a chosen hub.
* - `ready`: commands can be sent.
* - `reconnecting`: the link dropped after being ready (for example a hub reboot).
* - `failed`: the first connection failed; `error` says why.
*/
export type HubStatus = 'idle' | 'connecting' | 'pairing' | 'ready' | 'reconnecting' | 'failed';
export interface SelectedHub {
id: string;
name: string;
}
interface HubContextValue {
status: HubStatus;
hub: SelectedHub | null;
info: HubInfo | null;
error: string | null;
/** True once the chosen hub has been ready at least once in this session. */
entered: boolean;
connection: HubConnection | null;
connect: (hub: SelectedHub) => Promise<void>;
disconnect: () => Promise<void>;
send: (command: Message, timeoutMs?: number) => Promise<Message>;
onEvent: (listener: EventListener) => () => void;
refreshInfo: () => Promise<HubInfo | null>;
lastHub: SelectedHub | null;
}
const HubContext = createContext<HubContextValue | null>(null);
function describeError(e: unknown): string {
if (e instanceof BleError) {
switch (e.errorCode) {
case BleErrorCode.DeviceDisconnected:
return 'The hub dropped the connection. If you cancelled pairing or typed the wrong code, try again.';
case BleErrorCode.OperationCancelled:
return 'Connection cancelled.';
case BleErrorCode.DeviceConnectionFailed:
case BleErrorCode.OperationTimedOut:
return 'Could not reach the hub. Make sure it is powered on and nearby.';
default:
if (/encrypt|authenticat|insufficient/i.test(e.message)) {
return 'Pairing failed. If the hub was reset, open iPhone Settings > Bluetooth, choose "Forget This Device" for the hub, and connect again.';
}
return e.reason ?? e.message;
}
}
if (e instanceof Error && /disconnected/i.test(e.message)) {
return 'The hub dropped the connection. If you cancelled pairing or typed the wrong code, try again.';
}
return e instanceof Error ? e.message : String(e);
}
export function HubProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<HubStatus>('idle');
const [hub, setHub] = useState<SelectedHub | null>(null);
const [info, setInfo] = useState<HubInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [entered, setEntered] = useState(false);
const [connection, setConnection] = useState<HubConnection | null>(null);
const [lastHub, setLastHub] = useState<SelectedHub | null>(null);
const connRef = useRef<HubConnection | null>(null);
const listeners = useRef(new Set<EventListener>());
const sessionRef = useRef(0);
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const enteredRef = useRef(false);
useEffect(() => {
SecureStore.getItemAsync(LAST_HUB_KEY)
.then((raw) => {
if (raw) setLastHub(JSON.parse(raw));
})
.catch(() => undefined);
}, []);
const clearReconnect = () => {
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
};
const openLink = useCallback(async (target: SelectedHub, session: number, firstTime: boolean) => {
await waitForPoweredOn();
bleManager.stopDeviceScan();
let device: Device = await bleManager.connectToDevice(target.id, { requestMTU: 255, timeout: 15000 });
device = await device.discoverAllServicesAndCharacteristics();
if (session !== sessionRef.current) {
await bleManager.cancelDeviceConnection(device.id).catch(() => undefined);
return;
}
try {
const c = await device.readCharacteristicForService(SERVICE, INFO);
if (c.value) setInfo(JSON.parse(Buffer.from(c.value, 'base64').toString('utf8')));
} catch {
// Info is optional before pairing.
}
if (firstTime) setStatus('pairing');
const conn = new HubConnection(bleManager, device);
conn.onEvent((event) => listeners.current.forEach((l) => l(event)));
conn.start(() => handleDrop(session));
connRef.current = conn;
const response = await conn.send({ cmd: 'info' }, PAIRING_TIMEOUT_MS);
if (session !== sessionRef.current) {
await conn.close();
return;
}
setInfo(response.info as HubInfo);
setConnection(conn);
setStatus('ready');
setError(null);
enteredRef.current = true;
setEntered(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const scheduleReconnect = useCallback(
(target: SelectedHub, session: number, attempt: number) => {
clearReconnect();
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
reconnectTimer.current = setTimeout(async () => {
if (session !== sessionRef.current) return;
try {
await openLink(target, session, false);
} catch {
if (session !== sessionRef.current) return;
await connRef.current?.close();
connRef.current = null;
scheduleReconnect(target, session, attempt + 1);
}
}, delay);
},
[openLink],
);
const hubRef = useRef<SelectedHub | null>(null);
hubRef.current = hub;
function handleDrop(session: number) {
if (session !== sessionRef.current) return;
connRef.current = null;
setConnection(null);
const target = hubRef.current;
if (!target) return;
if (enteredRef.current) {
setStatus('reconnecting');
scheduleReconnect(target, session, 0);
}
}
const connect = useCallback(
async (target: SelectedHub) => {
clearReconnect();
const session = ++sessionRef.current;
await connRef.current?.close();
connRef.current = null;
enteredRef.current = false;
setEntered(false);
setConnection(null);
setHub(target);
hubRef.current = target;
setInfo(null);
setError(null);
setStatus('connecting');
try {
await openLink(target, session, true);
if (session !== sessionRef.current) return;
setLastHub(target);
await SecureStore.setItemAsync(LAST_HUB_KEY, JSON.stringify(target));
} catch (e) {
if (session !== sessionRef.current) return;
await (connRef.current as HubConnection | null)?.close();
connRef.current = null;
await bleManager.cancelDeviceConnection(target.id).catch(() => undefined);
setError(describeError(e));
setStatus('failed');
}
},
[openLink],
);
const disconnect = useCallback(async () => {
clearReconnect();
sessionRef.current++;
const conn = connRef.current;
connRef.current = null;
enteredRef.current = false;
setEntered(false);
setConnection(null);
setStatus('idle');
setInfo(null);
setError(null);
const target = hubRef.current;
setHub(null);
hubRef.current = null;
await conn?.close();
if (target) await bleManager.cancelDeviceConnection(target.id).catch(() => undefined);
setLastHub(null);
await SecureStore.deleteItemAsync(LAST_HUB_KEY).catch(() => undefined);
}, []);
const send = useCallback(async (command: Message, timeoutMs?: number) => {
const conn = connRef.current;
if (!conn) throw new Error('Not connected to the hub');
return conn.send(command, timeoutMs);
}, []);
const onEvent = useCallback((listener: EventListener) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
}, []);
const refreshInfo = useCallback(async () => {
const conn = connRef.current;
if (!conn) return null;
const response = await conn.send({ cmd: 'info' });
setInfo(response.info as HubInfo);
return response.info as HubInfo;
}, []);
useEffect(() => () => clearReconnect(), []);
const value = useMemo<HubContextValue>(
() => ({
status,
hub,
info,
error,
entered,
connection,
connect,
disconnect,
send,
onEvent,
refreshInfo,
lastHub,
}),
[status, hub, info, error, entered, connection, connect, disconnect, send, onEvent, refreshInfo, lastHub],
);
return <HubContext.Provider value={value}>{children}</HubContext.Provider>;
}
export function useHub(): HubContextValue {
const ctx = useContext(HubContext);
if (!ctx) throw new Error('useHub requires HubProvider');
return ctx;
}
/** Subscribes to hub events for the lifetime of the calling component. */
export function useHubEvent(listener: EventListener) {
const { onEvent } = useHub();
const ref = useRef(listener);
ref.current = listener;
useEffect(() => onEvent((e) => ref.current(e)), [onEvent]);
}

View File

@ -0,0 +1,24 @@
import type { EventListener, Message } from '../ble/HubConnection';
type OnEvent = (listener: EventListener) => () => void;
/**
* Resolves with the `complete` event (or rejects on the `error` event) for a
* queued command such as `valve` or `identify` (BLE_PROTOCOL.md sections 7.4-7.5).
*/
export function waitForResult(onEvent: OnEvent, target: string, serial: string, timeoutMs = 10_000): Promise<Message> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
off();
reject(new Error('No reply from the node'));
}, timeoutMs);
const off = onEvent((event) => {
if (event.target !== target || event.serial?.toUpperCase() !== serial.toUpperCase()) return;
if (event.event !== 'complete' && event.event !== 'error') return;
clearTimeout(timer);
off();
if (event.event === 'complete') resolve(event);
else reject(new Error(event.message ?? `${target} failed`));
});
});
}

View File

@ -0,0 +1,64 @@
import { useIsFocused } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { isUnsupported, type Message } from '../ble/HubConnection';
import { useHub } from './HubProvider';
export interface HubQuery<T> {
data: T | null;
error: string | null;
/** The hub answered `unknown_command`: its firmware predates this command. */
unsupported: boolean;
loading: boolean;
/** When `data` was last received (phone clock). */
fetchedAt: number | null;
refresh: () => Promise<void>;
}
/**
* Sends `command` while the hub is ready and the screen is focused, every
* `intervalMs` (or once when 0), and returns `select(response)`.
*/
export function useHubQuery<T>(
command: Message | null,
select: (response: Message) => T,
intervalMs = 0,
): HubQuery<T> {
const { status, send } = useHub();
const focused = useIsFocused();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [unsupported, setUnsupported] = useState(false);
const [loading, setLoading] = useState(false);
const [fetchedAt, setFetchedAt] = useState<number | null>(null);
const selectRef = useRef(select);
selectRef.current = select;
const key = command ? JSON.stringify(command) : null;
const refresh = useCallback(async () => {
if (!key) return;
setLoading(true);
try {
const response = await send(JSON.parse(key));
setData(selectRef.current(response));
setFetchedAt(Date.now());
setError(null);
setUnsupported(false);
} catch (e) {
if (isUnsupported(e)) setUnsupported(true);
else setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [key, send]);
const active = status === 'ready' && focused && key !== null;
useEffect(() => {
if (!active) return;
refresh();
if (!intervalMs || unsupported) return;
const id = setInterval(refresh, intervalMs);
return () => clearInterval(id);
}, [active, intervalMs, refresh, unsupported]);
return { data, error, unsupported, loading, fetchedAt, refresh };
}

View File

@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { ScanMode } from 'react-native-ble-plx';
import { parseAdvert, type HubAdvert } from '../ble/advert';
import { SERVICE } from '../ble/constants';
import { bleManager, waitForPoweredOn } from '../ble/manager';
export interface ScannedHub {
id: string;
name: string | null;
rssi: number | null;
advert: HubAdvert | null;
lastSeen: number;
}
const STALE_MS = 10_000;
/** Scans for hubs while `enabled`, dropping hubs not seen for 10 seconds. */
export function useHubScanner(enabled: boolean) {
const [hubs, setHubs] = useState<ScannedHub[]>([]);
const [error, setError] = useState<string | null>(null);
const seen = useRef(new Map<string, ScannedHub>());
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let flushTimer: ReturnType<typeof setInterval> | null = null;
const flush = () => {
const now = Date.now();
for (const [id, hub] of seen.current) {
if (now - hub.lastSeen > STALE_MS) seen.current.delete(id);
}
setHubs(
[...seen.current.values()].sort((a, b) => (a.name ?? a.id).localeCompare(b.name ?? b.id)),
);
};
waitForPoweredOn()
.then(() => {
if (cancelled) return;
setError(null);
bleManager.startDeviceScan(
[SERVICE],
{ allowDuplicates: true, scanMode: ScanMode.LowLatency },
(scanError, device) => {
if (scanError) {
setError(scanError.message);
return;
}
if (!device) return;
const previous = seen.current.get(device.id);
seen.current.set(device.id, {
id: device.id,
name: device.localName ?? previous?.name ?? device.name ?? null,
rssi: device.rssi ?? previous?.rssi ?? null,
advert: parseAdvert(device.manufacturerData) ?? previous?.advert ?? null,
lastSeen: Date.now(),
});
},
);
flushTimer = setInterval(flush, 1000);
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
});
return () => {
cancelled = true;
if (flushTimer) clearInterval(flushTimer);
bleManager.stopDeviceScan();
};
}, [enabled]);
return { hubs, error };
}

View File

@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { FONT } from '../theme/palette';
import { useTheme } from '../theme/ThemeProvider';
/** Native stack header styled like the web client's page titles: large Libre Baskerville, flat, themed. */
export function useStackScreenOptions() {
const theme = useTheme();
return useMemo(
() => ({
headerLargeTitle: true,
headerShadowVisible: false,
headerLargeTitleShadowVisible: false,
headerTransparent: false,
headerStyle: { backgroundColor: theme.bg },
headerLargeStyle: { backgroundColor: theme.bg },
headerTintColor: theme.accent,
headerTitleStyle: { fontFamily: FONT.semibold, color: theme.text },
headerLargeTitleStyle: { fontFamily: FONT.serif, color: theme.text },
headerBackButtonDisplayMode: 'minimal' as const,
contentStyle: { backgroundColor: theme.bg },
}),
[theme],
);
}

View File

@ -0,0 +1,30 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState } from 'react-native';
import { buildTheme, getDayProgress, type Theme } from './palette';
const ThemeContext = createContext<Theme | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [dayProgress, setDayProgress] = useState(() => getDayProgress());
useEffect(() => {
const tick = () => setDayProgress(getDayProgress());
const id = setInterval(tick, 60_000);
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') tick();
});
return () => {
clearInterval(id);
sub.remove();
};
}, []);
const theme = useMemo(() => buildTheme(dayProgress), [dayProgress]);
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
}
export function useTheme(): Theme {
const theme = useContext(ThemeContext);
if (!theme) throw new Error('useTheme requires ThemeProvider');
return theme;
}

View File

@ -0,0 +1,125 @@
/** Ported from packages/client/src/contexts/ThemeContext.jsx so the app follows the same day/night cycle. */
type Rgb = [number, number, number];
function parseHex(hex: string): Rgb {
const h = hex.replace('#', '');
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function toHex(rgb: Rgb): string {
return '#' + rgb.map((x) => Math.min(255, Math.max(0, x)).toString(16).padStart(2, '0')).join('');
}
function mixHex(a: string, b: string, t: number): string {
const pa = parseHex(a);
const pb = parseHex(b);
return toHex(pa.map((v, i) => Math.round(v + (pb[i] - v) * t)) as Rgb);
}
/** `#rrggbb` plus an alpha 0-1, as an rgba() string. */
export function withAlpha(hex: string, alpha: number): string {
const [r, g, b] = parseHex(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Smooth daylight factor: 0 around midnight, 1 at solar noon (local time). */
export function getDayProgress(d = new Date()): number {
const h = d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
return (Math.cos(((h - 12) / 24) * 2 * Math.PI) + 1) / 2;
}
const NIGHT = {
bg: '#0f1419',
surface: '#1a222c',
border: '#2a3544',
text: '#e8eef5',
accent: '#B9D835',
accentDim: '#1fa86a',
onAccent: '#0a120e',
};
const DAY = {
bg: '#ffffff',
surface: '#f3f5f7',
border: '#d5dce6',
text: '#0d0d0d',
accent: '#69980B',
accentDim: '#145a38',
onAccent: '#f4fbf7',
};
export const FIXED = {
danger: '#e56868',
warn: '#e6b35c',
info: '#3cd3fe',
water: '#3cd3fe',
waterDeep: '#3cd4fe3d',
temperature: '#FF9230',
};
export interface Theme {
dayProgress: number;
isDay: boolean;
bg: string;
surface: string;
border: string;
text: string;
muted: string;
faint: string;
accent: string;
accentDim: string;
onAccent: string;
/** 2px UI borders: text at 8 %. */
borderUi: string;
/** Pressed/hover wash: text at 8 %. */
wash: string;
accentWash: string;
danger: string;
warn: string;
info: string;
}
export function buildTheme(t: number): Theme {
const isDay = t >= 0.5;
const pick = (night: string, day: string) => (isDay ? day : night);
const text = pick(NIGHT.text, DAY.text);
const accent = pick(NIGHT.accent, DAY.accent);
return {
dayProgress: t,
isDay,
bg: mixHex(NIGHT.bg, DAY.bg, t),
surface: mixHex(NIGHT.surface, DAY.surface, t),
border: mixHex(NIGHT.border, DAY.border, t),
text,
muted: withAlpha(text, 0.75),
faint: withAlpha(text, 0.45),
accent,
accentDim: pick(NIGHT.accentDim, DAY.accentDim),
onAccent: pick(NIGHT.onAccent, DAY.onAccent),
borderUi: withAlpha(text, 0.08),
wash: withAlpha(text, 0.08),
accentWash: withAlpha(accent, 0.14),
danger: FIXED.danger,
warn: FIXED.warn,
info: FIXED.info,
};
}
export const RADIUS = {
control: 18,
option: 12,
tank: 20,
small: 10,
};
export const FONT = {
regular: 'Rubik_400Regular',
medium: 'Rubik_500Medium',
semibold: 'Rubik_600SemiBold',
bold: 'Rubik_700Bold',
extrabold: 'Rubik_800ExtraBold',
serif: 'LibreBaskerville_700Bold',
mono: 'JetBrainsMono_400Regular',
monoMedium: 'JetBrainsMono_500Medium',
};

4
packages/app/src/types/assets.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const asset: number;
export default asset;
}

View File

@ -0,0 +1,39 @@
export function formatAge(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) return '--';
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 3) return 'just now';
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ${m % 60}m ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function formatUptime(seconds: number | null | undefined): string {
if (seconds == null) return '--';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${Math.floor(seconds % 60)}s`;
}
export function signalQuality(rssi: number | null | undefined): string {
if (rssi == null || rssi === 0) return '--';
if (rssi >= -55) return 'strong';
if (rssi >= -67) return 'good';
if (rssi >= -80) return 'weak';
return 'poor';
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@ -0,0 +1,10 @@
import { useEffect, useState } from 'react';
export function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}

View File

@ -0,0 +1,15 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}

View File

@ -205,6 +205,57 @@
white-space: nowrap;
}
.select-trigger--multiple .select-value {
white-space: normal;
}
.select-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.select-tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 6px 3px 10px;
border-radius: 12px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
font-size: 0.85rem;
line-height: 1.2;
}
.select-tag-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select-tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 8px;
color: var(--muted);
transition:
background 0.15s ease,
color 0.15s ease;
}
.select-tag-remove:hover {
background: color-mix(in srgb, var(--text) 12%, transparent);
color: var(--text);
}
.select-option-check {
flex-shrink: 0;
color: var(--accent);
}
.select-selected-label {
overflow: hidden;
text-overflow: ellipsis;

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { IoChevronDown } from "react-icons/io5";
import { IoCheckmark, IoChevronDown, IoClose } from "react-icons/io5";
import SimpleBar from "simplebar-react";
import "simplebar-react/dist/simplebar.min.css";
import "./Select.css";
@ -10,10 +10,12 @@ function cx(...parts) {
/**
* Custom select with optional search. onChange receives a synthetic event
* `{ target: { value, name } }` like a native select element.
* `{ target: { value, name } }` like a native select element. With `multiple`,
* `value` and the emitted value are arrays and options toggle on click.
*
* @param {object} props
* @param {string} props.value
* @param {string | string[]} props.value
* @param {boolean} [props.multiple]
* @param {(e: { target: { value: string, name?: string } }) => void} [props.onChange]
* @param {{ value: string, label: string, disabled?: boolean, search?: string }[]} [props.options]
* @param {string} [props.placeholder]
@ -37,13 +39,29 @@ export function Select({
renderSelected,
className = "",
name,
multiple = false,
}) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const wrapperRef = useRef(null);
const searchInputRef = useRef(null);
const selectedOption = options.find((opt) => opt.value === value);
const selectedValues = multiple
? Array.isArray(value)
? value
: []
: [value];
const isSelected = (optionValue) => selectedValues.includes(optionValue);
const selectedOption = multiple
? null
: options.find((opt) => opt.value === value);
const selectedOptions = multiple
? selectedValues.map(
(v) => options.find((opt) => opt.value === v) ?? { value: v, label: v },
)
: [];
const emit = (next) => onChange?.({ target: { value: next, name } });
const filteredOptions =
searchable && searchTerm
@ -118,16 +136,25 @@ export function Select({
const handleOptionClick = (option) => {
if (option.disabled) return;
onChange?.({
target: {
value: option.value,
name,
},
});
if (multiple) {
emit(
isSelected(option.value)
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value],
);
return;
}
emit(option.value);
setIsOpen(false);
setSearchTerm("");
};
const handleTagRemove = (event, optionValue) => {
event.stopPropagation();
if (disabled) return;
emit(selectedValues.filter((v) => v !== optionValue));
};
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
@ -148,6 +175,32 @@ export function Select({
);
}
if (multiple) {
if (selectedOptions.length === 0) {
return <span className="select-placeholder">{placeholder}</span>;
}
return (
<span className="select-tags">
{selectedOptions.map((option) => (
<span key={option.value} className="select-tag">
<span className="select-tag-label">
{renderSelected ? renderSelected(option) : option.label}
</span>
<span
role="button"
tabIndex={-1}
aria-label={`Remove ${option.label}`}
className="select-tag-remove"
onClick={(e) => handleTagRemove(e, option.value)}
>
<IoClose size={12} />
</span>
</span>
))}
</span>
);
}
if (!selectedOption) {
return <span className="select-placeholder">{placeholder}</span>;
}
@ -178,7 +231,11 @@ export function Select({
<button
type="button"
id={id}
className={cx("select-trigger", isOpen && "select-trigger--open")}
className={cx(
"select-trigger",
isOpen && "select-trigger--open",
multiple && "select-trigger--multiple",
)}
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
@ -203,14 +260,17 @@ export function Select({
key={option.value}
className={cx(
"select-option",
option.value === value && "select-option--selected",
isSelected(option.value) && "select-option--selected",
option.disabled && "select-option--disabled",
)}
onClick={() => handleOptionClick(option)}
role="option"
aria-selected={option.value === value}
aria-selected={isSelected(option.value)}
>
{renderOptionContent(option)}
{multiple && isSelected(option.value) ? (
<IoCheckmark className="select-option-check" size={16} />
) : null}
</div>
))
)}

View File

@ -153,6 +153,7 @@ export function ApiProvider({ children }) {
(patchId) => emitAck("getPatch", { patchId }),
[emitAck],
);
const getNodes = useCallback(() => emitAck("getNodes"), [emitAck]);
const getHistory = useCallback(
(payload) => emitAck("getHistory", payload),
@ -274,6 +275,7 @@ export function ApiProvider({ children }) {
getWateringSessions,
getWaterSchedules,
getPatch,
getNodes,
getHistory,
editPatch,
uploadImage,

View File

@ -204,8 +204,12 @@ export function EditPatchDrawer({ open, onClose, patch }) {
const waterNeedsId = useId();
const soilPhMinId = useId();
const soilPhMaxId = useId();
const { editPatch } = useApi();
const nodesId = useId();
const { editPatch, getNodes } = useApi();
const { user } = useAuth();
const [nodeOptions, setNodeOptions] = useState([]);
const [selectedNodes, setSelectedNodes] = useState([]);
const [nodesLoaded, setNodesLoaded] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState(DEFAULT_PRODUCE_ICON);
@ -302,6 +306,30 @@ export function EditPatchDrawer({ open, onClose, patch }) {
}
}, [open, patch]);
const patchId = patch?.id;
useEffect(() => {
if (!open || !patchId) return;
let cancelled = false;
setNodesLoaded(false);
getNodes()
.then(({ nodes = [] }) => {
if (cancelled) return;
setNodeOptions(
nodes.map((n) => ({ value: n.serial, label: n.serial })),
);
setSelectedNodes(
nodes.filter((n) => n.patches?.includes(patchId)).map((n) => n.serial),
);
setNodesLoaded(true);
})
.catch(() => {
if (!cancelled) setNodeOptions([]);
});
return () => {
cancelled = true;
};
}, [open, patchId, getNodes]);
const handleSave = async () => {
if (!patch || saving || !user) return;
setSaving(true);
@ -338,6 +366,7 @@ export function EditPatchDrawer({ open, onClose, patch }) {
soilPhMax,
waterNeeds,
images: patchImages,
...(nodesLoaded ? { nodes: selectedNodes } : {}),
});
onClose();
} catch (e) {
@ -479,6 +508,28 @@ export function EditPatchDrawer({ open, onClose, patch }) {
/>
</Flex>
</Flex>
<Flex vertical gap={4} className="animated">
<Label label="Nodes" as="label" className="muted" htmlFor={nodesId} />
<Select
id={nodesId}
name="nodes"
multiple
searchable
value={selectedNodes}
options={nodeOptions}
disabled={!user || !nodesLoaded}
placeholder={
nodesLoaded && nodeOptions.length === 0
? "No nodes reported by the hub yet"
: "Sensor nodes for this patch"
}
renderOption={(o) => (
<span className="select-option-label mono">{o.label}</span>
)}
renderSelected={(o) => <span className="mono">{o.label}</span>}
onChange={(e) => setSelectedNodes(e.target.value)}
/>
</Flex>
<Flex gap={28} wrap className="animated">
<Flex vertical gap={4} style={{ flex: "1 1 8rem" }}>
<Label

View File

@ -1,3 +1,4 @@
import crypto from "crypto";
import { Router } from "express";
import { Issuer, generators } from "openid-client";
import config from "../config.js";
@ -5,11 +6,42 @@ import { getLogger } from "../logger.js";
const logger = getLogger("OIDC");
export const SESSION_COOKIE = "ta.sid";
const DEFAULT_APP_REDIRECT = "allotment://auth";
let client;
/** Same format as the express-session cookie value (`s:` + cookie-signature). */
function signSessionId(sessionId) {
const mac = crypto
.createHmac("sha256", config.auth.sessionSecret)
.update(sessionId)
.digest("base64")
.replace(/=+$/, "");
return `s:${sessionId}.${mac}`;
}
/**
* Lets the iOS app authenticate with `Authorization: Bearer <token>` by
* presenting the token from the app login redirect as the session cookie.
* Must run before the session middleware.
*/
export function bearerSessionMiddleware(req, _res, next) {
const match = /^Bearer\s+(s:.+)$/i.exec(req.headers.authorization || "");
if (match) {
req.headers.cookie = `${SESSION_COOKIE}=${encodeURIComponent(match[1].trim())}`;
}
next();
}
export async function createOidcClient() {
const { issuer: issuerUrl, clientId, clientSecret, redirectUri } =
config.auth.oidc;
const {
issuer: issuerUrl,
clientId,
clientSecret,
redirectUri,
appCallbackUri,
} = config.auth.oidc;
if (!issuerUrl || !clientId || !redirectUri) {
logger.warn(
@ -22,7 +54,9 @@ export async function createOidcClient() {
client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret || undefined,
redirect_uris: [redirectUri],
redirect_uris: appCallbackUri
? [redirectUri, appCallbackUri]
: [redirectUri],
response_types: ["code"],
});
return client;
@ -40,8 +74,13 @@ export function createOidcRouter(clientUrl) {
const state = generators.state();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
req.session.oidc = { nonce, state, code_verifier };
const appLogin = req.query.app === "1";
const redirectUri =
(appLogin && config.auth.oidc.appCallbackUri) ||
config.auth.oidc.redirectUri;
req.session.oidc = { nonce, state, code_verifier, appLogin, redirectUri };
const authorizationUrl = client.authorizationUrl({
redirect_uri: redirectUri,
scope: "openid profile email",
state,
nonce,
@ -69,7 +108,8 @@ export function createOidcRouter(clientUrl) {
nonce: req.session.oidc?.nonce,
code_verifier: req.session.oidc?.code_verifier,
};
const redirectUri = config.auth.oidc.redirectUri;
const redirectUri =
req.session.oidc?.redirectUri || config.auth.oidc.redirectUri;
const tokenSet = await client.callback(redirectUri, params, checks);
const claims = tokenSet.claims();
req.session.user = {
@ -77,10 +117,18 @@ export function createOidcRouter(clientUrl) {
email: claims.email,
name: claims.name,
};
const appLogin = req.session.oidc?.appLogin === true;
delete req.session.oidc;
await new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
if (appLogin) {
const token = signSessionId(req.sessionID);
const target = new URL(config.auth.appRedirectUri || DEFAULT_APP_REDIRECT);
target.searchParams.set("token", token);
res.redirect(target.toString());
return;
}
res.redirect(clientUrl || "/");
} catch (e) {
logger.error("callback error:", e);

View File

@ -24,6 +24,7 @@ function ensureShape(envConfig) {
if (!envConfig.device) envConfig.device = {};
if (!envConfig.mqtt) envConfig.mqtt = {};
if (!envConfig.s3) envConfig.s3 = {};
if (!envConfig.hub) envConfig.hub = {};
if (!Array.isArray(envConfig.patches)) envConfig.patches = [];
}
@ -72,6 +73,15 @@ function loadConfig() {
if (process.env.OIDC_REDIRECT_URI) {
envConfig.auth.oidc.redirectUri = process.env.OIDC_REDIRECT_URI;
}
if (process.env.OIDC_APP_CALLBACK_URI) {
envConfig.auth.oidc.appCallbackUri = process.env.OIDC_APP_CALLBACK_URI;
}
if (process.env.APP_REDIRECT_URI) {
envConfig.auth.appRedirectUri = process.env.APP_REDIRECT_URI;
}
if (process.env.HUB_API_KEY) {
envConfig.hub.apiKey = process.env.HUB_API_KEY;
}
if (process.env.DEMO_TTN_APP_ID) {
envConfig.device.ttnApplicationId = process.env.DEMO_TTN_APP_ID;
}

View File

@ -5,7 +5,7 @@
"logLevel": "trace"
},
"app": {
"urlClient": "http://localhost:5173"
"urlClient": "http://192.168.68.195:5173"
},
"database": {
"mongo": {
@ -18,7 +18,7 @@
"issuer": "https://auth.tombutcher.work/realms/master",
"clientId": "toms-allotment",
"clientSecret": "fzjKtXLbCvAUJ06ud2jdGe7OAfRSLZx3",
"redirectUri": "http://localhost:5173/auth/callback"
"redirectUri": "http://192.168.68.195:5173/auth/callback"
}
},
"device": {

View File

@ -0,0 +1,27 @@
import mongoose from "mongoose";
const nodeSchema = new mongoose.Schema(
{
deviceId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Device",
required: true,
index: true,
},
/** 8 upper-case hex digits, as reported by the hub. */
serial: {
type: String,
required: true,
uppercase: true,
trim: true,
match: /^[0-9A-F]{8}$/,
},
/** Logical patch ids (Patch `patchId`) this node reports for. */
patches: { type: [String], default: [] },
},
{ timestamps: true },
);
nodeSchema.index({ deviceId: 1, serial: 1 }, { unique: true });
export const Node = mongoose.model("Node", nodeSchema);

View File

@ -1,6 +1,7 @@
export { Device } from "./Device.schema.js";
export { History } from "./History.schema.js";
export { Image } from "./Image.schema.js";
export { Node } from "./Node.schema.js";
export { Patch } from "./Patch.schema.js";
export { WateringSession } from "./WateringSession.schema.js";
export { WaterSchedule } from "./WaterSchedule.schema.js";

View File

@ -19,8 +19,14 @@ import { listWaterSchedulesForConfiguredDevice } from "./services/waterSchedules
import { SubscriptionRegistry } from "./socket/SubscriptionRegistry.js";
import { registerAllotmentSocketApi } from "./socket/allotmentSocketApi.js";
import { startHistorySampler } from "./services/history.js";
import { createOidcClient, createOidcRouter } from "./auth/oidc.js";
import {
SESSION_COOKIE,
bearerSessionMiddleware,
createOidcClient,
createOidcRouter,
} from "./auth/oidc.js";
import { createImagesRouter } from "./routes/images.js";
import { createHubRouter } from "./routes/hub.js";
import { getLogger, getLogLevel } from "./logger.js";
const logger = getLogger("App");
@ -60,8 +66,10 @@ app.use(
}),
);
app.use("/api/hub", createHubRouter());
const sessionMiddleware = session({
name: "ta.sid",
name: SESSION_COOKIE,
secret: config.auth.sessionSecret,
resave: false,
saveUninitialized: false,
@ -77,6 +85,7 @@ const sessionMiddleware = session({
},
});
app.use(bearerSessionMiddleware);
app.use(sessionMiddleware);
app.use(express.json());
@ -101,6 +110,7 @@ const io = new Server(server, {
},
});
io.engine.use(bearerSessionMiddleware);
io.engine.use((req, res, next) => {
sessionMiddleware(req, res, next);
});

View File

@ -0,0 +1,99 @@
import crypto from "crypto";
import express from "express";
import config from "../config.js";
import { Patch } from "../db/models/index.js";
import { getConfiguredDevice } from "../services/devices.js";
import { normalizeProduceIcon } from "../services/patches.js";
import {
NodeInputError,
listNodes,
setNodePatches,
upsertNodes,
} from "../services/nodes.js";
import { getLogger } from "../logger.js";
const logger = getLogger("HubApi");
function keysMatch(given, expected) {
const a = Buffer.from(given);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function requireHubKey(req, res, next) {
const expected = config.hub?.apiKey;
if (!expected) {
res.status(503).json({ status: "error", error: "Hub API not configured" });
return;
}
const match = /^Bearer\s+(.+)$/i.exec(req.get("authorization") || "");
if (!match || !keysMatch(match[1].trim(), expected)) {
res.status(401).json({ status: "error", error: "Unauthorized" });
return;
}
next();
}
function sendError(res, e, fallback) {
if (e instanceof NodeInputError) {
res.status(400).json({ status: "error", error: e.message });
return;
}
logger.error(fallback, e);
res.status(500).json({ status: "error", error: e.message || fallback });
}
/** HTTP API used by the hub firmware to relay BLE patch assignments (BLE_PROTOCOL.md Appendix A). */
export function createHubRouter() {
const router = express.Router();
router.use(requireHubKey);
router.use(express.json());
router.get("/patches", async (_req, res) => {
try {
const device = await getConfiguredDevice();
const docs = device
? await Patch.find({ deviceId: device._id })
.select("patchId name icon")
.sort({ patchId: 1 })
.lean()
: [];
const patches = docs.map((p) => ({
id: p.patchId,
name: p.name ?? "",
icon: normalizeProduceIcon(p.icon),
}));
res.json({ status: "ok", data: { patches } });
} catch (e) {
sendError(res, e, "list patches failed");
}
});
router.get("/nodes", async (_req, res) => {
try {
res.json({ status: "ok", data: { nodes: await listNodes() } });
} catch (e) {
sendError(res, e, "list nodes failed");
}
});
router.post("/nodes", async (req, res) => {
try {
const nodes = await upsertNodes(req.body?.serials);
res.json({ status: "ok", data: { nodes } });
} catch (e) {
sendError(res, e, "register nodes failed");
}
});
router.put("/nodes/:serial", async (req, res) => {
try {
const node = await setNodePatches(req.params.serial, req.body?.patches);
res.json({ status: "ok", data: { node } });
} catch (e) {
sendError(res, e, "set node patches failed");
}
});
return router;
}

View File

@ -1,6 +1,7 @@
import config from "../config.js";
import { Device, Patch, WateringSession } from "../db/models/index.js";
import { formatClientPatches } from "./patches.js";
import { attachNodesToPatches } from "./nodes.js";
import { getOrCreateWaterTank } from "./waterTank.js";
import {
formatPowerStateForClient,
@ -45,7 +46,7 @@ export async function getDeviceState() {
...device,
id,
_id: undefined,
patches: formatClientPatches(patchList),
patches: await attachNodesToPatches(formatClientPatches(patchList)),
},
};
}
@ -78,5 +79,6 @@ export async function getPatchState(patchId) {
.populate({ path: "images", select: "blurHash contentType" })
.lean();
if (!p) return null;
return formatClientPatches([p])[0];
const [patch] = await attachNodesToPatches(formatClientPatches([p]));
return patch;
}

View File

@ -0,0 +1,143 @@
import { Node, Patch } from "../db/models/index.js";
import { getConfiguredDevice } from "./devices.js";
const SERIAL_RE = /^[0-9A-F]{8}$/;
export class NodeInputError extends Error {}
/** @returns {string} upper-case serial, or throws NodeInputError */
export function normalizeSerial(value) {
const s = String(value ?? "").trim().toUpperCase();
if (!SERIAL_RE.test(s)) {
throw new NodeInputError(`Invalid node serial "${value}"`);
}
return s;
}
function normalizeSerialList(values) {
if (!Array.isArray(values)) {
throw new NodeInputError("Expected a list of node serials");
}
return [...new Set(values.map(normalizeSerial))];
}
export function formatClientNode(n) {
return {
serial: n.serial,
patches: Array.isArray(n.patches) ? n.patches : [],
updatedAt: n.updatedAt,
};
}
async function requireDevice() {
const device = await getConfiguredDevice();
if (!device) throw new Error("No device configured");
return device;
}
/** Validates patch ids against the configured device's patches. */
async function normalizePatchIds(deviceId, values) {
if (!Array.isArray(values)) {
throw new NodeInputError("Expected a list of patch ids");
}
const ids = [
...new Set(values.map((v) => String(v ?? "").trim()).filter(Boolean)),
];
if (ids.length === 0) return ids;
const found = await Patch.find({ deviceId, patchId: { $in: ids } })
.select("patchId")
.lean();
const known = new Set(found.map((p) => p.patchId));
const unknown = ids.filter((id) => !known.has(id));
if (unknown.length) {
throw new NodeInputError(`Unknown patch id: ${unknown.join(", ")}`);
}
return ids;
}
export async function listNodes() {
const device = await getConfiguredDevice();
if (!device) return [];
const nodes = await Node.find({ deviceId: device._id })
.sort({ serial: 1 })
.lean();
return nodes.map(formatClientNode);
}
/** Creates Node documents for serials that do not exist yet. */
export async function upsertNodes(serials) {
const device = await requireDevice();
const list = normalizeSerialList(serials);
for (const serial of list) {
await Node.updateOne(
{ deviceId: device._id, serial },
{ $setOnInsert: { deviceId: device._id, serial, patches: [] } },
{ upsert: true },
);
}
return listNodes();
}
/** Replaces one node's patch list, creating the node if needed. */
export async function setNodePatches(serial, patchIds) {
const device = await requireDevice();
const s = normalizeSerial(serial);
const patches = await normalizePatchIds(device._id, patchIds);
const node = await Node.findOneAndUpdate(
{ deviceId: device._id, serial: s },
{ $set: { patches }, $setOnInsert: { deviceId: device._id, serial: s } },
{ new: true, upsert: true },
).lean();
return formatClientNode(node);
}
/**
* Makes `serials` the exact set of nodes assigned to `patchId`: adds the patch
* to each selected node (creating it if needed) and removes it from the rest.
*/
export async function setPatchNodes(patchId, serials) {
const device = await requireDevice();
const id = String(patchId ?? "").trim();
if (!id) throw new NodeInputError("patchId required");
const list = normalizeSerialList(serials);
await Node.updateMany(
{ deviceId: device._id, serial: { $nin: list }, patches: id },
{ $pull: { patches: id } },
);
for (const serial of list) {
await Node.updateOne(
{ deviceId: device._id, serial },
{
$addToSet: { patches: id },
$setOnInsert: { deviceId: device._id, serial },
},
{ upsert: true },
);
}
return list;
}
/** @returns {Promise<Map<string, string[]>>} patchId -> sorted node serials */
export async function getNodesByPatch() {
const nodes = await listNodes();
const map = new Map();
for (const n of nodes) {
for (const patchId of n.patches) {
if (!map.has(patchId)) map.set(patchId, []);
map.get(patchId).push(n.serial);
}
}
return map;
}
export async function nodesForPatch(patchId) {
const map = await getNodesByPatch();
return map.get(patchId) ?? [];
}
/** Adds `nodes: string[]` to client patch objects. */
export async function attachNodesToPatches(patches) {
const map = await getNodesByPatch();
return patches.map((p) => ({ ...p, nodes: map.get(p.id) ?? [] }));
}

View File

@ -1,5 +1,5 @@
import mongoose from "mongoose";
import { Device, Image, Patch } from "../db/models/index.js";
import { Device, Image, Node, Patch } from "../db/models/index.js";
import config from "../config.js";
import { deleteOrphanImages } from "./images.js";
import {
@ -255,7 +255,7 @@ function parsePlantedAtUpdate(value) {
return d;
}
function normalizeProduceIcon(value) {
export function normalizeProduceIcon(value) {
if (value == null || typeof value !== "string") return DEFAULT_PRODUCE_ICON;
const s = value.trim();
if (PRODUCE_ICON_IDS.includes(s)) return s;
@ -396,6 +396,7 @@ export async function syncPatchesFromConfig(config) {
if (!Array.isArray(config.patches) || config.patches.length === 0) {
await Patch.deleteMany({ deviceId: device._id });
await Node.updateMany({ deviceId: device._id }, { $set: { patches: [] } });
return;
}
@ -444,6 +445,10 @@ export async function syncPatchesFromConfig(config) {
deviceId: device._id,
patchId: { $nin: ids },
});
await Node.updateMany(
{ deviceId: device._id },
{ $pull: { patches: { $nin: ids } } },
);
}
export async function updatePatchesFromUplinkPayload(dev, payload) {

View File

@ -12,6 +12,12 @@ import {
} from "../services/wateringSessions.js";
import { SubscriptionRegistry } from "./SubscriptionRegistry.js";
import { updatePatchForConfiguredDevice } from "../services/patches.js";
import {
attachNodesToPatches,
listNodes,
normalizeSerial,
setPatchNodes,
} from "../services/nodes.js";
import {
getPatchHistory,
getPowerStateHistory,
@ -106,6 +112,16 @@ export function registerAllotmentSocketApi(socket, deps) {
}
});
socket.on("getNodes", async (cb) => {
try {
const nodes = await listNodes();
safeCb(cb, ackOk({ nodes }));
} catch (e) {
logger.error("getNodes", e);
safeCb(cb, ackErr(e.message || "getNodes failed"));
}
});
socket.on("getPatch", async (payload, cb) => {
try {
const patchId = payload?.patchId;
@ -169,7 +185,15 @@ export function registerAllotmentSocketApi(socket, deps) {
safeCb(cb, ackErr("patchId required"));
return;
}
const patch = await updatePatchForConfiguredDevice(patchId, {
const nodes = payload?.nodes;
if (nodes !== undefined) {
if (!Array.isArray(nodes)) {
safeCb(cb, ackErr("nodes must be a list of serials"));
return;
}
nodes.forEach(normalizeSerial);
}
const updated = await updatePatchForConfiguredDevice(patchId, {
name: payload?.name,
description: payload?.description,
icon: payload?.icon,
@ -183,10 +207,14 @@ export function registerAllotmentSocketApi(socket, deps) {
waterNeeds: payload?.waterNeeds,
images: payload?.images,
});
if (!patch) {
if (!updated) {
safeCb(cb, ackErr("Patch not found", "NOT_FOUND"));
return;
}
if (nodes !== undefined) {
await setPatchNodes(updated.id, nodes);
}
const [patch] = await attachNodesToPatches([updated]);
emitPatchUpdated(patch);
safeCb(cb, ackOk({ patch }));
} catch (e) {

8056
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More