diff --git a/.bmad-loop/operator/10-1-releases-with-one-version-and-firmware-binaries.json b/.bmad-loop/operator/10-1-releases-with-one-version-and-firmware-binaries.json new file mode 100644 index 00000000..a8cd23bf --- /dev/null +++ b/.bmad-loop/operator/10-1-releases-with-one-version-and-firmware-binaries.json @@ -0,0 +1,11 @@ +{ + "actions": [ + "After this PR merges, cut the first release from main: keep the charts at 0.1.0 (or run deploy/charts/set-version.sh in a chore(release) PR and merge it), then push the tag: git tag -a v0.1.0 -m 'Coldframe 0.1.0' && git push origin v0.1.0.", + "Watch the Release workflow run for that tag and confirm it is green and that the GitHub Release v0.1.0 has coldframe-hub/node-0.1.0.bin and .elf, every -0.1.0.tgz, SHA256SUMS and the grouped notes with the docs/build/apps.md link.", + "Confirm ghcr.io/escendit/coldframe/{server,web,migrations,keycloak}:0.1.0 exist, then download the Release assets and run sha256sum --check --strict SHA256SUMS." + ], + "parked_at": "2026-10-19", + "run_id": "20261012-230921-d74a", + "spec_file": "_bmad-output/implementation-artifacts/spec-10-1-releases-with-one-version-and-firmware-binaries.md", + "story_key": "10-1-releases-with-one-version-and-firmware-binaries" +} \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..f40b7dc6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,126 @@ +# Checks the adopter docs (Story 10.2): every relative link and referenced repository path +# resolves, and every command block of the build guide (docs/build/*.md) either runs against the +# firmware crates (``, builds only) or, when it needs a board +# (``), names only real espflash subcommands. Nothing is ever flashed, and no +# external link is fetched. Not a required status check. +# +# Actions are pinned to a commit; the comment names the release. The firmware setup of the +# `commands` job is copied from the `firmware` job of ci.yml: keep the two in step. +name: Docs + +on: + pull_request: + paths: + # Everything the docs link to or mirror (the toolchain pins of ci.yml and release.yml). + - "docs/**" + - "hardware/**" + - "README.md" + - "LICENSE" + - ".gitignore" + - "apps/**" + - "packages/**" + - "deploy/**" + - "tests/**" + - "aspire/**" + - ".github/workflows/**" + push: + branches: + - main + paths: + - "docs/**" + - "hardware/**" + - "README.md" + - "LICENSE" + - ".gitignore" + - "apps/**" + - "packages/**" + - "deploy/**" + - "tests/**" + - "aspire/**" + - ".github/workflows/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + links: + name: Links + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test the doc checks + run: docs/test.sh + + - name: Check links and paths + run: docs/check-links.sh + + commands: + name: Commands + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + # The same pins as the `firmware` job of ci.yml. + ESPUP_VERSION: "0.17.1" + ESP_TOOLCHAIN_VERSION: "1.95.0.0" + ESPFLASH_VERSION: "4.5.0" + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # cmake and ninja for the mbedTLS rebuild; libudev for espflash. + - name: Install build tools + run: sudo apt-get update && sudo apt-get install --yes --no-install-recommends cmake ninja-build libudev-dev pkg-config + + - name: Cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: | + apps/rs/hub + apps/rs/node + key: espup-${{ env.ESPUP_VERSION }}-esp-${{ env.ESP_TOOLCHAIN_VERSION }}-espflash-${{ env.ESPFLASH_VERSION }} + cache-bin: "true" + cache-directories: | + ~/.rustup/toolchains/esp + ~/export-esp.sh + + # On the runner's stable toolchain: apps/rs/hub/rust-toolchain.toml names `esp`, which is not + # installed until the next step. + - name: Install espup and espflash + working-directory: apps/rs/hub + run: | + if [[ "$(espup --version 2>/dev/null)" != "espup ${ESPUP_VERSION}" ]]; then + rustup run stable cargo install espup --version "${ESPUP_VERSION}" --locked --force + fi + if [[ "$(espflash --version 2>/dev/null)" != "espflash ${ESPFLASH_VERSION}" ]]; then + rustup run stable cargo install espflash --version "${ESPFLASH_VERSION}" --locked --force + fi + + - name: Install the esp toolchain + working-directory: apps/rs/hub + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + if [[ ! -f "${HOME}/export-esp.sh" ]] || ! rustup run esp rustc --version | grep -qF "(${ESP_TOOLCHAIN_VERSION})"; then + espup install --toolchain-version "${ESP_TOOLCHAIN_VERSION}" --targets esp32s3 --export-file "${HOME}/export-esp.sh" + fi + rustup run esp rustc --version + + # The guide's own install block runs too; with the tools above already in place it reuses them. + - name: Run the documented commands + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + docs/check-commands.sh --list + docs/check-commands.sh diff --git a/README.md b/README.md index df92d46a..3350de57 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ and deployment all live in this repository. ## Start here - [Developer quickstart](docs/quickstart.md): run the local stack and every test suite. +- [Build guide](docs/build/README.md): build, flash and add your own Node and Hub. +- [Hardware design](hardware/README.md): schematics, BOM, wiring, enclosure and probe sealing. - [Hub radio coexistence spike](docs/spikes/hub-radio-coexistence.md): what the Hub's radio can do at once. ## Repository layout @@ -21,7 +23,7 @@ and deployment all live in this repository. | [`tests/`](tests) | All tests, split by language | | [`aspire/`](aspire) | The local development stack, which is also the integration-test host | | [`deploy/`](deploy) | Helm charts, Fleet bundles and a Compose example | -| [`hardware/`](hardware) | Schematics, PCB and enclosure | +| [`hardware/`](hardware) | The reference Node and Hub design: schematics, BOM, wiring, enclosure | | [`docs/`](docs) | Guides and references | ## License diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index bbe09ead..80bd2b1a 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -930,3 +930,19 @@ location: tests/cs/server.integration/Edge/InvitationTests.cs source_spec: `spec-9-1-invite-someone-to-my-site.md` reason: InvitationTests asserts the link equals the configured redirect URI and accepts by signing in on Keycloak directly; the web app is not part of the AppHost, so link -> web sign-in -> invitation required action is unverified end to end. Settle by running the web app against the AppHost and following the Mailpit link. status: open + +### DW-120: A v* tag on any commit publishes; the documented "tag only from main" rule is not enforced by the workflow. +origin: spec-deferred 7aa938ff9d90 +location: .github/workflows/release.yml (mode job) +source_spec: `spec-10-1-releases-with-one-version-and-firmware-binaries.md` +severity: low +reason: Pre-existing since Story 2.1 (deploy/images/README.md already asks for a tag ruleset); the mode job accepts any v* push. +status: open + +### DW-121: The tag-only branches of release.yml (ghcr login, --push, imagetools summary, verify, gh release create, --prerelease) never run in the dry run. +origin: spec-deferred bce640467f2d +location: .github/workflows/release.yml (publish, release jobs) +source_spec: `spec-10-1-releases-with-one-version-and-firmware-binaries.md` +severity: medium +reason: dry_run is true for every non-push event, so these steps are skipped on PRs; only a real v* tag exercises them. Owed as an operator action (cut the first release and check it). +status: open diff --git a/_bmad-output/implementation-artifacts/spec-10-2-build-a-node-and-a-hub-from-the-docs.md b/_bmad-output/implementation-artifacts/spec-10-2-build-a-node-and-a-hub-from-the-docs.md new file mode 100644 index 00000000..a6a047fa --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-10-2-build-a-node-and-a-hub-from-the-docs.md @@ -0,0 +1,272 @@ +--- +title: 'Build a node and a hub from the docs' +type: 'feature' +created: '2026-10-19' +status: 'awaiting-operator' +baseline_revision: '21ad924cfaeb1caa249d9f8c17b682e7ea3e2c4a' +review_loop_iteration: 0 +followup_review_recommended: true +context: + - '{project-root}/_bmad-output/implementation-artifacts/epic-10-context.md' +warnings: [oversized] +deferred: [] +operator_actions: + - "Build one Node and one Hub from hardware/ (bom.md, node.md, hub.md, enclosure.md) on real parts, and correct any wiring or part error you find in those pages." + - "Check every example part number and figure in hardware/bom.md (TPS22917, RT9080-33, TP4056 input limit and R_PROG table, CN3791, the cell size) against its datasheet before anyone orders parts." + - "Flash a test board in dev mode from source, and a fresh board from the first release's binaries (after Story 10-1's v0.1.0 release), following docs/build/flash.md; confirm the identity line and the setup code appear as documented (Hub at first boot, Node on a 3 s long press), and fix any log line that differs." + - "Measure the reference Node's average sleep current with docs/bench/node-power-checklist.md, record the result in that checklist's Result table, and replace the 'not measured yet' notes in docs/build/power.md and hardware/node.md with the measured value." +--- + + + +## Intent + +**Problem:** `hardware/` is a placeholder, and `docs/build/` holds only the apps guide. A maker cannot build a Node or a Hub, wire the switched probe and divider, flash release or source firmware, or learn the irreversible eFuse burn without reading the firmware source. No CI job checks that the docs' links or build commands still work. + +**Approach:** Add a reference hardware design to `hardware/`: schematics as Markdown connection tables and ASCII block diagrams, a BOM, the wiring, enclosure notes and probe sealing. Add an adopter build guide to `docs/build/` covering flashing, first boot, eFuse and dev mode, power, and placement. Add a new path-filtered `docs.yml` workflow. It resolves every relative link and referenced repo path, and it runs every build command the guide documents against the firmware crates, with build only and no flashing. + +## Boundaries & Constraints + +**Always:** +- **Facts come from the code.** Every pin, constant and command must match the firmware. + - Pins: `apps/rs/node/src/board/pins.rs`. + - Wake and button timing: `packages/rs/sensing/src/{wake,button}.rs`. + - Partition offsets: `apps/rs/{hub,node}/partitions.csv`. + - Battery curve: `packages/rs/sensing/src/battery.rs`. + - Soil calibration: `apps/cs/server/Sensors/CalibrationMath.cs`. + - Link to the source file rather than restating a whole table where it could drift. +- **Label the design honestly.** `hardware/` is under Apache-2.0 through the root `LICENSE`, and every hardware page says so. Every part is a *reference choice* that has not been bench-verified: say so in `hardware/README.md`, and give the electrical requirement each part must meet. Examples: LDO quiescent current ≤ ~5 µA and ≥ 500 mA output; the charger has an NTC input and an open-drain active-low status pin; the load switches are active-high and need an external pull-down. +- **The eFuse warning is prominent.** It is a blockquote at the top of `docs/build/flash.md`, before any flash command. It says that the first boot of any build without `--features dev-mode`, debug or release, burns an `HMAC_UP` key block and that this cannot be undone. It says how to use dev mode on test boards, and that release binaries always burn. +- **State the setup code accurately.** The Hub prints `setup code=XXXXXXXX` on every boot while unprovisioned, which includes its first boot. The Node prints it when it enters setup mode, on a long press (3 s). Document how to watch the serial output, and the `erase-region` recovery. +- **Mark every command block.** Every fenced `bash`/`sh` block in `docs/build/*.md` is preceded by a marker: + - ``: CI runs the block in that directory, after `source ~/export-esp.sh`. + - ``: the block needs a board. CI checks that every `espflash ` in it is a real subcommand, with `espflash --help`. + - An unmarked block fails the check. +- Copy the firmware pins (espup 0.17.1, esp toolchain 1.95.0.0, espflash 4.5.0), the apt packages, the install steps and the rust-cache key from the `firmware` job in `ci.yml`. Pin actions by SHA with a release comment. + +**Never:** +- Don't edit `.github/workflows/ci.yml`, because that reruns the slow Images job. Don't make the docs workflow a required check. +- Don't change firmware behaviour or the Server. +- Don't check external `http(s)` links: they need the network and they flake. +- Don't add a docs site generator. +- Don't edit `sprint-status.yaml`. +- Don't claim a measured sleep current. The measurement is still pending. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Good docs | every relative link and referenced path exists | `check-links.sh` exits 0 | No error expected | +| Broken link | `[x](../nope.md)` in `docs/build/flash.md` | exits 1 | names the file, line and target | +| Anchor and query | `[x](power.md#budget)`, ``, `mailto:` | only the file part is checked; external links are skipped | No error expected | +| Backtick path | `` `apps/rs/node/src/board/pins.rs` `` | checked to exist | missing → names file:line | +| Placeholder path | `` `coldframe-node-X.Y.Z.bin` ``, `` `` ``, `` `apps/rs/*/` `` | not checked | No error expected | +| Run block | `ci:run apps/rs/node` + `cargo build --release --locked` | `check-commands.sh` runs it there | non-zero status → names the block's file:line | +| Hardware block | `ci:hardware` + `espflash write-bin 0x0 …` | only `espflash write-bin --help` runs | unknown subcommand → fails | +| Unmarked block | a ```` ```bash ```` block with no marker | fails | names file:line | + + + +## Code Map + +- `apps/rs/node/src/board/pins.rs:7-17,22-31,45-54,78-91`: the pin table. + - GPIO1 soil (ADC1); GPIO2 divider midpoint; GPIO4 probe switch; GPIO5 divider switch; GPIO6 CHRG (active low, pull-up); GPIO7 button to GND; GPIO8/9 I²C SDA/SCL; BME680 at 0x77. + - The divider is 100 kΩ/100 kΩ with a midpoint capacitor of at most 10 nF, and it is high-side switched; a low-side NMOS is ruled out. The pads float in deep sleep, so add external pull-downs. +- `apps/rs/node/README.md:140-172,204-238,282-329` and `apps/rs/hub/README.md:62-114,133-221`: existing prose to link to rather than duplicate. It covers buttons, build commands, the eFuse WARNING wording, `erase-region` recipes (Hub cf_setup 0xC000/0x2000, cf_ident 0xE000/0x1000; Node cf_setup 0x12000/0x2000, cf_ident 0xD000/0x1000) and WPA3-only being unsupported. The Hub also needs cmake and ninja. +- `packages/rs/sensing/src/button.rs:21-27`: debounce 50 ms, long press 3 s. +- `packages/rs/sensing/src/wake.rs:35`: wakes every 15 min. +- `packages/rs/setup/src/node.rs:30`: the setup window is 180 s. +- `packages/rs/sensing/src/battery.rs:1-5,32-54`: battery % comes from a 21-point curve and is always approximate. +- `apps/cs/server/Sensors/CalibrationMath.cs`: soil % is two-point linear, rounded to 5 %, with no temperature compensation. +- `docs/bench/node-power-checklist.md`: the measurement method (a meter in series with the battery, ≥ 3 periods, release build, pass at ≤ ~100 µA). Link to it from the power page. Read-only. +- `_bmad-output/planning-artifacts/prds/prd-coldframe-2026-09-25/addendum.md:35-44,119-120` and `_bmad-output/specs/spec-coldframe/device-hardware.md:52-75`: the solar requirements (~1 W panel, no charging below 0 °C or above ~45 °C), the capacitive-probe caveats (NE555 "v1.2" boards, unsealed edges), and the board sleep-current table. +- `docs/operations/release.md:43,140` and `.github/workflows/release.yml:266-273`: the release assets `coldframe-{hub,node}-X.Y.Z.bin`, a merged image written with `espflash write-bin 0x0`, and `SHA256SUMS`. Local equivalent: `espflash save-image --chip esp32s3 --merge --partition-table partitions.csv `. +- `.github/workflows/ci.yml:203-314`: the `firmware` job to copy setup from. Read-only. +- `deploy/release/test.sh:1-40`: the `expect`/`pass`/`failed` test style. +- `README.md:13-14`, `docs/quickstart.md:318-338,467`, `docs/build/apps.md`: add links to the new guide. +- `.gitignore:463-466`: `docs/build/` is already un-ignored, and `hardware/` is not ignored. Check new paths with `git check-ignore`. + +## Tasks & Acceptance + +**Execution:** +- `hardware/README.md`: rewrite it as an index: + - status ("reference design, not yet bench-verified"); + - the Apache-2.0 notice; + - links to every page below and to `docs/build/`. +- `hardware/bom.md`: the Node and Hub BOM, each line with a part, an example part number and the requirement it must meet. Reference choices: + - ESP32-S3-WROOM-1 (N8) module. The Hub uses an ESP32-S3 DevKitC-1 (N8) on USB 5 V. + - A capacitive soil probe, with the NE555 caveat. + - A BME680 breakout with SDO high. + - A 1S LiPo, 800 mAh, with a 10 kΩ NTC. + - A ~1 W 5–6 V solar panel. + - A TP4056 charger with TEMP/NTC and an open-drain `CHRG`; CN3791 as a solar alternative. + - A low-Iq 3.3 V LDO. + - Two high-side load switches. + - The 100 kΩ/100 kΩ divider with a ≤ 10 nF capacitor. + - Pull-downs, the button, and the enclosure parts. +- `hardware/node.md`: the Node schematic, as an ASCII block diagram and per-block connection tables for: + - the power path (panel → charger with NTC → LiPo → LDO → module); + - the switched probe power on GPIO4; + - the switched battery divider on GPIO5 → GPIO2; + - CHRG → GPIO6; + - the button GPIO7 → GND; + - I²C to the BME680; + - the USB/UART flashing header with EN/BOOT. + + Add a wiring table that is a full pin-to-pin list, and link to `pins.rs` as the source of truth. +- `hardware/hub.md`: the Hub schematic and wiring. A dev board on USB 5 V uses no GPIO, so cover power, antenna clearance and placement on the home network. +- `hardware/enclosure.md`: enclosure notes (IP rating, venting for the BME680, cable glands, keeping the panel and antenna clear), probe sealing (conformal coat or epoxy on the electronics and cut edges, heat-shrink, the insertion line), and the NFR-5 outdoor requirement. +- `docs/build/README.md`: the adopter build-guide index. It sets the order (parts → assemble → toolchain → flash Hub → flash Node → add them from the app) and links to `apps.md`. +- `docs/build/flash.md`: + - the eFuse and dev-mode blockquote at the top; + - installing espup and espflash at the pinned versions; + - flashing from release binaries: download, `sha256sum --check --ignore-missing SHA256SUMS`, `espflash write-bin 0x0 …`; + - flashing from source: `cargo build --release --locked`, then `save-image --merge` or `cargo run --release`; + - the first-boot serial output, with the identity line and the setup code (Hub at first boot, Node on a long press); + - recovery with `erase-region`; + - the WPA2 / mixed WPA2/WPA3 requirement; + - short press versus long press. + + Mark every block. +- `docs/build/power.md`: + - panel placement above the canopy, angled to the sun, not shaded by the plant; + - the ~1 W panel and 14 days without sun; + - the sleep-current budget of ≤ ~100 µA, why a stock dev board draws ~1 mA, and how to measure it (link the bench checklist); + - why soil % (two-point calibration, temperature and salinity, settling for 2–3 days) and battery % (curve, worse while charging) are approximate. +- `docs/check-links.sh`: usage `[root…]`; default `docs hardware README.md`. + - For every `.md` file, resolve inline `[text](target)` links that are not external, relative to the file. + - Resolve backtick paths that start with `apps/ packages/ deploy/ docs/ hardware/ tests/ .github/` from the repo root, skipping any that contain `<`, `*`, `{`, `X.Y.Z` or a space. + - Exit 1 and list every failure. +- `docs/check-commands.sh`: usage `[file…]`; default `docs/build/*.md`. It extracts the marked blocks and runs them as described in Always. `--list` prints the blocks without running them. +- `docs/test.sh`: tests the I/O matrix for both scripts against fixtures in `mktemp -d`, in the `deploy/release/test.sh` style. `check-commands.sh` is tested with a stub `espflash` and with blocks that run `true`/`false`. +- `.github/workflows/docs.yml`: + - Triggers: `pull_request` and `push` on main, with paths `docs/**`, `hardware/**`, `README.md`, `apps/rs/{hub,node}/**`, `packages/rs/**` and the workflow itself, plus `workflow_dispatch`. + - `links` job: `docs/test.sh`, then `docs/check-links.sh`. + - `commands` job: firmware setup copied from ci.yml, then `docs/check-commands.sh`. + - Use `permissions: contents: read`. +- `README.md`, `docs/quickstart.md`, `docs/build/apps.md`: link to `docs/build/README.md` and `hardware/README.md`. + +**Acceptance Criteria:** +- Given `hardware/`, when a maker opens it, then they find: + - the Node and Hub schematics; + - a BOM with every listed part (ESP32-S3, capacitive probe, BME680, 800 mAh LiPo, solar panel, a charger with NTC and a status pin); + - the wiring, including the switched probe power and the switched battery divider; + - enclosure notes and probe sealing; + - the Apache-2.0 notice. +- Given `docs/build/`, when a maker follows it, then it shows how to flash both Devices over USB with `espflash`, from release binaries and from source, and where the setup code appears. A blockquote before the first flash command warns that the eFuse burn is irreversible and explains dev mode. +- Given the power guidance, when read, then it covers panel placement above the canopy, the ≤ ~100 µA budget and how to measure it, and why soil % and battery % are approximate. +- Given a pull request touching `docs/build/`, when the `Docs` workflow runs, then it fails on any broken relative link or referenced path. It runs every `ci:run` block against the firmware crates, and only `--help` for `ci:hardware` blocks. + +## Spec Change Log + +## Review Triage Log + +### 2026-10-19 — Review pass +- verdicts: 33 findings — high 0, medium 8, low 22, false 2, maybe-false 1 +- findings: + - `[low]` `[patch]` (blind) `check-commands.sh --help` printed `set -uo pipefail` — the help range now ends at the last header line. + - `[low]` `[reject]` (blind) Anchors and reference-style links are not checked — no doc uses reference-style links, and a broken anchor still lands on the right page; checking anchors needs heading-slug generation, which is more than a direct fix. + - `[medium]` `[patch]` (blind) The docs.yml path filter misses files the docs link to (apps/cs, deploy, tests, …) — both path lists now cover apps/**, packages/**, deploy/**, tests/**, aspire/**, .github/workflows/**, LICENSE and .gitignore. + - `[low]` `[patch]` (blind) power.md listed the 20 µA row as ~1.1 mAh a day (inconsistent with the wake overhead), and the 100 µA budget had no row — corrected to ~1.6 mAh, and a Budget row was added (~3.5 mAh, about 180 days). + - `[medium]` `[patch]` (blind) BOM N3's example, Adafruit 3660, has a regulator and level shifter, which breaks its own requirement — now requires a bare breakout and says such boards do not qualify unless modified. + - `[medium]` `[patch]` (blind) No documented way to connect USB to the Node's header — added BOM N16a and a "Connecting USB" section in node.md (GND/D−/D+, no VBUS), linked from flash.md. + - `[medium]` `[patch]` (blind) No download-mode recovery for a sleeping or crash-looping Node — flash.md now has the BOOT+RESET steps and notes that the enclosure must be opened. + - `[maybe-false]` `[reject]` (blind) The CHRG internal pull-up may draw ~70 µA in deep sleep while charging — whether the pad's pull-up survives deep sleep is unverified. If it does, it only draws while the panel is charging, so it would be low; it is firmware behaviour from Story 4.1 either way. Settled by a bench measurement while charging. + - `[low]` `[reject]` (blind) Deep-discharge and brownout behaviour is not covered — rarely met (the low-battery Alert from Story 7.3 fires first); it needs new content, not a direct fix. + - `[low]` `[reject]` (blind) ci:hardware checks only subcommand names, not flags or other tools — every documented flag is valid today (`espflash write-bin --help` lists `--monitor`); checking flags means parsing per tool, which is more than a direct fix. + - `[low]` `[patch]` (blind) The default roots skipped apps/rs/hub and apps/rs/node READMEs — added; both pass. + - `[low]` `[patch]` (edge) On a fresh machine the install block failed before it could create the export file — the export file is now sourced only if it exists; the test was updated. + - `[low]` `[reject]` (edge) An unclosed fence or a 4-backtick fence swallows the rest of the file — no doc has either; the fix adds fence-length tracking. + - `[low]` `[reject]` (edge) A global option before the espflash subcommand goes unchecked — no doc writes it that way; the fix adds option parsing. + - `[low]` `[reject]` (edge) A missing espflash gives a misleading message — the run still fails; CI always installs espflash first. + - `[low]` `[reject]` (edge) An empty docs/build passes with 0 files — unlikely (the guide is the subject of the check, and docs/test.sh asserts the repository guide's blocks). + - `[low]` `[reject]` (edge) Reference-style links, HTML href and targets with `)` are not checked — none are used in the checked docs; same root cause as the blind anchor row. + - `[low]` `[reject]` (edge) A heading-anchor rename passes silently — same as the blind anchor row. + - `[low]` `[reject]` (edge) Outside a git checkout, the gitignored-path skip fails — CI and contributors use git; adding a work-tree probe is a guard for an unshown case. + - `[low]` `[patch]` (edge) An angle-bracket target containing a space was cut at the space — bracketed targets now keep spaces; two tests were added. + - `[medium]` `[patch]` (edge) Path filter misses referenced paths — same root cause and fix as the blind path-filter row. + - `[low]` `[patch]` (edge) check-commands `--help` extra line — same fix as the first blind row. + - `[false]` `[reject]` (edge) check-links `--help` omits the DOCS_REPO_ROOT line — the header ends at line 18 and `2,18p` prints it; the `set` line is 19. + - `[false]` `[reject]` (edge) The guide's install block re-downloads the toolchain on every run — measured locally: with the toolchain in place, the whole `check-commands.sh` run took 2.4 s, so espup reuses the existing install. + - `[low]` `[patch]` (edge) Inconsistent per-day figures in power.md — same fix as the blind power row. + - `[low]` `[reject]` (edge) The AC claims the workflow fails on any broken link — inline links, code-span paths and bracketed targets are checked; anchors and reference-style links are covered by the blind anchor row. + - `[medium]` `[patch]` (verification-gap) docs.yml's path filter lets link-breaking changes through — same fix as the blind path-filter row. + - `[medium]` `[patch]` (verification-gap) Nothing checks that the documented toolchain pins match ci.yml — docs/test.sh now reads the pins from ci.yml and fails when release.yml, docs.yml or flash.md (prose and install block) differ; ci.yml and release.yml are in the trigger paths. Drift was confirmed to fail the test. + - `[medium]` `[reject]` (intent) Human-owed work (bench build, real flash, sleep-current measurement) is not in operator_actions — the fix is a spec edit; finalization records it under operator_actions with status awaiting-operator. + - `[low]` `[reject]` (intent) No CI check compares the documented pins and constants with the firmware — the current values match (checked by hand against pins.rs, wake.rs, button.rs and partitions.csv); an automated fact comparison is new tooling, not a direct fix. + - `[low]` `[reject]` (intent) The release-binary path is only checked by subcommand name — it needs a real Release and a board; owed as an operator action. + - `[low]` `[reject]` (intent) The workflow has not run on GitHub yet — the PR run is that verification; it is listed under residual risks. + - `[low]` `[reject]` (intent) The spec restates the AC at document level — the fix is a spec edit. + +## Design Notes + +**Why Markdown schematics.** A KiCad project written without an EDA tool or a bench could not be verified in this repo. Connection tables can be reviewed against `pins.rs` and checked by CI. A KiCad capture and PCB can follow once the bench verification is done (an operator action). + +**Why markers.** The AC asks that *documented* commands run. With markers, every block is either executed or explicitly marked as needing hardware, so an unmarked block fails the check instead of going untested. + +## Verification + +**Commands:** +- `docs/test.sh`: expected all pass. +- `docs/check-links.sh`: expected exit 0. +- `docs/check-commands.sh --list`: lists every block with its marker. +- `docs/check-commands.sh`: expected exit 0, with the esp toolchain installed locally. +- `actionlint .github/workflows/docs.yml`, if available: expected no findings. + +**Manual checks (if no CLI):** +- `hardware/node.md` against `pins.rs`: every GPIO and divider value matches. + +## Auto Run Result + +Status: awaiting-operator + +**Summary.** `hardware/` now holds a reference Node and Hub design under Apache-2.0: schematics as ASCII block diagrams and connection tables, a BOM with the requirement each part must meet, a full pin-to-pin wiring list tied to `pins.rs` (including the switched probe power and the high-side switched battery divider), enclosure notes and probe sealing. It is labelled "not yet bench-verified". `docs/build/` gains an adopter build guide: +- `flash.md` opens with the eFuse and dev-mode warning. It covers flashing over USB with `espflash` from release binaries (with a checksum check) or from source, the first-boot output and the setup code (Hub at first boot, Node on a 3 s press), download mode, the buttons, WPA2, and `erase-region` recovery. +- `power.md` covers panel placement, the ≤ ~100 µA budget, how to measure it, and why soil % and battery % are approximate. + +A new `Docs` workflow checks every relative link and referenced repo path. It runs every `ci:run` command block against the firmware crates (build only), checks `ci:hardware` blocks by espflash subcommand, and keeps the documented toolchain pins in step with ci.yml. + +**Files changed** +- `hardware/README.md`, `bom.md`, `node.md`, `hub.md`, `enclosure.md`: the reference design. +- `docs/build/README.md`, `flash.md`, `power.md`: the adopter build guide. `docs/build/apps.md`, `README.md` and `docs/quickstart.md` link to it. +- `docs/check-links.sh`: the link and path resolver. +- `docs/check-commands.sh`: the runner for marked command blocks. +- `docs/test.sh`: 44 fixture tests, including the pin-drift tests. +- `.github/workflows/docs.yml`: the Links and Commands jobs. They are path-filtered and not required; `ci.yml` is untouched. + +**Review findings.** There were 33 findings: high 0, medium 8, low 22, false 2, maybe-false 1. +- **Patched (10 root causes):** 5 medium (the path filter, the BOM N3 example, Node USB connection, download mode, the pin-drift check) and 5 low (`--help` range, power table, default roots, the fresh-machine export, angle-bracket targets). +- **Deferred:** none. +- **Rejected:** each with its reason in the triage log: + - anchors and reference-style links; + - the CHRG pull-up in sleep (maybe-false, firmware); + - deep discharge; + - flag checks in `ci:hardware` blocks; + - unclosed fences; + - a global espflash option before the subcommand; + - a missing espflash; + - an empty `docs/build/`; + - a non-git checkout; + - the AC "any broken link" claim; + - the operator-action bookkeeping (handled here); + - an automated fact comparison with the firmware; + - the release path checked by name only; + - the not-yet-run workflow; + - the spec restatement; + - two false findings (`check-links --help`, toolchain re-download). + +**Follow-up review: recommended (true).** Five medium entries were patched. The risk not yet verified is the widened `docs.yml` triggers: the Commands job now runs on most PRs, and neither job has run on GitHub yet. + +**Verification.** +- `docs/test.sh`: 44 passed. +- `docs/check-links.sh`: 419 links and paths in 25 files resolve. +- `docs/check-commands.sh --list`: 14 blocks, all marked. +- `docs/check-commands.sh`: exit 0 locally with the esp toolchain. It ran the Hub and Node release and dev-mode builds and `save-image --merge`, and checked every espflash subcommand. +- The implementer ran actionlint and shellcheck: clean. Gitleaks on `docs/`, `hardware/` and `.github/`: no leaks. +- `hardware/node.md` was checked by hand against `pins.rs`. + +**Residual risks.** +- The design is a reference design that nobody has built yet. Part numbers and some figures (TPS22917, RT9080-33, the TP4056 input limit and R_PROG table, CN3791) come from memory and have not been checked against datasheets. +- The Node's sleep current has not been measured. +- The documented log lines come from the firmware source, not from a captured boot. +- The `Docs` workflow has not run on GitHub yet. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index d3010ecb..4f0ff21b 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -112,7 +112,7 @@ development_status: epic-9-retrospective: optional epic-10: backlog - 10-1-releases-with-one-version-and-firmware-binaries: backlog + 10-1-releases-with-one-version-and-firmware-binaries: awaiting-operator 10-2-build-a-node-and-a-hub-from-the-docs: backlog 10-3-deploy-coldframe-at-home-and-build-my-own-apps: backlog 10-4-firmware-learning-reference: backlog diff --git a/docs/build/README.md b/docs/build/README.md new file mode 100644 index 00000000..ffbcd4af --- /dev/null +++ b/docs/build/README.md @@ -0,0 +1,58 @@ +# Build guide + +How to build your own Coldframe Node and Hub from this repository, flash them, and add them to your +garden. Work through the steps in order. + +The hardware is a reference design that has not been bench-verified yet; see +[`hardware/README.md`](../../hardware/README.md) for its status and license. + +## 1. Get the parts + +[`hardware/bom.md`](../../hardware/bom.md) lists every part for one Node and one Hub, with an +example part and the requirement a substitute must meet. One Hub serves a garden; add as many Nodes +as you like. + +## 2. Assemble + +- **Node:** [`hardware/node.md`](../../hardware/node.md): the schematic, every connection, and the + full pin-to-pin wiring. Then the enclosure and the probe sealing: + [`hardware/enclosure.md`](../../hardware/enclosure.md). +- **Hub:** [`hardware/hub.md`](../../hardware/hub.md): a dev board on USB power. Nothing to wire. +- **Placement and power:** [power.md](power.md): where the panel goes, the sleep-current budget and + how to measure it, and why soil % and battery % are approximate. + +## 3. Install the toolchain + +[flash.md: Toolchain](flash.md#toolchain): `espflash`, and for building from source, `espup` and the +esp Rust toolchain at the versions CI pins. + +## 4. Flash the Hub + +[flash.md](flash.md): from the release binaries or from source. **Read the eFuse warning at the top +first**: the first boot of a build without dev mode burns the chip's identity, and that cannot be +undone. Note the Hub's setup code from its serial output. + +## 5. Flash the Node + +The same page, for the Node. Its setup code appears when you long-press its button (3 s). + +## 6. Add them from the app + +Build the iOS or Android app for your own Server: [apps.md](apps.md). The Server must be running +first (the developer [quickstart](../quickstart.md) runs one locally). Then, in the app: + +1. **Add a Hub.** The app finds the unprovisioned Hub over BLE, asks for its setup code, and sends it + your Wi-Fi network (2.4 GHz, WPA2 or mixed WPA2/WPA3) and your Server's address. +2. **Add a Node.** Long-press the Node's setup button (3 s), pick the Lot it measures, and enter + its setup code within the 180 s setup window. +3. Calibrate the soil probe once it has settled in the soil for 2–3 days + ([why](power.md#soil-moisture)). + +## Checking this guide + +CI checks these pages on every change (`.github/workflows/docs.yml`): + +- `docs/check-links.sh` fails on any broken relative link or referenced repository path. +- `docs/check-commands.sh` runs every command block marked `` against the + firmware crates (builds only, never flashing), and for blocks marked `` checks + only that their `espflash` subcommands exist. A command block without a marker fails. diff --git a/docs/build/apps.md b/docs/build/apps.md index 34526357..f0ee08ed 100644 --- a/docs/build/apps.md +++ b/docs/build/apps.md @@ -4,6 +4,9 @@ Coldframe publishes no mobile store binaries. Each adopter builds the iOS and An source for their own Server, with their own push credentials. This page is a short guide; Story 10.3 expands it. +This page is step 6 of the [build guide](README.md). The Node and Hub hardware is in +[`hardware/`](../../hardware/README.md). + ## What you need - **Your own push credentials.** iOS pushes go through the Apple Push Notification service with diff --git a/docs/build/flash.md b/docs/build/flash.md new file mode 100644 index 00000000..394a287c --- /dev/null +++ b/docs/build/flash.md @@ -0,0 +1,288 @@ +# Flash the Hub and the Node + +> [!WARNING] +> **The first boot of any build without `--features dev-mode`, debug or release, burns an eFuse +> key block, and that cannot be undone.** One of the chip's six key blocks becomes a +> read-protected, write-protected HMAC key with purpose `HMAC_UP`: the Device's identity for life. +> A plain `cargo build` or `cargo run` burns too, and **the release binaries always burn**: they +> are release builds without dev mode. +> +> - Flash such a build only onto a board meant to be a Coldframe Hub or Node, and +> [check its key purposes first](#before-you-flash-check-the-key-purposes). +> - **On a test board, use dev mode:** build from source with `--features dev-mode`. A dev-mode +> build compiles no eFuse code at all and keeps a software identity in the `cf_ident` partition +> instead, which you can erase. Dev mode is for debug builds only: +> `cargo build --release --features dev-mode` refuses to compile. + +Both Devices are flashed over USB with `espflash`. There is no over-the-air update. You can flash +the **release binaries** of a GitHub Release, or build **from source**. Do the Hub first: a Node +needs a Hub to send to. + +On Linux, add yourself to the group that owns the serial port (`dialout` or `uucp`) and log in +again, or `espflash` cannot open the board. + +## Toolchain + +You need the `esp` Rust toolchain from `espup`, and `espflash`. These are the versions CI pins: +espup 0.17.1, the esp toolchain 1.95.0.0 and espflash 4.5.0. First install Rust with +[rustup](https://rustup.rs), and the system packages: + +- To build `espflash` on Linux: `libudev-dev` and `pkg-config` (Debian and Ubuntu: + `sudo apt install libudev-dev pkg-config`). +- To build the **Hub** from source: **cmake and ninja** (Debian and Ubuntu: + `sudo apt install cmake ninja-build`, or `uv tool install cmake ninja`). The Hub rebuilds the + mbedTLS C sources. The Node needs neither. + +Then install the tools at the pinned versions: + + +```bash +cargo install espup --version 0.17.1 --locked +espup install --toolchain-version 1.95.0.0 --targets esp32s3 +cargo install espflash --version 4.5.0 --locked +``` + +`espup install` writes `~/export-esp.sh`. Source it in **every** shell before you build, and check +the versions: + + +```bash +source ~/export-esp.sh +espup --version +espflash --version +rustup run esp rustc --version +``` + +To flash only the release binaries you need just `espflash`, not the esp toolchain. + +## Before you flash: check the key purposes + +A board can become a Hub or a Node only if one of its eFuse key blocks is still unused. A board that +already has an `HMAC_UP` key keeps that identity (it reports `existing KEYn`). `espflash 4.5.0` +does not show key purposes, so use `espefuse` from esptool (`pipx install esptool`): + + +```bash +espflash list-ports +espflash board-info +espefuse --port summary +``` + +In the `espefuse` output, look at `KEY_PURPOSE_0` to `KEY_PURPOSE_5`: at least one must be `USER` +and unused. The full procedure is Part A of the +[Hub identity checklist](../bench/hub-identity-checklist.md), which applies to the Node unchanged. + +## Flash from the release binaries + +Every GitHub Release carries a merged flash image per Device (bootloader, partition table and +firmware in one file, written at offset 0) and a `SHA256SUMS` file +([what a release holds](../operations/release.md)). Download `coldframe-hub-X.Y.Z.bin`, +`coldframe-node-X.Y.Z.bin` and `SHA256SUMS` from the Release page, or with the GitHub CLI, then +check them and flash. Replace `X.Y.Z` with the version, and use the same version as your Server. + + +```bash +gh release download vX.Y.Z --repo escendit/coldframe --pattern 'coldframe-*.bin' --pattern SHA256SUMS +sha256sum --check --ignore-missing SHA256SUMS +espflash write-bin --monitor 0x0 coldframe-hub-X.Y.Z.bin +``` + +`sha256sum` must print `OK` for both images. Then connect the Node's flashing header to USB +([Connecting USB](../../hardware/node.md#connecting-usb)) and flash its image the same way: + + +```bash +espflash write-bin --monitor 0x0 coldframe-node-X.Y.Z.bin +``` + +If no port appears for the Node, put it in download mode. The Node sleeps almost all the time, and +its USB Serial/JTAG port disappears while it sleeps, so a sleeping or crash-looping Node may never +show a port. Hold `BOOT` (GPIO0), press and release `RESET` (EN), release `BOOT`, then flash. The +chip stays in the bootloader until the flash finishes and resets it. The buttons are on the board, +inside the enclosure ([BOM N14](../../hardware/bom.md#node)), so a deployed Node has to be opened. + +`--monitor` keeps the serial output open after flashing, so you see the +[first boot](#first-boot-and-the-setup-code). Press `Ctrl+C` to leave it. + +## Flash from source + +Build from the release tag you run (`git checkout vX.Y.Z`), or from `main` for development. Every +crate has a committed `Cargo.lock`; `--locked` keeps to it. + +### Hub + +Build the release firmware and turn it into the same merged image a Release carries: + + +```bash +source ~/export-esp.sh +cargo build --release --locked +espflash save-image --chip esp32s3 --merge --partition-table partitions.csv \ + target/xtensa-esp32s3-none-elf/release/coldframe-hub target/coldframe-hub.bin +``` + +Flash the image, or let `cargo run` flash the ELF with the partition table and open the monitor +(the runner is `espflash flash --monitor --partition-table partitions.csv`): + + +```bash +espflash write-bin --monitor 0x0 target/coldframe-hub.bin +# or, the same in one step: +cargo run --release +``` + +### Node + +Connect the Node over its flashing header ([Connecting USB](../../hardware/node.md#connecting-usb)). + + +```bash +source ~/export-esp.sh +cargo build --release --locked +espflash save-image --chip esp32s3 --merge --partition-table partitions.csv \ + target/xtensa-esp32s3-none-elf/release/coldframe-node target/coldframe-node.bin +``` + + +```bash +espflash write-bin --monitor 0x0 target/coldframe-node.bin +# or: +cargo run --release +``` + +### Dev mode, for test boards + +A dev-mode build burns nothing. It is a debug build, keeps its identity root in the `cf_ident` +partition, and the Node also logs its Reading values. Build both: + + +```bash +source ~/export-esp.sh +cargo build --features dev-mode --locked +``` + + +```bash +source ~/export-esp.sh +cargo build --features dev-mode --locked +``` + +and flash with `cargo run` from the crate's folder: + + +```bash +cargo run --features dev-mode +``` + +Never flash a build without `--features dev-mode` onto a board you want to keep as a test board. +A dev-mode Device is for the bench, not the garden: its identity is not bound to the chip. + +## First boot and the setup code + +Watch the serial output with `espflash monitor`. It resets the board when it connects, so you see +the boot from the start. + + +```bash +espflash monitor +``` + +The **Hub** logs its version and identity, then, while it is unprovisioned, prints its setup code +and advertises: + +```text +coldframe-hub X.Y.Z starting, identity mode=efuse +identity mode=efuse source=burned KEY0 device_id=<16 hex> +setup code=XXXXXXXX +ble advertising setup service as Coldframe Hub XXXX +``` + +- `source=burned KEYn` appears once, on the first boot: the key was burned just now. Every later + boot says `source=existing KEYn` with the same Device ID. A dev-mode build says + `identity mode=dev source=generated` and then `source=stored`. +- `setup code=XXXXXXXX` is printed **on every boot while the Hub is unprovisioned**, which includes + its first boot. Once the app has set it up, it is never shown again. To see it again before setup, + press the board's `RST` button with the monitor open. + +The **Node** logs its identity on every wake, then measures and goes to sleep: + +```text +coldframe-node X.Y.Z wake cause=cold_boot reset=… identity mode=efuse +identity mode=efuse source=burned KEY0 device_id=<16 hex> +boot id=1 +wake plan=measure +``` + +The Node prints its setup code **only when it enters setup mode**, on a **long press (3 s)** of the +setup button. Keep the monitor open: the USB serial link drops while the Node sleeps and +`espflash monitor` reconnects when it wakes. Then: + +```text +coldframe-node X.Y.Z wake cause=button reset=… identity mode=efuse +identity mode=efuse source=existing KEY0 device_id=<16 hex> +boot id=1 +wake plan=setup +setup code=XXXXXXXX +setup mode window_ms=180000 +``` + +The code is drawn on the first long press and stored; every later long press prints the same code. +If you missed the line, long-press again. + +**Write each code on a sticker on its Device**, never in the repository or a chat. The app asks for +it when you add the Device: it proves that you hold the Device. Every log line is explained in the +[Hub README](../../apps/rs/hub/README.md#log-lines) and the +[Node README](../../apps/rs/node/README.md#log-lines). + +## The buttons + +The Hub has no button of its own: an unprovisioned Hub is always ready for setup. + +The Node has one setup button: + +| Press | What it does | +| --- | --- | +| **Short** (50 ms to under 3 s) | Takes and sends one Reading now, then sleeps a full period | +| **Long** (held 3 s) | Opens the BLE setup window for 180 s and prints the setup code; then measures once and sleeps | + +A press shorter than 50 ms is a bounce and does nothing. BLE runs only in the setup window; the +Node never advertises otherwise. + +## Wi-Fi + +The Hub joins a **2.4 GHz WPA2-Personal** network, or a **mixed WPA2/WPA3** (transition mode) +network over WPA2. **WPA3-only networks are not supported**: the app lists them, and the Hub refuses +them with "unsupported security". If your router runs WPA3 only, switch it to WPA2/WPA3 mixed mode +for the 2.4 GHz band, or add a WPA2 network for the Hub. The Node never uses Wi-Fi: it talks to the +Hub over ESP-NOW. + +## Recovery: erase a region + +`espflash erase-region ` erases one partition. It never undoes an eFuse burn: a +release-build Device keeps its identity whatever you erase. + +| Device | Partition | Command | Effect | +| --- | --- | --- | --- | +| Hub | `cf_setup` | `espflash erase-region 0xC000 0x2000` | A new setup code on the next boot, and the Hub is unprovisioned: set it up again from the app | +| Hub | `cf_ident` | `espflash erase-region 0xE000 0x1000` | Dev mode only: a new identity on the next boot | +| Node | `cf_setup` | `espflash erase-region 0x12000 0x2000` | A new setup code on the next long press | +| Node | `cf_ident` | `espflash erase-region 0xD000 0x1000` | Dev mode only: a new identity on the next boot | + +For example, to give an unprovisioned Hub a new setup code: + + +```bash +espflash erase-region 0xC000 0x2000 +espflash monitor +``` + +The offsets come from each crate's `partitions.csv` +([Hub](../../apps/rs/hub/partitions.csv), [Node](../../apps/rs/node/partitions.csv)). + +> [!CAUTION] +> Do not run `espflash erase-flash` on a Node that has ever sent a Reading. It also erases the frame +> counter, and the Server then refuses the Node's frames as replays. The Node's other partitions, +> and when erasing them is safe, are in the +> [Node README](../../apps/rs/node/README.md#partitions). + +Next: [add the Hub and the Node from the app](README.md#6-add-them-from-the-app). diff --git a/docs/build/power.md b/docs/build/power.md new file mode 100644 index 00000000..13bd6419 --- /dev/null +++ b/docs/build/power.md @@ -0,0 +1,105 @@ +# Power and placement + +The Node runs on an 800 mAh LiPo cell charged by a small solar panel. This page covers where to put +the panel, the sleep-current budget the Node is designed for, and why the soil and battery +percentages are approximate. The parts are in [`hardware/bom.md`](../../hardware/bom.md). + +## Panel placement + +- **Above the canopy.** Plants grow over the panel during summer, and shading is the real risk, not + cloud. Mount the panel on a stake higher than the plants will grow by the end of the season. +- **Angled to the sun.** Face it south in the northern hemisphere (north in the southern), tilted + at about your latitude. A steeper tilt sheds rain and dirt better. +- **Not shaded by the plant**, a fence or the house at any time of day. Even a partly shaded panel + delivers little. +- Keep it clean, and keep the Node's antenna end free of the panel's metal frame + ([enclosure.md](../../hardware/enclosure.md#node-enclosure)). + +A shaded panel shows up in the app as falling battery % while the Node reports *not charging*, +until the low-battery Alert fires. If you see that in midsummer, look for leaves on the panel. + +## Solar and autonomy + +- **About 1 W** is enough. A ~1 W panel yields roughly 500 mAh a day into the cell in full sun, and + 25–50 mAh a day in heavy overcast. Both are far above what a well-built Node uses. +- So the question is not runtime but **autonomy**: how many days without sun the cell bridges. The + design target is **at least 14 days without sun**, on top of a season on solar. +- The charger stops charging below 0 °C and above about 45 °C, through the cell's NTC. In spring + frost or a sun-heated box the Node runs on the cell alone for a while; that is expected. + +## Sleep-current budget + +The Node wakes every 15 minutes (`WAKE_PERIOD_MS` in +[`packages/rs/sensing/src/wake.rs`](../../packages/rs/sensing/src/wake.rs)), measures for a fraction +of a second, sends over ESP-NOW and sleeps again. Between wakes it draws only its sleep current, so +the sleep current decides the autonomy. + +**The budget is an average sleep current of at most about 100 µA**, with short wakes (about +0.3–0.5 s). That covers a 6-month season even with no sun at all (about 3.5 mAh a day from about +640 mAh usable), and far more than 14 dark days on solar. + +| Board | Sleep current | Use per day | Days without sun | +| --- | --- | --- | --- | +| Optimized custom board | ~20 µA | ~1.6 mAh | about a year (self-discharge limits it) | +| Reasonable custom board | ~50 µA | ~2.5 mAh | about 250 | +| Budget | ~100 µA | ~3.5 mAh | about 180 | +| Stock dev board | ~1 mA | ~25 mAh | about 25 | + +These are estimates from the design, **not measurements**. The sleep current of the reference +design has **not been measured yet**. + +**Why a stock dev board draws about 1 mA asleep:** its USB-UART bridge, its power LED and its +always-on regulator draw current all the time, whatever the ESP32-S3 does. The chip itself sleeps +at a few microamps. A dev board works for a Node on the bench and bridges about 25 dark days on +solar, but it misses the budget by design. The [reference Node](../../hardware/node.md) avoids all +three: a bare module, no USB-UART bridge (it flashes over the chip's own USB), no LED, and a +low-quiescent LDO. The probe and the battery divider are switched off while asleep, and the BME680 +sleeps between forced-mode measurements. + +### Measure it + +Follow [`docs/bench/node-power-checklist.md`](../bench/node-power-checklist.md). In short: + +- Flash a **release build** (a dev-mode build logs more and is not representative). +- Put a power profiler or a µA meter **in series with the battery**, not on USB. +- Record **at least three full periods** (45 minutes or more), so the capture holds at least three + wakes. +- Read the average current while asleep and the average over whole periods. **Pass: about + 100 µA or less.** +- Check that the probe and divider switches are off while asleep; if either draws current, check + the pull-downs on their enable pins. + +## Why the readings are approximate + +### Soil moisture + +Soil moisture % is an estimate, not a measurement of water content: + +- **Two-point calibration.** The Server maps the probe's raw count linearly between two points you + record in the app, a dry one (0 %) and a wet one (100 %), clamps it to 0–100 and rounds it to the + nearest 5 % ([`CalibrationMath.cs`](../../apps/cs/server/Sensors/CalibrationMath.cs)). Real + probes are not linear between the two points. +- **Temperature and salinity** shift the raw reading. There is no temperature compensation in V1. + The Node records the air temperature with every Reading, so the effect can be analysed later. +- **Settling.** A freshly inserted probe needs **2–3 days** to settle before its readings are + stable. Calibrate it in the soil it will stay in, after it has settled. +- **Drift.** Water creeping into an unsealed probe makes it drift over the season. Seal it + ([enclosure.md](../../hardware/enclosure.md#sealing-the-soil-probe)). + +Use the Thresholds as "drier than usual" and "wetter than usual" for that one probe, not as +absolute values that compare across Nodes. + +### Battery + +Battery % is approximate too: + +- It comes from the cell voltage, read through the switched divider, mapped with a typical + single-cell LiPo **discharge curve** of 21 points, linear between them + ([`battery.rs`](../../packages/rs/sensing/src/battery.rs)). Every cell, and every cell's age and + temperature, gives a slightly different curve. +- The curve is flat in the middle, so a few millivolts of ADC error move the percentage by several + points. +- **It is worse while charging:** the charger lifts the cell voltage, so the percentage reads high + while the panel is in sun, and drops back once charging stops. + +Watch the trend over days, and the *charging* / *not charging* status, rather than a single value. diff --git a/docs/check-commands.sh b/docs/check-commands.sh new file mode 100755 index 00000000..09175422 --- /dev/null +++ b/docs/check-commands.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Runs the commands the build guide documents (Story 10.2). Firmware builds only, never flashing. +# +# docs/check-commands.sh [--list] [file...] +# +# The default files are docs/build/*.md, relative to the repository root unless absolute. Every +# fenced bash/sh block must be preceded by a marker (blank lines between them are allowed): +# +# the block runs in that directory, after +# `source ~/export-esp.sh` when it exists (the guide's +# install block creates it), with errexit and pipefail; +# the block needs a board: only `espflash --help` +# runs, for every espflash subcommand in it. +# +# An unmarked block, a marker that no bash/sh block follows, an unknown ci: marker or a ci:run +# directory that does not exist fails. --list prints every block with its marker and runs nothing. +# +# Exit 0 when every block passes, 1 naming each failing block as file:line, 2 on bad usage. +# DOCS_REPO_ROOT overrides the repository root and ESP_EXPORT the export file (the tests use both). +set -uo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo=${DOCS_REPO_ROOT:-$(cd "${here}/.." && pwd)} +esp_export=${ESP_EXPORT:-${HOME}/export-esp.sh} + +list=false +files=() +for arg in "$@"; do + case ${arg} in + --list) list=true ;; + -h | --help) + sed -n '2,19p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + echo "usage: check-commands.sh [--list] [file...]: unknown option '${arg}'" >&2 + exit 2 + ;; + /*) files+=("${arg}") ;; + *) files+=("${repo}/${arg}") ;; + esac +done +if [[ ${#files[@]} -eq 0 ]]; then + for file in "${repo}"/docs/build/*.md; do + [[ -f ${file} ]] && files+=("${file}") + done +fi +for file in "${files[@]}"; do + if [[ ! -f ${file} ]]; then + echo "usage: check-commands.sh [--list] [file...]: '${file}' is no file" >&2 + exit 2 + fi +done + +work=$(mktemp -d) +trap 'rm -rf "${work}"' EXIT + +# Splits a Markdown file into ${work}/.sh blocks and prints one manifest line per block or +# problem: " ", separated by +# the unit separator 0x1F (a tab would collapse an empty argument). +# Plain POSIX awk: the CI runner's awk is mawk. +extract() { + awk -v work="${work}" -v prefix="$2" ' + function emit(kind, arg, path) { print FNR "\037" kind "\037" arg "\037" path } + function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s } + infence { + if ($0 ~ /^[ \t]*(```|~~~)[ \t]*$/) { infence = 0; if (out != "") close(out); out = ""; next } + if (out != "") print > out + next + } + /^[ \t]*[ \t]*$/, "", m) + if (m == "ci:hardware") { pending = "hardware"; arg = "" } + else if (m ~ /^ci:run[ \t]+[^ \t]+$/) { pending = "run"; arg = trim(substr(m, 7)) } + else { emit("unknown", m, ""); pending = "" } + next + } + /^[ \t]*(```|~~~)/ { + info = $0 + sub(/^[ \t]*(```|~~~)/, "", info) + info = trim(info) + lang = info + sub(/[ \t{].*$/, "", lang) + infence = 1 + out = "" + if (lang == "bash" || lang == "sh" || lang == "shell") { + blocks++ + out = work "/" prefix "-" blocks ".sh" + printf "" > out + if (pending == "") emit("unmarked", "", out) + else emit(pending, arg, out) + } else if (pending != "") { + emit("orphan", pending, "") + } + pending = "" + next + } + /^[ \t]*$/ { next } + { + if (pending != "") emit("orphan", pending, "") + pending = "" + } + END { if (pending != "") emit("orphan", pending, "") } + ' "$1" +} + +failures=0 +blocks=0 +fail() { + failures=$((failures + 1)) + echo "FAIL $1" >&2 +} + +indent() { sed 's/^/ /' "$1"; } + +# The espflash subcommands a block calls, one per line, comment lines left out. +espflash_subcommands() { + grep -v '^[[:space:]]*#' "$1" | + grep -oE '(^|[^[:alnum:]_./-])espflash[[:space:]]+[a-z][a-z0-9-]*' | + awk '{ print $NF }' | sort -u +} + +run_block() { # + local where=$1 dir=$2 block=$3 + echo "==> ${where}: ci:run ${dir}" + indent "${block}" + { + if [[ -f ${esp_export} ]]; then + printf 'source %q\n' "${esp_export}" + fi + printf 'set -euo pipefail\n' + cat "${block}" + } > "${block}.run" + if ! (cd "${repo}/${dir}" && bash "${block}.run" < /dev/null); then + fail "${where}: the ci:run ${dir} block failed" + fi +} + +check_hardware() { # + local where=$1 block=$2 sub any=false + echo "==> ${where}: ci:hardware" + indent "${block}" + while IFS= read -r sub; do + any=true + if espflash "${sub}" --help > /dev/null 2>&1; then + echo " ok: espflash ${sub} is a subcommand" + else + fail "${where}: 'espflash ${sub}' is not an espflash subcommand" + fi + done < <(espflash_subcommands "${block}") + if [[ ${any} == false ]]; then + echo " (no espflash command to check)" + fi +} + +index=0 +for file in "${files[@]}"; do + index=$((index + 1)) + rel=${file#"${repo}"/} + while IFS=$'\x1f' read -r line kind arg block; do + where="${rel}:${line}" + case ${kind} in + unmarked) + blocks=$((blocks + 1)) + fail "${where}: unmarked bash/sh block; precede it with or " + ${list} && indent "${block}" + ;; + orphan) fail "${where}: a ci:${arg} marker is not followed by a bash/sh block" ;; + unknown) fail "${where}: unknown marker '${arg}'" ;; + run) + blocks=$((blocks + 1)) + if [[ ! -d ${repo}/${arg} ]]; then + fail "${where}: ci:run directory '${arg}' does not exist" + elif ${list}; then + echo "${where}: ci:run ${arg}" + indent "${block}" + else + run_block "${where}" "${arg}" "${block}" + fi + ;; + hardware) + blocks=$((blocks + 1)) + if ${list}; then + echo "${where}: ci:hardware" + indent "${block}" + else + check_hardware "${where}" "${block}" + fi + ;; + esac + done < <(extract "${file}" "${index}") +done + +if [[ ${failures} -gt 0 ]]; then + echo "check-commands: ${failures} failed (${blocks} blocks in ${#files[@]} files)" >&2 + exit 1 +fi +echo "check-commands: ${blocks} blocks in ${#files[@]} files pass" diff --git a/docs/check-links.sh b/docs/check-links.sh new file mode 100755 index 00000000..e3738d24 --- /dev/null +++ b/docs/check-links.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Checks the Markdown docs (Story 10.2): every relative link and every referenced repository path +# must exist. External links (http, https, mailto, any scheme) are never fetched. +# +# docs/check-links.sh [root...] +# +# Each root is a directory (every *.md below it) or a Markdown file, relative to the repository +# root unless absolute. The default is `docs hardware README.md apps/rs/hub/README.md +# apps/rs/node/README.md` (the build guide sends readers to the firmware READMEs). Outside fenced +# code blocks, two things are checked: +# +# - inline links `[text](target)`: the file part of the target (no #anchor, no ?query), relative +# to the Markdown file, or to the repository root when it starts with `/`; +# - code spans that start with apps/ packages/ deploy/ docs/ hardware/ tests/ .github/, from the +# repository root. A span holding `<`, `*`, `{`, `X.Y.Z`, `...` or a space is a placeholder and +# is skipped; a trailing `:` or `:-` is dropped. A missing path that git +# ignores is a local file the reader creates (an `.env`, a `*.local.xcconfig`) and passes. +# +# Exit 0 when everything resolves, 1 listing every failure as file:line, 2 on bad usage. +# DOCS_REPO_ROOT overrides the repository root (the tests use it). +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo=${DOCS_REPO_ROOT:-$(cd "${here}/.." && pwd)} + +if [[ ${1:-} == -h || ${1:-} == --help ]]; then + sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 +fi + +roots=("$@") +if [[ ${#roots[@]} -eq 0 ]]; then + roots=(docs hardware README.md apps/rs/hub/README.md apps/rs/node/README.md) +fi + +files=() +for root in "${roots[@]}"; do + if [[ ${root} == /* ]]; then path=${root}; else path=${repo}/${root}; fi + if [[ -d ${path} ]]; then + while IFS= read -r -d '' file; do + files+=("${file}") + done < <(find "${path}" -type f -name '*.md' -print0 | sort -z) + elif [[ -f ${path} ]]; then + files+=("${path}") + else + echo "usage: check-links.sh [root...]: '${root}' is no file or directory under ${repo}" >&2 + exit 2 + fi +done + +# Prints "\t\t" for every inline link and code span outside fences. +extract() { + awk ' + /^[[:space:]]*(```|~~~)/ { fenced = !fenced; next } + fenced { next } + { + line = $0 + rest = line + while (match(rest, /`[^`]+`/)) { + print FNR "\tcode\t" substr(rest, RSTART + 1, RLENGTH - 2) + rest = substr(rest, RSTART + RLENGTH) + } + gsub(/`[^`]+`/, "", line) + rest = line + while (match(rest, /\]\([^)]*\)/)) { + print FNR "\tlink\t" substr(rest, RSTART + 2, RLENGTH - 3) + rest = substr(rest, RSTART + RLENGTH) + } + } + ' "$1" +} + +failures=0 +checked=0 +fail() { + failures=$((failures + 1)) + echo "$1" >&2 +} + +for file in "${files[@]}"; do + rel=${file#"${repo}"/} + dir=$(dirname "${file}") + while IFS=$'\t' read -r line kind target; do + case ${kind} in + link) + # `` keeps its spaces; otherwise drop an optional "title". + if [[ ${target} == '<'* ]]; then + target=${target#<} + target=${target%%>*} + else + target=${target%% *} + fi + # External: any URI scheme (https:, mailto:, ...). + if [[ ${target} =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]; then + continue + fi + target=${target%%#*} + target=${target%%\?*} + [[ -z ${target} ]] && continue + if [[ ${target} == /* ]]; then + resolved=${repo}${target} + else + resolved=${dir}/${target} + fi + checked=$((checked + 1)) + if [[ ! -e ${resolved} ]]; then + fail "${rel}:${line}: broken link '${target}'" + fi + ;; + code) + case ${target} in + apps/* | packages/* | deploy/* | docs/* | hardware/* | tests/* | .github/*) ;; + *) continue ;; + esac + case ${target} in + *'<'* | *'*'* | *'{'* | *X.Y.Z* | *...* | *' '*) continue ;; + esac + target=${target%#*} + if [[ ${target} =~ ^(.*):[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$ ]]; then + target=${BASH_REMATCH[1]} + fi + checked=$((checked + 1)) + if [[ ! -e ${repo}/${target} ]] && ! git -C "${repo}" check-ignore --quiet --no-index -- "${target}" 2>/dev/null; then + fail "${rel}:${line}: missing path '${target}'" + fi + ;; + esac + done < <(extract "${file}") +done + +if [[ ${failures} -gt 0 ]]; then + echo "check-links: ${failures} broken of ${checked} in ${#files[@]} files" >&2 + exit 1 +fi +echo "check-links: ${checked} links and paths in ${#files[@]} files resolve" diff --git a/docs/quickstart.md b/docs/quickstart.md index 8be72dbc..ad6c82c6 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -337,6 +337,9 @@ hardware. The Hub's on-device behaviour is checked by hand with [`docs/bench/hub-setup-checklist.md`](bench/hub-setup-checklist.md) and [`docs/bench/hub-uplink-checklist.md`](bench/hub-uplink-checklist.md). +To build a real Node and Hub, flash them and add them from the app, follow the adopter +[build guide](build/README.md) and the [hardware design](../hardware/README.md). + ### TypeScript ```sh @@ -464,7 +467,7 @@ packages/ what the runtimes reference: contracts, shared libraries, generated tests/ all tests, by language: cs/ rs/ kt/ swift/ ts/ aspire/ the local stack and integration-test host deploy/ Helm charts, Fleet bundles, Compose example -hardware/ schematics, PCB, enclosure +hardware/ reference design: schematics, BOM, wiring, enclosure docs/ guides and references ``` diff --git a/docs/test.sh b/docs/test.sh new file mode 100755 index 00000000..99520d7a --- /dev/null +++ b/docs/test.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# Plain-bash tests for the doc checks (Story 10.2): check-links.sh and check-commands.sh against +# throwaway fixtures, with a stub espflash. Needs only bash, awk and coreutils. Run from anywhere: +# +# docs/test.sh +# The fixtures are Markdown in single quotes, backticks and all. +# shellcheck disable=SC2016 +set -uo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "${work}"' EXIT +failures=0 +passes=0 + +pass() { + passes=$((passes + 1)) + echo "ok - $1" +} + +failed() { + failures=$((failures + 1)) + echo "FAIL - $1" >&2 + shift + for line in "$@"; do + echo " ${line}" >&2 + done +} + +# expect -- +# The expected stdout and stderr are substrings, "" for none at all, or - for anything. +expect() { + local name=$1 want_status=$2 want_out=$3 want_err=$4 + shift 5 + local out err status + err=$(mktemp) + out=$("$@" 2>"${err}") + status=$? + local err_text + err_text=$(cat "${err}") + rm -f "${err}" + + if [[ ${status} -ne ${want_status} ]]; then + failed "${name}" "exit ${status}, expected ${want_status}" "stdout: ${out}" "stderr: ${err_text}" + elif [[ -z ${want_out} && -n ${out} ]]; then + failed "${name}" "stdout '${out}', expected none" + elif [[ -n ${want_out} && ${want_out} != "-" && ${out} != *"${want_out}"* ]]; then + failed "${name}" "stdout '${out}' does not contain '${want_out}'" + elif [[ -z ${want_err} && -n ${err_text} ]]; then + failed "${name}" "stderr '${err_text}', expected none" + elif [[ -n ${want_err} && ${want_err} != "-" && ${err_text} != *"${want_err}"* ]]; then + failed "${name}" "stderr '${err_text}' does not contain '${want_err}'" + else + pass "${name}" + fi +} + +links=${here}/check-links.sh +commands=${here}/check-commands.sh + +# A fresh fixture repository: -> its path. It holds docs/build/{flash,power}.md stubs and +# apps/rs/node/src/board/pins.rs, so the good links have something to resolve to. +fixture() { + local root=${work}/$1 + mkdir -p "${root}/docs/build" "${root}/apps/rs/node/src/board" + : > "${root}/apps/rs/node/src/board/pins.rs" + : > "${root}/apps/rs/node/Cargo.toml" + printf '# Power\n' > "${root}/docs/build/power.md" + printf '# Flash\n' > "${root}/docs/build/flash.md" + echo "${root}" +} + +# page : writes a Markdown page. +page() { + mkdir -p "$(dirname "$1/$2")" + printf '%s\n' "$3" > "$1/$2" +} + +echo "# check-links.sh" +good=$(fixture good) +page "${good}" docs/build/flash.md '# Flash + +See [power](power.md) and [the pins](../../apps/rs/node/src/board/pins.rs).' +expect "good docs pass" 0 "resolve" "" -- env DOCS_REPO_ROOT="${good}" "${links}" docs + +broken=$(fixture broken) +page "${broken}" docs/build/flash.md '# Flash + +A [broken link](../nope.md).' +expect "a broken link fails naming file, line and target" 1 "" "docs/build/flash.md:3: broken link '../nope.md'" \ + -- env DOCS_REPO_ROOT="${broken}" "${links}" docs + +anchors=$(fixture anchors) +page "${anchors}" docs/build/flash.md '# Flash + +[budget](power.md#budget), [query](power.md?plain=1), [here](#flash), , +[external](https://example.invalid/nope.md), [mail](mailto:someone@example.invalid), +[titled](power.md "Power"), [angled]().' +expect "anchors and queries are dropped, external links skipped" 0 "resolve" "" \ + -- env DOCS_REPO_ROOT="${anchors}" "${links}" docs + +spaced=$(fixture spaced) +page "${spaced}" docs/build/my\ file.md '# Mine' +page "${spaced}" docs/build/flash.md '[mine]() [titled]( "Mine") [gone]()' +expect "an angle-bracket target keeps its spaces" 1 "" "docs/build/flash.md:1: broken link 'no file.md'" \ + -- env DOCS_REPO_ROOT="${spaced}" "${links}" docs +expect "only the missing angle-bracket target fails" 1 "" "check-links: 1 broken" \ + -- env DOCS_REPO_ROOT="${spaced}" "${links}" docs + +anchor_missing=$(fixture anchor-missing) +page "${anchor_missing}" docs/build/flash.md '[gone](gone.md#budget)' +expect "the file part of an anchored link is checked" 1 "" "docs/build/flash.md:1: broken link 'gone.md'" \ + -- env DOCS_REPO_ROOT="${anchor_missing}" "${links}" docs + +backtick=$(fixture backtick) +page "${backtick}" docs/build/flash.md '# Flash + +The pins are in `apps/rs/node/src/board/pins.rs` and `apps/rs/node/src/board/pins.rs:7-17`.' +expect "an existing backtick path passes, with a line suffix" 0 "resolve" "" \ + -- env DOCS_REPO_ROOT="${backtick}" "${links}" docs + +backtick_missing=$(fixture backtick-missing) +page "${backtick_missing}" docs/build/flash.md '# Flash + +The pins are in `apps/rs/node/src/board/gone.rs`.' +expect "a missing backtick path fails naming file and line" 1 "" \ + "docs/build/flash.md:3: missing path 'apps/rs/node/src/board/gone.rs'" \ + -- env DOCS_REPO_ROOT="${backtick_missing}" "${links}" docs + +placeholders=$(fixture placeholders) +page "${placeholders}" docs/build/flash.md '# Flash + +`docs/coldframe-node-X.Y.Z.bin`, `apps/`, `apps/rs/*/`, `apps/rs/{hub,node}/`, +`apps/rs/node/src/.../x`, `apps/rs/node and more`, `cargo build`, `src/main.rs`.' +expect "placeholder and non-repository spans are skipped" 0 "resolve" "" \ + -- env DOCS_REPO_ROOT="${placeholders}" "${links}" docs + +fenced=$(fixture fenced) +page "${fenced}" docs/build/flash.md '# Flash + +```text +[not a link](nope.md) `apps/nope` +```' +expect "fenced blocks are not checked" 0 "resolve" "" -- env DOCS_REPO_ROOT="${fenced}" "${links}" docs + +rooted=$(fixture rooted) +page "${rooted}" README.md '[pins](/apps/rs/node/src/board/pins.rs) [gone](/apps/gone.md)' +expect "a root-relative link resolves from the repository root" 1 "" "README.md:1: broken link '/apps/gone.md'" \ + -- env DOCS_REPO_ROOT="${rooted}" "${links}" README.md + +several=$(fixture several) +page "${several}" docs/build/flash.md '[a](a.md) +[b](b.md) `docs/c.md`' +expect "every failure is listed" 1 "" "check-links: 3 broken" -- env DOCS_REPO_ROOT="${several}" "${links}" docs + +expect "a missing root is a usage error" 2 "" "usage:" -- env DOCS_REPO_ROOT="${good}" "${links}" nope +expect "the repository docs pass" 0 "resolve" "" -- "${links}" + +echo "# check-commands.sh" +# A stub espflash: logs its arguments, knows the real subcommand names, fails on any other. +stubs=${work}/bin +mkdir -p "${stubs}" +calls=${work}/espflash-calls +cat > "${stubs}/espflash" <> "${calls}" +case \$1 in + board-info|checksum-md5|completions|erase-flash|erase-parts|erase-region|flash|hold-in-reset|\\ + list-ports|monitor|partition-table|read-flash|reset|save-image|write-bin|help) exit 0 ;; + *) echo "error: unrecognized subcommand '\$1'" >&2; exit 2 ;; +esac +EOF +chmod +x "${stubs}/espflash" +esp_export=${work}/export-esp.sh +echo 'export STUB_ESP=sourced' > "${esp_export}" + +check() { # [args...] + local root=$1 + shift + env PATH="${stubs}:${PATH}" DOCS_REPO_ROOT="${root}" ESP_EXPORT="${esp_export}" "${commands}" "$@" +} + +run_ok=$(fixture run-ok) +page "${run_ok}" docs/build/flash.md '# Flash + + + +```bash +test -f Cargo.toml +test "${STUB_ESP}" = sourced +true +```' +expect "a ci:run block runs in its directory after the export file" 0 "1 blocks in 2 files pass" - \ + -- check "${run_ok}" + +run_fail=$(fixture run-fail) +page "${run_fail}" docs/build/flash.md '# Flash + + +```sh +false +true +```' +expect "a failing ci:run block names its file and line" 1 - "docs/build/flash.md:4: the ci:run apps/rs/node block failed" \ + -- check "${run_fail}" + +: > "${calls}" +hardware=$(fixture hardware) +page "${hardware}" docs/build/flash.md '# Flash + + +```bash +# espflash bogus in a comment is not checked +espflash write-bin 0x0 coldframe-node-X.Y.Z.bin +cargo run --release +```' +expect "a ci:hardware block only checks its espflash subcommands" 0 "espflash write-bin is a subcommand" "" \ + -- check "${hardware}" +if [[ $(cat "${calls}") == "write-bin --help" ]]; then + pass "a ci:hardware block runs only espflash --help" +else + failed "a ci:hardware block runs only espflash --help" "calls: $(cat "${calls}")" +fi + +hardware_bad=$(fixture hardware-bad) +page "${hardware_bad}" docs/build/flash.md ' +```bash +espflash write-binary 0x0 x.bin +```' +expect "an unknown espflash subcommand fails" 1 - "docs/build/flash.md:2: 'espflash write-binary' is not an espflash subcommand" \ + -- check "${hardware_bad}" + +unmarked=$(fixture unmarked) +page "${unmarked}" docs/build/flash.md '# Flash + +```bash +cargo build --release --locked +```' +expect "an unmarked block fails naming its file and line" 1 - "docs/build/flash.md:3: unmarked bash/sh block" \ + -- check "${unmarked}" +expect "--list reports an unmarked block too" 1 - "docs/build/flash.md:3: unmarked bash/sh block" \ + -- check "${unmarked}" --list + +orphan=$(fixture orphan) +page "${orphan}" docs/build/flash.md ' +Some prose between the marker and the block. + +```bash +true +```' +expect "a marker followed by prose is an orphan" 1 - "docs/build/flash.md:2: a ci:run marker is not followed" \ + -- check "${orphan}" + +unknown=$(fixture unknown) +page "${unknown}" docs/build/flash.md ' +```bash +true +```' +expect "an unknown marker fails" 1 - "docs/build/flash.md:1: unknown marker 'ci:skip'" -- check "${unknown}" + +nodir=$(fixture nodir) +page "${nodir}" docs/build/flash.md ' +```bash +true +```' +expect "a ci:run directory that does not exist fails" 1 - "ci:run directory 'apps/rs/gone' does not exist" \ + -- check "${nodir}" + +listed=$(fixture listed) +page "${listed}" docs/build/flash.md ' +```bash +touch ran +``` + +```text +an unmarked text block is not a command block +``` + + +```bash +espflash monitor +```' +: > "${calls}" +expect "--list prints every block with its marker" 0 "docs/build/flash.md:2: ci:run apps/rs/node" "" \ + -- check "${listed}" --list +expect "--list names the hardware block" 0 "docs/build/flash.md:11: ci:hardware" "" -- check "${listed}" --list +if [[ ! -e ${listed}/apps/rs/node/ran && ! -s ${calls} ]]; then + pass "--list runs nothing" +else + failed "--list runs nothing" "ran: $(ls "${listed}/apps/rs/node")" "calls: $(cat "${calls}")" +fi + +no_export=$(fixture no-export) +page "${no_export}" docs/build/flash.md ' +```bash +test -z "${STUB_ESP:-}" +```' +expect "without the export file a ci:run block still runs, unsourced" 0 "1 blocks in 2 files pass" - \ + -- env -u STUB_ESP PATH="${stubs}:${PATH}" DOCS_REPO_ROOT="${no_export}" ESP_EXPORT="${work}/none.sh" "${commands}" + +expect "a file argument that is no file is a usage error" 2 "" "usage:" -- check "${good}" docs/build/nope.md +expect "the repository guide marks every block" 0 "pass" "" -- "${commands}" --list + +echo "# toolchain pins" +# The pins of ci.yml's firmware job are the reference; release.yml, docs.yml and the guide follow. +pin() { # : the quoted value of `: ""` + sed -n "s/^[[:space:]]*$2:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$1" | head -n 1 +} +workflows=${here}/../.github/workflows +flash=${here}/build/flash.md +espup_pin=$(pin "${workflows}/ci.yml" ESPUP_VERSION) +toolchain_pin=$(pin "${workflows}/ci.yml" ESP_TOOLCHAIN_VERSION) +espflash_pin=$(pin "${workflows}/ci.yml" ESPFLASH_VERSION) +if [[ -n ${espup_pin} && -n ${toolchain_pin} && -n ${espflash_pin} ]]; then + pass "ci.yml names the three pins" +else + failed "ci.yml names the three pins" "espup '${espup_pin}' toolchain '${toolchain_pin}' espflash '${espflash_pin}'" +fi +for workflow in release.yml docs.yml; do + for variable in ESPUP_VERSION ESP_TOOLCHAIN_VERSION ESPFLASH_VERSION; do + want=$(pin "${workflows}/ci.yml" "${variable}") + got=$(pin "${workflows}/${workflow}" "${variable}") + if [[ ${got} == "${want}" ]]; then + pass "${workflow} pins ${variable} as ci.yml does" + else + failed "${workflow} pins ${variable} as ci.yml does" "${workflow}: '${got}', ci.yml: '${want}'" + fi + done +done +# guide : flash.md contains it. +guide() { + if grep -qF -- "$2" "${flash}"; then pass "$1"; else failed "$1" "flash.md does not contain '$2'"; fi +} +guide "the guide's prose names the espup pin" "espup ${espup_pin}" +guide "the guide's prose names the toolchain pin" "esp toolchain ${toolchain_pin}" +guide "the guide's prose names the espflash pin" "espflash ${espflash_pin}" +guide "the install block installs the espup pin" "cargo install espup --version ${espup_pin} --locked" +guide "the install block installs the toolchain pin" "espup install --toolchain-version ${toolchain_pin} " +guide "the install block installs the espflash pin" "cargo install espflash --version ${espflash_pin} --locked" +# No other version of the three tools appears in the guide. +stray=$(grep -noE "(espup|espflash) [0-9]+\.[0-9]+\.[0-9]+|--version [0-9.]+|--toolchain-version [0-9.]+|toolchain [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" "${flash}" | + grep -vE "(espup|--version) ${espup_pin//./\\.}\$|(espflash|--version) ${espflash_pin//./\\.}\$|toolchain(-version)? ${toolchain_pin//./\\.}\$") +if [[ -z ${stray} ]]; then + pass "the guide names no other tool version" +else + failed "the guide names no other tool version" "${stray}" +fi + +echo +echo "${passes} passed, ${failures} failed" +[[ ${failures} -eq 0 ]] diff --git a/hardware/README.md b/hardware/README.md index d1040ea8..cf0b3345 100644 --- a/hardware/README.md +++ b/hardware/README.md @@ -1,5 +1,40 @@ # hardware -Schematics, PCB layouts and enclosure designs for the Node and the Hub. Nothing is here yet. +The reference hardware design for the Coldframe Node and Hub: schematics, a bill of materials, the +wiring, and notes on the enclosure and on sealing the soil probe. -The designs and the build guide for adopters arrive in Epic 10. +> **License.** Everything in `hardware/` is part of Coldframe and is licensed under the +> [Apache License 2.0](../LICENSE), like the rest of the repository. + +## Status: reference design, not yet bench-verified + +This is a **reference design**. It has not been built and measured on the bench yet. + +- Every part is a *reference choice*. Each BOM line also gives the **electrical requirement** the + part must meet, so you can use a different part that meets it. +- The schematics are Markdown connection tables and ASCII block diagrams, not an EDA project. You + can review them line by line against the firmware. A KiCad capture and PCB can follow once the + design has been verified on the bench. +- The Node's sleep current has **not been measured** yet. The budget is an average of about + 100 µA or less, and [`docs/bench/node-power-checklist.md`](../docs/bench/node-power-checklist.md) + is how to measure it. + +The firmware is the source of truth for every pin. If a page here disagrees with +[`apps/rs/node/src/board/pins.rs`](../apps/rs/node/src/board/pins.rs), the firmware is right and the +page has a bug. + +## Pages + +| Page | What | +| --- | --- | +| [`bom.md`](bom.md) | The bill of materials for the Node and the Hub, with the requirement for each part | +| [`node.md`](node.md) | The Node: block diagram, connection tables per block, and the full pin-to-pin wiring | +| [`hub.md`](hub.md) | The Hub: a dev board on USB power, antenna clearance and placement | +| [`enclosure.md`](enclosure.md) | The Node enclosure (IP rating, venting, glands) and sealing the soil probe | + +## Building one + +Follow the adopter build guide in [`docs/build/`](../docs/build/README.md). It goes from parts to +assembly, the toolchain, flashing the Hub and the Node, and adding both from the app. The power +guidance (panel placement, the sleep-current budget, and why soil % and battery % are approximate) +is in [`docs/build/power.md`](../docs/build/power.md). diff --git a/hardware/bom.md b/hardware/bom.md new file mode 100644 index 00000000..aee58fcb --- /dev/null +++ b/hardware/bom.md @@ -0,0 +1,47 @@ +# Bill of materials + +The parts for one Node and one Hub. A site needs one Hub and any number of Nodes. + +> **License.** This design is part of Coldframe and is licensed under the +> [Apache License 2.0](../LICENSE). + +> **Reference choices, not yet bench-verified.** Each line names an example part, and the +> requirement a substitute must meet. The example parts have not been built and measured together +> yet (see the [status](README.md#status-reference-design-not-yet-bench-verified)). Check every +> datasheet before you buy. Part numbers are examples, not endorsements. + +## Node + +| # | Qty | Part | Example | Requirement | +| --- | --- | --- | --- | --- | +| N1 | 1 | ESP32-S3 module | Espressif ESP32-S3-WROOM-1-N8 (8 MB flash, PCB antenna) | ESP32-S3; at least 4 MB flash (the [partition table](../apps/rs/node/partitions.csv) ends at 4 MB); PCB antenna; 3.0–3.6 V supply. A bare module, not a dev board: a stock dev board draws about 1 mA asleep (see [power](../docs/build/power.md#sleep-current-budget)) | +| N2 | 1 | Capacitive soil probe | "Capacitive soil moisture sensor v2.0" with a TLC555 (CMOS 555) | Capacitive, not resistive. Runs at 3.3 V. Analogue output stays at or below about 3.1 V, the range of the ADC at 11 dB. **NE555 caveat:** many cheap "v1.2" boards carry an NE555, which works poorly at 3.3 V; read the chip marking and prefer a TLC555 or another CMOS timer. Their edges are unsealed: seal the probe as in [enclosure.md](enclosure.md#sealing-the-soil-probe) | +| N3 | 1 | BME680 breakout | A bare BME680 breakout (sensor, decoupling and I²C pull-ups only) with SDO pulled high, or the bare sensor on the Node board | Bosch BME680 on I²C at **0x77** (SDO to VCC). Powered from 3.3 V directly: no always-on regulator, no level shifter and no power LED, which would break the sleep budget. Boards like Adafruit 3660 carry an on-board 3.3 V regulator and level shifter (and the STEMMA QT revision a power LED), so they do **not** qualify unless modified. I²C pull-ups to 3.3 V | +| N4 | 1 | 1S LiPo cell | A 3.7 V, 800 mAh pouch (size code about 603040) with a protection circuit (PCM) and a 10 kΩ NTC lead | 1S LiPo, 4.2 V full, about 800 mAh. Built-in protection (over-discharge, over-current). A **10 kΩ NTC** (B about 3435 K) in the pack, wired to the charger's NTC input | +| N5 | 1 | Solar panel | A ~1 W, 5–6 V epoxy or glass panel (about 110 × 60 mm) | About 1 W. The open-circuit voltage must stay below the charger's maximum input: a TP4056 is rated to 8 V at most, and a "6 V" panel often reaches 7 V or more open-circuit, so pair a TP4056 with a 5 V panel, or use the CN3791 | +| N6 | 1 | Charger | TP4056 (linear, 1S LiPo, TEMP pin and open-drain `CHRG`). Solar alternative: CN3791 (MPPT, NTC input, open-drain `CHRG` and `DONE`) | 1S LiPo CC/CV charge to 4.2 V. An **NTC input** that stops charging below 0 °C and above about 45 °C. An **open-drain, active-low** charge status pin (`CHRG`), read by GPIO6. Charge current at most 0.5 C (400 mA for 800 mAh); a ~1 W panel delivers about 200 mA, so set it near that (TP4056: RPROG 5 kΩ gives about 250 mA, per its datasheet table). Many TP4056 modules tie TEMP to ground, which disables the NTC: use one that exposes TEMP, or build the charger from the IC | +| N7 | 1 | 3.3 V LDO | Richtek RT9080-33GJ5 (600 mA, about 2 µA quiescent) | 3.3 V out. **Quiescent current at most about 5 µA**, it flows all the time. **At least 500 mA** output for the radio peaks. Low dropout. Stable with ceramic capacitors | +| N8 | 2 | High-side load switch | TI TPS22917DBV (SOT-23-6, quick output discharge) | Input range covering 3.3 V (probe feed) and 4.2 V (cell, divider feed). **Active-high enable**, with an **external pull-down** on the enable pin (N11): the ESP32-S3 pads float in deep sleep. Off-state leakage and quiescent current at most about 1 µA. Output at 0 V when off (output discharge helps). A discrete P-MOSFET with a level-shifting driver also meets it. **Not** a low-side N-MOSFET (see [node.md](node.md#switched-battery-divider)) | +| N9 | 2 | Divider resistors | 100 kΩ, 1 %, 0603 | Exactly 100 kΩ / 100 kΩ: the firmware's `DIVIDER` in [`pins.rs`](../apps/rs/node/src/board/pins.rs) assumes it. 1 % or better | +| N10 | 1 | Divider midpoint capacitor | 4.7 nF ceramic, 0603 | **At most 10 nF.** With 100 kΩ / 100 kΩ that is RC = 0.5 ms, well inside the firmware's 10 ms settle time; a larger capacitor needs the firmware changed | +| N11 | 2 | Pull-downs | 100 kΩ, 0603 | One on each load-switch enable (GPIO4, GPIO5), to ground, so both switches stay off in deep sleep | +| N12 | 1 | Setup button | An IP67 panel-mount momentary push button (normally open) | Momentary, normally open, from GPIO7 to ground. No external pull-up: the firmware uses the internal one, kept in deep sleep | +| N13 | 1 | EN reset network | 10 kΩ pull-up and 1 µF to ground on EN | As in the ESP32-S3-WROOM-1 datasheet, so the module starts cleanly when power comes up | +| N14 | 2 | Bench buttons | 6 × 6 mm tactile switches, on the board | `RESET` (EN to ground) and `BOOT` (GPIO0 to ground, with a 10 kΩ pull-up), for flashing. Not reachable from outside the enclosure | +| N15 | 1 | Decoupling | 22 µF and 100 nF ceramic at the module's 3V3 pin; the LDO's input and output capacitors per its datasheet | As in the module and LDO datasheets | +| N16 | 1 | Flashing header | 1 × 7 pin header, 2.54 mm | GND, USB D− (GPIO19), USB D+ (GPIO20), EN, GPIO0, UART0 TX (GPIO43) and RX (GPIO44). The ESP32-S3's built-in USB Serial/JTAG needs no USB-UART bridge, which would draw current in sleep. See [node.md](node.md#flashing-header) | +| N16a | 1 | USB lead for flashing | A USB breakout board (USB-C or micro-USB) or a cut USB cable, wired to the header | GND, D− and D+ to header pins 1–3; **VBUS left unconnected**. Short (under about 20 cm). See [node.md](node.md#connecting-usb) | +| N17 | 1 | Enclosure and parts | An IP65 (or better) ABS or polycarbonate box, cable glands, a vent membrane, conformal coating and epoxy, heat-shrink | See [enclosure.md](enclosure.md) | + +## Hub + +The Hub uses no GPIO: it is a dev board on USB power, indoors. + +| # | Qty | Part | Example | Requirement | +| --- | --- | --- | --- | --- | +| H1 | 1 | ESP32-S3 dev board | Espressif ESP32-S3-DevKitC-1-N8 | ESP32-S3 on a WROOM-1 module (PCB antenna, not the -1U external-antenna variant) with at least 4 MB flash (the [partition table](../apps/rs/hub/partitions.csv) ends at 4 MB), and a USB port | +| H2 | 1 | USB power supply | Any 5 V USB charger | 5 V, at least 500 mA, always on. A good supply: brownouts reboot the Hub | +| H3 | 1 | USB cable | USB-A or USB-C to micro-USB or USB-C, to match the board | A data cable (for flashing), not a charge-only cable | +| H4 | 1 | Case | A small plastic case, or none | Not metal: the antenna needs clearance (see [hub.md](hub.md#antenna-clearance)) | + +Sleep current does not matter for the Hub: it is always on and mains powered. diff --git a/hardware/enclosure.md b/hardware/enclosure.md new file mode 100644 index 00000000..218ed7f1 --- /dev/null +++ b/hardware/enclosure.md @@ -0,0 +1,66 @@ +# Enclosure and probe sealing + +The Node lives outdoors for a whole season, with its probe in wet soil. The Hub lives indoors and +needs only a plastic case (see [hub.md](hub.md#antenna-clearance)). + +> **License.** This design is part of Coldframe and is licensed under the +> [Apache License 2.0](../LICENSE). + +> **Reference design, not yet bench-verified.** These are the reference choices; none of them has +> been through a season outdoors yet. + +## The outdoor requirement (NFR-5) + +A Node, its solar panel and its soil probe survive a season outdoors in wet soil, **without +corrosion-driven drift making Alerts unreliable**. Everything below serves that: keep water out of +the electronics, and keep the probe's reading stable over months. + +## Node enclosure + +- **IP rating:** IP65 or better (dust-tight, protected against water jets). An IP67 box tolerates + standing water, for example when the Node falls over. ABS or polycarbonate with UV stabilisation; + plain ABS goes brittle in sun. +- **Colour:** light grey or white. A dark box in full sun heats up, and the charger must stop + charging above about 45 °C anyway. +- **Venting for the BME680:** the BME680 measures air temperature, humidity and gas resistance, so + it needs outside air. Fit a breathable **ePTFE vent membrane** (an M12 vent plug) and mount the + BME680 right behind it, away from the LDO and the module, which warm up a little. A sealed box + with no vent reads its own air and collects condensation. +- **Cable glands:** one gland per cable: the probe cable, and the panel cable if the panel is + separate. Size the gland to the cable (for example M12 / PG7 for 3–6 mm cables). Leave a drip + loop below each gland so water runs off before it reaches it. Glands on the bottom face are + safest. +- **Button:** an IP67 panel-mount push button for the setup button + ([BOM N12](bom.md#node)), so you can press it without opening the box. +- **Mounting:** on a stake or post, with the box's glands facing down. +- **Panel and antenna clear:** mount the panel **above the canopy**, angled to the sun and not + shaded by the plant as it grows ([power.md](../docs/build/power.md#panel-placement)). Keep the + module's antenna end free of metal (no metal box, no metal mounting plate behind the antenna), + and keep wet foliage away from it. +- **Inside:** a small silica-gel bag absorbs the moisture trapped when you close the box. Replace + it when you open the box. + +## Sealing the soil probe + +Cheap capacitive probes have bare electronics at the top and **unsealed cut edges** along the +board. Water creeps into the board's edges and under the components, and the reading drifts as the +board absorbs it. Seal every probe before its first use: + +1. **Mark the insertion line.** The probe has a line (or a printed limit) above which it must not + go into the soil. Mark it with a pen. Only the part below the line goes into the soil; the + electronics stay above it. +2. **Coat the cut edges.** Paint every cut edge of the board, along its full length and around the + tip, with conformal coating or a thin layer of epoxy. The flat sensing faces are already covered + by solder mask; coat the edges so water cannot creep in. +3. **Pot the electronics.** Cover the components at the top (the timer chip, the regulator, the + connector) with conformal coating, or pot them in epoxy. Let it cure fully. +4. **Heat-shrink.** Slide adhesive-lined heat-shrink over the top of the probe, from the cable down + to just above the insertion line, and shrink it. It covers the electronics and the cable entry. +5. **Cable.** Use an outdoor-rated cable from the probe to the Node, and run it through a cable + gland. + +A sealed probe still needs **2–3 days to settle** after you insert it, and a calibration in your +soil; see [power.md](../docs/build/power.md#soil-moisture) for why soil % is approximate. + +Inspect the probe at the end of the season. Cracked coating, a lifted heat-shrink or a corroded +edge means the Readings of the last weeks may have drifted. diff --git a/hardware/hub.md b/hardware/hub.md new file mode 100644 index 00000000..c9a6621d --- /dev/null +++ b/hardware/hub.md @@ -0,0 +1,74 @@ +# Hub + +The Hub relays the Nodes' Readings to the Server. It talks ESP-NOW to the Nodes in the garden and +Wi-Fi with TLS to the Server on the home network, and it stores no Readings. It is always on. + +> **License.** This design is part of Coldframe and is licensed under the +> [Apache License 2.0](../LICENSE). + +> **Reference design, not yet bench-verified.** The parts are the reference choices of the +> [BOM](bom.md#hub), each with the requirement a substitute must meet. + +## Schematic + +The Hub is an unmodified ESP32-S3 dev board on USB power. The firmware uses **no GPIO**: no button, +no sensor, no LED. Setup is automatic: an unprovisioned Hub advertises over BLE on every boot. + +```text + +----------------+ USB 5 V, >= 500 mA +------------------------------+ + | USB power |----------------------->| ESP32-S3-DevKitC-1-N8 | + | supply (mains) | data cable | on-board 3.3 V regulator | + +----------------+ | ESP32-S3-WROOM-1 (PCB ant.) | + | RESET (EN) and BOOT buttons | + +------------------------------+ + ^ 2.4 GHz: Wi-Fi to the access point, + | ESP-NOW to the Nodes, + | BLE during setup only +``` + +## Wiring + +| From | To | Notes | +| --- | --- | --- | +| USB power supply | Dev board USB port | Either port of the DevKitC-1 powers the board. For flashing, use the same cable from your computer | + +That is all. Nothing connects to the pin headers. + +The DevKitC-1 has two USB ports, `UART` (through a USB-UART bridge) and `USB` (the ESP32-S3's +built-in USB Serial/JTAG). `espflash` flashes and monitors through either. + +The DevKitC-1's on-board buttons are the only controls: `RST` restarts the Hub (an unprovisioned +Hub then prints its setup code again, see [flashing](../docs/build/flash.md#first-boot-and-the-setup-code)), +and `BOOT` is only for the download mode while flashing. + +## Power + +- 5 V over USB from a mains USB supply, at least 500 mA, always on. The radio's peaks need it. +- The Hub never sleeps, so a battery or a small panel is not an option. +- Unplugging it is safe: it keeps no Readings, and the Nodes keep and resend theirs. + +## Antenna clearance + +The WROOM-1 module has a PCB antenna at the end of the board, beyond the module's metal shield. + +- Keep at least about 15 mm around the antenna end clear of metal, cables and the wall. +- Do not put the Hub in a metal box, behind a radiator, or on a metal shelf. +- A plastic case is fine. Mount the board upright, with the antenna end up, if you can. +- Use the WROOM-1 (PCB antenna), not the WROOM-1U, unless you also fit an external 2.4 GHz antenna. + +## Placement on the home network + +The Hub has to reach two things at once: + +1. **The access point**, over 2.4 GHz Wi-Fi. The ESP32-S3 has no 5 GHz radio. The network must be + WPA2-Personal or mixed WPA2/WPA3 (transition mode); **WPA3-only networks are not supported** + (see [`apps/rs/hub/README.md`](../apps/rs/hub/README.md#ble-setup)). The Hub joins the strongest + access point of the network you choose in the app. +2. **The Nodes**, over ESP-NOW, which uses the same radio and follows the access point's channel. + +So place the Hub indoors, on the garden side of the house, for example on a window sill facing the +garden, and still in good Wi-Fi range. Thick walls, metal-coated glass and wet foliage all cut the +range. The Hub is not weatherproof: keep it indoors or in a dry, sheltered place. + +The Hub reaches the Server by the Server's host name over HTTPS, so the home network's DNS must +resolve that name to the Server ([split DNS](../docs/operations/split-dns.md)). diff --git a/hardware/node.md b/hardware/node.md new file mode 100644 index 00000000..c4210df6 --- /dev/null +++ b/hardware/node.md @@ -0,0 +1,215 @@ +# Node + +The Node is a battery-powered ESP32-S3 on solar power. Every 15 minutes it wakes, powers the soil +probe and the battery divider for a moment, reads the BME680, sends the Readings to the Hub over +ESP-NOW, and goes back to deep sleep. + +> **License.** This design is part of Coldframe and is licensed under the +> [Apache License 2.0](../LICENSE). + +> **Reference design, not yet bench-verified.** The parts are the reference choices of the +> [BOM](bom.md), each with the requirement a substitute must meet. + +**The source of truth for every pin is +[`apps/rs/node/src/board/pins.rs`](../apps/rs/node/src/board/pins.rs).** This page follows it. If +you change a pin, change it there; the firmware then reads the new pin, and this page needs the +same change. + +## Block diagram + +```text + VBAT (3.3-4.2 V) + +-----------+ +-----------+ +----------------------+------------------------+ + | Solar |--->| Charger |-BAT-+ | | + | panel | | (TP4056) | | v v + | ~1 W 5-6 V| | | +-+-----------+ +-------------+ +----------------+ + +-----------+ | TEMP |<--| 1S LiPo | | LDO 3.3 V | | U2 load switch |<-- GPIO5 + | CHRG |-+ | 800 mAh | | Iq <= ~5 uA | | (divider) | + 100 k to GND + +-----------+ | | PCM, 10k NTC| +------+------+ +-------+--------+ + | +-------------+ | 3V3 | + | | R1 100 k + | | +-----------> GPIO2 (ADC1_CH1) + | | R2 100 k + C <= 10 nF + | | GND GND + | | + | +--> U1 load switch (probe) <-- GPIO4 + 100 k to GND + | | | + | | soil probe --AOUT------------> GPIO1 (ADC1_CH0) + | | + | +--> BME680, 0x77 (SDO high) <--I2C--> GPIO8 SDA, GPIO9 SCL + | | + | +--> ESP32-S3-WROOM-1 3V3 (EN: 10 k up, 1 uF down) + | + +---------------------------------- CHRG, active low --------> GPIO6 + + Setup button: GPIO7 ---[ push ]--- GND + Flashing header: GND, D- (GPIO19), D+ (GPIO20), EN, GPIO0, TXD0 (GPIO43), RXD0 (GPIO44) +``` + +The battery feeds the LDO directly. The charger has no power path, which is fine at the Node's +microamp load. + +## Power path + +| From | To | Notes | +| --- | --- | --- | +| Panel + | Charger VCC (input) | The panel's open-circuit voltage must stay below the charger's maximum input ([BOM N5](bom.md#node)) | +| Panel − | GND | | +| Charger BAT | Cell + (protected terminal) | Charge to 4.2 V, at most 0.5 C | +| Cell − (protected terminal) | GND | | +| Cell NTC | Charger TEMP / NTC | Through the network the charger's datasheet gives for a 0 °C to about 45 °C charging window. Never tie TEMP to ground: that disables the temperature cut-off | +| Cell + (VBAT) | LDO IN | Plus the input capacitor of the LDO datasheet | +| LDO OUT (3V3) | Module 3V3 | 22 µF and 100 nF at the module pin | +| LDO GND, module GND | GND | | +| 3V3 | EN, through 10 kΩ; EN to GND through 1 µF | The reset network | + +The battery is read through its own switched divider (below); the firmware maps the voltage to a +percentage with the curve in +[`packages/rs/sensing/src/battery.rs`](../packages/rs/sensing/src/battery.rs). The curve reads +0 % at 3.27 V. Below about 3.4 V the LDO is in dropout and the module runs a little under 3.3 V, +which the ESP32-S3 tolerates down to 3.0 V. + +## Switched probe power + +The probe is powered only while it is read: the firmware switches it on, waits 100 ms +(`PROBE_SETTLE_MS` in [`packages/rs/sensing/src/wake.rs`](../packages/rs/sensing/src/wake.rs)), and +takes 8 samples. + +| From | To | Notes | +| --- | --- | --- | +| GPIO4 | U1 ON (enable) | High = on | +| U1 ON | GND, through 100 kΩ | The pull-down: the pad floats in deep sleep, so the switch must stay off on its own | +| 3V3 | U1 VIN | | +| U1 VOUT | Probe VCC | 0 V while off | +| Probe AOUT | GPIO1 (ADC1_CH0) | Must stay at or below about 3.1 V (11 dB attenuation) | +| Probe GND | GND | | + +## Switched battery divider + +The divider is switched on the **high side**, between the cell and the top resistor. A low-side +N-MOSFET is ruled out: while off it would leave the midpoint, and GPIO2, at up to 4.2 V through the +top resistor, beyond the pad's rating, and current would leak through the pad. Switched on the high +side, GPIO2 sees 0 V while off. + +| From | To | Notes | +| --- | --- | --- | +| GPIO5 | U2 ON (enable) | High = on | +| U2 ON | GND, through 100 kΩ | The pull-down, as for U1 | +| VBAT | U2 VIN | | +| U2 VOUT | R1 100 kΩ (top) | | +| R1 | Midpoint | | +| Midpoint | R2 100 kΩ (bottom) to GND | 4.2 V at the cell reads 2.1 V | +| Midpoint | GPIO2 (ADC1_CH1) | Calibrated millivolts, 11 dB | +| Midpoint | GND, through at most 10 nF | RC = 50 kΩ × 10 nF = 0.5 ms, inside the 10 ms settle time. A larger capacitor needs `DIVIDER_SETTLE_MS` raised to at least 5·RC | + +The divider values are `DIVIDER` in [`pins.rs`](../apps/rs/node/src/board/pins.rs). Only ADC1 is +used, because ADC2 conflicts with the radio. + +## Charger status + +| From | To | Notes | +| --- | --- | --- | +| Charger `CHRG` | GPIO6 | Open-drain, active low: low while charging. The firmware enables the internal pull-up, so no external pull-up is needed. Read on each wake as *charging* or *not charging* | + +## Setup button + +| From | To | Notes | +| --- | --- | --- | +| GPIO7 | Button | GPIO7 is a low-power (RTC) pad, so a press wakes the Node from deep sleep | +| Button | GND | Active low. The internal pull-up is kept in deep sleep: no external resistor | + +A short press (50 ms to under 3 s) sends a Reading now. A long press (3 s) opens the BLE setup +window for 180 s. The timing is in +[`packages/rs/sensing/src/button.rs`](../packages/rs/sensing/src/button.rs). + +## I²C to the BME680 + +| From | To | Notes | +| --- | --- | --- | +| GPIO8 | BME680 SDA | | +| GPIO9 | BME680 SCL (SCK) | | +| 3V3 | BME680 VCC | Not switched: forced mode puts it back to sleep (about 0.15 µA) | +| 3V3 | BME680 SDO | Address **0x77** (`BME680_SECONDARY_ADDRESS` in `pins.rs`) | +| 3V3 | BME680 CS | Selects I²C; breakouts usually do this already | +| GND | BME680 GND | | +| 3V3 | SDA, SCL, through the breakout's pull-ups | Pull-ups to 3.3 V only | + +## Flashing header + +The ESP32-S3 has a built-in USB Serial/JTAG controller, so the Node needs no USB-UART bridge, which +would draw current while asleep. `espflash` flashes and monitors through it. + +| Header pin | To | Notes | +| --- | --- | --- | +| 1 GND | GND | | +| 2 D− | GPIO19 | USB D− | +| 3 D+ | GPIO20 | USB D+ | +| 4 EN | EN | Reset (the RESET button pulls it to ground) | +| 5 IO0 | GPIO0 | Strapping pin: held low at reset, the chip enters the download mode. 10 kΩ pull-up and the BOOT button to ground | +| 6 TXD0 | GPIO43 | UART0, for a USB-UART adapter instead of USB | +| 7 RXD0 | GPIO44 | UART0 | + +Power the board from the battery while you flash; do not connect USB VBUS to the 3V3 rail. The USB +serial link drops while the chip sleeps, and `espflash monitor` reconnects on the next wake. + +### Connecting USB + +The Node has no USB connector. To flash it, wire a USB breakout or a cut USB cable +([BOM N16a](bom.md#node)) to the header: + +- USB **GND** to header pin 1 (GND); +- USB **D−** to header pin 2 (GPIO19); +- USB **D+** to header pin 3 (GPIO20); +- leave USB **VBUS unconnected**: the Node runs from its battery while you flash. + +Keep the lead short (under about 20 cm) and the D−/D+ wires twisted together or side by side: USB +does not tolerate long, loose data wires. + +## Wiring: full pin-to-pin list + +Every connection on the module, checked against +[`pins.rs`](../apps/rs/node/src/board/pins.rs). Look up the module pad of each GPIO in the +ESP32-S3-WROOM-1 datasheet. + +| Module pin | Signal | Connects to | Direction | Notes | +| --- | --- | --- | --- | --- | +| 3V3 | Supply | LDO OUT; 22 µF + 100 nF to GND | in | | +| GND | Ground | GND | | | +| EN | Reset | 10 kΩ to 3V3, 1 µF to GND, RESET button to GND, header pin 4 | in | | +| GPIO0 | Boot strap | 10 kΩ to 3V3, BOOT button to GND, header pin 5 | in | Strapping pin, only for flashing | +| GPIO1 | Soil probe analogue out | Probe AOUT | in (ADC1_CH0) | Raw count, 11 dB | +| GPIO2 | Battery divider midpoint | R1/R2 midpoint, ≤ 10 nF to GND | in (ADC1_CH1) | Calibrated mV, 11 dB, 100 kΩ / 100 kΩ | +| GPIO4 | Probe power switch | U1 ON, 100 kΩ to GND | out | High-side switch, high = on | +| GPIO5 | Divider switch | U2 ON, 100 kΩ to GND | out | High-side switch, high = on | +| GPIO6 | Charger status (`CHRG`) | Charger `CHRG` | in | Active low, internal pull-up | +| GPIO7 | Setup button | Button to GND | in | Active low, internal pull-up kept in deep sleep; wakes the Node | +| GPIO8 | I²C SDA | BME680 SDA | in/out | | +| GPIO9 | I²C SCL | BME680 SCL | out | | +| GPIO19 | USB D− | Header pin 2 | in/out | Flashing and logs | +| GPIO20 | USB D+ | Header pin 3 | in/out | Flashing and logs | +| GPIO43 | UART0 TX | Header pin 6 | out | Optional | +| GPIO44 | UART0 RX | Header pin 7 | in | Optional | + +Every other GPIO is unconnected. Off-module parts: + +| Part | Pin | Connects to | +| --- | --- | --- | +| Panel | + / − | Charger VCC / GND | +| Charger | BAT / TEMP / `CHRG` / GND | Cell + / cell NTC network / GPIO6 / GND | +| Cell | + / − / NTC | VBAT (charger BAT, LDO IN, U2 VIN) / GND / charger TEMP | +| LDO | IN / OUT / GND | VBAT / 3V3 / GND | +| U1 (probe switch) | VIN / VOUT / ON / GND | 3V3 / probe VCC / GPIO4 + 100 kΩ to GND / GND | +| U2 (divider switch) | VIN / VOUT / ON / GND | VBAT / R1 / GPIO5 + 100 kΩ to GND / GND | +| R1 100 kΩ | 1 / 2 | U2 VOUT / midpoint | +| R2 100 kΩ | 1 / 2 | Midpoint / GND | +| C ≤ 10 nF | 1 / 2 | Midpoint / GND | +| Soil probe | VCC / AOUT / GND | U1 VOUT / GPIO1 / GND | +| BME680 | VCC / GND / SDA / SCL / SDO / CS | 3V3 / GND / GPIO8 / GPIO9 / 3V3 / 3V3 | +| Setup button | 1 / 2 | GPIO7 / GND | + +## Sleep current + +The design aims at an average sleep current of at most about 100 µA: the LDO's quiescent current, +the module in deep sleep, the BME680 asleep, the switches' leakage and the charger's drain on the +cell. **It has not been measured yet.** See [power.md](../docs/build/power.md#sleep-current-budget) +for the budget and how to measure it.