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.
@ -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=
|
||||
|
||||
4
packages/app/.env.example
Normal 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
@ -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
@ -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
53
packages/app/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/app/assets/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
packages/app/assets/images/pcb-board.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
packages/app/assets/images/solar-panel.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
1
packages/app/assets/lottie/water-animation.json
Normal file
3
packages/app/assets/produce/apple.svg
Normal 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 |
3
packages/app/assets/produce/beet.svg
Normal 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 |
3
packages/app/assets/produce/cabbage.svg
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
27
packages/app/assets/produce/carrot.svg
Normal 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 |
3
packages/app/assets/produce/cherry.svg
Normal 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 |
3
packages/app/assets/produce/corn.svg
Normal 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 |
3
packages/app/assets/produce/cucumber.svg
Normal 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 |
3
packages/app/assets/produce/kiwi.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
3
packages/app/assets/produce/leek.svg
Normal 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 |
3
packages/app/assets/produce/lettuce.svg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
3
packages/app/assets/produce/peas.svg
Normal 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 |
3
packages/app/assets/produce/plum.svg
Normal 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 |
3
packages/app/assets/produce/potato.svg
Normal 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 |
3
packages/app/assets/produce/radish.svg
Normal 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 |
3
packages/app/assets/produce/raspberry.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
3
packages/app/assets/produce/strawberry.svg
Normal 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 |
3
packages/app/assets/produce/tomato.svg
Normal 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 |
BIN
packages/app/assets/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
8
packages/app/metro.config.js
Normal 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
@ -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
|
||||
}
|
||||
55
packages/app/plugins/withSceneLifecycle.js
Normal 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));
|
||||
};
|
||||
27
packages/app/src/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
369
packages/app/src/app/(tabs)/nodes/[serial].tsx
Normal 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' },
|
||||
});
|
||||
13
packages/app/src/app/(tabs)/nodes/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
packages/app/src/app/(tabs)/nodes/calibrate/[serial].tsx
Normal 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 },
|
||||
});
|
||||
202
packages/app/src/app/(tabs)/nodes/index.tsx
Normal 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' },
|
||||
});
|
||||
11
packages/app/src/app/(tabs)/power/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
packages/app/src/app/(tabs)/power/index.tsx
Normal 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 },
|
||||
});
|
||||
13
packages/app/src/app/(tabs)/settings/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
packages/app/src/app/(tabs)/settings/about.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
packages/app/src/app/(tabs)/settings/firmware.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
packages/app/src/app/(tabs)/settings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
packages/app/src/app/(tabs)/tank/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
packages/app/src/app/(tabs)/tank/index.tsx
Normal 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 },
|
||||
});
|
||||
99
packages/app/src/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
packages/app/src/app/hubs.tsx
Normal 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'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's pairings were cleared, open iPhone Settings, then Bluetooth, choose "Forget This
|
||||
Device" 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 },
|
||||
});
|
||||
11
packages/app/src/app/index.tsx
Normal 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" />;
|
||||
}
|
||||
55
packages/app/src/app/login.tsx
Normal 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 },
|
||||
});
|
||||
117
packages/app/src/auth/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
132
packages/app/src/ble/HubConnection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
packages/app/src/ble/advert.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
114
packages/app/src/ble/calibration.ts
Normal 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 };
|
||||
}
|
||||
10
packages/app/src/ble/constants.ts
Normal 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;
|
||||
191
packages/app/src/ble/firmware.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
56
packages/app/src/ble/framer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/app/src/ble/manager.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
61
packages/app/src/ble/types.ts
Normal 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;
|
||||
}
|
||||
57
packages/app/src/components/Badge.tsx
Normal 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 },
|
||||
});
|
||||
63
packages/app/src/components/Bars.tsx
Normal 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 },
|
||||
});
|
||||
97
packages/app/src/components/Button.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
206
packages/app/src/components/FirmwareUploader.tsx
Normal 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 },
|
||||
});
|
||||
82
packages/app/src/components/FlowLine.tsx
Normal 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 },
|
||||
},
|
||||
});
|
||||
23
packages/app/src/components/HubBanner.tsx
Normal 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 },
|
||||
});
|
||||
26
packages/app/src/components/Icon.tsx
Normal 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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
packages/app/src/components/Input.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
229
packages/app/src/components/Layout.tsx
Normal 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 },
|
||||
});
|
||||
94
packages/app/src/components/LiveChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
packages/app/src/components/ProduceIcon.tsx
Normal 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" />;
|
||||
}
|
||||
68
packages/app/src/components/Segmented.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
141
packages/app/src/components/SelectSheet.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
37
packages/app/src/components/Text.tsx
Normal 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} />;
|
||||
}
|
||||
93
packages/app/src/components/Toast.tsx
Normal 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 },
|
||||
},
|
||||
});
|
||||
116
packages/app/src/components/Vessel.tsx
Normal 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 },
|
||||
});
|
||||
274
packages/app/src/hub/HubProvider.tsx
Normal 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]);
|
||||
}
|
||||
24
packages/app/src/hub/events.ts
Normal 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`));
|
||||
});
|
||||
});
|
||||
}
|
||||
64
packages/app/src/hub/useHubQuery.ts
Normal 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 };
|
||||
}
|
||||
75
packages/app/src/hub/useHubScanner.ts
Normal 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 };
|
||||
}
|
||||
24
packages/app/src/navigation/stackOptions.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
30
packages/app/src/theme/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
125
packages/app/src/theme/palette.ts
Normal 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
@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const asset: number;
|
||||
export default asset;
|
||||
}
|
||||
39
packages/app/src/utils/format.ts
Normal 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);
|
||||
}
|
||||
10
packages/app/src/utils/useNow.ts
Normal 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;
|
||||
}
|
||||
15
packages/app/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
27
packages/server/db/models/Node.schema.js
Normal 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);
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
99
packages/server/routes/hub.js
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
143
packages/server/services/nodes.js
Normal 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) ?? [] }));
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||