From 509f6f703a34513d24c1e47d532670c1513fedda Mon Sep 17 00:00:00 2001 From: JakeTheRabbit <123831499+JakeTheRabbit@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:35:39 +1200 Subject: [PATCH 1/3] fix: add checked substrate calibration and sizing workflow --- .github/workflows/build-firmware.yml | 70 +- .github/workflows/test.yml | 23 + .gitignore | 5 + README.md | 161 +- .../automation/tdr_dryback_irrigation.yaml | 144 +- blueprints/automation/tdr_ec_alert.yaml | 69 +- docs/CALIBRATION.md | 139 +- docs/CONFIG.md | 186 +- docs/FLASHING.md | 4 +- docs/HOMEASSISTANT.md | 92 +- docs/MIGRATION-v3.md | 23 + docs/PLACEMENT.md | 46 + docs/SENSORS.md | 93 +- docs/SOURCES.md | 19 + docs/SUBSTRATES.md | 77 + docs/TROUBLESHOOTING.md | 88 +- docs/VALIDATION.md | 47 + docs/WIRING.md | 34 +- docs/img/mt22-placement.svg | 52 + ...MT22-placement-template-A4-actual-size.pdf | Bin 0 -> 5651 bytes .../factory/tdr-sensor-atom-lite-factory.yaml | 2 +- .../factory/tdr-sensor-atom-poe-factory.yaml | 2 +- .../factory/tdr-sensor-atom-s3-factory.yaml | 2 +- .../tdr-sensor-esp32-generic-factory.yaml | 2 +- .../factory/tdr-sensor-m5-dial-factory.yaml | 2 +- esphome/import/atom-lite.yaml | 2 +- esphome/import/atom-poe.yaml | 2 +- esphome/import/atom-s3.yaml | 2 +- esphome/import/esp32-generic.yaml | 2 +- esphome/import/m5-dial.yaml | 2 +- esphome/packages/boards/atom-lite.yaml | 6 +- esphome/packages/boards/atom-poe.yaml | 6 +- esphome/packages/boards/atom-s3.yaml | 6 +- esphome/packages/boards/m5-dial.yaml | 6 +- esphome/packages/tdr_analytics.yaml | 1370 ++++---------- esphome/packages/tdr_sdi12_core.yaml | 1598 +++++++---------- esphome/tdr-sensor-atom-lite.yaml | 2 +- esphome/tdr-sensor-atom-poe.yaml | 2 +- esphome/tdr-sensor-atom-s3.yaml | 2 +- esphome/tdr-sensor-esp32-generic.yaml | 2 +- esphome/tdr-sensor-m5-dial.yaml | 2 +- lovelace/dashboard.yaml | 144 +- tests/calculator.test.js | 19 + tests/check_configs.py | 48 + tests/test_firmware.py | 124 ++ tests/test_repository.py | 47 + tools/setup/calculator.js | 67 + tools/setup/favicon.svg | 1 + tools/setup/index.html | 24 + tools/setup/style.css | 1 + tools/setup/substrates.js | 33 + tools/setup/ui.js | 68 + 52 files changed, 2147 insertions(+), 2823 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 docs/MIGRATION-v3.md create mode 100644 docs/PLACEMENT.md create mode 100644 docs/SOURCES.md create mode 100644 docs/SUBSTRATES.md create mode 100644 docs/VALIDATION.md create mode 100644 docs/img/mt22-placement.svg create mode 100644 docs/print/MT22-placement-template-A4-actual-size.pdf create mode 100644 tests/calculator.test.js create mode 100644 tests/check_configs.py create mode 100644 tests/test_firmware.py create mode 100644 tests/test_repository.py create mode 100644 tools/setup/calculator.js create mode 100644 tools/setup/favicon.svg create mode 100644 tools/setup/index.html create mode 100644 tools/setup/style.css create mode 100644 tools/setup/substrates.js create mode 100644 tools/setup/ui.js diff --git a/.github/workflows/build-firmware.yml b/.github/workflows/build-firmware.yml index 9a8ad4f..48b696d 100644 --- a/.github/workflows/build-firmware.yml +++ b/.github/workflows/build-firmware.yml @@ -1,72 +1,60 @@ name: Build firmware - -# Compiles the firmware for every board. On a tagged release it -# attaches the binaries to the release so people can download and -# flash them from the browser. On a normal push it just builds, so a -# broken config gets caught early. - on: push: branches: [main] - paths: - - "esphome/**" - - ".github/workflows/build-firmware.yml" + paths: ['esphome/**', '.github/workflows/build-firmware.yml'] + pull_request: + paths: ['esphome/**', '.github/workflows/build-firmware.yml'] release: types: [published] workflow_dispatch: - permissions: - contents: write - + contents: read +concurrency: + group: firmware-${{ github.ref }} + cancel-in-progress: true jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - include: - - board: atom-lite - file: esphome/factory/tdr-sensor-atom-lite-factory.yaml - - board: atom-s3 - file: esphome/factory/tdr-sensor-atom-s3-factory.yaml - - board: atom-poe - file: esphome/factory/tdr-sensor-atom-poe-factory.yaml - - board: m5-dial - file: esphome/factory/tdr-sensor-m5-dial-factory.yaml - - board: esp32-generic - file: esphome/factory/tdr-sensor-esp32-generic-factory.yaml + board: [atom-lite, atom-s3, atom-poe, m5-dial, esp32-generic] steps: - uses: actions/checkout@v4 - - name: Build ${{ matrix.board }} id: build uses: esphome/build-action@v7 with: - yaml-file: ${{ matrix.file }} + yaml-file: esphome/factory/tdr-sensor-${{ matrix.board }}-factory.yaml + version: '2026.8.2' complete-manifest: true - - # Name the files per board so five builds do not collide when - # they all land on the same release. - name: Stage firmware + env: + BUILD_NAME: ${{ steps.build.outputs.name }} + BOARD: ${{ matrix.board }} run: | mkdir -p dist - cp "${{ steps.build.outputs.name }}/firmware.factory.bin" \ - "dist/tdr-sensor-${{ matrix.board }}.factory.bin" - if [ -f "${{ steps.build.outputs.name }}/firmware.ota.bin" ]; then - cp "${{ steps.build.outputs.name }}/firmware.ota.bin" \ - "dist/tdr-sensor-${{ matrix.board }}.ota.bin" + cp "$BUILD_NAME/firmware.factory.bin" "dist/tdr-sensor-$BOARD.factory.bin" + if [ -f "$BUILD_NAME/firmware.ota.bin" ]; then + cp "$BUILD_NAME/firmware.ota.bin" "dist/tdr-sensor-$BOARD.ota.bin" fi - ls -la dist - - - name: Upload build artifact - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: tdr-sensor-${{ matrix.board }} path: dist retention-days: 30 - - - name: Attach to release - if: github.event_name == 'release' - uses: softprops/action-gh-release@v2 + release: + if: github.event_name == 'release' + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 with: files: dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9d53a4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Measurement and setup tests +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install PyYAML==6.0.2 + - run: node --test tests/calculator.test.js + - run: python tests/test_firmware.py + - run: python tests/test_repository.py diff --git a/.gitignore b/.gitignore index f6d820b..d48ba08 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ __pycache__/ *.pyc .DS_Store + +# Local browser verification +.playwright-cli/ +output/playwright/ +/esphome/factory/.gitignore diff --git a/README.md b/README.md index e2a99ce..59b1971 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,51 @@ # TDR Sensor -A WiFi substrate sensor for crop steering. It reads an SDI-12 moisture probe, works out water content, pore EC and temperature, and runs a full set of dryback and steering analytics on the device itself. No cloud, no subscription. Flash it from your browser, open its web page, and read your root zone. +An ESPHome reader for the **INFWIN MT22A SDI-12** substrate probe, with checked water-content calibration, bulk EC, temperature and observed dryback trends. Runs locally on ESP32/M5Stack hardware with a web page, Home Assistant, optional MQTT and CSV logging. -Built for rockwool and coco, tuned against the METER TEROS 12 calibration, runs on cheap M5Stack hardware and a clone probe that costs a fraction of a branded system. +**Start with the [substrate and calibration setup desk](tools/setup/index.html)**: download this repository ZIP, extract it and open that file in a browser. It works offline and covers cubes, cubes on shared slabs, coco containers, metric/custom sizes, weighed calibration records and an actual-size printable placement sheet. -## What it does +![MT22 placement on a three-plant slab](docs/img/mt22-placement.svg) -- Reads VWC, pore water EC, bulk EC and substrate temperature over SDI-12 -- Full calibration suite you set from the web page, no reflashing: substrate profiles, two point VWC calibration, single point EC calibration, and every coefficient exposed -- On-device crop steering analytics: peak, trough, dryback in points and percent, dryback rate, shot detection, EC stacking, saturation, and more -- Steering detection that reads whether the plant is being driven vegetative or generative from how the substrate behaves -- Its own web page with live readings and history, so you need nothing else -- MQTT for Mycodo, Node-RED, Grafana or anything else that speaks it -- Native Home Assistant integration with auto-discovery, blueprints and a dashboard -- CSV logging straight off the device with a small Python script +## What changed in v3 -## Which board and probe +- A saturated/drained capture saves a **wet-reference index**, not an invented 100% VWC. +- Headline VWC requires **two weighed points and an independent third-point check**. It is withheld during calibration, outside the fitted range and when data is stale. +- RAW, generic VWC, weighed estimates, bulk EC and optional modelled pore EC are distinguished. The unsupported bulk/VWC–Hilhorst blend has been removed. +- Cube + shared-slab volume is calculated once and allocated per plant. Real metric block sizes are used, including the nominal six-inch Hugo at about 3.2 L. +- Measured trends replace claims of vegetative/generative state or physiological confidence. Plateau detection, freshness and calibration-history resets are fixed. +- Firmware dependencies and CI use explicit versions. Host tests execute the actual calibration and analytics code extracted from the YAML. -Boards, any one of these: +Existing users: read [the v3 migration notes](docs/MIGRATION-v3.md) before upgrading. Old calibration values are not silently promoted into checked VWC. -- M5Stack Atom Lite, the cheap and common pick -- M5Stack AtomS3 Lite -- M5Stack Atom PoE, for wired ethernet with power down the same cable -- M5Stack Dial, which has a round screen that shows the readings -- Any generic ESP32 dev board +## Choose a path -Probe: an Infiwin MT22A is the value pick and what this is built around. A genuine METER TEROS 12 if you want the reference. Full buying guide, including who actually makes what, is in [docs/SENSORS.md](docs/SENSORS.md). +| Task | Guide | +|---|---| +| Work out cube, slab-share or coco volume | [Substrates and sizes](docs/SUBSTRATES.md) · [offline calculator](tools/setup/index.html) | +| Place the probe / print a template | [Placement](docs/PLACEMENT.md) · [A4 75/100 mm slab PDF](docs/print/MT22-placement-template-A4-actual-size.pdf) | +| Save a wet reference or calibrate with weights | [Calibration procedure](docs/CALIBRATION.md) | +| Build a device config / enable MQTT | [Configuration](docs/CONFIG.md) | +| Connect the hardware | [Wiring](docs/WIRING.md) · [sensor compatibility](docs/SENSORS.md) | +| Integrate with Home Assistant | [Dashboard and guarded shot requests](docs/HOMEASSISTANT.md) | +| Resolve unavailable readings | [Troubleshooting](docs/TROUBLESHOOTING.md) | +| Assess the claims and limits | [Sources](docs/SOURCES.md) · [validation](docs/VALIDATION.md) | -## Install the easy way, no software +## ESPHome installation -Pre-built firmware flashes straight from your browser using ESPHome's own web flasher. Nothing to install, no ESPHome, no command line. - -1. Download the `.factory.bin` for your board from the [Releases page](https://github.com/JakeTheRabbit/TDR-Sensor/releases). -2. Plug your board in over USB. -3. Go to [web.esphome.io](https://web.esphome.io) in Chrome, Edge or Opera on a desktop, and click **CONNECT**. -4. Click **Install**, not "Prepare for first use". Choose the file you downloaded and let it flash. -5. Enter your WiFi when it offers, then open `http://tdr-sensor.local` to see your readings. - -Full step by step, driver notes, and a fallback flasher if that page will not talk to your board: [docs/FLASHING.md](docs/FLASHING.md). - -## Install with ESPHome - -If you want to change the config or update over WiFi, run it through ESPHome instead. Your config is about ten lines that pull the packages from this repo, so fixes and new features come down when you rebuild: +Build with **ESPHome 2026.8.2**, the tested version. Clone/download the repo, copy `esphome/secrets.yaml.example` to `esphome/secrets.yaml`, enter your own values, and use the device YAML for your board. See [CONFIG.md](docs/CONFIG.md). Nothing in this repository flashes an existing node automatically. ```yaml substitutions: name: tdr-sensor + friendly_name: TDR Sensor sdi12_data_pin: GPIO26 + sdi12_address: "0" + sample_interval: 30s packages: tdr: url: https://github.com/JakeTheRabbit/TDR-Sensor - ref: main - refresh: 1d + ref: main # Pin a reviewed commit SHA for a production build. files: - esphome/packages/boards/atom-lite.yaml - esphome/packages/tdr_sdi12_core.yaml @@ -68,99 +61,25 @@ ota: - platform: esphome ``` -The full config for every board, the self-contained version, MQTT, and how to pin a version are all in [docs/CONFIG.md](docs/CONFIG.md). - -## Wiring - -Read [docs/WIRING.md](docs/WIRING.md) before you connect anything. The short version: the probe has three wires, data goes to the pin your board uses, power to 5V, ground to ground. The catch is that wire colours are not the same between sensor brands. On the Infiwin MT22 the red wire is data, not power, so wiring it by habit puts 5V on the data line. Check it against the colour table. - -## Calibration - -The sensor reads usefully out of the box, but for real work you calibrate it in your own substrate. It is a ten minute job with a scale and a bucket, done entirely from the web page. Step by step for rockwool and coco in [docs/CALIBRATION.md](docs/CALIBRATION.md). +Optional API encryption, OTA password, web credentials and fallback-AP credentials belong in your own secrets file; [examples are in CONFIG.md](docs/CONFIG.md). Public factory images contain no private credentials. Use a trusted local network for provisioning. -## The analytics, and what they tell you +Supported board configurations: Atom Lite (GPIO26), AtomS3 Lite (GPIO1), Atom PoE (GPIO26), M5 Dial (GPIO2), and generic ESP32 (GPIO16). These are build targets; physical wiring and probe accuracy require installation checks. PoE uses Ethernet and excludes the Wi-Fi package. See the [flashing guide](docs/FLASHING.md) for prebuilt releases; older releases may still contain v2 behaviour until a v3 release is published. -Everything below runs on the device, updates live, and shows on the web page and in Home Assistant. +## Measurements, trends and control -- Peak and trough VWC, with the time each happened -- Dryback since the last peak, both in points and as a percent of the peak -- Dryback rate in percent per hour, over a rolling hour -- Max dryback today and overnight dryback -- Rolling 24 hour min, max and average for VWC and pore EC -- Shots today, time since the last irrigation, and an irrigating flag, all from a shot detector that watches for the substrate rising then plateauing -- Pore EC captured at field capacity, and EC stacking, the rise in root zone EC since that point -- Saturation against field capacity -- Field capacity learned automatically from the seven day peak, as a cross-check on your manual figure -- A sensor fault flag that trips if the probe stops answering or freezes +The web page exposes raw counts, temperature, bulk EC at 25°C, wet-reference index and calibration controls. Once checked VWC is available, the optional analytics package tracks peak/trough VWC, dryback in percentage points and percent of peak, a rolling drying slope, and detected wetting events. A wetting event is not proof that a valve opened or that a known volume reached the plants. Runtime history resets when measurement continuity is lost. -### Steering detection +One probe measures a local region. There are no universal cube/slab/coco VWC targets, and no claim that a substrate curve measures plant water stress, yield or potency. The node itself does not drive irrigation. The Home Assistant example requests an independently bounded controller shot only after explicit enabling and valid readings. -The device works out whether your watering is pushing the plant vegetative or generative, from four signals: how big the drybacks are, how many shots a day, how much EC stacks through the day, and how much headroom there is between average VWC and field capacity. It combines them into a steering index from -1 to +1 and a plain label, Vegetative, Balanced or Generative, with a confidence. The thresholds are all settings you can move. It needs a few irrigation cycles before it will call anything, so it says Learning at first. +Bulk EC is the primary EC output. The optional Hilhorst pore-EC estimate is experimental and starts disabled. Calibrating a wet reference or bulk EC does not validate a pore-water model. -This is a read on what the substrate behaviour implies, not a controller. It tells you what your irrigation is doing so you can decide what to change. +## Logging and development -## Data logging without Home Assistant - -There is a small Python script at [tools/tdr_logger.py](tools/tdr_logger.py) that subscribes to the device and writes CSV. Standard library only, nothing to install. - -``` +```sh python tools/tdr_logger.py 192.168.1.50 --wide --interval 60 --out grow.csv +node --test tests/calculator.test.js +python tests/test_firmware.py +esphome compile esphome/factory/tdr-sensor-atom-lite-factory.yaml ``` -That writes a row a minute with a column per sensor. Drop the flags for a row per reading as they arrive. - -## Home Assistant - -The node is a native ESPHome device, so Home Assistant discovers it on its own, no custom component and no HACS. Two automation blueprints ship with it, dryback-triggered irrigation and an EC alert, plus a ready dashboard. Full guide, and an honest note on what HACS does and does not do, in [docs/HOMEASSISTANT.md](docs/HOMEASSISTANT.md). - -## Troubleshooting - -If the device runs but every reading is unknown, the probe is not talking to the board, and that has a short list of causes. Start with [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). - -## Repository layout - -``` -esphome/ - tdr-sensor-*.yaml device files, one per board - packages/ - tdr_sdi12_core.yaml SDI-12 read plus the whole calibration pipeline - tdr_analytics.yaml dryback, irrigation and steering analytics - tdr_mqtt.yaml optional MQTT - wifi_extras.yaml fallback hotspot and diagnostics - boards/ one file per board with its pins and LED - import/ minimal configs for adopting pre-built firmware - factory/ what CI builds into the browser-flashable firmware -tools/ - tdr_logger.py CSV logger over the device event stream -blueprints/automation/ Home Assistant blueprints -lovelace/dashboard.yaml Home Assistant dashboard -docs/ flashing, config, wiring, calibration, sensors, HA -.github/workflows/ builds firmware for every board, attaches it to releases -``` - -## What changed from the original config - -This is a rebuild of the earlier single-file config. What moved and why: - -- Restructured into packages so a device config is a handful of lines and updates pull from GitHub. -- Bulk EC at 25C is now actually temperature-normalised. The old config had a sensor named for it that only divided the raw number, so it did nothing. It defaults to off because the MT22 already normalises internally, and there is a coefficient to turn it on for probes that do not. -- The Hilhorst pore EC model now includes the pore water permittivity temperature term from the METER manual, instead of a fixed constant. More accurate as substrate temperature moves. -- The pore EC blend between the Hilhorst and mass balance models is now a pair of settings you can move, rather than hardcoded at 40 and 60 percent. -- The VWC polynomial was checked against the TEROS 12 manual and is correct. Added the mineral soil curve and a custom polynomial option alongside the soilless one. -- Added a median filter on the raw counts and a light smoothing filter on VWC, which kills the single-sample SDI-12 glitches that used to show as spikes. -- The device no longer reboots itself every fifteen minutes when no Home Assistant is connected, so it runs happily standalone on just the web page or MQTT. -- Web page upgraded to the sorted, grouped layout and bundled so it works with no internet. - -## Background reading - -If you want the theory behind what this thing is measuring, rather than how to wire it up, read [Root zone state estimation with the TEROS-12](https://jaketherabbit.github.io/cannabis-white-papers/root-zone-teros12.html). It covers how a capacitance probe turns an electric field into a water number, why the default calibration lies a little, what pore EC can and cannot tell you, and how to steer on the shape of the dryback instead of the absolute number. It is also honest about the limits, which is worth reading before you trust any single probe, including this one. - -The short version, and the reason the docs here keep repeating it: one probe sees about a litre of media. It is one local witness. Calibrate it, check the contact, and cross-check it against runoff or pot weight before you act on it. - -## Credits - -Calibration maths from the METER TEROS 11/12 manual. SDI-12 and half-duplex UART components by ssieb. Original inspiration from kromadg's soil-sensor project and the science-in-hydroponics writeups. Built by Legacy Ag. - -## License - -MIT. Use it, change it, sell what you grow with it. +The logger reads the local web event stream. See [VALIDATION.md](docs/VALIDATION.md) for dependencies and test scope. The existing [root-zone measurement paper](https://jaketherabbit.github.io/cannabis-white-papers/root-zone-teros12.html) provides additional discussion; hardware specifications and calibration limits for this implementation are documented in [SOURCES.md](docs/SOURCES.md). diff --git a/blueprints/automation/tdr_dryback_irrigation.yaml b/blueprints/automation/tdr_dryback_irrigation.yaml index 9cf02f9..16eab05 100644 --- a/blueprints/automation/tdr_dryback_irrigation.yaml +++ b/blueprints/automation/tdr_dryback_irrigation.yaml @@ -1,98 +1,100 @@ blueprint: - name: TDR dryback-triggered irrigation - description: > - Fire an irrigation shot when the substrate has dried back past your - target. Reads the dryback percent from a TDR Sensor node, opens a - valve or pump for a set time, and only runs during the lights-on - window with a minimum gap between shots. + name: TDR checked dryback - bounded shot request + description: >- + Request one shot from a separate irrigation controller that enforces its + own maximum runtime. This blueprint never directly opens a valve. It needs + explicit enabling, checked VWC, fresh data and a recent numeric dryback + reading. A request does not prove delivery. See docs/HOMEASSISTANT.md. domain: automation source_url: https://github.com/JakeTheRabbit/TDR-Sensor/blob/main/blueprints/automation/tdr_dryback_irrigation.yaml input: + enable_helper: + name: Explicit irrigation enable + description: Use an input_boolean which starts off until commissioning is complete. + selector: + entity: + filter: {domain: input_boolean} + vwc_ready: + name: VWC ready + selector: + entity: + filter: {domain: binary_sensor} + data_fresh: + name: Sensor data fresh + selector: + entity: + filter: {domain: binary_sensor} dryback_sensor: - name: Dryback percent sensor - description: The "Dryback Percent" sensor from your TDR node. + name: Dryback Percent sensor + description: Select relative Dryback Percent (%), not Dryback (pp) or wet index. selector: entity: - filter: - domain: sensor + filter: {domain: sensor} dryback_threshold: - name: Dryback target - description: Shot fires when dryback rises above this percent. - default: 15 + name: Operator-selected relative dryback threshold + description: No universal cube, slab or coco target is supplied. selector: - number: - min: 2 - max: 40 - step: 0.5 - unit_of_measurement: "%" - irrigation_switch: - name: Irrigation valve or pump - description: The switch that runs water to this zone. + number: {min: 1, max: 50, step: 0.5, unit_of_measurement: '%', mode: box} + shot_button: + name: Bounded shot controller button + description: The controller must independently stop the shot if Home Assistant disconnects or restarts. selector: entity: - filter: - domain: - - switch - - input_boolean - shot_duration: - name: Shot duration - description: How long to run the valve, in seconds. - default: 30 - selector: - number: - min: 1 - max: 600 - step: 1 - unit_of_measurement: s + filter: {domain: button} start_time: - name: Lights on - description: Do not irrigate before this time. - default: "06:00:00" - selector: - time: {} + name: Allowed window starts + default: '06:00:00' + selector: {time: {}} end_time: - name: Lights off - description: Do not irrigate after this time. - default: "18:00:00" - selector: - time: {} + name: Allowed window ends + default: '18:00:00' + selector: {time: {}} min_interval: - name: Minimum gap between shots - description: Ignore new triggers for this many minutes after a shot. + name: Minimum gap between requests (minutes) default: 30 selector: - number: - min: 1 - max: 240 - step: 1 - unit_of_measurement: min - + number: {min: 1, max: 240, step: 1, unit_of_measurement: min, mode: box} + max_age: + name: Maximum dryback report age (seconds) + default: 120 + selector: + number: {min: 10, max: 600, step: 10, unit_of_measurement: s, mode: box} mode: single max_exceeded: silent - variables: - min_interval: !input min_interval - + dryback_entity: !input dryback_sensor + threshold: !input dryback_threshold + gap_minutes: !input min_interval + age_limit: !input max_age trigger: - - platform: numeric_state - entity_id: !input dryback_sensor - above: !input dryback_threshold - + - platform: time_pattern + minutes: '/1' condition: + - condition: state + entity_id: !input enable_helper + state: 'on' + - condition: state + entity_id: !input vwc_ready + state: 'on' + - condition: state + entity_id: !input data_fresh + state: 'on' - condition: time after: !input start_time before: !input end_time - condition: template - value_template: > - {% set last = state_attr(this.entity_id, 'last_triggered') %} - {{ last is none or (as_timestamp(now()) - as_timestamp(last)) > (min_interval | int) * 60 }} - + value_template: >- + {% set reading = states(dryback_entity) %} + {% set entity = states[dryback_entity] %} + {{ is_number(reading) and entity is not none + and (reading | float) >= (threshold | float) + and (reading | float) <= 100 + and (as_timestamp(now()) - as_timestamp(entity.last_reported, 0)) <= (age_limit | float) }} + - condition: template + value_template: >- + {% set last = this.attributes.last_triggered | default(none) %} + {{ last is none or (as_timestamp(now()) - as_timestamp(last)) >= (gap_minutes | float) * 60 }} action: - - service: switch.turn_on - target: - entity_id: !input irrigation_switch - - delay: - seconds: !input shot_duration - - service: switch.turn_off + - action: button.press target: - entity_id: !input irrigation_switch + entity_id: !input shot_button diff --git a/blueprints/automation/tdr_ec_alert.yaml b/blueprints/automation/tdr_ec_alert.yaml index 2021d0a..6411ce8 100644 --- a/blueprints/automation/tdr_ec_alert.yaml +++ b/blueprints/automation/tdr_ec_alert.yaml @@ -1,47 +1,39 @@ blueprint: - name: TDR pore EC alert - description: > - Send a notification when pore water EC drifts out of range. Useful - for catching EC stacking in the root zone or a feed line that has - run dry. Fires once when EC crosses the high or low limit. + name: TDR EC observation alert + description: >- + Alert on operator-defined EC bounds. Bulk EC 25C is the default measured + quantity to consider; feed, runoff and modelled pore EC have different + meanings. Establish suitable bounds for the selected measurement first. domain: automation source_url: https://github.com/JakeTheRabbit/TDR-Sensor/blob/main/blueprints/automation/tdr_ec_alert.yaml input: ec_sensor: - name: Pore EC sensor - description: The "Pore EC" sensor from your TDR node. + name: EC measurement selector: entity: - filter: - domain: sensor + filter: {domain: sensor} high_limit: - name: High EC limit - description: Alert when pore EC rises above this (dS/m). - default: 9 + name: High bound (dS/m) selector: - number: - min: 1 - max: 20 - step: 0.1 - unit_of_measurement: dS/m + number: {min: 0.01, max: 30, step: 0.01, mode: box} low_limit: - name: Low EC limit - description: Alert when pore EC drops below this (dS/m). - default: 2 + name: Low bound (dS/m) selector: - number: - min: 0 - max: 10 - step: 0.1 - unit_of_measurement: dS/m + number: {min: 0, max: 30, step: 0.01, mode: box} notify_action: name: Notification action - description: What to do on an alert. Pick a notify service, for example your phone app. - selector: - action: {} - + description: The message variable is available to custom notification actions. + default: + - action: persistent_notification.create + data: + title: TDR EC observation + message: '{{ message }}' + selector: {action: {}} mode: single - +variables: + ec_entity: !input ec_sensor + lower: !input low_limit + upper: !input high_limit trigger: - platform: numeric_state entity_id: !input ec_sensor @@ -51,16 +43,15 @@ trigger: entity_id: !input ec_sensor below: !input low_limit id: low - -variables: - ec_entity: !input ec_sensor - +condition: + - condition: template + value_template: '{{ is_number(states(ec_entity)) and (lower | float) < (upper | float) }}' action: - variables: - reading: "{{ states(ec_entity) }}" - direction: "{{ 'high' if trigger.id == 'high' else 'low' }}" - message: > - Pore EC {{ direction }} on {{ state_attr(ec_entity, 'friendly_name') or ec_entity }}: - {{ reading }} dS/m + message: >- + {{ state_attr(ec_entity, 'friendly_name') or ec_entity }} is + {{ 'above' if trigger.id == 'high' else 'below' }} the selected bound: + {{ states(ec_entity) }} dS/m. Check the measurement and water content + before changing irrigation or feed. - choose: [] default: !input notify_action diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md index 49e0ddb..c232608 100644 --- a/docs/CALIBRATION.md +++ b/docs/CALIBRATION.md @@ -1,117 +1,80 @@ -# Calibration +# Calibrate without pretending saturation is 100% VWC -The sensor gives useful numbers out of the box, but a probe reading is only as good as its calibration in your exact substrate and feed. This guide walks through it step by step. Do the VWC calibration first, then field capacity, then EC. You do all of it from the web page or Home Assistant, nothing gets reflashed. +There are two different tasks: saving a repeatable wet reference, and estimating actual volumetric water content from independent weights. Version 3 keeps them separate. All captures are available on the node's web page and in Home Assistant; calibration does not require reflashing. -If you have never done this before, read the whole page once before you start. None of it is hard, but the order matters. +The MT22 reports raw dielectric response, temperature and bulk EC. Its manufacturer's generic soilless equation is not a validated calibration for every rockwool slab or coco mix. A substrate selection records the setup; it does not install a universal water-content target. See [sources and limits](SOURCES.md). -This page is the procedure. For why any of it works, and what the numbers can and cannot tell you, read [Root zone state estimation with the TEROS-12](https://jaketherabbit.github.io/cannabis-white-papers/root-zone-teros12.html). It goes through the measurement physics, the accuracy you can actually expect, and why a single probe needs a second witness before you act on it. +## Quick start: save a wet reference in the current slab -## What you are calibrating and why +1. Put the probe in its final, recorded location. For established cubes-on-slabs, use the slab for the main reading. Follow [PLACEMENT.md](PLACEMENT.md). +2. Turn **Calibration mode** on. This pauses headline VWC and dryback tracking. It starts a fresh ten-reading capture window. +3. Wet the substrate uniformly using the normal delivery path, then allow free drainage to settle. Record solution EC, temperature, drain configuration and elapsed time after watering. On an established crop, use a normal wet irrigation plateau; do not block drains or repeatedly flood the crop to force a number. +4. Wait for **Capture ready**. At default settings, ten fresh replies take about five minutes. The RAW range across the window must be at most 10 counts. If it does not settle, investigate distribution, movement, contact or continuing drainage. Do not simply increase the spread limit until an unstable reading passes. +5. Press **Save wet reference**. Check **Saved Wet RAW** and **Last calibration action**. Keep power on for at least ten seconds after saving so the setting is written to flash. +6. Turn Calibration mode off. Watch **Wet reference index** across subsequent irrigation and drainage cycles, alongside delivered volume and plant condition. -- VWC (volumetric water content) is the percent of the substrate volume that is water. The raw probe reading is a capacitance number, and the firmware converts it with a polynomial. That polynomial is close for rockwool and coco, but every probe and every block is slightly different, so you correct it with a two point calibration. -- Field capacity is the VWC right after the block has been saturated and allowed to drain. It is your reference line for dryback. Everything in crop steering is measured against it. -- Pore EC is the salt concentration in the water the roots actually drink. The firmware derives it from bulk EC and water content. You calibrate the bulk EC against a known solution so the derived pore EC is trustworthy. +At capture the index is about 100: `100 × generic_response(current RAW) / generic_response(saved wet RAW)`. It is an instrument-relative index, **not 100% VWC, not percentage of water remaining and not a calibrated dryback percentage**. Readings above 100 and a negative drop are allowed; these help expose a wetter condition or a changed setup. A low/invalid generic response can make the index unavailable; do not fix that by forcing saturation to 100. -## Before you start +One wet point cannot establish the curve's slope or shape. It also cannot establish an independent true wet VWC without a reference measurement. There is no fixed cube-to-slab offset. -Set your substrate first. On the web page, under Calibration, set Substrate Profile to Rockwool, Coco, Peat, or Mineral Soil. This loads sensible starting points for field capacity, the pore EC blend, and the Hilhorst offset. Do this before anything else, because changing it later reloads those defaults and undoes your tuning. +## Actual VWC: weighed A and B, then independent C -You will need: +Use a spare, unplanted sample matching the production medium, density, geometry, support, drainage and sensor placement. Do not dry a flowering plant to create a calibration endpoint. Calibrate each probe and repeat the check when the substrate or placement changes. -- A kitchen scale that reads grams -- An oven or a known dry block -- A bucket -- Your normal feed solution -- A known EC calibration solution (a 1.413 dS/m or a 2.76 dS/m standard is common and cheap) -- A handheld EC pen if you have one, for a sanity check +### 1. Define the sample and tare -## VWC two point calibration +- Measure substrate volume, using actual metric dimensions or actual filled container volume. Use [the setup desk](../tools/setup/index.html#weigh) for the arithmetic. +- Determine a defensible dry substrate mass with a suitable constant-mass laboratory procedure for that medium. Air-dry material can retain water. Keep sensors, electronics and packaging out of drying equipment; follow the material's handling instructions. If true dry mass cannot be established, retain a wet reference and avoid claiming an absolute VWC calibration. +- Record the total dry assembly mass: dry medium plus every constant item on the scale. The sleeve, container, support and sensor mass must be treated consistently at every weighing. Remove free water from trays. Account for cable tension or keep cables supported consistently. +- Living roots, changing plant mass, water outside the medium and retained fertiliser salts can bias a simple mass difference. A spare sample with a documented procedure is easier to audit than a planted slab. -The idea is simple. You show the probe what bone dry looks like, you show it what fully saturated looks like, and it works out the straight line between them. The dry point is true zero water. The saturated point is a value you measure. +The estimate is: -### Step 1: capture the dry point +**VWC (%) = (current assembly mass − dry assembly mass) ÷ water density ÷ sample volume in mL × 100** -You want the probe reading a completely dry sample of your substrate. +Using water density 1 g/mL is a practical approximation; the calculator allows another measured density. Example: 500 g dry assembly, 8,000 g current assembly and 11.25 L of substrate gives about **66.67% VWC**. This volume is the sample being weighed, not each plant's allocation of a shared slab. -For rockwool: take a piece of the same rockwool, dry it fully. An hour in an oven at 105C, or a few days somewhere warm and dry. It has to be properly dry, not just surface dry. +### 2. Capture two measured levels -For coco: same thing, oven dry a scoop of your coco until it stops losing weight. +1. Turn **Calibration mode** on. Choose the substrate profile before making captures. Changing it clears references; Rockwool/Coco/Peat use the generic soilless base curve and Mineral soil uses the separate manual equation. +2. Prepare a uniformly wet sample, allow free drainage and redistribution to stabilise, and weigh it. Keep the probe at the recorded depth. An apparently stable reading does not alone prove uniform water distribution. +3. Enter the calculated percentage as **Weighed reference VWC**, wait for Capture ready, then press **Capture weighed point B**. Check the saved RAW and VWC. The input resets to zero to prevent accidentally reusing a previous weight. +4. Let the same sample reach a lower moisture level without moving the probe, then repeat the weighing and capture it as **point A**. A and B can be entered in either order. +5. The two points must span at least **100 RAW counts** and **10 VWC percentage points**, and RAW must increase with VWC. Choose points that bracket the intended operating range. These are minimum project checks, not a guarantee that any such pair is scientifically sufficient. Zero and 100% entries are rejected; fully dry/fully saturated endpoints are not needed for this operating-range calibration. -Push the probe fully into the dry sample. Let the reading settle for a minute. Then press Capture Dry Point. +The firmware applies an affine correction to the manufacturer's generic curve: `V = VA + (G(R) − G(RA)) × (VB − VA) / (G(RB) − G(RA))`. It does not extrapolate outside the RAW interval. **VWC two-point estimate** is diagnostic until an independent check passes. -### Step 2: work out your saturated reference +### 3. Check a third independently weighed level -This is the real VWC of a saturated, drained block, and you get it with a scale. +1. Prepare a third moisture condition between A and B, with RAW within the middle 80% of their interval. Rewetting followed by equilibration is possible; record it because wetting/drying history may matter. +2. Weigh it independently and calculate VWC. Do not use the predicted VWC as the reference value. +3. Enter the measured percentage, wait for Capture ready and press **Check independent weighed point C**. +4. Inspect **Third-point error**: fitted VWC minus measured VWC, in percentage points. Default tolerance is **3 points**, a chosen acceptance criterion rather than an accuracy specification. A failed or out-of-range check leaves headline VWC unavailable. Investigate tare, volume, gradients, poor contact, density, temperature/EC effects or inadequate curve shape before changing tolerance. +5. Return the probe to the intended installation, verify the same placement conditions and check its transfer against an independent reference. Turn Calibration mode off. After three new readings, **VWC ready** becomes true only when the current RAW is inside the checked calibration interval. -1. Take a block or a pot of your substrate. Weigh it dry, note the grams. Call this the dry weight. -2. Saturate it fully with water or feed. Let it drain until it stops dripping. This is field capacity saturation, not dripping wet. -3. Weigh it again. Call this the wet weight. -4. Work out the water volume. Water weighs 1 gram per millilitre, so the grams of water is wet weight minus dry weight, and that number in grams is also the millilitres of water. -5. You need the total volume of the block in millilitres. For a rockwool block, length times width times height in centimetres gives millilitres. For a pot, use the pot volume. -6. Saturated VWC percent is water millilitres divided by block volume millilitres, times 100. +Changing A or B clears C. Rechecking one middle point does not prove the entire range: take additional independent points, including near its usable ends, and log the errors. If one corrected generic curve does not fit the data, use a properly characterised multi-point calibration/logger or a probe with a validated calibration for that substrate. Do not hide a bad fit with clipping or a large tolerance. -Example: a 10 by 10 by 6.5 cm rockwool cube is 650 ml of volume. Dry it weighs 40 g. Saturated and drained it weighs 460 g. That is 420 g of water, so 420 ml. 420 divided by 650 is 0.646, times 100 is 64.6 percent. Your saturated reference is about 65. +## Reading validity and persistence -Set Saturated Reference on the web page to that number. +- Headline **VWC** is unavailable until A/B/C pass, current RAW is in range, fresh replies exist and Calibration mode is off. It stays unavailable during calibration. +- **VWC generic estimate** and **VWC two-point estimate** are labelled diagnostics, not irrigation control signals. +- RAW replies are checked for finite values within the supported range. Missing RAW becomes stale after 90 seconds by default. Temperature and EC have independent timeouts. Receiving the same number again is not a fault. +- Saved reference values persist; capture-window samples and analytics history do not. Keep power on ten seconds after changing calibration. Calibration mode and experimental pwEC start off after reboot. +- Use **Restart capture window** after changing a sample's position or moisture condition; it discards old averaging samples. For a new medium, geometry or placement, clear references and recalibrate rather than assuming the old check still applies. +- Moving between a cube and a slab creates a different measurement context. Record a new calibration/session; do not join their VWC history as if only the water content changed. -### Step 3: capture the saturated point and apply +## Bulk EC and temperature -Put the probe into that same saturated, drained block. Let it settle for a minute. Press Capture Saturated Point. Then press Apply VWC Calibration. +The [MT22 manual](https://www.infwin.com/wp-content/uploads/UM-MT22-SDI-12-Soil-Moisture-EC-and-Temperature-Sensor-V6.01.pdf) specifies bulk EC already normalised to **25°C**. Firmware converts µS/cm to dS/m by dividing by 1,000, without a second temperature correction. 1 dS/m = 1 mS/cm. `Bulk EC gain` and `Bulk EC offset` are optional correction controls; defaults are 1 and 0. -The firmware now has both points and sets the gain and offset so dry reads zero and saturated reads your reference. Your VWC is calibrated. +Before adjusting them, check the probe using manufacturer-appropriate conductivity standards and immersion geometry, with adequate clearance from the vessel. Account for temperature normalisation and compare with an independent calibrated meter. Use more than one standard if changing slope and offset; retain the original and corrected results. A calibration solution tests EC response in that geometry, not the soil/wool/coir pore-EC conversion. Change temperature offset only after an independent temperature comparison. -If you only have one good point, capture just the saturated point and press Apply. It will shift the offset so saturated reads correct, keeping the existing gain. Two points is better. +Bulk EC changes with water content and substrate geometry. It is not interchangeable with feed EC, runoff EC or extracted pore solution EC. A runoff sample can be a useful separate observation but is not automatically the water surrounding the sensing rods. -## Field capacity +## Experimental pore EC -Field capacity is the anchor for every dryback number, so set it properly. +The previous `bulk EC / VWC` and Hilhorst blend has been removed. The bulk/VWC division alone is not a validated salt mass-balance model. Calibrating bulk EC in a solution does not validate pore EC in a substrate. -The manual way, which is the accurate way: saturate the block, let it drain fully, and read the calibrated VWC once it stabilises. Whatever the probe reads at that point is your field capacity. Set the Field Capacity number to it. +An **Experimental pwEC estimate** switch enables only the Hilhorst-type model `EC25 × 78.45 / (apparent permittivity − offset)`. It is off at every boot, requires checked in-range VWC above the selected minimum, and withholds invalid results rather than clamping them to a plausible limit. The initial offset 4.1 is a historical model assumption, not a measured constant for your rockwool or coco. Fit and validate it against appropriate substrate-specific pore-solution measurements across the intended moisture and EC range before interpreting it quantitatively. Default minimum VWC and numerical bounds are project gates, not a validated operating envelope. -The automatic way: the firmware also learns field capacity on its own. The Field Capacity (learned) sensor tracks the highest peak VWC over the last seven days, which after a few normal irrigation cycles converges on your true field capacity. Watch it for a week and compare it to your manual figure. If they agree, you are set. It is there as a cross-check, it does not overwrite your manual value. - -Field capacity is not fixed forever. As roots fill the block the media holds water differently, so re-check it every couple of weeks through a grow. - -## Pore EC calibration - -Two parts here. First you make sure bulk EC is accurate against a known solution. Then you sanity check the derived pore EC against your runoff. - -### Step 1: calibrate bulk EC to a reference solution - -1. Get a bottle of EC calibration standard, for example 1.413 dS/m. -2. Set EC Reference Solution on the web page to that value. -3. Rinse the probe rods and sit them fully in the solution so all the rods are submerged. When the rods are surrounded by solution, the bulk EC the probe reads is basically the solution EC. -4. Let it settle for a minute, then press Calibrate EC to Reference. - -The firmware sets the EC gain so the reading matches the standard. Rinse the probe with clean water afterwards. - -### Step 2: check pore EC against runoff - -Bulk EC calibration gets the raw measurement right. Pore EC is derived from it, and the derivation depends on water content, so it is worth a real world check. - -1. Run a normal irrigation until you get runoff. -2. Catch the runoff and measure its EC with your handheld pen. -3. Compare it to the Pore EC reading on the device at the same time. - -Runoff EC and pore EC are not identical, runoff is a mix and pore EC is the root zone, but they should be in the same ballpark and they should move together. If pore EC reads wildly higher or lower than runoff, adjust: - -- If pore EC reads too high in dry media, raise Pore EC Blend Low and Blend High a little so the mass balance model gives way to Hilhorst sooner. -- If pore EC never settles, check that your VWC calibration is right first. Pore EC leans on water content, so a bad VWC number throws the EC off. - -## Sanity check against a handheld meter - -Whatever you calibrate, cross-check it once against a trusted instrument. - -- VWC: squeeze test aside, the honest check is another calibrated probe or the gravimetric method from the VWC section. If your saturated block maths said 65 and the probe reads 65, you are good. -- Temperature: compare against any thermometer stuck in the block. It should be within half a degree. -- EC: the runoff comparison above, plus checking the probe in the calibration standard reads the standard. - -If a reading is out and will not come right with calibration, suspect the probe before the maths. Budget probes vary unit to unit. Check a second probe if you have one. - -## Coco vs rockwool, the short version - -The procedure is the same. The numbers differ. - -- Rockwool holds less bound water, so field capacity sits higher, often 60 to 70 percent, and drybacks are crisp and fast. -- Coco holds more bound water, so field capacity is lower, often 50 to 60 percent, and it dries back more slowly. The Coco profile sets a lower default field capacity and blend window to match. -- Do the two point VWC calibration separately for each. A calibration done in rockwool is not valid in coco. +For a simpler dependable installation, leave experimental pwEC off and log **RAW, checked VWC, bulk EC, temperature, delivered irrigation and separate solution EC measurements**. A weighing reference and additional representative probes often resolve more uncertainty than adding another unvalidated conversion. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 36ebc9a..9d183e3 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1,167 +1,73 @@ -# The config files +# Configuration -Two ways to run this. Pick one. +Use ESPHome **2026.8.2** for this revision. The two external UART/SDI-12 components are pinned to reviewed commit IDs in the core package. The board configurations use ESP-IDF; do not switch them to Arduino or ESP8266 and assume equivalent behaviour. -**The short config** pulls the packages from GitHub. Your file is about ten lines, and when the project gets a fix or a new feature you rebuild and it comes down automatically. This is what most people want. +## Local files -**The full config** is everything in one file with nothing external except the SDI-12 components. Use it if you want to read the whole thing, change the maths, or keep working when GitHub is unreachable. You update it by hand. +1. Clone or download the repository. +2. Copy `esphome/secrets.yaml.example` to `esphome/secrets.yaml` and enter your network credentials. The secrets file is ignored by Git. +3. Choose a file below; change the name, address, pin and timezone where appropriate. +4. Validate with `esphome config `, compile with `esphome compile `, then explicitly install it on the intended node using your normal flashing workflow. -Both need a `secrets.yaml` next to them: - -```yaml -wifi_ssid: "YourNetwork" -wifi_password: "YourPassword" -``` - -## The short config, updates from GitHub - -Copy this into the ESPHome dashboard as a new device. Change the board line to match your hardware and the data pin to match your wiring. - -```yaml -substitutions: - name: tdr-sensor - friendly_name: TDR Sensor - sdi12_data_pin: GPIO26 # G26 Atom Lite/PoE, G1 AtomS3, G2 Dial - sdi12_address: "0" - sample_interval: 10s - timezone: Pacific/Auckland - -packages: - tdr: - url: https://github.com/JakeTheRabbit/TDR-Sensor - ref: main - refresh: 1d - files: - - esphome/packages/boards/atom-lite.yaml # your board file - - esphome/packages/tdr_sdi12_core.yaml - - esphome/packages/tdr_analytics.yaml - - esphome/packages/wifi_extras.yaml - -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password - ap: {} - -ota: - - platform: esphome -``` - -Swap the board file line for your board: - -| Board | Board file | Data pin | +| Board | Device YAML | SDI-12 GPIO | |---|---|---| -| M5Stack Atom Lite | `esphome/packages/boards/atom-lite.yaml` | GPIO26 | -| M5Stack AtomS3 Lite | `esphome/packages/boards/atom-s3.yaml` | GPIO1 | -| M5Stack Atom PoE | `esphome/packages/boards/atom-poe.yaml` | GPIO26 | -| M5Stack Dial | `esphome/packages/boards/m5-dial.yaml` | GPIO2 | -| Generic ESP32 | `esphome/packages/boards/esp32-generic.yaml` | GPIO16 | +| Atom Lite | [tdr-sensor-atom-lite.yaml](../esphome/tdr-sensor-atom-lite.yaml) | 26 | +| AtomS3 Lite | [tdr-sensor-atom-s3.yaml](../esphome/tdr-sensor-atom-s3.yaml) | 1 | +| Atom PoE | [tdr-sensor-atom-poe.yaml](../esphome/tdr-sensor-atom-poe.yaml) | 26 | +| M5 Dial | [tdr-sensor-m5-dial.yaml](../esphome/tdr-sensor-m5-dial.yaml) | 2 | +| Generic ESP32 | [tdr-sensor-esp32-generic.yaml](../esphome/tdr-sensor-esp32-generic.yaml) | 16 | -The Atom PoE has no WiFi, so drop the `wifi:` block and the `wifi_extras.yaml` line for that one. The generic ESP32 also takes a `board:` substitution, default `esp32dev`. +These files include local packages; they are not single-file standalone configurations. `esphome config` expands them for inspection, but may include resolved secrets. Do not publish that output or treat it as a sanitised configuration export. -`refresh: 1d` means it re-checks GitHub for changes once a day when you build. Your substitutions always win over the package defaults, so anything you set in your own file sticks. +The [README remote-package example](../README.md#esphome-installation) is another option. Pin its `ref` to a reviewed commit SHA for reproducibility. Following `main` changes what you build next time; a package refresh does not automatically install firmware. No v3 tag is assumed to exist until a release is actually published. -### Pin it to a version +## Packages -`ref: main` follows the latest. If you would rather not move until you choose to, point at a tag instead: +| Package | Purpose | +|---|---| +| `packages/tdr_sdi12_core.yaml` | MT22 measurements, freshness, weighted-reference calibration, web controls | +| `packages/tdr_analytics.yaml` | Optional checked-VWC trends and detected wettings | +| `packages/boards/*.yaml` | Board-specific hardware; the Atom status LEDs and Dial display also use analytics | +| `packages/wifi_extras.yaml` | Wi-Fi diagnostics and fallback portal; omit on PoE | +| `packages/tdr_mqtt.yaml` | Optional MQTT discovery | -```yaml - ref: v2.0.0 -``` +For a bare core-only custom board configuration, omit the analytics-dependent LED/display code. The standard board files include both packages. -Then bump the tag when you want the update. +## Sampling -### Add MQTT +Defaults are `sample_interval: 30s`, `sample_timeout: 90s` and `sample_timeout_ms: "90000"`. If changing the timeout, change both forms to the same duration. Keep timeout longer than the sampling interval and allow for missed replies. The ten-reading capture window takes about five minutes at default cadence; a gap longer than timeout resets it. A successful unchanged reply refreshes data age. -Add the package and the broker details to `secrets.yaml`: +## Runtime calibration -```yaml -packages: - tdr: - url: https://github.com/JakeTheRabbit/TDR-Sensor - ref: main - files: - - esphome/packages/boards/atom-lite.yaml - - esphome/packages/tdr_sdi12_core.yaml - - esphome/packages/tdr_analytics.yaml - - esphome/packages/wifi_extras.yaml - - esphome/packages/tdr_mqtt.yaml -``` +Select the medium/geometry, enable Calibration mode and use the capture buttons. See [CALIBRATION.md](CALIBRATION.md). There is no need to edit polynomial coefficients or copy a different YAML for each pot size. Geometry belongs in the calibration record and volume calculation; adding litres to a YAML does not turn a local dielectric reading into an average of a whole root zone. -```yaml -# secrets.yaml -mqtt_broker: "192.168.1.10" -mqtt_username: "mqtt" -mqtt_password: "password" -``` +Calibration globals are saved with a five-second flash-write interval. Wait ten seconds after a capture before disconnecting power. Runtime trend history is not stored in flash. On Atom boards: violet = checked VWC unavailable, blue = wetting detected, green = tracking below the chosen dryback observation threshold, amber = threshold reached. This is not a plant-health indicator. -### Lock down the Home Assistant connection +## Network credentials -By default the API is open on your LAN. To require a key, generate one at [esphome.io/components/api](https://esphome.io/components/api) and add: +For a private deployment, add these sections to your device config and define unique values in `secrets.yaml`: ```yaml api: encryption: - key: "your-generated-key-here" -``` - -Home Assistant asks for it when it adopts the device. + key: !secret api_encryption_key -## The full config, self-contained - -If you want everything in one file, clone the repo and use the device files directly. They are the same packages, just included from disk instead of GitHub: - -``` -git clone https://github.com/JakeTheRabbit/TDR-Sensor.git -cd TDR-Sensor/esphome -cp secrets.yaml.example secrets.yaml -``` - -Edit `secrets.yaml`, then flash the file for your board: - -``` -esphome run tdr-sensor-atom-lite.yaml -``` - -The files are: - -| Board | File | -|---|---| -| M5Stack Atom Lite | [tdr-sensor-atom-lite.yaml](../esphome/tdr-sensor-atom-lite.yaml) | -| M5Stack AtomS3 Lite | [tdr-sensor-atom-s3.yaml](../esphome/tdr-sensor-atom-s3.yaml) | -| M5Stack Atom PoE | [tdr-sensor-atom-poe.yaml](../esphome/tdr-sensor-atom-poe.yaml) | -| M5Stack Dial | [tdr-sensor-m5-dial.yaml](../esphome/tdr-sensor-m5-dial.yaml) | -| Generic ESP32 | [tdr-sensor-esp32-generic.yaml](../esphome/tdr-sensor-esp32-generic.yaml) | - -If you want one single file with no `!include` at all, run `esphome config tdr-sensor-atom-lite.yaml`. That prints the fully resolved configuration with every package expanded, which you can save and use as a standalone file. - -## What the packages are - -| Package | What is in it | -|---|---| -| [tdr_sdi12_core.yaml](../esphome/packages/tdr_sdi12_core.yaml) | SDI-12 bus, the reading pipeline, the whole calibration suite, web server | -| [tdr_analytics.yaml](../esphome/packages/tdr_analytics.yaml) | Dryback tracking, irrigation detection, steering detection | -| [tdr_mqtt.yaml](../esphome/packages/tdr_mqtt.yaml) | Optional MQTT with discovery | -| [wifi_extras.yaml](../esphome/packages/wifi_extras.yaml) | Fallback hotspot and WiFi diagnostics | -| [boards/](../esphome/packages/boards) | Per board pins, LED, display, ethernet | - -The core and analytics packages carry no board or network config, so they work on any of the boards. - -## The SDI-12 components +ota: + - platform: esphome + password: !secret ota_password -Both configs pull two external components: +web_server: + auth: + username: !secret web_username + password: !secret web_password -```yaml -external_components: - - source: github://ssieb/esphome@uarthalf - components: [ uart ] - - source: github://ssieb/esphome_components@sdi12 - components: [ sdi12 ] +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + password: !secret fallback_ap_password ``` -The first is a fork of the ESPHome UART with half-duplex support, which mainline still does not have. The second is the SDI-12 component. Both are needed and both only work on an ESP32 with the esp-idf framework. This is the thing that catches people out, so it is worth repeating: on the Arduino framework or an ESP8266 the build succeeds and the probe never answers. - -## Settings you can change without editing YAML - -Almost everything worth tuning is a control on the web page and in Home Assistant, and it survives reboots. You do not reflash to calibrate. Substrate profile, VWC gain and offset, field capacity, EC gain and offset, the Hilhorst offset, the pore EC blend window, every analytics threshold and every steering anchor. See [CALIBRATION.md](CALIBRATION.md). +ESPHome requires a valid 32-byte base64 API key. Generate one locally or through ESPHome's documented key generator; do not copy somebody else's key. The PoE node excludes Wi-Fi settings. Factory configs intentionally omit deployment credentials to support provisioning; an uncredentialed web page exposes calibration controls on the local network. Add credentials when adopting it. -The YAML substitutions are only for things that are fixed at build time: the device name, the data pin, the SDI-12 address, the sample interval and the timezone. +For MQTT, uncomment the `tdr_mqtt.yaml` package and fill in `mqtt_broker`, `mqtt_username` and `mqtt_password` in secrets. The existing CSV logger currently supports an unauthenticated local web event stream; use Home Assistant/MQTT logging if you enable web authentication, unless you extend the logger's authentication support. diff --git a/docs/FLASHING.md b/docs/FLASHING.md index f09bb2d..e092d87 100644 --- a/docs/FLASHING.md +++ b/docs/FLASHING.md @@ -1,3 +1,5 @@ +> Check the release version before downloading: older published binaries may contain v2. A v3 source branch or successful CI artifact is not itself a published v3 release. Factory hostnames include a MAC suffix; check DHCP/discovery for the actual address. + # Flashing the firmware Two ways in. Pick the one that matches what you want. @@ -83,7 +85,7 @@ Once a device is running this firmware and is on your network, you have a few op **Over the air from ESPHome.** If you build your own config, `esphome run` pushes updates over WiFi. No cable. This is the nicest way to live, and it is why the [remote package config](CONFIG.md) is worth setting up. -**From the browser again.** Download the new `.factory.bin` from Releases and repeat the steps above over USB. Your calibration settings are stored separately from the firmware and survive a normal update. +**From the browser again.** Download the new `.factory.bin` from Releases and repeat the steps above over USB. Matching saved settings can survive a normal update, but v3 deliberately requires new checked calibration references. Read [MIGRATION-v3.md](MIGRATION-v3.md) before upgrading; do not assume a factory erase preserves settings. **Home Assistant adoption.** If you run the ESPHome Dashboard, either the Home Assistant add-on or standalone, a device flashed with the pre-built firmware shows up as discovered and offers to be adopted. Adopting pulls the config from this repo and lets you manage and update it from the dashboard. This only works in the full ESPHome Dashboard, not on web.esphome.io, because adopting means compiling a config. See [HOMEASSISTANT.md](HOMEASSISTANT.md). diff --git a/docs/HOMEASSISTANT.md b/docs/HOMEASSISTANT.md index cbc7e20..317c919 100644 --- a/docs/HOMEASSISTANT.md +++ b/docs/HOMEASSISTANT.md @@ -1,93 +1,23 @@ # Home Assistant -You do not need Home Assistant to use this sensor. The device serves its own web page and can log to CSV or push to MQTT on its own. But if you already run Home Assistant, the node drops straight in, and this is how. +Add the node through the standard ESPHome integration. It does not require HACS. After upgrading, inspect actual entity IDs: renamed readings and units may leave old entities unavailable. Use [MIGRATION-v3.md](MIGRATION-v3.md). -## What HACS does and does not do - -Worth being straight about this, because a lot of guides are vague. - -HACS installs custom integrations, dashboard cards, themes, python scripts, templates, and AppDaemon apps. That is the full list of what it handles. - -HACS does not install ESPHome firmware. Firmware for an ESP32 is built and flashed by ESPHome or from the browser installer, not by HACS. There is nothing to add to HACS for the sensor itself. - -HACS does not have a blueprint category either. Blueprints are imported through Home Assistant's own blueprint import, which is a genuine one-click link and is covered below. That is why this repo ships blueprints and dashboards but no hacs.json. There is nothing here that installs through HACS, and pretending otherwise would just send you in circles. - -So the real one-click paths are: ESPHome auto-discovery for the device, and the native blueprint import for the automations. - -## Adding the device - -The device speaks the native ESPHome API, so Home Assistant finds it by itself. - -1. Flash the board (browser installer or ESPHome, see the main README). -2. Once it is on your network, Home Assistant shows a discovered device notification for it. Settings, Devices and Services, and it appears under Discovered. -3. Click Configure, confirm, and every sensor, number, button and the steering text sensor come in as entities. - -If you set an API encryption key in your config, Home Assistant asks for it here. If you left the API open, it just connects. - -That is the whole integration. No custom component, no HACS, no YAML. - -## Adopting a pre-built device into the ESPHome Dashboard - -Separate from the step above, which brings readings into Home Assistant, you can also take over the firmware itself so you can change and update it. - -A board flashed with the pre-built firmware carries a pointer back to this repo. If you run the **ESPHome Dashboard**, either the Home Assistant add-on called ESPHome Device Builder or a standalone install, the device turns up there as discovered with an option to adopt or take control. Adopting creates a config for it that pulls the packages from this repo, compiles it, and pushes it over the air. From then on it is a normal device in your dashboard and you can edit the substitutions, add MQTT, or pin a version. - -This only happens in the full ESPHome Dashboard, not on web.esphome.io. Adopting means compiling a config, and the web flasher has no compiler in it. If you do not run a dashboard you are not missing anything, the pre-built firmware is already complete. - -## Keeping your config short - -If you want to build the firmware yourself rather than use the pre-built image, you do not need to copy the whole config. Point a short device file at the packages in this repo and let it pull them from GitHub. Every board, plus the self-contained version, is in [CONFIG.md](CONFIG.md). - -```yaml -substitutions: - name: tdr-sensor - sdi12_data_pin: GPIO26 - -packages: - tdr: - url: https://github.com/JakeTheRabbit/TDR-Sensor - ref: main - files: - - esphome/packages/boards/atom-lite.yaml - - esphome/packages/tdr_sdi12_core.yaml - - esphome/packages/tdr_analytics.yaml - - esphome/packages/wifi_extras.yaml - -wifi: - ssid: !secret wifi_ssid - password: !secret wifi_password - ap: {} - -ota: - - platform: esphome -``` - -Swap the board file for your board. Your substitutions override the package defaults, so this is where you set the data pin, the name, the timezone, anything else. - -## Automation blueprints - -Two blueprints ship with this repo. Import them with these links. They open your Home Assistant and ask you to confirm the import. - -Dryback-triggered irrigation. Fires a shot when the substrate dries back past your target, inside the lights-on window, with a minimum gap between shots. - -[Import the dryback irrigation blueprint](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FJakeTheRabbit%2FTDR-Sensor%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Ftdr_dryback_irrigation.yaml) - -Pore EC alert. Notifies you when pore EC climbs above or drops below your limits. +## Dashboard -[Import the EC alert blueprint](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FJakeTheRabbit%2FTDR-Sensor%2Fblob%2Fmain%2Fblueprints%2Fautomation%2Ftdr_ec_alert.yaml) +Copy [lovelace/dashboard.yaml](../lovelace/dashboard.yaml) into a dashboard and replace `tdr_sensor` with the actual entity prefix. It separates checked VWC, wet-reference index, bulk EC, diagnostics and calibration. There are no generic “healthy” red/green VWC bands because a universal substrate target is not established. -If a link does not work, in Home Assistant go to Settings, Automations and Scenes, Blueprints, Import Blueprint, and paste the raw URL of the blueprint file from this repo. +## Requesting irrigation -After importing, create an automation from the blueprint and fill in your entities. The dryback one needs your Dryback Percent sensor and your irrigation switch. The EC one needs your Pore EC sensor and a notify action. +[The dryback blueprint](../blueprints/automation/tdr_dryback_irrigation.yaml) now requests a **bounded shot from a separate controller button**. It no longer opens a switch and relies on an in-memory delay to close it. Before enabling it, the chosen controller must enforce its own maximum runtime and provide delivery/fault handling independent of Home Assistant staying online. -A word on the irrigation blueprint: it turns a valve on for a set time based on a sensor reading. Test it with the pump off and watch it fire before you let it run water. A stuck sensor or a bad threshold can over-irrigate. Start with a conservative dryback target and a short shot. +The blueprint requires an explicit enable helper, the node's `VWC ready` binary sensor, its `Sensor data fresh` binary sensor, a recent numeric `Dryback Percent` reading, an allowed time window and a minimum gap. It polls once a minute, so an above-threshold condition can be re-evaluated after the gap. A request is not evidence of a delivered shot. It does not implement a complete irrigation strategy, daily volume ceiling or multi-sensor voting. -## Dashboard +Default enable helper state should be off. Commission against an inert/test controller before connecting a production request button. Do not select a button whose action merely leaves a valve switched on. The old valve selector is a breaking change: existing automation instances must be recreated/reconfigured. -There is a ready-made dashboard at [lovelace/dashboard.yaml](../lovelace/dashboard.yaml). It has gauges for VWC, pore EC and saturation, the steering panel, the irrigation stats, and history graphs. +An installation should also check controller health, actual flow/drainage, emitter uniformity and independent maximum volume/runtime limits. Treat a single local VWC trace as one input, not proof that the whole zone needs water. -To use it: Settings, Dashboards, Add Dashboard, then open it, Edit, the three-dot menu, Raw configuration editor, and paste the file in. The entity ids assume the device is named tdr-sensor. If you named it something else, find and replace tdr_sensor with your name. +## EC alerts -## MQTT instead of the native API +[The EC alert blueprint](../blueprints/automation/tdr_ec_alert.yaml) accepts an EC sensor and user-defined bounds. Pick **Bulk EC 25C** for the default measurement; do not paste nutrient-solution targets into it. Choose limits from comparable checked measurements in that installation. Modelled pore EC and bulk EC are different quantities. The blueprint uses local persistent notifications unless you supply another notification action. -If you run something other than Home Assistant, or you want MQTT anyway, enable the MQTT package. Uncomment the mqtt line in your device file, add the broker details to secrets.yaml, and the node publishes every sensor with Home Assistant discovery. It works alongside the native API, you can run both. +The ESP32 itself has no valve/pump outputs in these packages. Nothing here changes an existing irrigation schedule automatically. diff --git a/docs/MIGRATION-v3.md b/docs/MIGRATION-v3.md new file mode 100644 index 0000000..ba1105d --- /dev/null +++ b/docs/MIGRATION-v3.md @@ -0,0 +1,23 @@ +# Upgrading from v2 + +Version 3 changes measurement meaning. Build and inspect it on a spare node first. Save your current YAML, sensor identity, calibration values and recent observations. This repository change does not flash an installed device. + +| v2 behaviour | v3 behaviour / action | +|---|---| +| Generic curve plus gain/offset shown as VWC | `VWC generic estimate` remains diagnostic; headline `VWC` requires weighed A/B, an independent C check, fresh data and calibration mode off | +| Capture dry / saturated, assign a default saturated % | Save wet reference for an index; capture measured nonzero VWC points for an actual fit | +| Profile loads field-capacity and EC blend defaults | Profile identifies the medium/geometry; changing it clears saved references. Mineral soil selects the manual's separate curve | +| Existing gain/offset and captures | Intentionally not migrated into a claimed checked calibration. Recalibrate; v3 uses new saved A/B/C IDs | +| Pore EC blended from bulk/VWC and Hilhorst | Removed. Bulk EC at 25°C is the primary EC reading; experimental Hilhorst model is off at every boot | +| Saturation % / learned field capacity | Removed: a highest observed value cannot prove saturation or drained container capacity | +| Vegetative/generative label and confidence | Replaced by observed `Water trend`; the sensor cannot establish plant physiology | +| Frozen raw value declared a fault | Fresh, identical replies remain valid. A timeout or invalid RAW response is a communication/data fault | +| Dryback and shot rise labelled % | VWC differences use `pp`; relative dryback keeps `%`. Rise events are `Detected wettings`, not confirmed delivered shots | +| Persisted analytics | History restarts on reboot, calibration changes, unavailable VWC and relocation capture resets; no comparison across different scales | +| Unsafe direct valve on/delay/off blueprint | Blueprint requests a bounded shot from a separate controller button; explicit enable and checked/fresh measurement gates are required | + +`VWC`, `Temperature`, `Bulk EC 25C`, `Peak VWC`, `Trough VWC`, `Dryback`, `Dryback Percent` and internal IDs needed by the board display are retained where practical. ESPHome/Home Assistant entity IDs can still change when units/names change. Review the actual entity registry and update dashboards and automations rather than assuming an old ID's meaning is unchanged. Old entities may remain unavailable until you remove them. + +Use [CALIBRATION.md](CALIBRATION.md) and the [offline setup desk](../tools/setup/index.html). Default polling is now 30 seconds, with a 90-second data timeout and a ten-sample capture window (about five minutes). If changing poll cadence, update both timeout substitutions consistently and leave time for the SDI-12 response cycle. + +Before relying on a threshold, verify stable wet/dry readings in the actual medium, check a third independently weighed point, test a disconnected sensor, inspect the calibration status and verify delivered irrigation physically. The software tests and firmware builds do not establish agronomic accuracy or electrical compatibility for your installation. diff --git a/docs/PLACEMENT.md b/docs/PLACEMENT.md new file mode 100644 index 0000000..5c21705 --- /dev/null +++ b/docs/PLACEMENT.md @@ -0,0 +1,46 @@ +# Place the MT22 consistently + +Use the [placement drawing](img/mt22-placement.svg), [A4 slab templates](print/MT22-placement-template-A4-actual-size.pdf), or the [custom printable sheet](../tools/setup/index.html#place). The drawing is schematic; only the PDF and custom sheet are intended to print at physical scale. + +![MT22 side insertion and three-plant slab placement](img/mt22-placement.svg) + +## What is dimensioned, and what is proposed + +INFWIN's [MT22 dimension drawing](https://www.infwin.com/wp-content/uploads/product-mt22-sdi-12-soil-moisture-ec-temperature-sensor-dimension.jpg) shows an **88 × 26 mm contact face**, **18 mm housing depth** and **53 mm rods**. Compare these with your actual hardware revision. The published drawing does **not** dimension the distance between pins. The template deliberately has no guessed pin holes. + +The midpoint positions below are project suggestions for a repeatable initial comparison, **not manufacturer-validated MT22 positions for Grodan Prestige or every coco container**. Mid-height is not mathematically guaranteed to represent mean water content. The sensing field extends around the electrodes; fully inserting the metal alone does not prove that the surrounding field is free of boundaries. + +## Cubes on a slab / slab only + +1. Confirm roots have entered the slab. Use the slab probe as the main measurement; keep a second cube probe if you need upper-block information. Record the move because the old cube trace and new slab trace cannot be treated as one continuous calibration. +2. Pick a representative slab. On a three-plant slab, start beside the middle block, outside its footprint, clear of slab ends and drain cuts. Record the actual distance from a slab end and the cube edge. Grodan's own GroSens placement instructions are for a different sensor; do not copy its bracket dimensions as MT22 dimensions. +3. Enter through a long side. Hold the **88 mm body dimension horizontally along the slab length**. The three rods point straight across the slab width and sit at the same elevation. Do not rotate the long body vertically in a shallow slab. +4. A proposed starting rod centreline is **37.5 mm above the substrate base for a 75 mm slab**, or **50 mm for a 100 mm slab**. Measure from the bottom of the actual growing medium, not the gutter lip or wrapper seam. Check alternative positions against independent measurements before standardising a production layout. +5. Pierce the wrapper only enough for the sensor. Insert all 53 mm of each rod without rocking, twisting, pre-drilling cavities or crushing the medium. The contact face should meet the substrate surface, with no air gap around the rods. Avoid touching a trough, support, neighbouring block or metal fitting with the sensing region. +6. Support the cable so it cannot rotate the probe. Record location, orientation, depth, sensor serial, substrate lot and drain position. Calibrate in this geometry. + +The factory does not publish an MT22/Prestige-specific minimum distance from every edge or dripper. Keep away from direct feed paths and test representativeness rather than presenting an invented exact offset as validated. + +## Cubes alone + +Use a repeatable side insertion into a sufficiently large block, with the rod row level. A midpoint height is an initial mapping position, subject to the same boundary and representativeness checks. A 150 mm-wide Hugo can accommodate the 88 mm contact face geometrically; that is not proof of whole-block accuracy. + +**Small propagation blocks need a different sensor or validation arrangement.** A 75 mm-wide cube cannot accommodate the MT22's 88 mm-wide face in this orientation. Do not cut a larger hole or leave an outer pin in air. Some 100 mm blocks also leave very little lateral clearance; check the sensing footprint rather than relying solely on rod length. A physically smaller substrate-specific probe may be a better fit. + +## Coco / peat containers + +Measure at a recorded depth in the actual packed, rooted medium, away from the emitter stream, stem, drainage layer and container boundary. Map more than one depth or compare representative pots before choosing a standard position. The middle of the filled height is a possible initial comparison point, not a universal coco rule. + +Where practical, place the sensor during filling and pack hydrated medium around all rods consistently without creating cavities or compacting a special dense pocket. On an established container, make a minimal side opening if the container design allows it and avoid repeated insertion through roots. A curved pot wall does not provide a flat 88 mm contact surface; verify full substrate contact along every rod instead of forcing the housing against the wall. Keep the chosen position fixed as moisture changes; coco shrinkage can create air gaps. + +Different pot heights and media mixes need their own calibration and placement record. A probe in one container does not measure every container on its irrigation line. + +## Print and use the template + +- Print A4, **Actual size / 100%**, with Fit, Shrink and browser headers/footers disabled. +- Measure both perpendicular 100 mm scale bars. Aim for no more than 0.5 mm discrepancy; reject a scaled print. +- Select the correct slab-height page. Align the base datum with the bottom face of the rockwool. +- Transfer your actual sensor's three pin positions onto the printed centreline using scrap backing. Remove the paper before final insertion; do not drill oversized holes in the substrate. +- The printed 88 × 26 mm outline locates the housing. It does not calibrate VWC or prove the chosen location is representative. + +If only one probe is available, establish a repeatable location and compare its trace with weighed samples, delivered volume and drainage. A second fixed probe or a load-cell reference can distinguish a local wet/dry pocket from a change across the whole root zone. These checks are more useful than forcing different positions to display the same number. diff --git a/docs/SENSORS.md b/docs/SENSORS.md index 283636f..cdb3d8c 100644 --- a/docs/SENSORS.md +++ b/docs/SENSORS.md @@ -1,85 +1,18 @@ -# Sensor buying guide +# Sensor compatibility -Which probes work with this project, what they cost, where to buy them, and who actually makes them. Prices are 2026 street prices in USD and they move around, so treat them as a guide not a quote. +This revision is implemented around the **INFWIN MT22A SDI-12** measurement format documented in its [manual](https://www.infwin.com/wp-content/uploads/UM-MT22-SDI-12-Soil-Moisture-EC-and-Temperature-Sensor-V6.01.pdf): RAW dielectric response, temperature and bulk EC. Raw conversion and units are sensor-specific. -For the measurement theory behind all of this, and an honest account of what one probe can earn you, see [Root zone state estimation with the TEROS-12](https://jaketherabbit.github.io/cannabis-white-papers/root-zone-teros12.html). +| Sensor / interface | Status in this repository | +|---|---| +| INFWIN MT22A SDI-12 | Implemented target; each installation still needs electrical checks and substrate calibration | +| MT22B | Omits EC; requires a reviewed two-field configuration, not the stock three-field package | +| Genuine METER TEROS 12 | Related protocol/formulas do not prove identical scaling, EC compensation or accuracy. Verify the exact manual and adapt/test before use | +| Other SDI-12 sensor reporting VWC directly | Needs its own parser/units path; never apply the MT22 RAW polynomial to a percentage | +| RS485 / Modbus variant | Different electrical interface and protocol; does not work with this SDI-12 configuration | +| Proprietary bus or branded controller sensor | Unsupported until interface, data format and dimensions are documented and tested | -This node is an SDI-12 reader. It applies the METER TEROS 12 calibration maths to the raw counts coming off the probe. A sensor is a drop-in only if two things are true: it talks SDI-12, and it returns TEROS-12-style raw counts. Anything else either needs its own calibration or will not connect at all. The table in the compatibility section tells you which is which. +The repository name is historical. INFWIN markets the MT22 using FDR/dielectric measurement terminology. Matching a protocol does not establish the same sensing electronics, substrate-specific calibration or high-salinity performance as a reference instrument. The previous buying guide's broad drop-in compatibility, OEM and price claims have been removed because they were not adequate evidence for this firmware. -## Quick answer: what to buy +Check the actual label, connector pinout, voltage requirements and serial number before wiring. The MT22 dimensions used by the printable sheet are **88 × 26 mm contact face, 18 mm housing depth and 53 mm rods**. Published pin spacing is not dimensioned; transfer it from your own probe. See [PLACEMENT.md](PLACEMENT.md). -- Most people should buy an **Infiwin MT22A**. It is the cheap TEROS 12 clone this whole project is built around. It speaks SDI-12, returns the same raw counts as a TEROS 12, so the calibration just works. Budget around 60 to 150 USD depending on where you buy. -- If you want the reference instrument and do not care about the price, buy a **genuine METER TEROS 12**. It is what everything else gets checked against. -- If you steer at high EC and want readings that hold up when the salts stack, buy an **Acclima TDR-310W**. It is real time-domain reflectometry, not capacitance, so it stays honest under high EC. It does not use the TEROS raw-counts path, it outputs its own calibrated VWC, which is fine, you just skip the polynomial. -- Skip the cheap 7-in-1 NPK probes. The NPK numbers are guesses and the EC is bulk EC only. - -## What "TDR" actually means here - -Real TDR sends a step pulse down the rods and times the reflection. Acclima does this. It is the accurate way to measure water content and it does not drift much with salinity. - -Everything else in this guide, including the TEROS 12 and every clone, is **capacitance / FDR** (frequency domain). It measures the dielectric of the substrate at a fixed frequency and converts that to water content. It is good enough for crop steering and it is far cheaper, but it drifts with EC and temperature, which is exactly why this firmware does temperature normalisation and a Hilhorst pore-EC correction. Do not let the "TDR" in a product name fool you, most of these are capacitance probes. - -## Compatibility list - -| Sensor | Interface | Measures | Real TDR | Works with the polynomial | Rough price | -|---|---|---|---|---|---| -| METER TEROS 12 | SDI-12 / DDI | VWC, temp, bulk EC | No (capacitance) | Yes, it is the reference | 200 to 370 | -| METER TEROS ONE | SDI-12 | VWC, temp, bulk EC | No (capacitance) | Needs its own path, newer protocol | 250+ | -| Acclima TDR-310W | SDI-12 | VWC, temp, bulk EC | Yes | No, outputs its own VWC | 349 | -| Acclima TDR-315H | SDI-12 | VWC, temp, bulk EC | Yes | No, outputs its own VWC | 300+ | -| Infiwin MT22A | SDI-12 | VWC, temp, bulk EC | No (capacitance) | Yes, TEROS 12 protocol | 60 to 150 | -| Infiwin MT22B | SDI-12 | VWC, temp | No (capacitance) | Yes for VWC, no EC, TEROS 11 protocol | 60 to 130 | -| Infiwin MT20A | SDI-12 | VWC, temp, bulk EC | No (capacitance) | Partly, Decagon 5TE protocol | 50 to 120 | -| Infiwin SlabSense | SDI-12 / RS485 | VWC, temp, bulk EC | No (capacitance) | Yes, built for slabs | 90 to 160 | -| Growlink TerraLink | SDI-12 | VWC, temp, bulk EC | No (capacitance) | Yes, its own sensor | 299 | -| THC-S / JXCT / Renke | RS485 Modbus | VWC, temp, bulk EC | No (capacitance) | No, RS485 not SDI-12 | 20 to 40 | -| Sentek Drill and Drop | SDI-12 | VWC, temp, EC (multi-depth) | No (capacitance) | No, profile probe, own scaling | 400+ | -| Delta-T GS3 / TEROS clone lineage | SDI-12 | VWC, temp, bulk EC | No (capacitance) | Varies by model | 200+ | - -Notes on the ones that need explaining: - -- **THC-S / JXCT / Renke family.** This is the classic cheap Chinese RS485 probe, the one behind the old [kromadg/soil-sensor](https://github.com/kromadg/soil-sensor) project. It is RS485 Modbus, not SDI-12, so it does not talk to this node without a different interface. It reports bulk EC only, no pore EC and no raw permittivity, so you have to build the EC conversion yourself. Calibrated against a TEROS 12 it gets VWC within a few percent, but its EC is the weak point. Fine as a cheap water-content logger, not something to trust for EC steering out of the box. -- **Sentek Drill and Drop.** A multi-depth profile probe. Different job, different scaling, not a single-point substrate sensor. Great for field soil, overkill and wrong shape for a rockwool cube. -- **Acclima.** Outputs calibrated VWC directly. You do not run the TEROS polynomial on it and you do not need to. Set the VWC gain to 1 and offset to 0 and read it straight. - -## Where to buy - -- **Infiwin MT22A and SlabSense.** Direct from [infwin.com](https://www.infwin.com) or infwintech.com, or on AliExpress and Alibaba. Search "SDI-12 soil moisture EC sensor TEROS" or "Dalian Endeavour MT22". Retail minimum order is one. Buy in a small batch on Alibaba and the unit price drops a fair bit. -- **METER TEROS 12.** Cheapest direct from [metergroup.com](https://metergroup.com/products/teros-12/), but you also need a cable or reader. Retail bundles with the cable run 300 to 370. UK buyers use [Labcell](https://www.labcell.com). -- **Acclima.** TDR-310W through [Growlink](https://shop.growlink.com/products/tdr310w-acclima-substrate-sensor), full-rod TDR-315 direct from [acclima.com](https://acclima.com). -- **Growlink TerraLink.** From [Growlink](https://shop.growlink.com) directly. -- **THC-S / JXCT.** ComWinTop store, JXCT store, or generic AliExpress listings from about 20 USD. - -## Who owns who - -This matters because the same probe gets sold at very different prices depending on whose logo is on it. - -**METER Group.** Decagon Devices and the German firm UMS AG merged into METER Group in 2016. In 2022 METER's environment sensor business was bought by Campbell Scientific, and the indoor-ag and food-science arms were spun out as a separate company called ADDIUM. So the TEROS sensors trace back to Decagon, and the AROYA cannabis platform is the ADDIUM side. - -**AROYA.** This is METER's cannabis brand, now under ADDIUM. It is not a third-party rebrand, it is genuine METER hardware wrapped in a platform and a subscription. The **AROYA Solus** is a real TEROS 12 with a Bluetooth module bolted on so you can spot-read it with a phone. If you own an AROYA Solus you own a TEROS 12. - -**Infiwin, made by Dalian Endeavour Technology.** This is the OEM behind most of the cheap "TEROS clone" probes. They build to the TEROS and Decagon protocols on purpose: -- MT22A is protocol-compatible with the TEROS 12 (VWC, temp, EC). -- MT22B is protocol-compatible with the TEROS 11 (VWC, temp, no EC). -- MT20A is protocol-compatible with the Decagon 5TE. -Because they return the same raw counts as the METER parts, the TEROS calibration maths applies directly. That is the whole reason this project uses the MT22. - -**Growlink TerraLink.** Growlink's own substrate sensor, marketed as made in the USA. It is SDI-12 and its published specs sit right on top of the Infiwin MT22 class, but there is no public teardown or FCC filing proving a shared OEM, so treat "TerraLink is a rebadged MT22" as a reasonable guess, not a fact. What is confirmed: Growlink controllers also accept a genuine TEROS 12 and Acclima probes over SDI-12, so you are not locked to their sensor. - -**Acclima.** Their own real-TDR technology. Not a clone of anything, and the only true TDR probe in this list. - -**Grodan GroSens.** Grodan's own patented water-content sensor and Smartbox, made by the rockwool company. Own tech, closed system. - -**Pulse Grow.** An integrator. They sell a retrofit kit that adapts a genuine TEROS 12 to their hub, plus their own simpler probes. Not a rebrand, they resell the real METER part. - -**TrolMaster Aqua-X WCS.** A different physical design, a 5-prong capacitance probe on TrolMaster's own bus, not SDI-12 and not TEROS protocol. OEM origin is not public. - -The pattern across all of it: the OEM TEROS-protocol probe from Dalian Endeavour costs 60 to 150 USD. Put a Western brand on it and it is 300. Put it inside a platform with a subscription and it is 550 plus a monthly fee. The genuine METER article sits in the middle and is the thing everyone else validates against. - -## What the community actually rates - -- **TEROS 12** is the trusted reference. Big measurement volume, solid epoxy, calibration backed by published papers. -- **Acclima** earns its keep when you steer at high EC, where capacitance probes start lying. -- **Infiwin MT22A** is the value pick and the reason this repo exists. Expect the unit-to-unit variation you get with any budget probe, so cross-check each one against a known-good meter when it arrives and offset-calibrate it. There is no specific bad-batch scandal to report, just normal cheap-probe variance. -- **THC-S** is a fine cheap water-content logger once calibrated. Do not trust its EC for steering without work. -- Bare resistive probes and no-name analog capacitive garden sensors are not worth your time for rockwool or coco. No real EC, poor stability. +For medium too small to accommodate the MT22's sensing geometry, or where weighed calibration fails independent checks, use a sensor designed and validated for that substrate and size. Do not force a generic curve to match a desired reading. See [SOURCES.md](SOURCES.md) for primary references and what they establish. diff --git a/docs/SOURCES.md b/docs/SOURCES.md new file mode 100644 index 0000000..6b2bb59 --- /dev/null +++ b/docs/SOURCES.md @@ -0,0 +1,19 @@ +# Sources and evidence limits + +Reviewed 9 September 2026. Calculations and operating choices are separated from manufacturer specifications. + +| Source | Used for | Does not establish | +|---|---|---| +| [INFWIN MT22 product page](https://www.infwin.com/mt22-soil-moisture-ec-temperature-sensor-sdi-12/) and [manual v6.01](https://www.infwin.com/wp-content/uploads/UM-MT22-SDI-12-Soil-Moisture-EC-and-Temperature-Sensor-V6.01.pdf) | SDI-12 raw/temperature/EC mapping; generic soilless and mineral conversion; raw range; EC already normalised to 25°C | A validated rockwool-Prestige or coco-specific calibration, or actual pore-water EC | +| [INFWIN dimension drawing](https://www.infwin.com/wp-content/uploads/product-mt22-sdi-12-soil-moisture-ec-temperature-sensor-dimension.jpg) | 88 × 26 mm contact face, 18 mm housing depth, 53 mm rods | Pin pitch, exact electromagnetic footprint in each medium, best Prestige location | +| [Grodan Precision Irrigation guide](https://www.grodan101.com/siteassets/downloads/grow-guide/chapter-4---precision-irrigation.pdf), especially pp. 4–6 | Stacked growing volumes, block and 90 cm slab dimensions | A universal target for an independently calibrated MT22, or the dimensions of every 1 m Prestige SKU | +| [Grodan Prestige](https://www.grodan.com/solutions/product-overview/vegetable-solutions/grodan-prestige/) | Product context | User's exact slab width and height | +| [GroSens sensor placement instructions](https://www.grodan.com/nl/syssiteassets/downloads/downloads-nl/brochures-grodan-nl/downloads-multi-sensor-systeem/grodan141571-grosens-sensor-instruction.pdf) | Importance of reproducible positioning and depth | An interchangeable MT22 bracket or placement specification | +| [METER electrical conductivity guide](https://metergroup.com/education-guides/soil-electrical-conductivity-the-complete-guide-to-measurements/) | Distinction between bulk and pore EC, substrate dependence of conversion | That bulk EC divided by VWC is a validated salt mass-balance model | +| [METER TEROS 11/12 manual](https://publications.metergroup.com/Manuals/20587_TEROS11-12_Manual_Web.pdf) | Independent measurement-method background | Same accuracy, electronics or calibration merely because another sensor uses a compatible protocol | +| [CANNA compressed coco product](https://www.canna.com.au/canna-coco-professional-plus-cube) | Hydrated growing volume versus compressed shipping volume | Actual fill volume of a particular user's pot | +| [ESPHome template numbers](https://esphome.io/components/number/template/), [globals](https://esphome.io/components/globals/), [sensor filters](https://esphome.io/components/sensor/#sensor-filters) | Runtime inputs, persistent calibration and timeouts | Probe calibration accuracy | + +The 100-count / 10-percentage-point A/B separation, central-80% C placement, 3-point default check tolerance, ten-sample stability window and midpoint placement guides are **project acceptance/design choices**. They are not INFWIN or Grodan guarantees. A third-point check only tests that observation under those conditions; it does not certify the whole curve, every location, temperature or EC range. + +This project does not present crop steering as a guaranteed increase in flower yield or potency. Reliable measurements, representative placement and actual irrigation delivery come before crop-specific trials. diff --git a/docs/SUBSTRATES.md b/docs/SUBSTRATES.md new file mode 100644 index 0000000..663665b --- /dev/null +++ b/docs/SUBSTRATES.md @@ -0,0 +1,77 @@ +# Substrate volume and sensor location + +Open [the offline setup desk](../tools/setup/index.html) from a downloaded copy of this repository. It calculates block, slab, container, per-plant and whole-zone volumes, including custom sizes. On GitHub, download the repository ZIP first; the file viewer does not run the tool. + +## Cubes alone + +Use the actual block volume. For a rectangular block, **litres = length × width × height in cm ÷ 1,000**. Marketed inch sizes are often approximate. Use the product label or measure the block without compressing it. + +| Grodan block | Dimensions, cm | Geometric volume, L | +|---|---:|---:| +| GR4 | 7.5 × 7.5 × 6.5 | 0.366 | +| GR5.6 | 7.5 × 7.5 × 10 | 0.563 | +| GR6.5 | 10 × 10 × 6.5 | 0.650 | +| GR7.5 | 10 × 10 × 7.5 | 0.750 | +| GR10 | 10 × 10 × 10 | 1.000 | +| Jumbo / GR22.5, nominal 6 × 6 × 4 inches | 15 × 15 × 10 | 2.250 | +| Hugo / GR32, nominal six-inch cube | 15 × 15 × 14.2 | 3.195, usually rounded to 3.2 | +| Uniblock | 20 × 20 × 10 | 4.000 | +| Big Mama | 20.3 × 20.3 × 20.3 | 8.365 | +| Uni-Slab | 24 × 19.5 × 10 | 4.680 | + +Dimensions follow [Grodan's Precision Irrigation guide, pages 5–6](https://www.grodan101.com/siteassets/downloads/grow-guide/chapter-4---precision-irrigation.pdf). Geometric volumes are calculated; rounding differs from commercial labels. This is a reference list, not every regional SKU. + +## Cubes on slabs + +For equal allocation among plants: + +**litres per plant = cube litres + slab litres ÷ plants per slab** + +**litres per slab unit = slab litres + number of cubes × cube litres** + +For three 3.195 L Hugo blocks on one 1 m slab: + +| Slab dimensions, cm | Slab alone, L | Cube + slab share per plant, L | Whole slab + three cubes, L | +|---|---:|---:|---:| +| 100 × 15 × 7.5 | 11.25 | 6.945 | 20.835 | +| 100 × 15 × 10 | 15 | 8.195 | 24.585 | +| 100 × 20 × 7.5 | 15 | 8.195 | 24.585 | +| 100 × 20 × 10 | 20 | 9.862 | 29.585 | +| 100 × 30 × 7.5 | 22.5 | 10.695 | 32.085 | +| 100 × 30 × 10 | 30 | 13.195 | 39.585 | + +These are **geometric examples** for 1 m slabs. Prestige length alone does not determine volume: verify width and height on the wrapper. Do not substitute a 90 cm US slab volume. The setup desk also includes the 90 cm slab dimensions in Grodan's guide. + +If your crop-steering application asks for substrate volume **per plant**, use the third column. If it asks for the volume served by the **whole slab/zone**, use the fourth column multiplied by the relevant slab count. Do not divide a slab twice. Equal allocation is bookkeeping for irrigation calculations; roots and water are not partitioned equally by this formula. + +### Why the cube can read lower + +A cube in good hydraulic contact with a slab forms a taller connected substrate column. Gravity and the substrate's water-retention characteristics establish a vertical water-content gradient. Water can move from the upper block into the slab, and roots extract water from both. Capillary connection enables redistribution; it does not mean the cube must always be wetter or drier by a fixed amount. A poor contact interface changes that behaviour again. + +Once roots have established into the slab, monitor the slab as the main root-zone reservoir. A separate cube reading can diagnose the upper block. Flowering week three is context, not proof that roots have established. Do not raise irrigation merely to make the upper block match a standalone cube's reading. Compare independently calibrated readings, drainage, delivered water and plant response. [Grodan's discussion of stacked volumes and irrigation](https://www.grodan101.com/siteassets/downloads/grow-guide/chapter-4---precision-irrigation.pdf) supports considering the connected growing-media system; exact gradients in your installation require measurement. + +There is **no dependable fixed conversion** from cube-only targets to cube-on-slab targets. This firmware does not install universal wet VWC or dryback targets when you select a substrate. + +## Coco, coco/perlite and peat containers + +Use the hydrated, settled volume actually filled to the working level. Exclude unused headspace and separate drainage material. Different coco grades, perlite proportions, packing density, container heights and root development need separate checks; changing a dropdown cannot account for these. + +The setup desk accepts any positive volume, with quick entries from 1 to 50 L. For measured containers it supports rectangular bags and round tapered pots: + +**tapered-pot litres = π × filled height × (top diameter² + top diameter × bottom diameter + bottom diameter²) ÷ 12,000**, all dimensions in cm. Measure the top diameter at the actual fill line. A cylinder is the special case where both diameters match. Fabric bags and irregular filled pots may be better measured by fill volume than idealised geometry. + +One US liquid gallon = **3.785411784 L**; one Imperial gallon = **4.54609 L**. A nominal “three-gallon” nursery pot may have a different actual fill volume; verify it. A compressed coco package is another quantity entirely: [CANNA describes its compressed cube expanding on hydration](https://www.canna.com.au/canna-coco-professional-plus-cube). Do not enter shipping volume as growing-medium volume. + +## Three different volumes + +1. **Irrigation allocation:** the substrate volume served per plant/slab/zone. +2. **Calibration sample:** the substrate whose retained water mass you weigh, with its actual known volume. +3. **Sensor measurement region:** the local volume influenced by the electrodes, including possible boundary effects. + +These are not interchangeable. One slab probe's VWC multiplied by all cube-plus-slab litres is not a measured whole-system water content. A whole-slab weighing method also requires sufficiently representative moisture around the probe; a three-point fit cannot eliminate spatial gradients. + +## Shot sizes and dryback units + +If an operator selects a shot as a fraction of substrate volume: **shot mL per plant = allocated L × 1,000 × shot percent ÷ 100**. Divide by measured total emitter flow per plant to calculate runtime. For a 6.945 L allocation and an illustrative 2% shot, that is 138.9 mL; this example is arithmetic, not a crop recommendation. Two emitters at a measured 2 L/h each would take about 125 seconds. + +70% VWC falling to 60% VWC is **10 percentage points** or **14.29% of the initial VWC**. The device's `Dryback` uses `pp`; `Dryback Percent` uses `%`. Neither should be computed from the wet-reference index as if it were a true water fraction. Drainage, uptake and redistribution prevent a shot-volume fraction from guaranteeing an identical VWC rise. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index be991fb..e43490a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,72 +1,20 @@ # Troubleshooting -Work top to bottom. Most problems are the probe never talking to the board, and that has a short list of causes. - -## The device works but every reading is unknown or NaN - -This is the common one. The board is fine, the probe is not answering. - -- Wrong framework or wrong chip. The SDI-12 half-duplex UART only works on an ESP32 with the esp-idf framework. On an ESP8266, or with the Arduino framework, the build succeeds and the probe stays silent. Use the board files in this repo and do not change the framework. -- Data on the wrong pin. Check sdi12_data_pin matches the pin you actually wired to. G26 on Atom Lite and PoE, G1 on AtomS3, G2 on the Dial, GPIO16 on generic. -- Red wire in the wrong hole. On the MT22 the red wire is data, not power. If red went to 5V, move it to the data pin. See the wiring guide. -- Not enough voltage. The MT22 needs at least 3.6V. Power it from 5V, not the 3.3V rail. -- Wrong SDI-12 address. Factory default on the MT22 is 0, which is what the config uses. If someone changed the probe address, set sdi12_address to match. -- Bad joint. Reflow or re-crimp the three wires. A cold joint on the data line looks exactly like a dead probe. - -Check the logs. Open the device web page, or run the ESPHome logs, and watch for SDI-12 timeouts. A timeout every cycle means the board is asking and nothing is answering, which points at wiring or power. No SDI-12 activity at all points at the framework or pin. - -## Readings are jumpy or noisy - -- The probe is not fully inserted, or it is sitting in an air gap. Push the rods all the way in, into solid representative media. -- The rods are near the dripper. A reading taken under the emitter jumps every time water lands. Move it into the root zone, away from the drip point. -- Long unshielded cable near a light ballast or a pump. SDI-12 handles interference fairly well, but a metre of cable draped over an EC ballast will still pick up noise. Route it away from mains and drivers. -- The median and EMA filters already smooth a lot. If it is still jumpy after placement is fixed, the probe itself may be marginal. - -## VWC looks wrong - -- It reads high everywhere. Water is tracking down the rods and pooling. Angle the rods slightly downward or go in horizontally so water does not run along them. -- It reads low everywhere. Rods not fully in, or an air gap around them. Reinsert. -- It is believable but off by a fixed amount. That is what calibration is for. Do the two point VWC calibration in the calibration guide. -- You changed substrate profile and your tuning vanished. Changing the profile reloads that substrate's defaults on purpose. Set the profile first, then calibrate. - -## Pore EC looks wrong - -- Pore EC needs a good VWC number to work. If VWC is off, fix that first, then look at EC again. -- Spikes in dry media. Raise Pore EC Blend Low and High so the model leans on mass balance while dry. The blend already handles this, but very dry media can still spike. -- Bulk EC itself is off. Calibrate it against a known solution, see the calibration guide. -- The MT22 already normalises EC to 25C inside the probe, so the EC Temp Coefficient defaults to 0. Only raise it if your probe outputs raw, uncompensated EC. - -## The Sensor Fault flag is on - -It turns on for one of two reasons. - -- The reading is NaN, meaning the probe is not answering. Same causes as the unknown-readings section above. -- The raw counts have not changed for a while. A probe that answers but returns the exact same number every cycle is usually stuck or unplugged mid-cable. The window is set by Fault Freeze Minutes, default 15. If your substrate genuinely does not move for long stretches, raise that number. - -## Steering mode just says Learning - -It needs a few full irrigation cycles before it will classify anything, at least three. Give it a day of normal irrigation. If it never leaves Learning after that, the irrigation detector is not seeing your shots. Lower Rise Threshold so smaller shots register, or check that VWC is actually moving when you irrigate. - -## Irrigation events are miscounted - -- Too many counted. Rise Threshold is too low and it is triggering on noise. Raise it. -- Shots missed. Rise Threshold is too high for your shot size, or the shots are so slow they fall outside the Irrigation Rise Window. Lower the threshold, or widen the window. -- Peaks confirmed too early or too late. Plateau Confirm Drop and Plateau Confirm Time control how it decides a shot has peaked. Longer time and larger drop make it wait for a clearer plateau. - -## Cannot flash from the browser - -- Use Chrome, Edge or Opera on a desktop. Web Serial does not exist in Safari, Firefox, or on phones. -- Plug the board in before clicking Install, and pick the right serial port. -- If no port shows up, you are missing the USB serial driver for that board, or the cable is charge-only. Try a known data cable first, then the driver. - -## Cannot reach the web page after flashing - -- Try http://tdr-sensor.local first. If mDNS does not resolve on your network, find the device IP from your router and use that. -- If it never joined WiFi, it falls back to its own hotspot called tdr-sensor. Join that from a phone and open http://192.168.4.1 to enter your network details. -- On the PoE board there is no WiFi. It comes up on ethernet over DHCP, so find its IP on the router. - -## OTA update fails - -- The device has to be on the network and reachable. Confirm you can load its web page first. -- If it dropped off mid-update, power cycle it. It keeps the old firmware until a new one is fully written, so a failed update does not brick it. -- Persistent OTA trouble on a weak WiFi signal usually means signal. Check the WiFi Signal reading and move the board or add an access point. +| Symptom | Check | +|---|---| +| RAW, temperature and EC all unavailable | Power, common ground, actual sensor pinout, SDI-12 address, selected GPIO, ESP-IDF and pinned half-duplex UART component. Confirm sensor variant is SDI-12, not RS485 | +| RAW works but headline VWC unavailable | Calibration status. A/B need valid weighed references and separation, C needs an independent passing check, current RAW must lie inside A/B, Calibration mode must be off and three new samples must have arrived | +| Wet index available but VWC unavailable | Expected when only a wet reference has been saved. An index of 100 is not 100% VWC | +| Capture not ready | Turn Calibration mode on; wait for ten fresh samples. Check RAW spread, contact, cable movement, uneven wetting and continuing drainage | +| Same reading for many minutes | Check RAW sample age. Fresh unchanged replies are valid; a static value alone is not proof of failure | +| One field remains available while another is unavailable | RAW, EC and temperature have independent validity/timeout checks; inspect the failing field and its protocol mapping | +| C check fails | Verify tare, sample volume, density/packing, actual independent weight, stable moisture distribution and same probe position. Do not enter the predicted value as C | +| Cube reads much lower than slab | Check hydraulic contact, height gradient, calibration and location. A connected upper cube cannot be assigned a fixed offset to a slab | +| Wet reference exceeds 100 | A wetter response or changed context; the index is intentionally not clipped. Inspect sensor position, wetting, EC and saved reference | +| Generic VWC unavailable at some RAW values | The generic curve is outside 0–100 or RAW is invalid. It is withheld instead of clamped. Calibration is not a fix for air gaps or the wrong protocol | +| Pore EC unavailable | Experimental model starts off, requires valid checked VWC and has moisture/denominator/range gates. Use bulk EC and independent solution measurements | +| Trends vanish after restart or calibration | Expected. Trend history does not span different calibration scales or data gaps | +| Wetting counts differ from irrigation controller shots | They are inferred VWC-rise events. Small/overlapping shots, drainage and redistribution can merge or hide events; verify actual delivery separately | +| Factory node cannot be found by the generic hostname | Name adds a MAC suffix in factory firmware. Use DHCP/client discovery or the actual advertised hostname | + +Use [CONFIG.md](CONFIG.md) for version and sampling settings, [CALIBRATION.md](CALIBRATION.md) for the procedure and [MIGRATION-v3.md](MIGRATION-v3.md) for changed entity meanings. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..039e022 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,47 @@ +# Validation + +Use **ESPHome 2026.8.2**. Source checks, compiled host tests and firmware builds validate software behaviour; they do not establish physical sensor accuracy, electrical compatibility or crop targets. + +## Reproduce the checks + +```sh +pip install esphome==2026.8.2 PyYAML==6.0.2 +node --test tests/calculator.test.js +python tests/test_firmware.py +python tests/test_repository.py +python tests/check_configs.py +esphome compile esphome/factory/tdr-sensor-atom-lite-factory.yaml +``` + +The host firmware test needs a C++17 compiler (`g++` by default); use `--cxx /path/to/clang++` or `--cxx C:/path/to/zig.exe` when appropriate. It extracts and executes the **actual YAML lambdas**, including the capture/publish path, with small host stubs for ESPHome entities. This checks calibration arithmetic and state transitions, while the real ESPHome build checks framework/API integration. + +Covered cases include A/B order, invalid/reversed/too-close points, rejected extrapolation, independent C checks, paused calibration output, endpoint recapture invalidating C, stale readings, unchanged but fresh RAW, millisecond rollover, initial missing peaks, equal-value plateau completion, and history resets after calibration changes or unavailable VWC. + +The setup tests run the same JavaScript used in the page. They cover real Hugo dimensions, shared-slab allocation, coco pots, US/Imperial gallons, tapered geometry, weighed tare/density, emitter runtime, dryback units, input rejection and CSV escaping. Repository checks verify local links and important measurement/blueprint contracts. + +`check_configs.py` validates the five local device configs and five factory configs, plus MQTT and private credential options, in a temporary copy with dummy values. It never reads deployment secrets. CI builds all five factory board targets and runs the measurement/setup tests on pull requests. The external UART and SDI-12 components are pinned to commit hashes in the core YAML. + +## Browser and print checks + +Open `tools/setup/index.html` from an extracted download and verify it without a network connection. Check cube-only, cube-on-slab, slab-only and coco calculations; invalid input must clear the previous answer. Add a weighed record and download the CSV. Check the layout at desktop and phone widths. + +The supplied two-page PDF has A4 pages and a dimensioned 88 × 26 mm face. The custom print sheet uses an A4 SVG in physical millimetres, including perpendicular 100 mm scale bars. PDF geometry checks cannot compensate for a printer driver scaling the page: always measure both bars on the physical print. + +## What remains a field check + +- Actual probe serial/revision, pinout and electrical signal levels. +- Representative sensing position and contact in each block, slab or container. +- Independently weighed calibration points and fit error across the intended moisture/EC/temperature range. +- Persistence after saving, transport behaviour on the installed bus, reconnection and Home Assistant entity mapping. +- Controller maximum runtime, delivery/flow and drainage, if the optional shot-request blueprint is commissioned. + +There is no automatic deployment or flashing step in these checks. A successful build is not a calibrated installation. The experimental pore-EC output has no validated MT22/Prestige or coco accuracy claim. + +## Local verification record — 9 September 2026 + +- ESPHome 2026.8.2: Atom Lite factory firmware compiled successfully, including linking and image generation (1,010,431-byte application; 30.1% reported RAM and 55.1% reported application flash). +- All 11 configuration variants validated: five device files, five factory files and a private API/OTA/web-auth + MQTT combination using dummy credentials. +- 14 JavaScript calculation tests and five repository-contract checks passed. The host C++ harness passed all assertions against the actual calibration, capture, publication, freshness and analytics lambdas. +- Browser interaction checks passed for all four systems, preset/custom edits, gallons, tapered pots, invalid inputs, weighed calculations, CSV download, desktop/phone layout and a custom A4 print sheet. +- The custom printed PDF had one A4 page; its contact-face rectangle measured 87.999 × 26.000 mm in PDF coordinates. Physical printer scaling still requires the two ruler checks. +- No firmware was installed on a physical node and no Home Assistant or irrigation settings were changed. Consult the PR checks for the final five-board CI build result. diff --git a/docs/WIRING.md b/docs/WIRING.md index 29ea46c..871b2f8 100644 --- a/docs/WIRING.md +++ b/docs/WIRING.md @@ -4,11 +4,11 @@ Everything about getting the probe connected to the board and into the substrate ## The one thing that breaks most builds -This project uses a half-duplex UART, which only works on the ESP32 with the esp-idf framework and the ssieb fork the config already pulls in. If you try to run it on an ESP8266, or you switch the framework to Arduino, the build still succeeds and the probe just never answers. The device boots, joins WiFi, serves the web page, and every reading sits at unknown. If that is what you are seeing, this is almost always why. Stay on the board files in this repo and do not change the framework. +This project uses a half-duplex UART, which only works on the ESP32 with the esp-idf framework and the ssieb fork the config already pulls in. ESP8266 and Arduino are not supported by these configurations; validation or communication can fail. The device boots, joins WiFi, serves the web page, and every reading sits at unknown. If that is what you are seeing, this is almost always why. Stay on the board files in this repo and do not change the framework. ## Wire colours -The wire that carries data is a different colour on every brand. Only the ground is consistent. Meter each wire against the sensor manual before you power anything up. +The wire that carries data is a different colour on every brand. Confirm every connection against the exact sensor manual before power-up; do not infer functions from colour alone. ![Sensor wire colours](img/wire-colours.svg) @@ -56,32 +56,10 @@ Data on G2, which is Port B. Port A is left alone because the Dial uses it for i Data defaults to GPIO16. Any free GPIO works, just set sdi12_data_pin to match and stay off the strapping pins (GPIO0, 2, 12, 15) unless you know what you are doing. Power the probe from the 5V or VIN pin. -## Sensor placement +## Placement and interface checks -Where you put the probe matters as much as calibration. The sensor reads a volume of substrate around the rods, roughly the size of a small orange, so it needs to sit in representative media, not in an air gap and not jammed against the block wall. +Use the dedicated [placement guide](PLACEMENT.md) and [actual-size MT22 templates](print/MT22-placement-template-A4-actual-size.pdf). The earlier generic “small orange” sensing volume, angled top insertion and claim that mid-height equals whole-block average were not adequate MT22-specific placement evidence and have been removed. -General rules for every substrate: +The diagrams show logical pin assignments. Verify the sensor's electrical output levels against ESP32 input limits before direct connection; SDI-12 is not simply a generic 3.3 V UART connection. Use a suitable SDI-12 interface/buffer if the signalling voltage requires it. The UART software's half-duplex setting does not provide voltage protection. This revision has build validation, not a physical certification of every illustrated wiring combination. -- Full rod insertion. The whole length of the prongs goes in. A partly inserted probe reads low and noisy. -- Away from the dripper. Keep the rods at least a few centimetres from where the emitter lands so you are measuring the root zone, not the wet spot under the dripper. -- Away from the main stem. A couple of centimetres out from the stem, in the root mass, not crushed against the base. -- One consistent spot. Pick a position and use the same one on every block so your numbers are comparable plant to plant. -- Rods horizontal or on a slight downward angle. This stops water tracking down the rods and pooling, which would read high. - -### Rockwool cube (small blocks, propagation and early veg) - -Push the rods in through the side of the cube, horizontal, about halfway up the height. Halfway up gives you the average of the cube rather than the wet bottom or the dry top. Keep clear of the drip line. - -### Rockwool slab - -Insert through the top face at a slight downward angle, or through the side, so the rods sit in the middle third of the slab depth. That middle band is where the roots live and where dryback actually happens. Avoid the very bottom of the slab where water always pools, and avoid the top skin where it dries out first. Put it near a healthy plant, not at the dry end of the slab. - -### Coco pots - -Depth depends on pot size. You want the rods in the main root zone, which is the middle of the pot by depth, not the surface and not the drainage layer at the bottom. - -- Small pots up to about 1 gallon: insert from the top at an angle so the rods reach the middle of the pot. -- Larger pots, 2 to 5 gallon: go in through the side at roughly a third to halfway up. In a tall pot the top dries and the bottom holds water, so mid-height reads the zone the plant actually feeds from. -- One probe reads one pot. If your pots vary, sample a typical one, not your best or worst. - -Once the probe is placed, leave it. Moving it resets your baseline and you will chase readings that only changed because the rods moved. +Primary pinout reference: [INFWIN MT22 manual](https://www.infwin.com/wp-content/uploads/UM-MT22-SDI-12-Soil-Moisture-EC-and-Temperature-Sensor-V6.01.pdf). Treat diagrams for other probe brands as requiring their own current manual check. diff --git a/docs/img/mt22-placement.svg b/docs/img/mt22-placement.svg new file mode 100644 index 0000000..f160ef6 --- /dev/null +++ b/docs/img/mt22-placement.svg @@ -0,0 +1,52 @@ + + + + +MT22 in a Grodan Prestige slab +Three plants per 1 m slab | proposed repeatable midpoint placement + +TOP VIEW +Example location beside the middle cube; not beneath its footprint. + + +PLANT 1PLANT 2PLANT 3 + + + + + +Long sensor body parallel to slab length +Rods go across the slab width, horizontally. +Keep clear of drain holes +and the slab end. + +END CROSS-SECTION +Only one pin is visible end-on. + + + + + + +53 mm + +Housing flush against rockwool +No exposed rods or air gaps. +75 mm slab: 37.5 mm above base +100 mm slab: 50 mm above base + +SIDE FACE / TEMPLATE ALIGNMENT + + + +MT22 +Base datum = bottom of actual rockwool, not the tray rim. +All three pin centres on one horizontal line +Body contact face: 88 mm long x 26 mm high +Use the real sensor to transfer its pin spacing. +Remove the paper before inserting into rockwool. +Rod spacing here is illustrative, not a machining dimension. +Print the separate A4 template at Actual size / 100%; verify both 100 mm check bars. +Manufacturer-verified: housing dimensions and rod length. Proposed: midpoint height and location. +This explanatory drawing is not to scale. The placement PDF contains the 1:1 registration guides. + \ No newline at end of file diff --git a/docs/print/MT22-placement-template-A4-actual-size.pdf b/docs/print/MT22-placement-template-A4-actual-size.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1c9a4bc3ae44319f13a05527247a1b065e616421 GIT binary patch literal 5651 zcmdT|S=*}Ea=!n5g*f1VGASqsD1$hH12`*+42lRS>PdGl@&}T;+~>CjckkW3(|w+t z^W-jE2&=5Bwd$>^wcyQ7B)NjYwXfWN{>Q)nS8C?mqxdy4PpPgGI{OzWvLovq zIN>W*i0)4KjNY;36u+<(nu<=>FNXO-WGsaz!&wTE@l^Hm^u=*sPH2A=KL7ks6wIe1 zM~N$>=iL70ygbWHA)U~QY%e^gkfj&$;nVv=pqhb`IK@(k_CCFSy(a@b`Ax>3Tk=oh z>!+zcQ~xhhRfOnf|18MedJbXmYAE5tMN22*MZ_)WDJnBFB;NP!H6sHPi7=^zE(fe6UA!6*9 zFE0wE56T6J#!;L^DU`zSYNJ|%Fg%Z<`5$_JHXb^^{!0|w&r~7NNLKfcoFw_T-9M)SJ6v@mrpgmbp4s(zfsLd3P+&^&C5yPxoH8WXXz-D??fG*7eTw*x z0(vKgNf!;hB*<;*(Jl0D+n z@Sml9A0(bePI!J@QW#Z3Kc1eiBrCyJ*LT%(6L0}XDHea`B|!seHXI$_4WW-Nwi}Wwn2Hm%Pf^=ZO5}6(8uhMC$C}57ak@8h z7PLWR_Lblcyg*1A${-G}I+wRCZP4;!J`HrGehrlJ<8($Y0?+D=!`UeU<#+isWgnoB z>_ojAS05Z^85%7P))jlwowQ700%YiwDK0a{v<2Pv2cyDTK~l7jR(+&;>Q0TI;)6iM zEJLL&Hh8VrQ=_C7F?@6PJ4IoR^e0m#L|gH0F{_l*WEUJVGJ54}L(*e+w`3J1g=NOT z_{wEED=-(_&DHTVD$AV`22UEj`V&C34HRbXqI9iZpkb&qYNcCU>95FP)P&a#A#NMW z8fYp_r;=%pTUwi!^(YsEBONQxXQZbWPh3Xn01h7A!$t>RNYp$Ot)L8%leap9w)I0N zL@i)E$i%g|eS8_#vi=?*xK18!Xm3EhNE1LJ`!_IoAb_bHQ@+gDH zx(oU=V0jL`4&*Wi=ewIyek{$cps+U1*Ed~^-FY7kbM@Ms0vIy(?z>)` zSxrzuX`!QGmwB%Wlqnm6-|EyBU9Iq_^s_u|HF$nupho_g*9FK~Ic5D`8S{Z26zrv_ z*QCvBAs>!VwlK-tVgXq>K&`%uhTX7(8c;dgEXxeZWt9s@6z z&ay0zE6*(FNyEc@i1}K$TJ|Y@mUN3%#xh28&?j6?s*e0pckZPZlT1=AtW&Ob>yJ*J z&C12|N!Yi;MVE_EO1b6BVc1ANk-->!3M{h*2z?GI8H61YWTmcf!HX>iH6m>0v^|uq z8l2%B-*I-tC>$_{1;OQ_5ieDwlFCt4M;q6Q!Vx;{^E7oT4y5?he8jC80j3{iM=+xr zI|baHRGOAm^vMU6NuE3Jf}rOUM=*G2RNGavCSi%EK~OnWs&Mou_)Yk13h$Cqs&@+u z`66b8eHgHraxJ~6^~^DIar@f{C>$o#&Z}y~dJNrhRxfRe!fg{So>))!=|=-I@tQ<* z3ozW~fWeTz4gu%k566}2obcp*W>DUT1Od1^c|x!q(WX|9Vb6bdF>uh#H!n^eSZ{lx zv<4&Nq0*bq4$nE>t%z2YvK)+e^N^`8`U{)aaz0MxtbEtLY=^o{t?gEWx-C1e!@9W6?!~e?c&dgy&tUDV?-#Ja1Hf`O4EGoB%?_`W z=|#bS>fvcLC>Q4~a&x7Wl{&eB5K~-nvG48M#(UbPY8%bXG~OpMrx47jr0l`w_+?;1 zIE3QIl$%ZMDnt8Rwkad(WOB*M&tY0In9h65%+So!8TAVYO;zj6{p>Xe_gyBjDx#F{ z2~?vYI3)7potr0}BfMuRQ(&rh1U=mzgm0hMs$Rdo2KCLo$LGK(hMM^`H)ME1-gnq| z9#+d%y2G!-PKnG10h9)S7W(WGHfaX2bBT^Gr1cV;nKFCJtj-RsI1Q@+VW7gtU zcbbbCXplRw-aD51;{~8!26!`l1@#hpxwR1z8TUuswy9ROZYzR-*e>1c*CBKcn&*qV z1WM(dw-(k}nQu?@MG)qIOHhT)cLN+|+#YLK7?96bjq9#b{GOEv=-B z1VXZ9$h~!shepSI9XsEf`6=5pcex&WUo{)`J7 zZLG~X=RvI77eFb<>s~mnE&G9r9qgKa$Ilryml0`^xonI1GI#Opmv!{qO_1BiNUNL8 zBHdfBeva`S19SaAAR)iKEP91qyPKO*2hPoDr;$-@-F&e;^6QMR-kzFP$en#Gm#O7Y z8VMfgl<7BnbHr6=cWAtM$|BfkXKG`p3DSChk5w^NyJa&F?VqJqvae0t3PhI5FKB_=Z@y4uYRcr9Ix?2jZ z@M7;5dU|Fv^@^F^=#yh~hX=>HGKn_F9%HVM8e|qd>5>=ekn1nRTqV;#SSo62)oD=G zg~1!SJ=GRtmcrmTPeYYyU665Zf8>_zMI1b%K3zh@uE(6TY56oBowWnHS(YAd_SuGR z)pq=_PXSuHmk!wna9Wmn0Uh;c^lGj#;R1z6j`j2?`o>rF!;xd8!jjA8IP=)LHqmsZ zHxR=`Zd{8(TWk{zDLwDB8r{jcAzP*Kg*7?)_#P~xcTvxGk%lAnqLT;Jf! zft235D#3fl(>?ASM~vevinpk9y)%dUbpD#|3N$R1tkTe%c1HUHbWw%=VryjSfYC-% z7?5N7ruts(Ev?3i7Px)MXNTQw=y*oD1XL&HIy#(g74C&LLuN67quzX=9#tYAySuIL z(xTFTKk?yM8f^B)iJ=Ra>=(yjy~Wr{kEn0SeJ6R3iXqe~WSqfMqSj0QeHoXuYRL7N zD)1aOpZIM(>(m*tVd&*+xbvIH)|Ccb4@h&!xGF*nhS}40Ex2~ql^6YXZIZ87i}bx) z$kgPm$!sbUDy~KrJ(C}~i2z28{cPD4GUxm&Jv8vNcJOlr{3gXY?=la%Mj| zoaJ0+=D^x~)Jmh2Dsa`6_s;Uug)?&YpnllY55%5dk_1T@NGM3BCJd@~54atg`EB`t z*5NV`;DXd3s5=Tyq0_U3kN9UrQ7)hU1!V1!RL!rbbyKW4+T1YZqS zo*kB%(SyHe1PX}bxN4iC7H!1)lYf{7U4h=oSl9PAw66NJrZdsRGq0^OHt&y)Chu5jk z4g`&omXqVLhOFSC-|J0qj*Dx>IUNYDj1dvcO^3=NMv&&#-767R8~qsqT(Bm0UZ5eR@b>zSEHN*>}wsr~Z61a<;9!kY`@Q${je*R#MBJ z?8q54ogOBKi?0vag63}ZW!}eUkP4Bs!xkW{Q50J>2`*ZH*ChU5i#b3W=$3QORtm z(>mJ|*<5+>Laj4v$6ni2%Tmc*WdWY&4-nEoS`Pt8PW1t5?917;J+*5Gc%-bR;8twq z7j!8X1W1wxXt-RRI*v@%rj<6BUOzx6H#15jGMg?SX}L6>-;v9aEk-JNqjS2v!dN99 zBXYa#TuXJARCUp0xW>EHkLQF~qXf2K7tV)S$>?8Ve>h(p=_%U5rkA`wk3_Ts9m`TV z8eF}Ed4OZ`a3A=2<9O%Zr8P!Z()#8eU51mY3RI>zTy3cv_0bFRdb(lm34hGwOZN`z zyS=HaXqWXx+Ua7EBQSagoK|*gd_kanc?LsP?>1z+%BlqwPW|M&cTr%63zC8$VXv_R z;OsI?wk&ZrDU1oR%r_n=l%`;9*!eq${4cTNexWH0`$B)eQY1lCiIkiAT}CD}*XK%w zzsoQ*p$-2eOTN|rBCFzmwI%R`Z}^jK9Z&d)Kg+0uDEgl=3`76o55vg6=v6T)A&7q1 zzGBn6Iq^3tv@fN;whY>^P1H5(7+ODJCjvvOn2k9N8*7*-Rkuv;A8YKtaz8P$=fBU$ zRw^kwp>Ljl;%2{)e&|N24~I4TN~pl-mHKdE$+MQQIG=QUBTLNq^({dJjy1k=xz1Sr F`fu3KRW$$r literal 0 HcmV?d00001 diff --git a/esphome/factory/tdr-sensor-atom-lite-factory.yaml b/esphome/factory/tdr-sensor-atom-lite-factory.yaml index 6346740..5659cfb 100644 --- a/esphome/factory/tdr-sensor-atom-lite-factory.yaml +++ b/esphome/factory/tdr-sensor-atom-lite-factory.yaml @@ -8,7 +8,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/factory/tdr-sensor-atom-poe-factory.yaml b/esphome/factory/tdr-sensor-atom-poe-factory.yaml index b60f445..edc123c 100644 --- a/esphome/factory/tdr-sensor-atom-poe-factory.yaml +++ b/esphome/factory/tdr-sensor-atom-poe-factory.yaml @@ -7,7 +7,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/factory/tdr-sensor-atom-s3-factory.yaml b/esphome/factory/tdr-sensor-atom-s3-factory.yaml index 46f1f66..d0dc693 100644 --- a/esphome/factory/tdr-sensor-atom-s3-factory.yaml +++ b/esphome/factory/tdr-sensor-atom-s3-factory.yaml @@ -8,7 +8,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO1 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/factory/tdr-sensor-esp32-generic-factory.yaml b/esphome/factory/tdr-sensor-esp32-generic-factory.yaml index db6b524..fc313c0 100644 --- a/esphome/factory/tdr-sensor-esp32-generic-factory.yaml +++ b/esphome/factory/tdr-sensor-esp32-generic-factory.yaml @@ -9,7 +9,7 @@ substitutions: board: esp32dev sdi12_data_pin: GPIO16 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/factory/tdr-sensor-m5-dial-factory.yaml b/esphome/factory/tdr-sensor-m5-dial-factory.yaml index ce5681b..96cb197 100644 --- a/esphome/factory/tdr-sensor-m5-dial-factory.yaml +++ b/esphome/factory/tdr-sensor-m5-dial-factory.yaml @@ -8,7 +8,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO2 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/import/atom-lite.yaml b/esphome/import/atom-lite.yaml index b897512..124990f 100644 --- a/esphome/import/atom-lite.yaml +++ b/esphome/import/atom-lite.yaml @@ -7,7 +7,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/import/atom-poe.yaml b/esphome/import/atom-poe.yaml index 2af376d..d1aa5f3 100644 --- a/esphome/import/atom-poe.yaml +++ b/esphome/import/atom-poe.yaml @@ -7,7 +7,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/import/atom-s3.yaml b/esphome/import/atom-s3.yaml index 1637380..4996294 100644 --- a/esphome/import/atom-s3.yaml +++ b/esphome/import/atom-s3.yaml @@ -7,7 +7,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO1 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/import/esp32-generic.yaml b/esphome/import/esp32-generic.yaml index 69bf0a8..e201b4d 100644 --- a/esphome/import/esp32-generic.yaml +++ b/esphome/import/esp32-generic.yaml @@ -8,7 +8,7 @@ substitutions: board: esp32dev sdi12_data_pin: GPIO16 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/import/m5-dial.yaml b/esphome/import/m5-dial.yaml index 5a314a1..c15d22b 100644 --- a/esphome/import/m5-dial.yaml +++ b/esphome/import/m5-dial.yaml @@ -7,7 +7,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO2 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/packages/boards/atom-lite.yaml b/esphome/packages/boards/atom-lite.yaml index 335d098..4075034 100644 --- a/esphome/packages/boards/atom-lite.yaml +++ b/esphome/packages/boards/atom-lite.yaml @@ -1,6 +1,6 @@ # M5Stack Atom Lite board package # Onboard SK6812 LED on GPIO27, button on GPIO39, Grove port G26/G32. -# The LED shows steering state at a glance: +# The LED shows measurement state at a glance: # blue = irrigation in progress # green = drying, before dryback target # orange = dryback target reached @@ -56,7 +56,9 @@ interval: return; } float r = 0.0f, g = 0.0f, b = 0.0f; - if (id(g_phase) == 1) { + if (!id(vwc_ready).state) { + r = 0.65f; b = 0.65f; // Violet: uncalibrated, stale or calibrating. + } else if (id(g_phase) == 1) { b = 1.0f; } else { float p = id(g_peak); diff --git a/esphome/packages/boards/atom-poe.yaml b/esphome/packages/boards/atom-poe.yaml index fb90b01..ef759a4 100644 --- a/esphome/packages/boards/atom-poe.yaml +++ b/esphome/packages/boards/atom-poe.yaml @@ -2,7 +2,7 @@ # Wired ethernet with PoE power, no WiFi config needed. The Grove # port on the PoE base passes through G26/G32 so the probe wiring is # the same as a bare Atom Lite. -# LED colours: blue = irrigating, green = drying, orange = target hit. +# LED colours: blue = wetting detected, green = drying, orange = target hit. esp32: board: m5stack-atom @@ -71,7 +71,9 @@ interval: return; } float r = 0.0f, g = 0.0f, b = 0.0f; - if (id(g_phase) == 1) { + if (!id(vwc_ready).state) { + r = 0.65f; b = 0.65f; // Violet: uncalibrated, stale or calibrating. + } else if (id(g_phase) == 1) { b = 1.0f; } else { float p = id(g_peak); diff --git a/esphome/packages/boards/atom-s3.yaml b/esphome/packages/boards/atom-s3.yaml index e7cc3ef..99f63bf 100644 --- a/esphome/packages/boards/atom-s3.yaml +++ b/esphome/packages/boards/atom-s3.yaml @@ -1,7 +1,7 @@ # M5Stack AtomS3 Lite board package # Onboard SK6812 LED on GPIO35, button on GPIO41, Grove port G1/G2. # Remember to set sdi12_data_pin: GPIO1 in your device file. -# LED colours: blue = irrigating, green = drying, orange = target hit. +# LED colours: blue = wetting detected, green = drying, orange = target hit. esp32: board: m5stack-atoms3 @@ -58,7 +58,9 @@ interval: return; } float r = 0.0f, g = 0.0f, b = 0.0f; - if (id(g_phase) == 1) { + if (!id(vwc_ready).state) { + r = 0.65f; b = 0.65f; // Violet: uncalibrated, stale or calibrating. + } else if (id(g_phase) == 1) { b = 1.0f; } else { float p = id(g_peak); diff --git a/esphome/packages/boards/m5-dial.yaml b/esphome/packages/boards/m5-dial.yaml index ed88f50..6e9e277 100644 --- a/esphome/packages/boards/m5-dial.yaml +++ b/esphome/packages/boards/m5-dial.yaml @@ -88,12 +88,12 @@ display: } else { it.printf(120, 92, id(font_big), c_white, TextAlign::CENTER, "%.1f%%", v); } - float ec = id(pwec).state; + float ec = id(bulk_ec_25).state; if (!isnan(ec)) - it.printf(120, 140, id(font_med), c_amber, TextAlign::CENTER, "EC %.2f", ec); + it.printf(120, 140, id(font_med), c_amber, TextAlign::CENTER, "bEC %.2f", ec); float db = id(dryback_pts).state; if (!isnan(db)) - it.printf(120, 168, id(font_med), c_white, TextAlign::CENTER, "DB %.1f%%", db); + it.printf(120, 168, id(font_med), c_white, TextAlign::CENTER, "DB %.1fpp", db); it.printf(120, 200, id(font_small), c_green, TextAlign::CENTER, "%s", id(steering_mode).state.c_str()); diff --git a/esphome/packages/tdr_analytics.yaml b/esphome/packages/tdr_analytics.yaml index 722c7fe..5fe3617 100644 --- a/esphome/packages/tdr_analytics.yaml +++ b/esphome/packages/tdr_analytics.yaml @@ -1,1050 +1,342 @@ -# =================================================================== -# TDR-Sensor crop steering analytics package -# ------------------------------------------------------------------- -# All of this runs on the ESP32. No Home Assistant needed, the -# numbers show up on the built-in web page and over MQTT. -# -# Driven off every VWC sample: -# - Irrigation detection state machine (rise then plateau) -# - Peak and trough VWC, with the time each happened -# - Dryback since last peak, in points and in percent -# - Dryback rate in %/hr over a rolling 60 minute window -# - Daily max dryback, overnight dryback -# - Rolling 24h min / max / average for VWC and pore EC -# - Shots today, time since last irrigation, "irrigating" flag -# - Pore EC captured at field capacity, and EC stacking since then -# - Saturation percent against field capacity -# - Field capacity auto-learned from the 7 day peak -# - Sensor fault flag (reading NaN, or raw counts frozen) -# - Steering detection: Vegetative / Balanced / Generative, with a -# confidence and a numeric index for graphing -# -# Requires the core package (uses the vwc, pwec, field_capacity ids). -# =================================================================== - +# Observed moisture trends, not plant physiology or measured irrigation delivery. +# Runtime history resets on reboot, data loss or a calibration change. substitutions: timezone: Pacific/Auckland - time: - - platform: sntp - id: tdr_time - timezone: ${timezone} - on_time: - # Midnight rollover: push today's peak into the 7 day ring for - # the learned field capacity, then reset the daily counters. - - seconds: 0 - minutes: 0 - hours: 0 - then: - - lambda: |- - id(g_fc_days).push_back(id(g_daily_peak)); - if (id(g_fc_days).size() > 7) id(g_fc_days).erase(id(g_fc_days).begin()); - ESP_LOGI("analytics", "Daily rollover, stored peak %.1f", id(g_daily_peak)); - id(g_daily_peak) = 0.0f; - id(g_shots_today) = 0; - id(g_max_dryback_today) = 0.0f; - # Lights on / off snapshots for overnight dryback. - - seconds: 15 - minutes: 0 - hours: '*' - then: - - lambda: |- - int h = id(tdr_time).now().hour; - float v = id(vwc).state; - if (isnan(v)) return; - if (h == (int) id(lights_off_hour).state) { - id(g_lightsoff_vwc) = v; - } - if (h == (int) id(lights_on_hour).state && !isnan(id(g_lightsoff_vwc))) { - id(g_overnight_db) = id(g_lightsoff_vwc) - v; - } - +- platform: sntp + id: tdr_time + timezone: ${timezone} + on_time: + - seconds: 0 + minutes: 0 + hours: 0 + then: + - lambda: |- + id(g_shots_today)=0; id(g_max_dryback_today)=0; globals: - # 0 = drying, 1 = wetting (irrigation in progress) - - id: g_phase - type: int - restore_value: true - initial_value: '0' - - id: g_peak - type: float - restore_value: true - initial_value: 'NAN' - - id: g_peak_ts - type: int - restore_value: true - initial_value: '0' - - id: g_trough - type: float - restore_value: true - initial_value: 'NAN' - - id: g_trough_ts - type: int - restore_value: true - initial_value: '0' - - id: g_cand_peak - type: float - restore_value: false - initial_value: 'NAN' - - id: g_cand_peak_ts - type: int - restore_value: false - initial_value: '0' - - id: g_irr_start - type: float - restore_value: true - initial_value: 'NAN' - - id: g_last_irr_ts - type: int - restore_value: true - initial_value: '0' - - id: g_shots_today - type: int - restore_value: true - initial_value: '0' - - id: g_last_shot - type: float - restore_value: true - initial_value: 'NAN' - - id: g_max_dryback_today - type: float - restore_value: true - initial_value: '0.0' - - id: g_daily_peak - type: float - restore_value: true - initial_value: '0.0' - - id: g_lightsoff_vwc - type: float - restore_value: true - initial_value: 'NAN' - - id: g_overnight_db - type: float - restore_value: true - initial_value: 'NAN' - - id: g_pwec_at_fc - type: float - restore_value: true - initial_value: 'NAN' - - id: g_cycles - type: int - restore_value: true - initial_value: '0' - # Rolling 60 min ring for the dryback rate (up to 12 x 5 min). Kept - # as vectors because ESPHome globals cannot initialise C arrays. - - id: g_rate_vwc - type: std::vector - restore_value: false - - id: g_rate_ts - type: std::vector - restore_value: false - # 7 day ring of daily peaks for the learned field capacity. - - id: g_fc_days - type: std::vector - restore_value: false - # Fault detection: last raw value and when it last changed. - - id: g_last_raw - type: float - restore_value: false - initial_value: 'NAN' - - id: g_last_change_ts - type: int - restore_value: false - initial_value: '0' - -# ------------------------------------------------------------------- -# Sample intake and periodic samplers -# ------------------------------------------------------------------- +- id: g_phase + type: int + restore_value: false + initial_value: '0' +- id: g_peak + type: float + restore_value: false + initial_value: NAN +- id: g_trough + type: float + restore_value: false + initial_value: NAN +- id: g_cand_peak + type: float + restore_value: false + initial_value: NAN +- id: g_irr_start + type: float + restore_value: false + initial_value: NAN +- id: g_last_shot + type: float + restore_value: false + initial_value: NAN +- id: g_max_dryback_today + type: float + restore_value: false + initial_value: '0' +- id: g_shots_today + type: int + restore_value: false + initial_value: '0' +- id: g_trough_ms + type: uint32_t + restore_value: false + initial_value: '0' +- id: g_cand_peak_ms + type: uint32_t + restore_value: false + initial_value: '0' +- id: g_last_irr_ms + type: uint32_t + restore_value: false + initial_value: '0' +- id: g_analytics_revision + type: uint32_t + restore_value: false + initial_value: '0' +- id: g_has_irrigated + type: bool + restore_value: false + initial_value: 'false' +- id: g_rate_vwc + type: std::vector + restore_value: false + initial_value: std::vector{} +- id: g_rate_ms + type: std::vector + restore_value: false + initial_value: std::vector{} +number: +- platform: template + id: rise_threshold + name: Rise threshold + optimistic: true + restore_value: true + initial_value: 1.5 + min_value: 0.3 + max_value: 10 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: pp + web_server: + sorting_group_id: sg_tuning +- platform: template + id: fall_confirm + name: Plateau confirm drop + optimistic: true + restore_value: true + initial_value: 0.8 + min_value: 0.1 + max_value: 5 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: pp + web_server: + sorting_group_id: sg_tuning +- platform: template + id: peak_confirm_min + name: Plateau confirm time + optimistic: true + restore_value: true + initial_value: 10 + min_value: 1 + max_value: 60 + step: 1 + mode: box + entity_category: config + unit_of_measurement: min + web_server: + sorting_group_id: sg_tuning +- platform: template + id: irr_window_min + name: Rise detection window + optimistic: true + restore_value: true + initial_value: 20 + min_value: 1 + max_value: 120 + step: 1 + mode: box + entity_category: config + unit_of_measurement: min + web_server: + sorting_group_id: sg_tuning +- platform: template + id: target_dryback + name: Dryback observation threshold + optimistic: true + restore_value: true + initial_value: 10 + min_value: 1 + max_value: 40 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: pp + web_server: + sorting_group_id: sg_tuning sensor: - - platform: copy - source_id: vwc - id: vwc_stream - internal: true - on_value: - - script.execute: - id: tdr_process - sample: !lambda 'return x;' - - # ---- Analytics outputs ------------------------------------------- - - platform: template - name: "Peak VWC" - id: peak_vwc - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 30s - state_class: measurement - icon: mdi:arrow-up-bold - web_server: - sorting_group_id: sg_analytics - sorting_weight: 10 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: 'return id(g_peak);' - - - platform: template - name: "Trough VWC" - id: trough_vwc - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 30s - state_class: measurement - icon: mdi:arrow-down-bold - web_server: - sorting_group_id: sg_analytics - sorting_weight: 11 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: 'return id(g_trough);' - - - platform: template - name: "Dryback" - id: dryback_pts - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 30s - state_class: measurement - icon: mdi:trending-down - web_server: - sorting_group_id: sg_analytics - sorting_weight: 12 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: |- - float p = id(g_peak); - float v = id(vwc).state; - if (isnan(p) || isnan(v)) return NAN; - return p - v < 0.0f ? 0.0f : p - v; - - - platform: template - name: "Dryback Percent" - id: dryback_pct - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 30s - state_class: measurement - icon: mdi:percent - web_server: - sorting_group_id: sg_analytics - sorting_weight: 13 - filters: [ { or: [ delta: 0.1, heartbeat: 300s ] } ] - lambda: |- - float p = id(g_peak); - float v = id(vwc).state; - if (isnan(p) || isnan(v) || p < 1.0f) return NAN; - float db = (p - v) / p * 100.0f; - return db < 0.0f ? 0.0f : db; - - - platform: template - name: "Dryback Rate" - id: dryback_rate - unit_of_measurement: "%/h" - accuracy_decimals: 2 - update_interval: 60s - state_class: measurement - icon: mdi:speedometer - web_server: - sorting_group_id: sg_analytics - sorting_weight: 14 - filters: [ { or: [ delta: 0.02, heartbeat: 300s ] } ] - lambda: |- - // Slope across the rolling 60 min buffer. Positive means the - // substrate is losing water. Samples are pushed oldest-first. - if (id(g_phase) != 0) return NAN; - size_t n = id(g_rate_vwc).size(); - if (n < 2) return NAN; - float v_old = id(g_rate_vwc).front(); - float v_new = id(g_rate_vwc).back(); - float hours = (id(g_rate_ts).back() - id(g_rate_ts).front()) / 3600.0f; - if (hours < 0.15f) return NAN; - float rate = (v_old - v_new) / hours; - return rate < 0.0f ? 0.0f : rate; - - - platform: template - name: "Time Since Irrigation" - id: time_since_irr - unit_of_measurement: "min" - device_class: duration - accuracy_decimals: 0 - update_interval: 30s - state_class: measurement - icon: mdi:timer-outline - web_server: - sorting_group_id: sg_analytics - sorting_weight: 15 - lambda: |- - if (id(g_last_irr_ts) == 0) return NAN; - auto now_t = id(tdr_time).now(); - if (!now_t.is_valid()) return NAN; - return (now_t.timestamp - id(g_last_irr_ts)) / 60.0f; - - - platform: template - name: "Irrigations Today" - id: irrigations_today - accuracy_decimals: 0 - update_interval: 30s - state_class: total_increasing - icon: mdi:sprinkler - web_server: - sorting_group_id: sg_analytics - sorting_weight: 16 - filters: [ { or: [ delta: 0.5, heartbeat: 300s ] } ] - lambda: 'return id(g_shots_today);' - - - platform: template - name: "Last Shot Size" - id: last_shot_size - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 30s - state_class: measurement - icon: mdi:cup-water - web_server: - sorting_group_id: sg_analytics - sorting_weight: 17 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: 'return id(g_last_shot);' - - - platform: template - name: "Max Dryback Today" - id: max_dryback_today - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 60s - state_class: measurement - icon: mdi:arrow-expand-down - web_server: - sorting_group_id: sg_analytics - sorting_weight: 18 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: 'return id(g_max_dryback_today) > 0.0f ? id(g_max_dryback_today) : NAN;' - - - platform: template - name: "Overnight Dryback" - id: overnight_dryback - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 60s - state_class: measurement - icon: mdi:weather-night - web_server: - sorting_group_id: sg_analytics - sorting_weight: 19 - filters: [ { or: [ delta: 0.05, heartbeat: 300s ] } ] - lambda: 'return id(g_overnight_db);' - - - platform: template - name: "Saturation" - id: saturation_pct - unit_of_measurement: "%" - accuracy_decimals: 0 - update_interval: 30s - state_class: measurement - icon: mdi:water-percent-alert - web_server: - sorting_group_id: sg_analytics - sorting_weight: 20 - filters: [ { or: [ delta: 0.5, heartbeat: 300s ] } ] - lambda: |- - float v = id(vwc).state; - float fc = id(field_capacity).state; - if (isnan(v) || fc < 1.0f) return NAN; - float s = v / fc * 100.0f; - if (s < 0.0f) s = 0.0f; - if (s > 120.0f) s = 120.0f; - return s; - - - platform: template - name: "Field Capacity (learned)" - id: fc_learned - unit_of_measurement: "%" - accuracy_decimals: 1 - update_interval: 300s - state_class: measurement - icon: mdi:school - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 21 - lambda: |- - float mx = id(g_daily_peak); - for (float d : id(g_fc_days)) if (d > mx) mx = d; - return mx > 1.0f ? mx : NAN; - - - platform: template - name: "Pore EC at Field Capacity" - id: pwec_at_fc - unit_of_measurement: "dS/m" - accuracy_decimals: 2 - update_interval: 60s - state_class: measurement - icon: mdi:water-opacity - web_server: - sorting_group_id: sg_analytics - sorting_weight: 22 - filters: [ { or: [ delta: 0.02, heartbeat: 300s ] } ] - lambda: 'return id(g_pwec_at_fc);' - - - platform: template - name: "EC Stacking" - id: ec_stacking - unit_of_measurement: "dS/m" - accuracy_decimals: 2 - update_interval: 60s - state_class: measurement - icon: mdi:layers-plus - web_server: - sorting_group_id: sg_analytics - sorting_weight: 23 - filters: [ { or: [ delta: 0.02, heartbeat: 300s ] } ] - lambda: |- - float now = id(pwec).state; - float fc = id(g_pwec_at_fc); - if (isnan(now) || isnan(fc)) return NAN; - return now - fc; - - # ---- Rolling 24h stats via built-in filters ---------------------- - - platform: copy - source_id: vwc - name: "VWC 24h Min" - id: vwc_24h_min - icon: mdi:arrow-collapse-down - accuracy_decimals: 1 - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 30 - filters: - - throttle_average: 300s - - min: - window_size: 288 - send_every: 6 - send_first_at: 1 - - - platform: copy - source_id: vwc - name: "VWC 24h Max" - id: vwc_24h_max - icon: mdi:arrow-collapse-up - accuracy_decimals: 1 - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 31 - filters: - - throttle_average: 300s - - max: - window_size: 288 - send_every: 6 - send_first_at: 1 - - - platform: copy - source_id: vwc - name: "VWC 24h Average" - id: vwc_24h_avg - icon: mdi:chart-timeline-variant - accuracy_decimals: 1 - web_server: - sorting_group_id: sg_analytics - sorting_weight: 32 - filters: - - throttle_average: 300s - - sliding_window_moving_average: - window_size: 288 - send_every: 6 - send_first_at: 1 - - - platform: copy - source_id: pwec - name: "Pore EC 24h Min" - id: pwec_24h_min - unit_of_measurement: "dS/m" - icon: mdi:arrow-collapse-down - accuracy_decimals: 2 - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 33 - filters: - - throttle_average: 300s - - min: - window_size: 288 - send_every: 6 - send_first_at: 1 - - - platform: copy - source_id: pwec - name: "Pore EC 24h Max" - id: pwec_24h_max - unit_of_measurement: "dS/m" - icon: mdi:arrow-collapse-up - accuracy_decimals: 2 - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 34 - filters: - - throttle_average: 300s - - max: - window_size: 288 - send_every: 6 - send_first_at: 1 - - - platform: copy - source_id: pwec - name: "Pore EC 24h Average" - id: pwec_24h_avg - unit_of_measurement: "dS/m" - icon: mdi:chart-timeline-variant - accuracy_decimals: 2 - web_server: - sorting_group_id: sg_analytics - sorting_weight: 35 - filters: - - throttle_average: 300s - - sliding_window_moving_average: - window_size: 288 - send_every: 6 - send_first_at: 1 - - # ---- Steering detection ------------------------------------------ - - platform: template - name: "Steering Index" - id: steering_index - accuracy_decimals: 2 - update_interval: 60s - state_class: measurement - icon: mdi:swap-vertical-bold - web_server: - sorting_group_id: sg_steering - sorting_weight: 10 - filters: [ { or: [ delta: 0.01, heartbeat: 300s ] } ] - lambda: |- - // Four signals, each mapped to -1 (vegetative) .. +1 - // (generative), then weighted. Anchors are user settable. - if (id(g_cycles) < 3) return NAN; - - // Maps lo -> -1, hi -> +1, clamped. - auto mr = [](float x, float lo, float hi) -> float { - if (hi == lo) return 0.0f; - float t = 2.0f * (x - lo) / (hi - lo) - 1.0f; - if (t < -1.0f) t = -1.0f; - if (t > 1.0f) t = 1.0f; - return t; - }; - - // 1) Dryback magnitude (bigger = generative) - float db = id(g_max_dryback_today); - if (db <= 0.0f) db = isnan(id(g_last_shot)) ? 0.0f : id(g_last_shot); - float s_db = mr(db, id(steer_dryback_veg).state, id(steer_dryback_gen).state); - - // 2) Shots per day (fewer = generative) - float shots = id(g_shots_today); - float s_shots = -mr(shots, id(steer_shots_gen).state, id(steer_shots_veg).state); - - // 3) EC stacking through the day (more = generative) - float s_ec = 0.0f; - if (!isnan(id(pwec).state) && !isnan(id(g_pwec_at_fc))) - s_ec = mr(id(pwec).state - id(g_pwec_at_fc), 0.0f, 3.0f); - - // 4) Average VWC vs field capacity headroom (drier = generative) - float avg = id(vwc_24h_avg).state; - float fc = id(field_capacity).state; - float s_head = 0.0f; - if (!isnan(avg) && fc > 1.0f) { - float headroom = (fc - avg) / fc; // 0 = full, larger = drier - s_head = mr(headroom, 0.05f, 0.35f); - } - - float idx = 0.40f * s_db + 0.30f * s_shots + 0.15f * s_ec + 0.15f * s_head; - if (idx < -1.0f) idx = -1.0f; - if (idx > 1.0f) idx = 1.0f; - return idx; - - - platform: template - name: "Steering Confidence" - id: steering_confidence - unit_of_measurement: "%" - accuracy_decimals: 0 - update_interval: 60s - state_class: measurement - icon: mdi:gauge - web_server: - sorting_group_id: sg_steering - sorting_weight: 11 - filters: [ { or: [ delta: 1.0, heartbeat: 300s ] } ] - lambda: |- - float idx = id(steering_index).state; - if (isnan(idx)) return NAN; - float c = fabsf(idx) / 0.66f * 100.0f; - if (c > 100.0f) c = 100.0f; - return c; - +- platform: copy + source_id: vwc + id: vwc_stream + internal: true + on_value: + - script.execute: tdr_process +- platform: template + id: peak_vwc + name: Peak VWC + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_analytics + lambda: |- + return id(g_peak); +- platform: template + id: trough_vwc + name: Trough VWC + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_analytics + lambda: |- + return id(g_trough); +- platform: template + id: dryback_pts + name: Dryback + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: pp + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(vwc_ready).state || !std::isfinite(id(g_peak))) return NAN; + return std::max(0.0f,id(g_peak)-id(vwc).state); +- platform: template + id: dryback_pct + name: Dryback Percent + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(vwc_ready).state || !std::isfinite(id(g_peak)) || id(g_peak)<=0) return NAN; + return std::max(0.0f,100*(id(g_peak)-id(vwc).state)/id(g_peak)); +- platform: template + id: last_shot_size + name: Last detected rise + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: pp + web_server: + sorting_group_id: sg_analytics + lambda: |- + return id(g_last_shot); +- platform: template + id: max_dryback_today + name: Max Dryback Today + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: pp + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(tdr_time).now().is_valid()) return NAN; + return id(g_max_dryback_today); +- platform: template + id: irrigations_today + name: Detected wettings today + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(tdr_time).now().is_valid()) return NAN; + return id(g_shots_today); +- platform: template + id: time_since_irr + name: Time Since Irrigation + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: min + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(g_has_irrigated)) return NAN; + return (uint32_t)(millis()-id(g_last_irr_ms))/60000.0f; +- platform: template + id: dryback_rate + name: Dryback Rate + update_interval: 5s + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: pp/h + web_server: + sorting_group_id: sg_analytics + lambda: |- + if (!id(vwc_ready).state || id(g_phase)!=0 || id(g_rate_vwc).size()<2) return NAN; + const float hours=(uint32_t)(id(g_rate_ms).back()-id(g_rate_ms).front())/3600000.0f; + if (hours<0.15) return NAN; + return std::max(0.0f,(id(g_rate_vwc).front()-id(g_rate_vwc).back())/hours); binary_sensor: - - platform: template - name: "Irrigating" - id: irrigating - device_class: running - icon: mdi:water-pump - web_server: - sorting_group_id: sg_analytics - sorting_weight: 40 - lambda: 'return id(g_phase) == 1;' - - - platform: template - name: "Dryback Target Reached" - id: dryback_target_reached - icon: mdi:flag-checkered - web_server: - sorting_group_id: sg_analytics - sorting_weight: 41 - lambda: |- - float p = id(g_peak); - float v = id(vwc).state; - if (isnan(p) || isnan(v)) return false; - return (p - v) >= id(target_dryback).state; - - - platform: template - name: "Sensor Fault" - id: sensor_fault - device_class: problem - icon: mdi:alert-circle - entity_category: diagnostic - web_server: - sorting_group_id: sg_diag - sorting_weight: 15 - lambda: |- - if (isnan(id(raw_counts).state)) return true; - auto now_t = id(tdr_time).now(); - if (!now_t.is_valid() || id(g_last_change_ts) == 0) return false; - float mins = (now_t.timestamp - id(g_last_change_ts)) / 60.0f; - return mins >= id(fault_freeze_min).state; - +- platform: template + name: Wetting detected + id: irrigating + lambda: return id(vwc_ready).state && id(g_phase)==1; +- platform: template + name: Sensor fault + device_class: problem + lambda: return !id(sensor_fresh).state; text_sensor: - - platform: template - name: "Steering Mode" - id: steering_mode - update_interval: 60s - icon: mdi:sprout - web_server: - sorting_group_id: sg_steering - sorting_weight: 5 - lambda: |- - if (id(g_cycles) < 3) return {"Learning"}; - float idx = id(steering_index).state; - if (isnan(idx)) return {"Learning"}; - if (idx >= id(steer_gen_threshold).state) return {"Generative"}; - if (idx <= id(steer_veg_threshold).state) return {"Vegetative"}; - return {"Balanced"}; - - - platform: template - name: "Peak Time" - id: peak_time - update_interval: 60s - icon: mdi:clock-time-four - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 50 - lambda: |- - if (id(g_peak_ts) == 0) return {"--:--"}; - return {ESPTime::from_epoch_local(id(g_peak_ts)).strftime("%H:%M")}; - - - platform: template - name: "Trough Time" - id: trough_time - update_interval: 60s - icon: mdi:clock-time-ten - entity_category: diagnostic - web_server: - sorting_group_id: sg_analytics - sorting_weight: 51 - lambda: |- - if (id(g_trough_ts) == 0) return {"--:--"}; - return {ESPTime::from_epoch_local(id(g_trough_ts)).strftime("%H:%M")}; - -# ------------------------------------------------------------------- -# Periodic samplers -# ------------------------------------------------------------------- -interval: - # Push a sample into the dryback-rate ring every 5 minutes. - - interval: 300s - then: - - lambda: |- - auto now_t = id(tdr_time).now(); - if (!now_t.is_valid()) return; - float v = id(vwc).state; - if (isnan(v)) return; - // Keep the last 12 samples, oldest at the front. - id(g_rate_vwc).push_back(v); - id(g_rate_ts).push_back((int64_t) now_t.timestamp); - while (id(g_rate_vwc).size() > 12) { - id(g_rate_vwc).erase(id(g_rate_vwc).begin()); - id(g_rate_ts).erase(id(g_rate_ts).begin()); - } - -# ------------------------------------------------------------------- -# The irrigation and dryback engine. Runs on every VWC sample. -# ------------------------------------------------------------------- +- platform: template + name: Water trend + id: steering_mode + update_interval: 5s + lambda: |- + if (!id(vwc_ready).state) return {"VWC unavailable"}; + if (id(g_phase)==1) return {"Wetting detected"}; + if (!std::isfinite(id(g_peak))) return {"Waiting for a wetting cycle"}; + return {"Tracking dryback"}; script: - - id: tdr_process - mode: single - parameters: - sample: float - then: - - lambda: |- - float v = sample; - auto now_t = id(tdr_time).now(); - if (!now_t.is_valid()) return; // wait for time sync - time_t now = now_t.timestamp; - - // ---- Fault tracking (raw counts frozen or NaN) ---- - float raw = id(raw_counts).state; - if (!isnan(raw)) { - if (isnan(id(g_last_raw)) || fabsf(raw - id(g_last_raw)) > 0.5f) { - id(g_last_raw) = raw; - id(g_last_change_ts) = now; - } - } - if (isnan(v)) return; - - float rise = id(rise_threshold).state; - float fall = id(fall_confirm).state; - float confirm_s = id(peak_confirm_min).state * 60.0f; - float window_s = id(irr_window_min).state * 60.0f; - - if (id(g_phase) == 0) { - // ---- DRYING ---- - if (isnan(id(g_trough)) || v <= id(g_trough)) { - id(g_trough) = v; - id(g_trough_ts) = now; - } - if (!isnan(id(g_peak))) { - float db = id(g_peak) - v; - if (db > id(g_max_dryback_today)) id(g_max_dryback_today) = db; - } - if (!isnan(id(g_trough)) && (v - id(g_trough)) >= rise) { - if ((now - id(g_trough_ts)) > (time_t) window_s) { - // Rise too slow to be a shot, re-anchor and keep drying. - id(g_trough) = v; - id(g_trough_ts) = now; - } else { - // ---- Irrigation detected ---- - id(g_phase) = 1; - id(g_irr_start) = id(g_trough); - id(g_cand_peak) = v; - id(g_cand_peak_ts) = now; - if (id(g_last_irr_ts) > 0) { - float iv = (now - id(g_last_irr_ts)) / 60.0f; - (void) iv; - } - id(g_last_irr_ts) = now; - id(g_shots_today) += 1; - id(g_cycles) += 1; - ESP_LOGI("analytics", "Irrigation at VWC %.1f (trough %.1f)", v, id(g_irr_start)); - } - } +- id: tdr_process + then: + - lambda: |- + if (!id(vwc_ready).state || !std::isfinite(id(vwc).state) || id(g_analytics_revision)!=id(cal_revision)) { + id(g_phase)=0; id(g_peak)=NAN; id(g_trough)=NAN; id(g_cand_peak)=NAN; + id(g_irr_start)=NAN; id(g_last_shot)=NAN; id(g_has_irrigated)=false; + id(g_shots_today)=0; id(g_max_dryback_today)=0; + id(g_rate_vwc).clear(); id(g_rate_ms).clear(); + id(g_analytics_revision)=id(cal_revision); + return; + } + const float v=id(vwc).state; + const uint32_t now=millis(); + // Monotonic timers work before time sync and across the millis rollover. + // A flat, freshly received RAW response is not a communication failure. + if (id(g_phase)==0) { + if (!std::isfinite(id(g_trough)) || v<=id(g_trough)) {id(g_trough)=v; id(g_trough_ms)=now;} + if (std::isfinite(id(g_peak))) id(g_max_dryback_today)=std::max(id(g_max_dryback_today),id(g_peak)-v); + if (v-id(g_trough)>=id(rise_threshold).state) { + if ((uint32_t)(now-id(g_trough_ms))>id(irr_window_min).state*60000) { + id(g_trough)=v; id(g_trough_ms)=now; } else { - // ---- WETTING ---- - if (isnan(id(g_cand_peak)) || v >= id(g_cand_peak)) { - id(g_cand_peak) = v; - id(g_cand_peak_ts) = now; - } - bool fell = (id(g_cand_peak) - v) >= fall; - bool timed = (now - id(g_cand_peak_ts)) >= (time_t) confirm_s; - if (fell || timed) { - // ---- Peak confirmed (plateau reached) ---- - id(g_peak) = id(g_cand_peak); - id(g_peak_ts) = id(g_cand_peak_ts); - if (id(g_peak) > id(g_daily_peak)) id(g_daily_peak) = id(g_peak); - if (!isnan(id(g_irr_start))) { - float shot = id(g_peak) - id(g_irr_start); - if (shot > 0.0f) id(g_last_shot) = shot; - } - // Capture pore EC at the top of the shot as the field - // capacity baseline for EC stacking. - float ecnow = id(pwec).state; - if (!isnan(ecnow)) id(g_pwec_at_fc) = ecnow; - id(g_phase) = 0; - id(g_trough) = v; - id(g_trough_ts) = now; - id(g_cand_peak) = NAN; - ESP_LOGI("analytics", "Peak %.1f, shot %.1f", id(g_peak), id(g_last_shot)); - } + id(g_phase)=1; id(g_irr_start)=id(g_trough); id(g_cand_peak)=v; id(g_cand_peak_ms)=now; + id(g_last_irr_ms)=now; id(g_has_irrigated)=true; + if (id(tdr_time).now().is_valid()) id(g_shots_today)++; + id(g_rate_vwc).clear(); id(g_rate_ms).clear(); } - -# ------------------------------------------------------------------- -# Tuning knobs -# ------------------------------------------------------------------- -number: - - platform: template - name: "Rise Threshold" - id: rise_threshold - optimistic: true - restore_value: true - initial_value: 1.5 - min_value: 0.3 - max_value: 10.0 - step: 0.1 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:arrow-top-right - web_server: - sorting_group_id: sg_tuning - sorting_weight: 10 - - - platform: template - name: "Plateau Confirm Drop" - id: fall_confirm - optimistic: true - restore_value: true - initial_value: 0.8 - min_value: 0.1 - max_value: 5.0 - step: 0.1 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:arrow-bottom-right - web_server: - sorting_group_id: sg_tuning - sorting_weight: 11 - - - platform: template - name: "Plateau Confirm Time" - id: peak_confirm_min - optimistic: true - restore_value: true - initial_value: 10.0 - min_value: 2.0 - max_value: 60.0 - step: 1.0 - mode: box - unit_of_measurement: "min" - entity_category: config - icon: mdi:timer-check-outline - web_server: - sorting_group_id: sg_tuning - sorting_weight: 12 - - - platform: template - name: "Irrigation Rise Window" - id: irr_window_min - optimistic: true - restore_value: true - initial_value: 20.0 - min_value: 5.0 - max_value: 120.0 - step: 1.0 - mode: box - unit_of_measurement: "min" - entity_category: config - icon: mdi:timer-sand-complete - web_server: - sorting_group_id: sg_tuning - sorting_weight: 13 - - - platform: template - name: "Dryback Target" - id: target_dryback - optimistic: true - restore_value: true - initial_value: 15.0 - min_value: 3.0 - max_value: 40.0 - step: 0.5 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:target - web_server: - sorting_group_id: sg_tuning - sorting_weight: 14 - - - platform: template - name: "Lights On Hour" - id: lights_on_hour - optimistic: true - restore_value: true - initial_value: 6 - min_value: 0 - max_value: 23 - step: 1 - mode: box - unit_of_measurement: "h" - entity_category: config - icon: mdi:weather-sunny - web_server: - sorting_group_id: sg_tuning - sorting_weight: 15 - - - platform: template - name: "Lights Off Hour" - id: lights_off_hour - optimistic: true - restore_value: true - initial_value: 18 - min_value: 0 - max_value: 23 - step: 1 - mode: box - unit_of_measurement: "h" - entity_category: config - icon: mdi:weather-night - web_server: - sorting_group_id: sg_tuning - sorting_weight: 16 - - - platform: template - name: "Fault Freeze Minutes" - id: fault_freeze_min - optimistic: true - restore_value: true - initial_value: 15.0 - min_value: 2.0 - max_value: 120.0 - step: 1.0 - mode: box - unit_of_measurement: "min" - entity_category: config - icon: mdi:timer-alert - web_server: - sorting_group_id: sg_tuning - sorting_weight: 17 - - # ---- Steering thresholds and anchors ----------------------------- - - platform: template - name: "Steering Gen Threshold" - id: steer_gen_threshold - optimistic: true - restore_value: true - initial_value: 0.33 - min_value: 0.05 - max_value: 0.9 - step: 0.01 - mode: box - entity_category: config - icon: mdi:sprout - web_server: - sorting_group_id: sg_tuning - sorting_weight: 20 - - - platform: template - name: "Steering Veg Threshold" - id: steer_veg_threshold - optimistic: true - restore_value: true - initial_value: -0.33 - min_value: -0.9 - max_value: -0.05 - step: 0.01 - mode: box - entity_category: config - icon: mdi:leaf - web_server: - sorting_group_id: sg_tuning - sorting_weight: 21 - - - platform: template - name: "Steer Dryback Veg Anchor" - id: steer_dryback_veg - optimistic: true - restore_value: true - initial_value: 6.0 - min_value: 1.0 - max_value: 20.0 - step: 0.5 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:leaf - web_server: - sorting_group_id: sg_tuning - sorting_weight: 22 - - - platform: template - name: "Steer Dryback Gen Anchor" - id: steer_dryback_gen - optimistic: true - restore_value: true - initial_value: 15.0 - min_value: 5.0 - max_value: 40.0 - step: 0.5 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:sprout - web_server: - sorting_group_id: sg_tuning - sorting_weight: 23 - - - platform: template - name: "Steer Shots Gen Anchor" - id: steer_shots_gen - optimistic: true - restore_value: true - initial_value: 4.0 - min_value: 1.0 - max_value: 20.0 - step: 1.0 - entity_category: config - icon: mdi:sprout - web_server: - sorting_group_id: sg_tuning - sorting_weight: 24 - - - platform: template - name: "Steer Shots Veg Anchor" - id: steer_shots_veg - optimistic: true - restore_value: true - initial_value: 10.0 - min_value: 2.0 - max_value: 40.0 - step: 1.0 - entity_category: config - icon: mdi:leaf - web_server: - sorting_group_id: sg_tuning - sorting_weight: 25 - + } + } else { + // Strict > matters: equal readings must allow a plateau to complete. + if (!std::isfinite(id(g_cand_peak)) || v>id(g_cand_peak)) {id(g_cand_peak)=v; id(g_cand_peak_ms)=now;} + if (id(g_cand_peak)-v>=id(fall_confirm).state || (uint32_t)(now-id(g_cand_peak_ms))>=id(peak_confirm_min).state*60000) { + id(g_peak)=id(g_cand_peak); id(g_last_shot)=id(g_peak)-id(g_irr_start); + id(g_phase)=0; id(g_trough)=v; id(g_trough_ms)=now; id(g_cand_peak)=NAN; + id(g_rate_vwc).clear(); id(g_rate_ms).clear(); + } + } +- id: reset_analytics + then: + - lambda: |- + id(g_phase)=0; id(g_peak)=NAN; id(g_trough)=NAN; id(g_cand_peak)=NAN; + id(g_irr_start)=NAN; id(g_last_shot)=NAN; id(g_has_irrigated)=false; + id(g_shots_today)=0; id(g_max_dryback_today)=0; + id(g_rate_vwc).clear(); id(g_rate_ms).clear(); + id(g_analytics_revision)=id(cal_revision); button: - - platform: template - name: "Reset Analytics" - id: btn_reset_analytics - entity_category: config - icon: mdi:restore-alert - web_server: - sorting_group_id: sg_tuning - sorting_weight: 30 - on_press: - - lambda: |- - id(g_phase) = 0; - id(g_peak) = NAN; id(g_peak_ts) = 0; - id(g_trough) = NAN; id(g_trough_ts) = 0; - id(g_cand_peak) = NAN; id(g_cand_peak_ts) = 0; - id(g_irr_start) = NAN; - id(g_last_irr_ts) = 0; - id(g_last_shot) = NAN; - id(g_max_dryback_today) = 0.0f; - id(g_daily_peak) = 0.0f; - id(g_lightsoff_vwc) = NAN; - id(g_overnight_db) = NAN; - id(g_pwec_at_fc) = NAN; - id(g_cycles) = 0; - id(g_shots_today) = 0; - id(g_rate_vwc).clear(); - id(g_rate_ts).clear(); - id(g_fc_days).clear(); - ESP_LOGI("analytics", "Analytics fully reset"); - - - platform: template - name: "Reset Daily Counters" - id: btn_reset_daily - entity_category: config - icon: mdi:counter - web_server: - sorting_group_id: sg_tuning - sorting_weight: 31 - on_press: - - lambda: |- - id(g_shots_today) = 0; - id(g_max_dryback_today) = 0.0f; - ESP_LOGI("analytics", "Daily counters reset"); +- platform: template + name: Reset Analytics + entity_category: config + on_press: + - script.execute: reset_analytics + web_server: + sorting_group_id: sg_calibration +interval: +- interval: 300s + then: + - lambda: |- + if (!id(vwc_ready).state || id(g_phase)!=0) return; + id(g_rate_vwc).push_back(id(vwc).state); id(g_rate_ms).push_back(millis()); + while (!id(g_rate_ms).empty() && ((uint32_t)(millis()-id(g_rate_ms).front())>3600000U || id(g_rate_ms).size()>13)) { + id(g_rate_vwc).erase(id(g_rate_vwc).begin()); id(g_rate_ms).erase(id(g_rate_ms).begin()); + } diff --git a/esphome/packages/tdr_sdi12_core.yaml b/esphome/packages/tdr_sdi12_core.yaml index 8a03501..0b7fdfb 100644 --- a/esphome/packages/tdr_sdi12_core.yaml +++ b/esphome/packages/tdr_sdi12_core.yaml @@ -1,941 +1,731 @@ -# =================================================================== -# TDR-Sensor core package -# SDI-12 substrate sensor (Infiwin MT22 / TEROS-12 compatible) -# ------------------------------------------------------------------- -# Reads raw counts, temperature and bulk EC over SDI-12, then runs -# the full calibration pipeline on the ESP32: -# -# raw counts -# -> substrate polynomial (rockwool/coco/peat share the TEROS-12 -# soilless curve, mineral soil uses the linear curve, custom -# uses your own a/b/c/d) -# -> field calibration (gain and offset, settable by hand or from -# the two-point capture buttons) -# -> VWC % -# -# bulk EC -# -> gain and offset -# -> normalise to 25 C (off by default, the MT22 already does this -# inside the probe) -# -> pore water EC: Hilhorst when the media is wet, mass balance -# when it is dry, blended across a VWC window you can set -# -# Everything is tunable at runtime from the web page or Home -# Assistant. You never reflash to calibrate. -# -# This package carries no board, wifi or ethernet config. Those live -# in the board package and the device file. See the repo README. -# =================================================================== - +# MT22 SDI-12 measurement and weighed calibration. See docs/CALIBRATION.md. +# Wet index is not VWC. Calibration captures require Calibration mode ON. substitutions: name: tdr-sensor friendly_name: TDR Sensor - # SDI-12 data wire GPIO. Set by the board package normally. sdi12_data_pin: GPIO26 - # SDI-12 sensor address. Factory default on the MT22 is 0. - sdi12_address: "0" - # How often to poll the probe. 10s is fine. Do not go below 3s, an - # SDI-12 measurement cycle takes a second or two. - sample_interval: 10s - + sdi12_address: '0' + sample_interval: 30s + sample_timeout: 90s + sample_timeout_ms: '90000' esphome: name: ${name} friendly_name: ${friendly_name} - min_version: 2025.2.0 + min_version: 2026.8.2 project: name: jaketherabbit.tdr-sensor - version: "2.0.0" + version: 3.0.0 on_boot: - # Runs after restore so changing a setting later applies its - # defaults, but a reboot does not stomp your saved values. priority: -100 then: - - lambda: 'id(g_ready) = true;' - + - lambda: id(g_ready) = true; logger: level: INFO - -# reboot_timeout 0 so the node runs happily standalone (web page or -# MQTT only, no Home Assistant connected). Without this the ESP -# reboots every 15 minutes when no API client is attached. api: reboot_timeout: 0s - -# Batch persisted writes to cut flash wear from the analytics globals. preferences: - flash_write_interval: 15min - -# Prometheus scrape endpoint at http:///metrics for Grafana, -# VictoriaMetrics or Telegraf. Free logging without Home Assistant. -prometheus: - + flash_write_interval: 5s +prometheus: {} web_server: port: 80 version: 3 - # Bundle the web assets into the firmware so the page works with no - # internet access. local: true sorting_groups: - - id: sg_live - name: "Live Readings" - sorting_weight: 10 - - id: sg_analytics - name: "Crop Steering Analytics" - sorting_weight: 20 - - id: sg_steering - name: "Steering Detection" - sorting_weight: 25 - - id: sg_calibration - name: "Calibration" - sorting_weight: 30 - - id: sg_tuning - name: "Analytics Tuning" - sorting_weight: 40 - - id: sg_diag - name: "Diagnostics" - sorting_weight: 50 - -# ------------------------------------------------------------------- -# SDI-12 bus -# ------------------------------------------------------------------- -# half_duplex UART only exists in ssieb's esp-idf fork. The Arduino -# framework and the ESP8266 have no half-duplex path, the build looks -# fine and the probe just never answers. Keep esp-idf, keep the fork. + - id: sg_live + name: Readings + sorting_weight: 10 + - id: sg_analytics + name: Water-content trends + sorting_weight: 20 + - id: sg_calibration + name: Calibration + sorting_weight: 30 + - id: sg_tuning + name: Trend settings + sorting_weight: 40 + - id: sg_diag + name: Diagnostics + sorting_weight: 50 external_components: - - source: github://ssieb/esphome@uarthalf - components: [ uart ] - refresh: 1d - - source: github://ssieb/esphome_components@sdi12 - components: [ sdi12 ] - refresh: 1d - -# On ESP32 half_duplex you set tx_pin only. Setting both pins is a -# validation error. +- source: github://ssieb/esphome@5c80bde7d6ab68b4c73bc5e0a6646209315991a3 + components: + - uart +- source: github://ssieb/esphome_components@b31b547ff269672abbb9a1840cd777b7c118723f + components: + - sdi12 uart: - - id: sdi12_uart - tx_pin: - number: ${sdi12_data_pin} - inverted: true - baud_rate: 1200 - data_bits: 7 - parity: even - stop_bits: 1 - half_duplex: true - +- id: sdi12_uart + tx_pin: + number: ${sdi12_data_pin} + inverted: true + baud_rate: 1200 + data_bits: 7 + parity: even + stop_bits: 1 + half_duplex: true sdi12: - - id: sdi12_bus - uart_id: sdi12_uart - -# ------------------------------------------------------------------- -# Globals -# ------------------------------------------------------------------- +- id: sdi12_bus + uart_id: sdi12_uart globals: - - id: g_ready - type: bool - restore_value: false - initial_value: 'false' - # Substrate as an index so the lambdas do not depend on the select - # object. 0 Rockwool, 1 Coco, 2 Peat, 3 Mineral Soil, 4 Custom. - - id: g_substrate_idx - type: int - restore_value: true - initial_value: '0' - - id: g_cal_dry_poly - type: float - restore_value: true - initial_value: 'NAN' - - id: g_cal_wet_poly - type: float - restore_value: true - initial_value: 'NAN' - -# ------------------------------------------------------------------- -# Sensors -# ------------------------------------------------------------------- +- id: g_ready + type: bool + restore_value: false + initial_value: 'false' +- id: raw_window + type: std::array + restore_value: false + initial_value: std::array{} +- id: raw_count + type: int + restore_value: false + initial_value: '0' +- id: raw_pos + type: int + restore_value: false + initial_value: '0' +- id: last_raw_ms + type: uint32_t + restore_value: false + initial_value: '0' +- id: raw_seen + type: bool + restore_value: false + initial_value: 'false' +- id: cal_revision + type: uint32_t + restore_value: false + initial_value: '0' +- id: wet_raw + type: float + restore_value: true + initial_value: NAN +- id: a_raw + type: float + restore_value: true + initial_value: NAN +- id: a_vwc + type: float + restore_value: true + initial_value: NAN +- id: b_raw + type: float + restore_value: true + initial_value: NAN +- id: b_vwc + type: float + restore_value: true + initial_value: NAN +- id: c_raw + type: float + restore_value: true + initial_value: NAN +- id: c_vwc + type: float + restore_value: true + initial_value: NAN +- id: ec_current_frame + type: bool + restore_value: false + initial_value: 'false' +number: +- platform: template + id: weighed_input + name: Weighed reference VWC + optimistic: true + restore_value: false + initial_value: 0 + min_value: 0 + max_value: 100 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_calibration +- platform: template + id: capture_spread_limit + name: Capture maximum RAW spread + optimistic: true + restore_value: true + initial_value: 10 + min_value: 1 + max_value: 100 + step: 1 + mode: box + entity_category: config + unit_of_measurement: counts + web_server: + sorting_group_id: sg_calibration +- platform: template + id: validation_tolerance + name: Third-point tolerance + optimistic: true + restore_value: true + initial_value: 3 + min_value: 0.5 + max_value: 10 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: pp + web_server: + sorting_group_id: sg_calibration + on_value: + - lambda: if (id(g_ready)) { id(cal_revision)++; id(vwc_ready).publish_state(false); id(vwc).publish_state(NAN); + } +- platform: template + id: ec_gain + name: Bulk EC gain + optimistic: true + restore_value: true + initial_value: 1 + min_value: 0.25 + max_value: 2 + step: 0.001 + mode: box + entity_category: config + web_server: + sorting_group_id: sg_calibration +- platform: template + id: ec_offset_us + name: Bulk EC offset + optimistic: true + restore_value: true + initial_value: 0 + min_value: -1000 + max_value: 1000 + step: 1 + mode: box + entity_category: config + unit_of_measurement: µS/cm + web_server: + sorting_group_id: sg_calibration +- platform: template + id: temp_offset + name: Temperature offset + optimistic: true + restore_value: true + initial_value: 0 + min_value: -5 + max_value: 5 + step: 0.1 + mode: box + entity_category: config + unit_of_measurement: °C + web_server: + sorting_group_id: sg_calibration +- platform: template + id: hilhorst_e0 + name: Experimental Hilhorst offset + optimistic: true + restore_value: true + initial_value: 4.1 + min_value: 0 + max_value: 20 + step: 0.1 + mode: box + entity_category: config + web_server: + sorting_group_id: sg_calibration +- platform: template + id: pwec_min_vwc + name: Experimental pwEC minimum VWC + optimistic: true + restore_value: true + initial_value: 30 + min_value: 10 + max_value: 90 + step: 1 + mode: box + entity_category: config + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_calibration +select: +- platform: template + id: substrate_profile + name: Substrate Profile + optimistic: true + restore_value: true + initial_option: Rockwool cube on slab + options: + - Rockwool cube + - Rockwool cube on slab + - Rockwool slab + - Coco + - Peat mix + - Mineral soil + entity_category: config + on_value: + - if: + condition: + lambda: return id(g_ready); + then: + - script.execute: clear_calibration +switch: +- platform: template + id: calibration_mode + name: Calibration mode + optimistic: true + restore_mode: ALWAYS_OFF + entity_category: config + on_turn_on: + - script.execute: restart_capture + on_turn_off: + - script.execute: restart_capture +- platform: template + id: enable_pwec + name: Experimental pwEC estimate + optimistic: true + restore_mode: ALWAYS_OFF + entity_category: config +binary_sensor: +- platform: template + name: Capture ready + id: capture_ready +- platform: template + name: VWC ready + id: vwc_ready +- platform: template + name: Sensor data fresh + id: sensor_fresh +text_sensor: +- platform: template + name: Calibration status + id: calibration_status + update_interval: never +- platform: template + name: Last calibration action + id: last_action + update_interval: never sensor: - # ---- Raw values from the probe ----------------------------------- - # A 5-wide median knocks out single-sample SDI-12 glitches before - # anything downstream sees them. - - platform: sdi12 - address: ${sdi12_address} - update_interval: ${sample_interval} - sensors: - - index: 1 - name: "Raw Counts" - id: raw_counts - accuracy_decimals: 0 - entity_category: diagnostic - state_class: measurement - icon: mdi:counter - web_server: - sorting_group_id: sg_diag - sorting_weight: 10 - filters: - - median: - window_size: 5 - send_every: 1 - send_first_at: 1 - - index: 2 - name: "Substrate Temperature Raw" - id: substrate_temp_raw - internal: true - accuracy_decimals: 1 - - index: 3 - name: "Bulk EC Raw" - id: bulk_ec_us_raw - internal: true - accuracy_decimals: 0 - filters: - - median: - window_size: 5 - send_every: 1 - send_first_at: 1 - - # ---- VWC pipeline ------------------------------------------------ - # Polynomial output before field calibration. Not clamped, the - # capture buttons need the real number. - - platform: template - name: "VWC Uncalibrated" - id: vwc_poly - unit_of_measurement: "%" - accuracy_decimals: 2 - update_interval: ${sample_interval} +- platform: sdi12 + address: ${sdi12_address} + update_interval: ${sample_interval} + sensors: + - index: 1 + name: RAW Counts + id: raw_counts + accuracy_decimals: 0 entity_category: diagnostic - disabled_by_default: true - state_class: measurement - web_server: - sorting_group_id: sg_diag - sorting_weight: 20 - lambda: |- - float R = id(raw_counts).state; - if (isnan(R) || R <= 0.0f) return NAN; - int p = id(g_substrate_idx); - float theta; // m3/m3 - if (p == 3) { - // TEROS-12 mineral soil calibration - theta = 3.879e-4f * R - 0.6956f; - } else if (p == 4) { - theta = id(custom_a).state * R * R * R - + id(custom_b).state * R * R - + id(custom_c).state * R - + id(custom_d).state; - } else { - // TEROS-12 soilless calibration, shared by rockwool, coco - // and peat. - theta = 6.771e-10f * R * R * R - - 5.105e-6f * R * R - + 1.302e-2f * R - - 10.848f; - } - return theta * 100.0f; - - # Field-calibrated VWC. Median already applied upstream, a light EMA - # here smooths the last bit of jitter for the analytics. - - platform: template - name: "VWC" - id: vwc - unit_of_measurement: "%" - device_class: moisture - state_class: measurement - accuracy_decimals: 1 - update_interval: ${sample_interval} - icon: mdi:water-percent - web_server: - sorting_group_id: sg_live - sorting_weight: 10 - filters: - - exponential_moving_average: - alpha: 0.2 - send_every: 1 - send_first_at: 1 - lambda: |- - float p = id(vwc_poly).state; - if (isnan(p)) return NAN; - float v = p * id(vwc_gain).state + id(vwc_offset).state; - if (v < 0.0f) v = 0.0f; - if (v > 100.0f) v = 100.0f; - return v; - - # ---- Temperature ------------------------------------------------- - - platform: template - name: "Substrate Temperature" + on_value: + - lambda: |- + id(ec_current_frame)=false; + if (!std::isfinite(x) || x <= 0 || x > 4095) { + id(raw_count)=0; id(raw_seen)=false; return; + } + const uint32_t now=millis(); + if (!id(raw_seen) || (uint32_t)(now-id(last_raw_ms)) > ${sample_timeout_ms}U) { + id(raw_count)=0; id(raw_pos)=0; + } + id(raw_window)[id(raw_pos)]=x; + id(raw_pos)=(id(raw_pos)+1)%10; + if (id(raw_count)<10) id(raw_count)++; + id(last_raw_ms)=now; id(raw_seen)=true; + - script.execute: publish_readings + - index: 2 + name: Temperature id: substrate_temp - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement + unit_of_measurement: °C accuracy_decimals: 1 - update_interval: ${sample_interval} - web_server: - sorting_group_id: sg_live - sorting_weight: 30 - lambda: |- - float t = id(substrate_temp_raw).state; - if (isnan(t)) return NAN; - return t + id(temp_offset).state; - - # ---- EC pipeline ------------------------------------------------- - - platform: template - name: "Bulk EC" - id: bulk_ec - unit_of_measurement: "dS/m" + device_class: temperature state_class: measurement - accuracy_decimals: 3 - update_interval: ${sample_interval} - icon: mdi:flash - entity_category: diagnostic - web_server: - sorting_group_id: sg_diag - sorting_weight: 30 - lambda: |- - float us = id(bulk_ec_us_raw).state; - if (isnan(us)) return NAN; - float cal = us * id(ec_gain).state + id(ec_offset_us).state; - if (cal < 0.0f) cal = 0.0f; - return cal / 1000.0f; - - - platform: template - name: "Bulk EC @25C" + filters: + - lambda: 'return std::isfinite(x) && x >= -40 && x <= 80 ? x + id(temp_offset).state : NAN;' + - timeout: ${sample_timeout} + - index: 3 + name: Bulk EC 25C id: bulk_ec_25 - unit_of_measurement: "dS/m" - state_class: measurement + unit_of_measurement: dS/m accuracy_decimals: 3 - update_interval: ${sample_interval} - icon: mdi:flash - web_server: - sorting_group_id: sg_live - sorting_weight: 40 - lambda: |- - float ec = id(bulk_ec).state; - if (isnan(ec)) return NAN; - float t = id(substrate_temp).state; - if (isnan(t)) return ec; - float k = id(ec_temp_coeff).state; - float denom = 1.0f + k * (t - 25.0f); - if (denom < 0.2f) denom = 0.2f; - return ec / denom; - - # Apparent dielectric permittivity, TEROS-12 squared-polynomial form - # from the MT22 manual. Drives the Hilhorst pore EC model. - - platform: template - name: "Permittivity" - id: permittivity - accuracy_decimals: 2 - update_interval: ${sample_interval} - entity_category: diagnostic - icon: mdi:sine-wave - state_class: measurement - web_server: - sorting_group_id: sg_diag - sorting_weight: 40 - lambda: |- - float R = id(raw_counts).state; - if (isnan(R) || R <= 0.0f) return NAN; - float inner = 2.887e-9f * R * R * R - - 2.080e-5f * R * R - + 5.276e-2f * R - - 43.39f; - return inner * inner; - - - platform: template - name: "Pore EC (mass-balance model)" - id: ec_mass_diag - unit_of_measurement: "dS/m" - accuracy_decimals: 2 - update_interval: ${sample_interval} - entity_category: diagnostic - disabled_by_default: true state_class: measurement - web_server: - sorting_group_id: sg_diag - sorting_weight: 50 - lambda: |- - float theta = id(vwc).state / 100.0f; - float sb25 = id(bulk_ec_25).state; - if (isnan(theta) || isnan(sb25)) return NAN; - if (theta < 0.10f) theta = 0.10f; - return sb25 / theta; - - - platform: template - name: "Pore EC (Hilhorst model)" - id: ec_hil_diag - unit_of_measurement: "dS/m" - accuracy_decimals: 2 - update_interval: ${sample_interval} - entity_category: diagnostic - disabled_by_default: true - state_class: measurement - web_server: - sorting_group_id: sg_diag - sorting_weight: 60 - lambda: |- - float eb = id(permittivity).state; - float sb25 = id(bulk_ec_25).state; - if (isnan(eb) || isnan(sb25)) return NAN; - float e0 = id(hilhorst_e0).state; - float denom = eb - e0; - if (denom <= 0.5f) return NAN; - // Pore water permittivity vs temperature, METER Eq 2. - float t = id(substrate_temp).state; - float ep = isnan(t) ? 80.3f : (80.3f - 0.37f * (t - 20.0f)); - float ec = sb25 * ep / denom; - if (ec <= 0.0f || ec >= 30.0f) return NAN; - return ec; - - # The headline pore water EC. Hilhorst holds up when the media is - # wet, mass balance holds up when it is dry, so we blend between - # them across the VWC window. This kills the NaN and the 50 dS/m - # spikes you get from Hilhorst alone in dry media. - - platform: template - name: "Pore EC" - id: pwec - unit_of_measurement: "dS/m" - state_class: measurement - accuracy_decimals: 2 - update_interval: ${sample_interval} - icon: mdi:water-opacity - web_server: - sorting_group_id: sg_live - sorting_weight: 20 - lambda: |- - float theta = id(vwc).state / 100.0f; - float sb25 = id(bulk_ec_25).state; - if (isnan(theta) || isnan(sb25)) return NAN; - float th = theta < 0.10f ? 0.10f : theta; - float ec_mass = sb25 / th; - float ec_hil = id(ec_hil_diag).state; - float lo = id(pwec_blend_low).state / 100.0f; - float hi = id(pwec_blend_high).state / 100.0f; - if (hi <= lo + 0.01f) hi = lo + 0.01f; - float ec = ec_mass; - if (!isnan(ec_hil)) { - float w; - if (theta <= lo) w = 0.0f; - else if (theta >= hi) w = 1.0f; - else w = (theta - lo) / (hi - lo); - ec = (1.0f - w) * ec_mass + w * ec_hil; - } - if (ec < 0.0f) ec = 0.0f; - if (ec > 30.0f) ec = 30.0f; - return ec; - - # ---- Housekeeping ------------------------------------------------ - - platform: uptime - name: "Uptime" - entity_category: diagnostic - web_server: - sorting_group_id: sg_diag - sorting_weight: 90 - -# ------------------------------------------------------------------- -# Substrate profile and calibration controls. All persist across -# reboots. -# ------------------------------------------------------------------- -select: - - platform: template - name: "Substrate Profile" - id: substrate_profile - optimistic: true - restore_value: true - entity_category: config - icon: mdi:layers-triple - web_server: - sorting_group_id: sg_calibration - sorting_weight: 5 - options: - - "Rockwool" - - "Coco" - - "Peat" - - "Mineral Soil" - - "Custom" - initial_option: "Rockwool" + filters: + - lambda: |- + if (!std::isfinite(x) || x < 0 || x > 23000) return NAN; + const float corrected=(x*id(ec_gain).state+id(ec_offset_us).state)/1000.0f; + return std::isfinite(corrected) && corrected >= 0 && corrected <= 23 ? corrected : NAN; + - timeout: ${sample_timeout} on_value: - - lambda: |- - if (x == "Coco") id(g_substrate_idx) = 1; - else if (x == "Peat") id(g_substrate_idx) = 2; - else if (x == "Mineral Soil") id(g_substrate_idx) = 3; - else if (x == "Custom") id(g_substrate_idx) = 4; - else id(g_substrate_idx) = 0; - - if: - condition: - lambda: 'return id(g_ready);' - then: - - script.execute: apply_substrate_defaults - -number: - # ---- VWC field calibration --------------------------------------- - - platform: template - name: "VWC Gain" - id: vwc_gain - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0.25 - max_value: 2.0 - step: 0.001 - mode: box - entity_category: config - icon: mdi:multiplication - web_server: - sorting_group_id: sg_calibration - sorting_weight: 20 - - - platform: template - name: "VWC Offset" - id: vwc_offset - optimistic: true - restore_value: true - initial_value: 0.0 - min_value: -40.0 - max_value: 40.0 - step: 0.1 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:plus-minus-variant - web_server: - sorting_group_id: sg_calibration - sorting_weight: 21 - - - platform: template - name: "Saturated Reference" - id: sat_reference - optimistic: true - restore_value: true - initial_value: 65.0 - min_value: 20.0 - max_value: 95.0 - step: 0.5 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:cup-water - web_server: - sorting_group_id: sg_calibration - sorting_weight: 22 - - - platform: template - name: "Field Capacity" - id: field_capacity - optimistic: true - restore_value: true - initial_value: 65.0 - min_value: 20.0 - max_value: 95.0 - step: 0.5 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:water-check - web_server: - sorting_group_id: sg_calibration - sorting_weight: 23 - - # ---- Temperature ------------------------------------------------- - - platform: template - name: "Temperature Offset" - id: temp_offset - optimistic: true - restore_value: true - initial_value: 0.0 - min_value: -5.0 - max_value: 5.0 - step: 0.1 - mode: box - unit_of_measurement: "°C" - entity_category: config - icon: mdi:thermometer-lines - web_server: - sorting_group_id: sg_calibration - sorting_weight: 30 - - # ---- EC calibration ---------------------------------------------- - - platform: template - name: "EC Gain" - id: ec_gain - optimistic: true - restore_value: true - initial_value: 1.0 - min_value: 0.25 - max_value: 2.0 - step: 0.001 - mode: box - entity_category: config - icon: mdi:multiplication - web_server: - sorting_group_id: sg_calibration - sorting_weight: 40 - - - platform: template - name: "EC Offset" - id: ec_offset_us - optimistic: true - restore_value: true - initial_value: 0.0 - min_value: -1000.0 - max_value: 1000.0 - step: 1.0 - mode: box - unit_of_measurement: "µS/cm" - entity_category: config - icon: mdi:plus-minus-variant - web_server: - sorting_group_id: sg_calibration - sorting_weight: 41 - - - platform: template - name: "EC Reference Solution" - id: ec_reference - optimistic: true - restore_value: true - initial_value: 1.413 - min_value: 0.1 - max_value: 20.0 - step: 0.001 - mode: box - unit_of_measurement: "dS/m" - entity_category: config - icon: mdi:test-tube - web_server: - sorting_group_id: sg_calibration - sorting_weight: 42 - - # The MT22 already normalises bulk EC to 25 C in the probe, so this - # defaults to 0. If your probe outputs raw EC, set 0.019 (1.9 %/C) - # and the node does the correction. - - platform: template - name: "EC Temp Coefficient" - id: ec_temp_coeff - optimistic: true - restore_value: true - initial_value: 0.0 - min_value: 0.0 - max_value: 0.03 - step: 0.001 - mode: box - unit_of_measurement: "1/°C" - entity_category: config - icon: mdi:thermometer-water - web_server: - sorting_group_id: sg_calibration - sorting_weight: 43 - - - platform: template - name: "Hilhorst e0" - id: hilhorst_e0 - optimistic: true - restore_value: true - initial_value: 4.1 - min_value: 0.0 - max_value: 7.0 - step: 0.1 - mode: box - entity_category: config - icon: mdi:tune-vertical - web_server: - sorting_group_id: sg_calibration - sorting_weight: 44 - - - platform: template - name: "Pore EC Blend Low" - id: pwec_blend_low - optimistic: true - restore_value: true - initial_value: 40.0 - min_value: 10.0 - max_value: 60.0 - step: 1.0 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:arrow-collapse-down - web_server: - sorting_group_id: sg_calibration - sorting_weight: 45 - - - platform: template - name: "Pore EC Blend High" - id: pwec_blend_high - optimistic: true - restore_value: true - initial_value: 60.0 - min_value: 30.0 - max_value: 90.0 - step: 1.0 - mode: box - unit_of_measurement: "%" - entity_category: config - icon: mdi:arrow-collapse-up - web_server: - sorting_group_id: sg_calibration - sorting_weight: 46 - - # ---- Custom polynomial (only used by the Custom profile) --------- - # theta (m3/m3) = a*R^3 + b*R^2 + c*R + d, R = raw counts. Defaults - # are the TEROS-12 soilless curve. Type values in the box, the - # slider step is only for nudging. - - platform: template - name: "Custom Poly a (R^3)" - id: custom_a - optimistic: true - restore_value: true - initial_value: 0.0000000006771 - min_value: -0.00001 - max_value: 0.00001 - step: 0.0000000001 - mode: box - entity_category: config - icon: mdi:math-integral - disabled_by_default: true - web_server: - sorting_group_id: sg_calibration - sorting_weight: 50 - - - platform: template - name: "Custom Poly b (R^2)" - id: custom_b - optimistic: true - restore_value: true - initial_value: -0.000005105 - min_value: -0.01 - max_value: 0.01 - step: 0.0000001 - mode: box - entity_category: config - icon: mdi:math-integral - disabled_by_default: true - web_server: - sorting_group_id: sg_calibration - sorting_weight: 51 - - - platform: template - name: "Custom Poly c (R)" - id: custom_c - optimistic: true - restore_value: true - initial_value: 0.01302 - min_value: -1.0 - max_value: 1.0 - step: 0.00001 - mode: box - entity_category: config - icon: mdi:math-integral - disabled_by_default: true - web_server: - sorting_group_id: sg_calibration - sorting_weight: 52 - - - platform: template - name: "Custom Poly d (const)" - id: custom_d - optimistic: true - restore_value: true - initial_value: -10.848 - min_value: -100.0 - max_value: 100.0 - step: 0.001 - mode: box - entity_category: config - icon: mdi:math-integral - disabled_by_default: true - web_server: - sorting_group_id: sg_calibration - sorting_weight: 53 - -# ------------------------------------------------------------------- -# Calibration scripts and buttons -# ------------------------------------------------------------------- -script: - - id: apply_substrate_defaults - then: - - lambda: |- - int p = id(g_substrate_idx); - float fc, e0, bl, bh; - if (p == 1) { // Coco - fc = 58.0f; e0 = 4.1f; bl = 35.0f; bh = 55.0f; - } else if (p == 2) { // Peat - fc = 50.0f; e0 = 4.1f; bl = 35.0f; bh = 55.0f; - } else if (p == 3) { // Mineral Soil - fc = 42.0f; e0 = 4.1f; bl = 25.0f; bh = 45.0f; - } else if (p == 4) { // Custom - return; // leave a custom setup alone - } else { // Rockwool - fc = 65.0f; e0 = 4.1f; bl = 40.0f; bh = 60.0f; - } - id(field_capacity).make_call().set_value(fc).perform(); - id(hilhorst_e0).make_call().set_value(e0).perform(); - id(pwec_blend_low).make_call().set_value(bl).perform(); - id(pwec_blend_high).make_call().set_value(bh).perform(); - ESP_LOGI("cal", "Applied defaults: FC %.0f, e0 %.1f, blend %.0f-%.0f", - fc, e0, bl, bh); - + - lambda: id(ec_current_frame)=std::isfinite(x); +- platform: template + id: vwc_poly + name: VWC generic estimate + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + entity_category: diagnostic + web_server: + sorting_group_id: sg_diag +- platform: template + id: vwc_weighed + name: VWC two-point estimate + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + entity_category: diagnostic + web_server: + sorting_group_id: sg_diag +- platform: template + id: vwc + name: VWC + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + web_server: + sorting_group_id: sg_live +- platform: template + id: wet_index + name: Wet reference index + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: index + web_server: + sorting_group_id: sg_live +- platform: template + id: wet_drop + name: Drop from wet reference + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: index points + web_server: + sorting_group_id: sg_live +- platform: template + id: permittivity + name: Apparent permittivity + update_interval: never + accuracy_decimals: 2 + state_class: measurement + entity_category: diagnostic + web_server: + sorting_group_id: sg_diag +- platform: template + id: pwec + name: Experimental pore EC estimate + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: dS/m + entity_category: diagnostic + web_server: + sorting_group_id: sg_diag +- platform: template + id: raw_average + name: Capture RAW average + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: raw_spread + name: Capture RAW spread + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: sample_age + name: RAW sample age + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: s + entity_category: diagnostic + web_server: + sorting_group_id: sg_diag +- platform: template + id: validation_error + name: Third-point error + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: pp + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_wet_raw + name: Saved Wet RAW + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_a_raw + name: Saved A RAW + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_a_vwc + name: Saved A VWC + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_b_raw + name: Saved B RAW + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_b_vwc + name: Saved B VWC + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_c_raw + name: Saved Check RAW + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: counts + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: template + id: saved_c_vwc + name: Saved Check VWC + update_interval: never + accuracy_decimals: 2 + state_class: measurement + unit_of_measurement: '%' + entity_category: diagnostic + web_server: + sorting_group_id: sg_calibration +- platform: uptime + name: Uptime + device_class: duration + state_class: total_increasing button: - - platform: template - name: "Capture Dry Point" - id: btn_cap_dry - entity_category: config - icon: mdi:water-off - web_server: - sorting_group_id: sg_calibration - sorting_weight: 60 - on_press: - - lambda: |- - float p = id(vwc_poly).state; - if (isnan(p)) { ESP_LOGW("cal", "No reading yet"); return; } - id(g_cal_dry_poly) = p; - ESP_LOGI("cal", "Dry point captured: uncalibrated VWC %.2f", p); - - - platform: template - name: "Capture Saturated Point" - id: btn_cap_wet - entity_category: config - icon: mdi:water - web_server: - sorting_group_id: sg_calibration - sorting_weight: 61 - on_press: - - lambda: |- - float p = id(vwc_poly).state; - if (isnan(p)) { ESP_LOGW("cal", "No reading yet"); return; } - id(g_cal_wet_poly) = p; - ESP_LOGI("cal", "Saturated point captured: uncalibrated VWC %.2f", p); - - - platform: template - name: "Apply VWC Calibration" - id: btn_apply_vwc - entity_category: config - icon: mdi:check-decagram - web_server: - sorting_group_id: sg_calibration - sorting_weight: 62 - on_press: - - lambda: |- - float dry = id(g_cal_dry_poly); - float wet = id(g_cal_wet_poly); - float ref = id(sat_reference).state; - if (!isnan(dry) && !isnan(wet)) { - if (fabsf(wet - dry) < 2.0f) { - ESP_LOGW("cal", "Dry (%.2f) and saturated (%.2f) too close, aborting", dry, wet); - return; - } - // Oven-dry is true 0, saturated is the reference value. - float gain = ref / (wet - dry); - float off = -dry * gain; - id(vwc_gain).make_call().set_value(gain).perform(); - id(vwc_offset).make_call().set_value(off).perform(); - ESP_LOGI("cal", "Two-point cal: gain %.3f, offset %.2f", gain, off); - } else if (!isnan(wet)) { - // One point: keep gain, shift so saturated reads the ref. - float gain = id(vwc_gain).state; - float off = ref - wet * gain; - id(vwc_offset).make_call().set_value(off).perform(); - ESP_LOGI("cal", "Single-point cal: offset %.2f", off); - } else { - ESP_LOGW("cal", "Capture at least the saturated point first"); - } - - - platform: template - name: "Calibrate EC to Reference" - id: btn_cal_ec - entity_category: config - icon: mdi:test-tube - web_server: - sorting_group_id: sg_calibration - sorting_weight: 63 - on_press: - - lambda: |- - // Probe sitting in a known EC solution reads bulk EC close - // to the solution EC. Set the gain so it matches. - float us = id(bulk_ec_us_raw).state; - if (isnan(us) || us < 50.0f) { - ESP_LOGW("cal", "EC reading too low, is the probe in solution?"); - return; - } - float raw_ds = us / 1000.0f; - float ref = id(ec_reference).state; - float gain = ref / raw_ds; - id(ec_gain).make_call().set_value(gain).perform(); - id(ec_offset_us).make_call().set_value(0.0f).perform(); - ESP_LOGI("cal", "EC calibrated to %.3f dS/m, gain %.3f", ref, gain); - - - platform: template - name: "Reset to Substrate Defaults" - id: btn_reset_defaults - entity_category: config - icon: mdi:backup-restore - web_server: - sorting_group_id: sg_calibration - sorting_weight: 70 - on_press: - - script.execute: apply_substrate_defaults - - - platform: template - name: "Reset VWC Calibration" - id: btn_reset_vwc - entity_category: config - icon: mdi:eraser - web_server: - sorting_group_id: sg_calibration - sorting_weight: 71 - on_press: - - lambda: |- - id(vwc_gain).make_call().set_value(1.0f).perform(); - id(vwc_offset).make_call().set_value(0.0f).perform(); - id(g_cal_dry_poly) = NAN; - id(g_cal_wet_poly) = NAN; - ESP_LOGI("cal", "VWC calibration reset"); - - - platform: template - name: "Reset EC Calibration" - id: btn_reset_ec - entity_category: config - icon: mdi:eraser - web_server: - sorting_group_id: sg_calibration - sorting_weight: 72 - on_press: - - lambda: |- - id(ec_gain).make_call().set_value(1.0f).perform(); - id(ec_offset_us).make_call().set_value(0.0f).perform(); - ESP_LOGI("cal", "EC calibration reset"); - - - platform: restart - name: "Restart" - entity_category: diagnostic - web_server: - sorting_group_id: sg_diag - sorting_weight: 95 - - - platform: safe_mode - name: "Restart (Safe Mode)" - entity_category: diagnostic - disabled_by_default: true - web_server: - sorting_group_id: sg_diag - sorting_weight: 96 - -text_sensor: - - platform: template - name: "Active Substrate" - id: active_substrate - update_interval: 60s - icon: mdi:layers-triple - entity_category: diagnostic - web_server: - sorting_group_id: sg_diag - sorting_weight: 5 - lambda: |- - switch (id(g_substrate_idx)) { - case 1: return {"Coco"}; - case 2: return {"Peat"}; - case 3: return {"Mineral Soil"}; - case 4: return {"Custom"}; - default: return {"Rockwool"}; +- platform: template + name: Save wet reference + entity_category: config + on_press: + - script.execute: + id: capture_reference + kind: 0 + web_server: + sorting_group_id: sg_calibration +- platform: template + name: Capture weighed point A + entity_category: config + on_press: + - script.execute: + id: capture_reference + kind: 1 + web_server: + sorting_group_id: sg_calibration +- platform: template + name: Capture weighed point B + entity_category: config + on_press: + - script.execute: + id: capture_reference + kind: 2 + web_server: + sorting_group_id: sg_calibration +- platform: template + name: Check independent weighed point C + entity_category: config + on_press: + - script.execute: + id: capture_reference + kind: 3 + web_server: + sorting_group_id: sg_calibration +- platform: template + name: Restart capture window + entity_category: config + on_press: + - script.execute: restart_capture + web_server: + sorting_group_id: sg_calibration +- platform: template + name: Clear calibration references + entity_category: config + on_press: + - script.execute: clear_calibration + web_server: + sorting_group_id: sg_calibration +- platform: restart + name: Restart + entity_category: diagnostic +- platform: safe_mode + name: Restart Safe Mode + entity_category: diagnostic +script: +- id: restart_capture + then: + - lambda: |- + id(raw_count)=0; id(raw_pos)=0; id(cal_revision)++; + id(vwc_ready).publish_state(false); id(vwc).publish_state(NAN); + id(capture_ready).publish_state(false); + id(last_action).publish_state("Window restarted. Wait for 10 new readings before capture."); +- id: clear_calibration + then: + - script.execute: restart_capture + - lambda: |- + id(wet_raw)=NAN; id(a_raw)=NAN; id(a_vwc)=NAN; + id(b_raw)=NAN; id(b_vwc)=NAN; id(c_raw)=NAN; id(c_vwc)=NAN; + id(cal_revision)++; + id(vwc_ready).publish_state(false); id(vwc).publish_state(NAN); + id(last_action).publish_state("References cleared. Wait 10 seconds before power off."); + - script.execute: publish_readings +- id: capture_reference + parameters: + kind: int + then: + - lambda: |- + if (!id(calibration_mode).state) { + id(last_action).publish_state("Turn Calibration mode ON before capturing references."); return; } - - - platform: version - name: "ESPHome Version" - entity_category: diagnostic - disabled_by_default: true - web_server: - sorting_group_id: sg_diag - sorting_weight: 97 + if (!id(raw_seen) || (uint32_t)(millis()-id(last_raw_ms)) > ${sample_timeout_ms}U || id(raw_count)<10) { + id(last_action).publish_state("Not saved: wait for 10 fresh readings."); return; + } + float lo=id(raw_window)[0], hi=lo, sum=0; + for (float r:id(raw_window)) {lo=std::min(lo,r); hi=std::max(hi,r); sum+=r;} + if (!std::isfinite(id(capture_spread_limit).state) || hi-lo > id(capture_spread_limit).state) { + id(last_action).publish_state("Not saved: RAW is still changing. Check contact and wait."); return; + } + const float r=sum/10.0f; + if (kind==0) { + id(wet_raw)=r; + id(last_action).publish_state("Wet reference saved. Index 100 is not 100% VWC. Wait 10s before power off."); + } else { + const float v=id(weighed_input).state; + if (!std::isfinite(v) || v<=0 || v>=100) { + id(last_action).publish_state("Not saved: enter a measured VWC between 0 and 100 first."); return; + } + if (kind==1) {id(a_raw)=r; id(a_vwc)=v; id(c_raw)=NAN; id(c_vwc)=NAN;} + if (kind==2) {id(b_raw)=r; id(b_vwc)=v; id(c_raw)=NAN; id(c_vwc)=NAN;} + if (kind==3) {id(c_raw)=r; id(c_vwc)=v;} + id(weighed_input).make_call().set_value(0).perform(); + id(cal_revision)++; + id(last_action).publish_state("Point saved. Check Calibration status and saved values. Wait 10s before power off."); + } + - script.execute: publish_readings +- id: publish_readings + mode: restart + then: + - delay: 100ms + - lambda: |- + // BEGIN TESTABLE MATH + auto generic = [](double R, bool mineral) -> double { + if (!std::isfinite(R) || R <= 0 || R > 4095) return NAN; + return mineral ? 100.0 * (0.0003879 * R - 0.6956) : + 100.0 * (((6.771e-10 * R - 5.105e-6) * R + 1.302e-2) * R - 10.848); + }; + auto fit = [&](double R, double ar, double av, double br, double bv, bool mineral) -> double { + if (!std::isfinite(ar) || !std::isfinite(br) || !std::isfinite(av) || !std::isfinite(bv) || + ar <= 0 || br <= 0 || ar > 4095 || br > 4095 || av <= 0 || bv <= 0 || av >= 100 || bv >= 100 || + std::abs(ar-br) < 100 || std::abs(av-bv) < 10 || (ar-br)*(av-bv) <= 0 || + !std::isfinite(R) || R < std::min(ar,br) || R > std::max(ar,br)) return NAN; + const double ga=generic(ar,mineral), gb=generic(br,mineral); + if (!std::isfinite(ga) || !std::isfinite(gb) || std::abs(gb-ga) < 0.1) return NAN; + const double v=av+(generic(R,mineral)-ga)*(bv-av)/(gb-ga); + return std::isfinite(v) && v >= 0 && v <= 100 ? v : NAN; + }; + auto check = [&](double ar, double av, double br, double bv, double cr, double cv, + double tolerance, bool mineral) -> double { + const double margin = 0.1 * std::abs(ar-br); + if (!std::isfinite(cr) || !std::isfinite(cv) || cv <= 0 || cv >= 100 || + !std::isfinite(tolerance) || tolerance <= 0 || tolerance > 10 || + cr <= std::min(ar,br)+margin || cr >= std::max(ar,br)-margin) return NAN; + return fit(cr,ar,av,br,bv,mineral)-cv; + }; + // END TESTABLE MATH + const bool mineral=id(substrate_profile).current_option() == "Mineral soil"; + const uint32_t age=millis()-id(last_raw_ms); + const bool fresh=id(raw_seen) && age <= ${sample_timeout_ms}U; + id(sensor_fresh).publish_state(fresh); + id(sample_age).publish_state(id(raw_seen) ? age/1000.0f : NAN); + float average=NAN, spread=NAN; + if (fresh && id(raw_count)>0) { + float lo=id(raw_window)[0], hi=lo, sum=0; + for(int i=0;i=0 && g<=100 ? g : NAN); + const double wet=generic(id(wet_raw),mineral); + const double idx=std::isfinite(wet) && wet>5 && std::isfinite(g) && g>=0 ? 100*g/wet : NAN; + id(wet_index).publish_state(idx); id(wet_drop).publish_state(std::isfinite(idx) ? 100-idx : NAN); + const double fitted=fit(R,ar,av,br,bv,mineral); + id(vwc_weighed).publish_state(fitted); + const bool checked=std::isfinite(err) && std::abs(err)<=id(validation_tolerance).state; + const bool ready=fresh && id(raw_count)>=3 && checked && std::isfinite(fitted) && !id(calibration_mode).state; + id(vwc_ready).publish_state(ready); + // Publish headline VWC last: analytics consumes its fresh validity state. + const double inner=((2.887e-9*R-2.080e-5)*R+5.276e-2)*R-43.39; + const double eb=inner*inner; + id(permittivity).publish_state(std::isfinite(eb) && eb>=1 && eb<=100 ? eb : NAN); + // MT22 EC is already normalised to 25 C. Use water permittivity at 25 C. + // This optional Hilhorst output remains a model, not a measured pore EC. + const double denom=eb-id(hilhorst_e0).state, bulk=id(bulk_ec_25).state; + const double model=bulk*78.45/denom; + const bool model_ok=id(enable_pwec).state && id(ec_current_frame) && ready && fitted>=id(pwec_min_vwc).state && + std::isfinite(bulk) && bulk>=0 && std::isfinite(eb) && eb>=1 && eb<=100 && denom>0.5 && + std::isfinite(model) && model>=0 && model<=30; + id(pwec).publish_state(model_ok ? model : NAN); + if (!fresh) id(calibration_status).publish_state("No fresh RAW data. Check wiring, power and sensor."); + else if (id(calibration_mode).state) id(calibration_status).publish_state(checked ? + "Third point passed. Turn Calibration mode OFF after returning to final placement." : + "Calibration mode: headline VWC and trends paused. Capture A, B and independent C."); + else if (!std::isfinite(ar) || !std::isfinite(br)) id(calibration_status).publish_state( + "Weighed A/B missing. Wet index and generic estimate are not calibrated VWC."); + else if (!checked) id(calibration_status).publish_state( + "A/B or independent C not valid, or check error exceeds tolerance. VWC withheld."); + else if (!std::isfinite(fitted)) id(calibration_status).publish_state("Outside weighed A/B range. VWC withheld."); + else if (!ready) id(calibration_status).publish_state("Waiting for three new readings after reset."); + else id(calibration_status).publish_state("VWC: weighed A/B with third-point check. Valid only for this substrate and placement."); + id(vwc).publish_state(ready ? fitted : NAN); +interval: +- interval: 5s + then: + - script.execute: publish_readings diff --git a/esphome/tdr-sensor-atom-lite.yaml b/esphome/tdr-sensor-atom-lite.yaml index a7ff9cb..2fefff2 100644 --- a/esphome/tdr-sensor-atom-lite.yaml +++ b/esphome/tdr-sensor-atom-lite.yaml @@ -14,7 +14,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/tdr-sensor-atom-poe.yaml b/esphome/tdr-sensor-atom-poe.yaml index e839cbe..cd75f02 100644 --- a/esphome/tdr-sensor-atom-poe.yaml +++ b/esphome/tdr-sensor-atom-poe.yaml @@ -11,7 +11,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO26 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/tdr-sensor-atom-s3.yaml b/esphome/tdr-sensor-atom-s3.yaml index 89c434f..4c354c7 100644 --- a/esphome/tdr-sensor-atom-s3.yaml +++ b/esphome/tdr-sensor-atom-s3.yaml @@ -14,7 +14,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO1 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/tdr-sensor-esp32-generic.yaml b/esphome/tdr-sensor-esp32-generic.yaml index adf0013..56f9e15 100644 --- a/esphome/tdr-sensor-esp32-generic.yaml +++ b/esphome/tdr-sensor-esp32-generic.yaml @@ -16,7 +16,7 @@ substitutions: board: esp32dev sdi12_data_pin: GPIO16 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/esphome/tdr-sensor-m5-dial.yaml b/esphome/tdr-sensor-m5-dial.yaml index 478dd65..6603db1 100644 --- a/esphome/tdr-sensor-m5-dial.yaml +++ b/esphome/tdr-sensor-m5-dial.yaml @@ -14,7 +14,7 @@ substitutions: friendly_name: TDR Sensor sdi12_data_pin: GPIO2 sdi12_address: "0" - sample_interval: 10s + sample_interval: 30s timezone: Pacific/Auckland packages: diff --git a/lovelace/dashboard.yaml b/lovelace/dashboard.yaml index e050949..771f537 100644 --- a/lovelace/dashboard.yaml +++ b/lovelace/dashboard.yaml @@ -1,120 +1,72 @@ -# TDR Sensor dashboard -# ------------------------------------------------------------------- -# Paste this into a new dashboard: Settings -> Dashboards -> Add -# dashboard -> Edit -> three dots -> Raw configuration editor. -# -# The entity ids below assume the device name is "tdr-sensor", which -# gives ids like sensor.tdr_sensor_vwc. If you renamed the node, -# find and replace tdr_sensor with your name. - +# Replace tdr_sensor with the actual entity prefix after checking the entity registry. title: TDR Sensor views: - title: Substrate path: substrate icon: mdi:water-percent cards: - - type: horizontal-stack - cards: - - type: gauge - name: VWC - entity: sensor.tdr_sensor_vwc - unit: "%" - min: 0 - max: 100 - severity: - green: 45 - yellow: 25 - red: 0 - - type: gauge - name: Pore EC - entity: sensor.tdr_sensor_pore_ec - unit: dS/m - min: 0 - max: 12 - severity: - green: 2 - yellow: 8 - red: 10 - - type: gauge - name: Saturation - entity: sensor.tdr_sensor_saturation - unit: "%" - min: 0 - max: 110 - - type: entities - title: Crop steering + title: Measurement quality entities: - - entity: sensor.tdr_sensor_steering_mode - name: Steering mode - - entity: sensor.tdr_sensor_steering_confidence - name: Confidence - - entity: sensor.tdr_sensor_steering_index - name: Steering index - - entity: sensor.tdr_sensor_dryback_percent - name: Dryback now - - entity: sensor.tdr_sensor_max_dryback_today - name: Max dryback today - - entity: sensor.tdr_sensor_overnight_dryback - name: Overnight dryback - - entity: binary_sensor.tdr_sensor_irrigating - name: Irrigating - + - binary_sensor.tdr_sensor_vwc_ready + - binary_sensor.tdr_sensor_sensor_data_fresh + - sensor.tdr_sensor_calibration_status + - sensor.tdr_sensor_raw_sample_age - type: entities - title: Irrigation + title: Root-zone observations entities: - - entity: sensor.tdr_sensor_irrigations_today - name: Shots today - - entity: sensor.tdr_sensor_time_since_irrigation - name: Time since last shot - - entity: sensor.tdr_sensor_last_shot_size - name: Last shot size - - entity: sensor.tdr_sensor_peak_vwc - name: Peak VWC - - entity: sensor.tdr_sensor_trough_vwc - name: Trough VWC - - entity: sensor.tdr_sensor_ec_stacking - name: EC stacking since field capacity - + - sensor.tdr_sensor_vwc + - sensor.tdr_sensor_wet_reference_index + - sensor.tdr_sensor_bulk_ec_25c + - sensor.tdr_sensor_temperature + - sensor.tdr_sensor_water_trend + - sensor.tdr_sensor_dryback + - sensor.tdr_sensor_dryback_percent + - sensor.tdr_sensor_detected_wettings_today + - sensor.tdr_sensor_last_detected_rise - type: history-graph - title: VWC over time + title: Checked VWC hours_to_show: 48 entities: - - entity: sensor.tdr_sensor_vwc - - entity: sensor.tdr_sensor_peak_vwc - - entity: binary_sensor.tdr_sensor_irrigating - + - sensor.tdr_sensor_vwc + - sensor.tdr_sensor_peak_vwc + - sensor.tdr_sensor_trough_vwc - type: history-graph - title: Pore EC over time + title: Bulk EC at 25 C hours_to_show: 48 entities: - - entity: sensor.tdr_sensor_pore_ec - - entity: sensor.tdr_sensor_pore_ec_24h_average - + - sensor.tdr_sensor_bulk_ec_25c - title: Calibration path: calibration icon: mdi:tune cards: + - type: markdown + content: >- + Save a wet reference for an index. For actual VWC, capture weighed A/B + and a separately weighed C check. Follow the repository calibration + guide; an index of 100 is not 100% VWC. - type: entities - title: Substrate and VWC + title: Prepare and capture entities: - - entity: select.tdr_sensor_substrate_profile - - entity: number.tdr_sensor_field_capacity - - entity: number.tdr_sensor_vwc_gain - - entity: number.tdr_sensor_vwc_offset - - entity: number.tdr_sensor_saturated_reference - - entity: button.tdr_sensor_capture_dry_point - - entity: button.tdr_sensor_capture_saturated_point - - entity: button.tdr_sensor_apply_vwc_calibration - - entity: button.tdr_sensor_reset_to_substrate_defaults - + - select.tdr_sensor_substrate_profile + - switch.tdr_sensor_calibration_mode + - binary_sensor.tdr_sensor_capture_ready + - sensor.tdr_sensor_capture_raw_average + - sensor.tdr_sensor_capture_raw_spread + - button.tdr_sensor_save_wet_reference + - number.tdr_sensor_weighed_reference_vwc + - button.tdr_sensor_capture_weighed_point_a + - button.tdr_sensor_capture_weighed_point_b + - button.tdr_sensor_check_independent_weighed_point_c + - sensor.tdr_sensor_third_point_error + - sensor.tdr_sensor_last_calibration_action - type: entities - title: EC + title: Saved references entities: - - entity: number.tdr_sensor_ec_gain - - entity: number.tdr_sensor_ec_offset - - entity: number.tdr_sensor_ec_reference_solution - - entity: number.tdr_sensor_hilhorst_e0 - - entity: number.tdr_sensor_pore_ec_blend_low - - entity: number.tdr_sensor_pore_ec_blend_high - - entity: button.tdr_sensor_calibrate_ec_to_reference + - sensor.tdr_sensor_saved_wet_raw + - sensor.tdr_sensor_saved_a_raw + - sensor.tdr_sensor_saved_a_vwc + - sensor.tdr_sensor_saved_b_raw + - sensor.tdr_sensor_saved_b_vwc + - sensor.tdr_sensor_saved_check_raw + - sensor.tdr_sensor_saved_check_vwc diff --git a/tests/calculator.test.js b/tests/calculator.test.js new file mode 100644 index 0000000..a4c782b --- /dev/null +++ b/tests/calculator.test.js @@ -0,0 +1,19 @@ +const test=require('node:test'),assert=require('node:assert/strict'); +const T=require('../tools/setup/calculator.js'),S=require('../tools/setup/substrates.js'); +const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`); +test('Hugo is based on real metric dimensions, not a perfect six-inch cube',()=>near(T.box(15,15,14.2),3.195)); +test('three Hugo blocks on 1m x 15cm x 7.5cm slab',()=>{const v=T.allocation(3.195,11.25,3,4);near(v.plant,6.945);near(v.unit,20.835);near(v.zone,83.34);assert.equal(v.plants,12);}); +test('100 x 20 x 10 shared slab counts once',()=>near(T.allocation(3.195,T.box(100,20,10),3,1).plant,9.861666666666666)); +test('cube only and shared coco allocation',()=>{near(T.allocation(3.195,0,1,1).plant,3.195);near(T.allocation(0,10,2,1).plant,5);}); +test('US and Imperial gallons stay distinct',()=>{near(T.litres(3,'USgal'),11.356235352);near(T.litres(3,'Impgal'),13.63827);}); +test('cylinder and truncated-cone volume',()=>{near(T.taperedPot(20,20,30),3*Math.PI);near(T.taperedPot(30,20,25),25*Math.PI*1900/12000);}); +test('weighed water volume and tare',()=>{const v=T.weighed(500,8000,11.25);near(v.waterMl,7500);near(v.vwc,66.66666666666667);near(T.weighed(600,8100,11.25).vwc,v.vwc);}); +test('weighed density correction',()=>near(T.weighed(100,1098,2,.998).vwc,50)); +test('shot uses emitter total flow per plant',()=>{const s=T.shot(6.945,2,2,2);near(s.ml,138.9);near(s.seconds,125.01);}); +test('dryback points differ from relative percent',()=>{const d=T.dryback(70,60);near(d.points,10);near(d.relative,100/7);}); +test('rising VWC produces negative dryback, not a hidden clamp',()=>near(T.dryback(60,70).points,-10)); +test('invalid geometry, fractions, missing values and impossible water rejected',()=>{ + for(const f of [()=>T.box(0,10,10),()=>T.allocation(1,2,0,1),()=>T.allocation(1,2,1.5,1),()=>T.allocation(0,0,1,1),()=>T.box(NaN,1,1),()=>T.weighed(500,400,1),()=>T.weighed(10,2010,1),()=>T.shot(1,2,0,2),()=>T.litres(3,'gallons'),()=>T.weighed(-1,500,1),()=>T.box('10',10,10),()=>T.dryback(0,0)])assert.throws(f); +}); +test('every preset has valid dimensions and unique key within its group',()=>{for(const entries of Object.values(S)){const ids=new Set;for(const [id,label,l,w,h] of entries){assert.ok(!ids.has(id));ids.add(id);assert.ok(label);assert.ok(T.box(l,w,h)>0);}}}); +test('CSV escapes quoted labels and neutralises formulas',()=>{assert.equal(T.escapeCsv('a"b'),'"a""b"');assert.equal(T.escapeCsv('=1+1'),'"\'=1+1"');assert.equal(T.escapeCsv('plain'),'"plain"');}); diff --git a/tests/check_configs.py b/tests/check_configs.py new file mode 100644 index 0000000..a57e9ab --- /dev/null +++ b/tests/check_configs.py @@ -0,0 +1,48 @@ +"""Validate all configurations without reading or overwriting deployment secrets.""" +from pathlib import Path +import base64, shutil, subprocess, sys, tempfile + +ROOT=Path(__file__).resolve().parents[1] +def main(): + with tempfile.TemporaryDirectory(prefix='tdr-configs-') as folder: + dest=Path(folder)/'esphome' + shutil.copytree(ROOT/'esphome',dest,ignore=shutil.ignore_patterns('.esphome','secrets.yaml')) + key=base64.b64encode(bytes(32)).decode() + (dest/'secrets.yaml').write_text(f'''wifi_ssid: validation-only +wifi_password: validation-only-password +mqtt_broker: 127.0.0.1 +mqtt_username: validation-only +mqtt_password: validation-only-password +api_encryption_key: "{key}" +ota_password: validation-only-password +web_username: validation-only +web_password: validation-only-password +fallback_ap_password: validation-only-password +''') + configs=sorted(dest.glob('tdr-sensor-*.yaml'))+sorted((dest/'factory').glob('*-factory.yaml')) + private=dest/'validation-private.yaml' + private.write_text('''packages: + base: !include tdr-sensor-atom-lite.yaml + mqtt: !include packages/tdr_mqtt.yaml +api: + encryption: + key: !secret api_encryption_key +ota: + - platform: esphome + password: !secret ota_password +web_server: + auth: + username: !secret web_username + password: !secret web_password +wifi: + ap: + password: !secret fallback_ap_password +''') + configs.append(private) + for config in configs: + result=subprocess.run([sys.executable,'-m','esphome','config',str(config)],capture_output=True,text=True,encoding='utf-8',errors='replace') + if result.returncode: + print(result.stdout);print(result.stderr);raise SystemExit(result.returncode) + print(f'Validated {config.relative_to(dest)}',flush=True) + print(f'{len(configs)} configurations validated; temporary dummy secrets removed.') +if __name__=='__main__':main() diff --git a/tests/test_firmware.py b/tests/test_firmware.py new file mode 100644 index 0000000..14e3e16 --- /dev/null +++ b/tests/test_firmware.py @@ -0,0 +1,124 @@ +"""Compile and execute the actual firmware lambdas on the host. + +No copied calibration/analytics implementation: YAML is the source under test. +Usage: python tests/test_firmware.py [--cxx /path/to/zig.exe] +Requires PyYAML and a C++17 compiler (g++ or clang++; zig c++ on Windows). +""" +from pathlib import Path +import argparse, re, subprocess, tempfile, yaml + +ROOT=Path(__file__).resolve().parents[1] +def script(doc, name): + return next(x for x in doc['script'] if x['id']==name)['then'][-1]['lambda'] + +def source(): + core=yaml.safe_load((ROOT/'esphome/packages/tdr_sdi12_core.yaml').read_text(encoding='utf-8')) + analytics=yaml.safe_load((ROOT/'esphome/packages/tdr_analytics.yaml').read_text(encoding='utf-8')) + body=script(core,'publish_readings') + math=body.split('// BEGIN TESTABLE MATH\n')[1].split('// END TESTABLE MATH')[0] + process=script(analytics,'tdr_process') + raw=core['sensor'][0]['sensors'][0]['on_value'][0]['lambda'].replace('${sample_timeout_ms}','90000') + publish=body.replace('${sample_timeout_ms}','90000') + capture=next(x for x in core['script'] if x['id']=='capture_reference')['then'][0]['lambda'].replace('${sample_timeout_ms}','90000') + declarations=[] + for n in core['number']: + declarations.append(f"Sensor {n['id']}{{{float(n['initial_value'])}f}};") + for n in core['sensor'][1:]: + if n.get('id') and n['id']!='vwc': declarations.append(f"Sensor {n['id']};") + declarations.extend(['Sensor raw_counts, bulk_ec_25;', 'Binary calibration_mode, enable_pwec, sensor_fresh, capture_ready;', 'Text substrate_profile, calibration_status, last_action;']) + for g in analytics['globals']+core['globals']: + declarations.append(f"{g['type']} {g['id']} = {g['initial_value']};") + return r''' +#include +#include +#include +#include +#include +#include +#include +#include +using std::isnan; +#define id(x) x +uint32_t now_ms=0; +uint32_t millis(){return now_ms;} +struct Sensor {float state=NAN;void publish_state(float s){state=s;} + struct Call{Sensor* sensor;float value=0;Call& set_value(float v){value=v;return *this;}void perform(){sensor->state=value;}}; + Call make_call(){return {this};} +}; +struct Text {std::string state="Rockwool cube on slab";void publish_state(const char* v){state=v;} std::string current_option(){return state;}}; +struct Binary {bool state=false;void publish_state(bool s){state=s;}}; +struct Time {struct Stamp {bool is_valid(){return true;}}; Stamp now(){return {};}}; +Sensor vwc, rise_threshold{1.5f}, fall_confirm{.8f}, peak_confirm_min{10}, irr_window_min{20}; +Binary vwc_ready; +Time tdr_time; +'''+'\n'.join(declarations)+'\nvoid publish(){\n'+publish+'\n}\nvoid capture(int kind){\n'+capture+'\n}\nvoid process(){\n'+process+'\n}\nvoid receive(float x){\n'+raw+'\n}\nint main(){\n'+math+r''' +auto near=[](double a,double b){assert(std::abs(a-b)<1e-6);}; +assert(std::isnan(generic(NAN,false))); assert(std::isnan(generic(4096,false))); +near(fit(2800,2800,40,3200,80,false),40); +near(fit(3200,2800,40,3200,80,false),80); +near(fit(3000,2800,40,3200,80,false),fit(3000,3200,80,2800,40,false)); +assert(std::isnan(fit(2700,2800,40,3200,80,false))); +assert(std::isnan(fit(3250,2800,40,3200,80,false))); +assert(std::isnan(fit(3000,2800,80,3200,40,false))); +assert(std::isnan(fit(2820,2800,40,2850,80,false))); +assert(std::isnan(fit(3000,2800,40,3200,45,false))); +assert(std::isnan(fit(3000,2800,0,3200,80,false))); +assert(std::isnan(fit(3000,NAN,40,3200,80,false))); +const auto middle=fit(3000,2800,40,3200,80,false); +near(check(2800,40,3200,80,3000,middle,3,false),0); +near(check(2800,40,3200,80,3000,middle+5,3,false),-5); +assert(std::isnan(check(2800,40,3200,80,2800,40,3,false))); +assert(std::isnan(check(2800,40,3200,80,2839,45,3,false))); +assert(std::isnan(check(2800,40,3200,80,3000,middle,0,false))); +near(fit(3000,2800,40,3200,80,true),60); +// Identical but freshly delivered RAW samples remain live and accumulate. +for(int i=0;i<10;i++){now_ms+=30000;receive(3000);} +assert(raw_seen && raw_count==10 && raw_window[0]==3000); +now_ms+=100000; receive(3000); assert(raw_count==1); +receive(NAN);assert(!raw_seen && raw_count==0); +now_ms=std::numeric_limits::max()-10000;receive(3000); +now_ms=20000;receive(3000);assert(raw_count==2); // unsigned rollover +// Full publication rejects uncalibrated RAW despite a plausible generic result. +now_ms=1000;raw_counts.state=3000;receive(3000);publish();assert(!vwc_ready.state && std::isnan(vwc.state)); +// Capture requires an enabled calibration mode, a full fresh window, stability and a weighed input. +weighed_input.state=40;capture(1);assert(std::isnan(a_raw)); +calibration_mode.state=true;capture(1);assert(std::isnan(a_raw)); +for(int i=0;i<10;i++){now_ms+=30000;raw_counts.state=2800;receive(2800);}capture(1);near(a_raw,2800);near(a_vwc,40);near(weighed_input.state,0); +weighed_input.state=80;for(int i=0;i<10;i++){now_ms+=30000;raw_counts.state=3200;receive(3200);}capture(2);near(b_raw,3200); +publish();assert(std::isnan(vwc.state)); +weighed_input.state=middle;for(int i=0;i<10;i++){now_ms+=30000;raw_counts.state=3000;receive(3000);}capture(3);publish();assert(std::isnan(vwc.state)); +calibration_mode.state=false;publish();assert(vwc_ready.state);near(vwc.state,middle);assert(std::isnan(pwec.state)); +now_ms+=90001;publish();assert(!vwc_ready.state && !sensor_fresh.state && std::isnan(vwc.state)); +now_ms+=30000;raw_counts.state=3300;receive(3300);publish();assert(std::isnan(vwc.state)); +// Recapturing an endpoint invalidates the independent check. +calibration_mode.state=true;weighed_input.state=40; +for(int i=0;i<10;i++){now_ms+=30000;raw_counts.state=2800;receive(2800);}capture(1);assert(std::isnan(c_raw)); +// Discard the calibration-session revision before the independent analytics tests. +g_analytics_revision=cal_revision; +// Startup has no fabricated irrigation peak. +now_ms=1000;vwc_ready.state=true;vwc.state=50;process();assert(std::isnan(g_peak)); +// Rising VWC starts wetting, an exactly flat plateau completes it. +now_ms+=30000;vwc.state=52;process();assert(g_phase==1 && g_shots_today==1); +for(int i=0;i<21;i++){now_ms+=30000;process();} +assert(g_phase==0);near(g_peak,52);near(g_last_shot,2); +vwc.state=49;now_ms+=30000;process();near(g_max_dryback_today,3); +// Calibration scale changes and outages cannot be mistaken for wetting events. +cal_revision++;vwc.state=90;process();assert(std::isnan(g_peak) && g_shots_today==0); +vwc_ready.state=false;process();assert(g_phase==0 && std::isnan(g_trough)); +// Wetting timer also survives the millisecond counter wrap. +vwc_ready.state=true;now_ms=std::numeric_limits::max()-40000; +vwc.state=50;process();now_ms+=30000;vwc.state=53;process();assert(g_phase==1); +now_ms+=660000;process();assert(g_phase==0);near(g_peak,53); +std::cout << "Firmware calibration, RAW freshness, plateau, reset and rollover assertions passed.\n"; +} +''' + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('--cxx',default='g++');args=parser.parse_args() + with tempfile.TemporaryDirectory(prefix='tdr-tests-') as temp: + cpp=Path(temp)/'firmware_test.cpp';out=Path(temp)/('firmware_test.exe' if __import__('os').name=='nt' else 'firmware_test') + cpp.write_text(source(),encoding='utf-8') + compiler=[args.cxx]+(['c++'] if Path(args.cxx).stem=='zig' else []) + subprocess.run(compiler+['-std=c++17','-O0','-Wall','-Wextra',str(cpp),'-o',str(out)],check=True) + subprocess.run([str(out)],check=True) +if __name__=='__main__':main() diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..5b4b648 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,47 @@ +"""Repository contracts that protect calibration/automation meaning.""" +from pathlib import Path +import re, unittest, yaml + +ROOT=Path(__file__).resolve().parents[1] +class RepoTests(unittest.TestCase): + def test_local_document_links(self): + for path in [ROOT/'README.md',*(ROOT/'docs').glob('*.md')]: + for link in re.findall(r'\]\(([^)]+)\)',path.read_text(encoding='utf-8')): + link=link.split('#')[0] + if not link or '://' in link or link.startswith('mailto:'):continue + self.assertTrue((path.parent/link).exists(),f'{path.relative_to(ROOT)} -> {link}') + def test_no_direct_valve_blueprint(self): + doc=yaml.load((ROOT/'blueprints/automation/tdr_dryback_irrigation.yaml').read_text(),Loader=yaml.BaseLoader) + self.assertEqual(doc['action'][0]['action'],'button.press') + for input in ['enable_helper','vwc_ready','data_fresh','shot_button','max_age']: + self.assertIn(input,doc['blueprint']['input']) + self.assertEqual(doc['blueprint']['input']['shot_button']['selector']['entity']['filter']['domain'],'button') + self.assertNotIn('default',doc['blueprint']['input']['dryback_threshold']) + def test_core_contract(self): + core=yaml.safe_load((ROOT/'esphome/packages/tdr_sdi12_core.yaml').read_text(encoding='utf-8')) + self.assertEqual(core['substitutions']['sample_interval'],'30s') + self.assertEqual(core['substitutions']['sample_timeout'],'90s') + self.assertEqual(core['substitutions']['sample_timeout_ms'],'90000') + for component in core['external_components']: + self.assertRegex(component['source'],r'@[a-f0-9]{40}$') + switches={s['id']:s for s in core['switch']} + self.assertEqual(switches['enable_pwec']['restore_mode'],'ALWAYS_OFF') + self.assertEqual(switches['calibration_mode']['restore_mode'],'ALWAYS_OFF') + ids={s.get('id') for s in core['sensor']} + self.assertTrue({'vwc','wet_index','vwc_poly','vwc_weighed'}.issubset(ids)) + self.assertNotIn('ec_mass_diag',ids) + self.assertNotIn('sat_reference',{n['id'] for n in core['number']}) + def test_all_factory_boards_use_local_packages(self): + configs=list((ROOT/'esphome/factory').glob('*-factory.yaml')) + self.assertEqual(len(configs),5) + for path in configs: + data=yaml.load(path.read_text(),Loader=yaml.BaseLoader) + self.assertEqual(data['substitutions']['sample_interval'],'30s') + self.assertIn('core',data['packages']);self.assertIn('analytics',data['packages']) + def test_offline_ui_assets(self): + html=(ROOT/'tools/setup/index.html').read_text(encoding='utf-8') + for path in re.findall(r'(?:src|href)="([^"]+)"',html): + if path.startswith(('http','#')):continue + self.assertTrue((ROOT/'tools/setup'/path.split('#')[0]).exists(),path) + self.assertNotRegex(html,r']*src="https?://') +if __name__=='__main__':unittest.main() diff --git a/tools/setup/calculator.js b/tools/setup/calculator.js new file mode 100644 index 0000000..7a6125a --- /dev/null +++ b/tools/setup/calculator.js @@ -0,0 +1,67 @@ +/* Offline calculations. The browser and node tests run this same code. */ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.TDR = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + function positive(n, label) { + if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) throw new Error(`${label} must be greater than zero.`); + return n; + } + function nonnegative(n, label) { + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or greater.`); + return n; + } + function whole(n, label) { + positive(n, label); + if (!Number.isInteger(n)) throw new Error(`${label} must be a whole number.`); + return n; + } + function box(l, w, h) { return positive(l, 'Length') * positive(w, 'Width') * positive(h, 'Height') / 1000; } + function taperedPot(top, bottom, height) { + positive(top, 'Top diameter'); positive(bottom, 'Bottom diameter'); positive(height, 'Fill height'); + return Math.PI * height * (top * top + top * bottom + bottom * bottom) / 12000; + } + function litres(value, unit) { + positive(value, 'Volume'); + const factors = { L: 1, USgal: 3.785411784, Impgal: 4.54609 }; + if (!(unit in factors)) throw new Error('Choose litres, US gallons or Imperial gallons.'); + return value * factors[unit]; + } + function allocation(cubeL, baseL, plants, units) { + nonnegative(cubeL, 'Block volume'); nonnegative(baseL, 'Shared base volume'); + whole(plants, 'Plants per unit'); whole(units, 'Number of units'); + const unit = baseL + plants * cubeL; + positive(unit, 'Total substrate'); + return { plant: unit / plants, unit, zone: unit * units, plants: plants * units }; + } + function weighed(dry, wet, volumeL, density = 1) { + nonnegative(dry, 'Dry assembly mass'); positive(wet, 'Wet assembly mass'); + positive(volumeL, 'Sample volume'); positive(density, 'Water density'); + if (wet < dry) throw new Error('Wet mass cannot be below dry mass.'); + const waterMl = (wet - dry) / density; + const vwc = 100 * waterMl / (volumeL * 1000); + if (vwc > 100) throw new Error('Calculated VWC exceeds 100%. Check volume, tare and units.'); + return { waterMl, vwc }; + } + function shot(volumeL, percent, emitters, flowLh) { + positive(volumeL, 'Allocated substrate volume'); positive(percent, 'Shot percent'); + if (percent > 100) throw new Error('Shot percent cannot exceed 100.'); + whole(emitters, 'Emitters per plant'); positive(flowLh, 'Measured emitter flow'); + const ml = volumeL * 1000 * percent / 100; + return { ml, seconds: ml / (emitters * flowLh * 1000 / 3600) }; + } + function dryback(peak, current) { + positive(peak, 'Peak VWC'); nonnegative(current, 'Current VWC'); + if (peak > 100 || current > 100) throw new Error('VWC cannot exceed 100%.'); + return { points: peak - current, relative: 100 * (peak - current) / peak }; + } + function escapeCsv(value) { + // Block spreadsheet formula execution in user-entered record labels. + let s = String(value ?? ''); + if (/^[\s]*[=+@-]/.test(s)) s = "'" + s; + return '"' + s.replaceAll('"', '""') + '"'; + } + return { box, taperedPot, litres, allocation, weighed, shot, dryback, escapeCsv }; +}); diff --git a/tools/setup/favicon.svg b/tools/setup/favicon.svg new file mode 100644 index 0000000..4244b76 --- /dev/null +++ b/tools/setup/favicon.svg @@ -0,0 +1 @@ + diff --git a/tools/setup/index.html b/tools/setup/index.html new file mode 100644 index 0000000..2d1aa8b --- /dev/null +++ b/tools/setup/index.html @@ -0,0 +1,24 @@ + + +TDR Sensor · Substrate & calibration desk + +
TDR SENSOR FIELD TOOLS / 03

Know the volume.
Measure the water.

A practical setup desk for blocks, shared slabs and coco. Everything runs on this device; entries stay here.

+
01 / YOUR ROOT ZONE

Substrate volume

Use physical growing-medium volume. A shared slab is counted once, then divided between its plants.

+
+
Block above each plant's root zone
+
Shared slab
+ +
+
+
Optional shot-volume calculation

This converts an operator-selected shot percentage into volume and time. It does not recommend an irrigation target or predict the VWC rise.

+

Grodan block dimensions: Precision Irrigation guide, pages 5–6. The 1 m entries are geometric examples; check your Prestige label. Nominal six-inch Hugo volume is 3.195 L from 15 × 15 × 14.2 cm.

+ +
02 / CALIBRATION BENCH

Wet reference or actual VWC?

Quick wet reference

Place the sensor, wet the medium uniformly, let free drainage settle and turn Calibration mode on. Wait for Capture ready, then press Save wet reference.

The index becomes 100. It is a repeatable instrument reference, not 100% water, field capacity, or water remaining. Turn calibration mode off after capture.

Weighed VWC calibration

Use a spare unplanted sample of the same medium and geometry. Capture two weighed moisture levels, A and B, bracketing the intended range. Check a third independent level C.

Headline VWC becomes available only after the check passes and calibration mode is off. Keep the sensor position fixed throughout the measurements.

+

This is the sample on the scale, NOT the allocated litres per plant above. Do not include cubes unless they are part of the weighed, representative calibration specimen.

Water density

1 is a practical water approximation. Consistent tare, retained solution salts and spatial gradients can introduce larger errors; record the method.

+
Dryback: percentage points versus percent

A slab reading represents a local sensing region. Multiplying it by the entire slab-plus-cubes volume assumes uniform moisture and can give a false whole-root-zone water balance.

+ +
03 / POSITION & REPEAT

MT22 placement

For roots established in a slab, use the slab as the primary measurement. Keep a cube probe only when you want a separate upper-block reading. Starting flower week alone cannot prove rooting into the slab.

MT22 side insertion into a slab beside the middle of three blocks, with the long body level and all three 53 mm rods at one height. +

Placement for cubes, slabs and coco ↗ · Download 75 / 100 mm slab templates (PDF) ↗

+

Custom actual-size placement sheet

Enter the actual substrate height and your chosen rod height from the base. The suggested midpoint is a repeatable starting position, not an MT22 placement specification validated for every medium.

This A4 sheet supports heights 26–160 mm. For a taller container use a measured base datum and ruler. Pin pitch is not dimensioned by INFWIN: transfer the real probe pins onto the centreline, never guessed hole positions.

+
+ diff --git a/tools/setup/style.css b/tools/setup/style.css new file mode 100644 index 0000000..b77308c --- /dev/null +++ b/tools/setup/style.css @@ -0,0 +1 @@ +:root{color-scheme:light;--ink:#182f36;--muted:#586d73;--green:#216f5c;--paper:#f4f3ee;--line:#d8dfd9}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 system-ui,sans-serif}header,main{max-width:1160px;margin:auto}header{padding:48px 28px 38px}.brand{letter-spacing:.16em;font-size:13px;font-weight:800}.brand span{float:right;font-size:11px;color:var(--muted)}h1{font-family:Georgia,serif;font-weight:400;line-height:1.04;font-size:clamp(40px,6.5vw,78px);letter-spacing:-.04em;margin:35px 0 22px}header p{max-width:630px;color:var(--muted);font-size:18px}nav{display:flex;flex-wrap:wrap;gap:25px;padding-top:14px}a{color:var(--green);text-underline-offset:4px}main{padding:0 28px}section{padding:38px 0;border-top:1px solid var(--line);scroll-margin-top:15px}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.15em;color:var(--green)}h2{font-family:Georgia,serif;font-weight:400;font-size:34px;margin:9px 0 12px;line-height:1.2}h3{font-size:19px;margin:0 0 12px}p{margin:10px 0 20px}.grid{display:grid;grid-template-columns:1.25fr 1fr;gap:24px;margin:25px 0}.panel,.cards article{padding:26px;background:white;border:1px solid var(--line);border-radius:10px}.result{padding:28px;background:#e2eee6;border-radius:10px;align-self:start}.big{font-size:clamp(38px,5vw,64px);font-weight:600;letter-spacing:-.04em;line-height:1.3;margin:14px 0 22px}.two,.three{display:grid;gap:15px}.two{grid-template-columns:repeat(2,minmax(0,1fr))}.three{grid-template-columns:repeat(3,minmax(0,1fr))}label{display:block;font-size:13px;font-weight:650;margin:0 0 17px}input,select{display:block;font:inherit;font-size:15px;font-weight:400;width:100%;min-height:44px;border:1px solid #acbdb5;border-radius:5px;background:white;padding:10px;margin-top:6px;color:var(--ink)}input:focus,select:focus,button:focus{outline:3px solid #89bdaa;outline-offset:2px}fieldset{padding:15px 0 0;margin:12px 0;border:0;border-top:1px solid var(--line)}legend{font-size:13px;font-weight:700;padding-right:14px}.small{font-size:12px;color:var(--muted)}dl{margin:0}dt{font-size:12px;color:var(--muted);margin-top:15px}dd{font-size:24px;margin:0}.note{padding-top:18px;border-top:1px solid #bbd4c6;font-size:13px}.error{color:#9b241a;font-size:14px;font-weight:650}.error:empty{display:none}details{margin:22px 0;padding:18px 0;border-top:1px solid var(--line)}summary{cursor:pointer;font-weight:650;margin-bottom:10px}button{cursor:pointer;border:0;background:var(--green);color:white;border-radius:5px;min-height:44px;padding:12px 18px;font:inherit;font-size:14px;font-weight:650;margin:8px 8px 8px 0}button.secondary{background:white;color:var(--green);border:1px solid var(--green)}button:disabled{opacity:.5;cursor:default}.diagram{width:100%;height:auto;background:white;border:1px solid var(--line);border-radius:8px;margin:20px 0}footer{padding:30px 0 55px;color:var(--muted);font-size:13px}[hidden],#print-sheet{display:none!important}@media(max-width:720px){.grid,.cards{grid-template-columns:1fr}header{padding-top:28px}.brand span{float:none;display:block;margin-top:8px}.panel,.result{padding:20px}.three{gap:8px}main,header{padding-left:18px;padding-right:18px}}@page{size:A4 portrait;margin:0}@media print{body>*:not(#print-sheet){display:none!important}#print-sheet{display:block!important;width:210mm;height:297mm;background:white;overflow:hidden}#print-sheet svg{display:block;width:210mm;height:297mm}} diff --git a/tools/setup/substrates.js b/tools/setup/substrates.js new file mode 100644 index 0000000..7ba33e4 --- /dev/null +++ b/tools/setup/substrates.js @@ -0,0 +1,33 @@ +/* Dimensions are cm. Check the package label: nominal inches are not exact dimensions. + * Grodan source: https://www.grodan101.com/siteassets/downloads/grow-guide/chapter-4---precision-irrigation.pdf + * 1 m slab sizes are geometric examples, not a claim about every Prestige SKU. + */ +const SUBSTRATES = { + blocks: [ + ['custom', 'Custom measured block', 15, 15, 14.2], + ['gr4', 'Grodan GR4 / Delta: 7.5 × 7.5 × 6.5 cm', 7.5, 7.5, 6.5], + ['gr56', 'Grodan GR5.6: 7.5 × 7.5 × 10 cm', 7.5, 7.5, 10], + ['gr65', 'Grodan GR6.5: 10 × 10 × 6.5 cm', 10, 10, 6.5], + ['gr75', 'Grodan GR7.5: 10 × 10 × 7.5 cm', 10, 10, 7.5], + ['gr10', 'Grodan GR10: 10 × 10 × 10 cm', 10, 10, 10], + ['jumbo', 'Grodan Jumbo / GR22.5 (6 × 6 × 4 nominal)', 15, 15, 10], + ['hugo', 'Grodan Hugo / GR32 (6-inch nominal)', 15, 15, 14.2], + ['uniblock', 'Grodan Uniblock: 20 × 20 × 10 cm', 20, 20, 10], + ['bigmama', 'Grodan Big Mama: 20.3 × 20.3 × 20.3 cm', 20.3, 20.3, 20.3], + ['unislab', 'Grodan Uni-Slab: 24 × 19.5 × 10 cm', 24, 19.5, 10] + ], + slabs: [ + ['custom', 'Custom measured slab', 100, 15, 7.5], + ['s1001575', '1 m slab: 100 × 15 × 7.5 cm', 100, 15, 7.5], + ['s1001510', '1 m slab: 100 × 15 × 10 cm', 100, 15, 10], + ['s1002075', '1 m slab: 100 × 20 × 7.5 cm', 100, 20, 7.5], + ['s1002010', '1 m slab: 100 × 20 × 10 cm', 100, 20, 10], + ['s1003075', '1 m slab: 100 × 30 × 7.5 cm', 100, 30, 7.5], + ['s1003010', '1 m slab: 100 × 30 × 10 cm', 100, 30, 10], + ['s903', 'Grodan guide: 90 × 15 × 7.5 cm', 90, 15, 7.5], + ['s904', 'Grodan guide: 90 × 15 × 10 cm', 90, 15, 10], + ['s908', 'Grodan guide: 90 × 19.5 × 7.5 cm', 90, 19.5, 7.5], + ['s9012', 'Grodan guide: 90 × 30 × 7.5 cm', 90, 30, 7.5] + ] +}; +if (typeof module === 'object' && module.exports) module.exports = SUBSTRATES; diff --git a/tools/setup/ui.js b/tools/setup/ui.js new file mode 100644 index 0000000..6e0ee23 --- /dev/null +++ b/tools/setup/ui.js @@ -0,0 +1,68 @@ +'use strict'; +const $ = id => document.getElementById(id); +const value = id => { const s=$(id).value.trim(); return s === '' ? NaN : Number(s); }; +const fmt = (v, d=3) => Number(v).toLocaleString(undefined,{maximumFractionDigits:d}); +let allocation=null, weighed=null; +const records=[]; +function setPresets(id, entries, selected) { + for(const [key,label] of entries) $(id).add(new Option(label,key,key===selected,key===selected)); +} +setPresets('block-preset',SUBSTRATES.blocks,'hugo');setPresets('slab-preset',SUBSTRATES.slabs,'s1001575'); +function loadPreset(id, entries, prefix) { + const entry=entries.find(x=>x[0]===$(id).value); + if(entry && entry[0]!=='custom') ['l','w','h'].forEach((x,i)=>$(prefix+x).value=entry[i+2]); + calculate(); +} +$('block-preset').addEventListener('change',()=>loadPreset('block-preset',SUBSTRATES.blocks,'b')); +$('slab-preset').addEventListener('change',()=>loadPreset('slab-preset',SUBSTRATES.slabs,'s')); +for(const prefix of ['b','s']) for(const axis of ['l','w','h']) $(prefix+axis).addEventListener('input',()=>$(prefix==='b'?'block-preset':'slab-preset').value='custom'); +$('pot-preset').addEventListener('change',()=>{if($('pot-preset').value!=='custom'){$('pv').value=$('pot-preset').value;$('pu').value='L';}calculate();}); +$('pv').addEventListener('input',()=>$('pot-preset').value='custom'); +$('pu').addEventListener('change',()=>$('pot-preset').value='custom'); +$('system').addEventListener('change',()=>{if(['cube','coco'].includes($('system').value))$('plants').value=1;else $('plants').value=3;calculate();}); +function calculate() { + const mode=$('system').value, pot=$('pot-method').value; + $('block-fields').hidden=!['cube','stack'].includes(mode);$('slab-fields').hidden=!['slab','stack'].includes(mode);$('coco-fields').hidden=mode!=='coco'; + $('known-pot').hidden=pot!=='known';$('pot-dimensions').hidden=pot==='known'; + $('pot-a-label').textContent=pot==='box'?'Inside length · cm':'Top diameter · cm';$('pot-b-label').textContent=pot==='box'?'Inside width · cm':'Bottom diameter · cm'; + allocation=null;$('volume-error').textContent=''; + for(const id of ['per-plant','per-unit','per-zone','plant-total'])$(id).textContent='—'; + $('volume-detail').textContent='';$('shot-result').textContent=''; + try { + const block=['cube','stack'].includes(mode)?TDR.box(value('bl'),value('bw'),value('bh')):0; + let base=['slab','stack'].includes(mode)?TDR.box(value('sl'),value('sw'),value('sh')):0; + if(mode==='coco')base=pot==='known'?TDR.litres(value('pv'),$('pu').value):pot==='box'?TDR.box(value('pa'),value('pb'),value('ph')):TDR.taperedPot(value('pa'),value('pb'),value('ph')); + if(mode==='cube' && value('plants')!==1)throw new Error('For cubes only use one plant per unit; enter the number of cubes as units.'); + allocation=TDR.allocation(block,base,value('plants'),value('units')); + $('per-plant').textContent=fmt(allocation.plant)+' L';$('per-unit').textContent=fmt(allocation.unit)+' L';$('per-zone').textContent=fmt(allocation.zone)+' L';$('plant-total').textContent=fmt(allocation.plants,0); + $('volume-detail').textContent=mode==='stack'?`${fmt(block)} L cube + ${fmt(base)} L slab ÷ ${value('plants')} plants. This is an allocation, not a root boundary.`:mode==='cube'?`${fmt(block)} L per measured block.`:`${fmt(base)} L filled medium per unit, shared by ${value('plants')} plant(s).`; + try {const s=TDR.shot(allocation.plant,value('shot-pct'),value('emitters'),value('flow'));$('shot-result').textContent=`${fmt(s.ml,1)} mL per plant · ${fmt(s.seconds,1)} seconds at the entered flow. Measure delivery with a catch test; drainage and redistribution change the resulting VWC.`;}catch(e){$('shot-result').textContent=e.message;} + }catch(e){$('volume-error').textContent=e.message;} + weighed=null;$('weigh-error').textContent='';$('weighed-vwc').textContent='—';$('water-volume').textContent=''; + try {weighed=TDR.weighed(value('dry'),value('wet'),value('sample-volume'),value('density'));$('weighed-vwc').textContent=fmt(weighed.vwc,2)+'%';$('water-volume').textContent=fmt(weighed.waterMl,1)+' mL of water in the weighed sample.';}catch(e){$('weigh-error').textContent=e.message;} + $('save-record').disabled=!weighed;$('download-record').disabled=records.length===0; + try{const d=TDR.dryback(value('peak'),value('current'));$('dryback-result').textContent=`${fmt(d.points,2)} percentage points = ${fmt(d.relative,2)}% of the peak VWC.`;}catch(e){$('dryback-result').textContent=e.message;} +} +document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',calculate)); +$('save-record').addEventListener('click',()=>{ + calculate(); if(!weighed)return; + const raw=value('record-raw');if(!Number.isFinite(raw)||raw<=0||raw>4095){$('record-status').textContent='Enter a RAW average between 1 and 4095 before saving.';return;} + records.push([new Date().toISOString(),$('record-label').value,$('point').value,value('sample-volume'),value('dry'),value('wet'),value('density'),raw,weighed.vwc]); + $('record-status').textContent=`${records.length} record(s) added. Download before closing this page.`;calculate(); +}); +$('download-record').addEventListener('click',()=>{ + const header=['timestamp_utc','sample_sensor_reference','point','sample_volume_L','dry_assembly_g','current_assembly_g','density_g_ml','capture_raw','weighed_vwc_percent']; + const csv=[header,...records].map(row=>row.map(TDR.escapeCsv).join(',')).join('\r\n'); + const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='tdr-calibration-record.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); +}); +function printTemplate(){ + const h=value('print-height'),z=value('print-center');$('print-error').textContent=''; + if(!Number.isFinite(h)||!Number.isFinite(z)||h<26||h>160||z<13||z>h-13){$('print-error').textContent='Height must be 26–160 mm; keep the full 26 mm contact face inside the substrate.';return false;} + const base=242,cy=base-z,top=base-h; + // Every SVG coordinate is a physical millimetre on an A4 page. + $('print-sheet').innerHTML=`MT22 placement templateSubstrate ${h} mm · chosen rod centreline ${z} mm above the basePrint A4 / actual size / 100%. Disable Fit, Shrink and headers/footers.Measure BOTH 100 mm bars before using the sheet.User-selected position; not a manufacturer-validated depth.Transfer the actual pins to the line. Pin spacing is not specified.SUBSTRATE TOPBASE DATUM — bottom of growing medium, not gutter lip88 × 26 mm contact faceAll three rod centres on this lineFull rod insertion: 53 mm. Keep the long body level along the slab.100 mm100 mmINFWIN MT22 dimensions · verify your sensor revision · docs/PLACEMENT.md`; + return true; +} +$('print-button').addEventListener('click',()=>{if(printTemplate())window.print();}); +window.addEventListener('beforeprint',printTemplate); +calculate(); From e0d2c25e0d6035d0267048920c3d266fda8aa480 Mon Sep 17 00:00:00 2001 From: JakeTheRabbit <123831499+JakeTheRabbit@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:36:22 +1200 Subject: [PATCH 2/3] fix: preserve physical-scale PDF as binary --- .gitattributes | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e90c49c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto +*.yaml text eol=lf +*.yml text eol=lf +*.js text eol=lf +*.py text eol=lf +*.md text eol=lf +*.html text eol=lf +*.css text eol=lf +*.svg text eol=lf +*.pdf binary +*.png binary +*.jpg binary +*.bin binary From ce8ed0716c802cb48d01d92b083b02d32a57f7ad Mon Sep 17 00:00:00 2001 From: JakeTheRabbit <123831499+JakeTheRabbit@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:41:35 +1200 Subject: [PATCH 3/3] fix: package firmware artifacts and expire stale CSV readings --- .github/workflows/build-firmware.yml | 6 +- .github/workflows/test.yml | 1 + README.md | 3 + docs/CONFIG.md | 3 + docs/MIGRATION-v3.md | 3 + tests/calculator.test.js | 100 +++++- tests/test_logger.py | 27 ++ tools/setup/calculator.js | 100 ++++-- tools/setup/index.html | 500 +++++++++++++++++++++++++-- tools/setup/style.css | 292 +++++++++++++++- tools/setup/substrates.js | 48 +-- tools/setup/ui.js | 259 +++++++++++--- tools/tdr_logger.py | 102 ++++-- 13 files changed, 1268 insertions(+), 176 deletions(-) create mode 100644 tests/test_logger.py diff --git a/.github/workflows/build-firmware.yml b/.github/workflows/build-firmware.yml index 48b696d..dec3bd0 100644 --- a/.github/workflows/build-firmware.yml +++ b/.github/workflows/build-firmware.yml @@ -35,9 +35,9 @@ jobs: BOARD: ${{ matrix.board }} run: | mkdir -p dist - cp "$BUILD_NAME/firmware.factory.bin" "dist/tdr-sensor-$BOARD.factory.bin" - if [ -f "$BUILD_NAME/firmware.ota.bin" ]; then - cp "$BUILD_NAME/firmware.ota.bin" "dist/tdr-sensor-$BOARD.ota.bin" + cp "$BUILD_NAME/$BUILD_NAME.factory.bin" "dist/tdr-sensor-$BOARD.factory.bin" + if [ -f "$BUILD_NAME/$BUILD_NAME.ota.bin" ]; then + cp "$BUILD_NAME/$BUILD_NAME.ota.bin" "dist/tdr-sensor-$BOARD.ota.bin" fi - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d53a4a..e207ad3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,3 +21,4 @@ jobs: - run: node --test tests/calculator.test.js - run: python tests/test_firmware.py - run: python tests/test_repository.py + - run: python tests/test_logger.py diff --git a/README.md b/README.md index 59b1971..280a2fa 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,6 @@ esphome compile esphome/factory/tdr-sensor-atom-lite-factory.yaml ``` The logger reads the local web event stream. See [VALIDATION.md](docs/VALIDATION.md) for dependencies and test scope. The existing [root-zone measurement paper](https://jaketherabbit.github.io/cannabis-white-papers/root-zone-teros12.html) provides additional discussion; hardware specifications and calibration limits for this implementation are documented in [SOURCES.md](docs/SOURCES.md). + + +CSV logging: wide format now records each field's observation age, blanks readings after `--max-age` (default 120 seconds) or a disconnected stream, and refuses to append a mismatched header. Start a new CSV after upgrading. Use long format for entities that appear after the initial snapshot; wide mode warns rather than silently dropping new columns. Adjust maximum age to the actual reporting cadence, not the desired irrigation interval. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 9d183e3..5bd7c80 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -71,3 +71,6 @@ wifi: ESPHome requires a valid 32-byte base64 API key. Generate one locally or through ESPHome's documented key generator; do not copy somebody else's key. The PoE node excludes Wi-Fi settings. Factory configs intentionally omit deployment credentials to support provisioning; an uncredentialed web page exposes calibration controls on the local network. Add credentials when adopting it. For MQTT, uncomment the `tdr_mqtt.yaml` package and fill in `mqtt_broker`, `mqtt_username` and `mqtt_password` in secrets. The existing CSV logger currently supports an unauthenticated local web event stream; use Home Assistant/MQTT logging if you enable web authentication, unless you extend the logger's authentication support. + + +CSV logging: wide format now records each field's observation age, blanks readings after `--max-age` (default 120 seconds) or a disconnected stream, and refuses to append a mismatched header. Start a new CSV after upgrading. Use long format for entities that appear after the initial snapshot; wide mode warns rather than silently dropping new columns. Adjust maximum age to the actual reporting cadence, not the desired irrigation interval. diff --git a/docs/MIGRATION-v3.md b/docs/MIGRATION-v3.md index ba1105d..ab17f8a 100644 --- a/docs/MIGRATION-v3.md +++ b/docs/MIGRATION-v3.md @@ -21,3 +21,6 @@ Version 3 changes measurement meaning. Build and inspect it on a spare node firs Use [CALIBRATION.md](CALIBRATION.md) and the [offline setup desk](../tools/setup/index.html). Default polling is now 30 seconds, with a 90-second data timeout and a ten-sample capture window (about five minutes). If changing poll cadence, update both timeout substitutions consistently and leave time for the SDI-12 response cycle. Before relying on a threshold, verify stable wet/dry readings in the actual medium, check a third independently weighed point, test a disconnected sensor, inspect the calibration status and verify delivered irrigation physically. The software tests and firmware builds do not establish agronomic accuracy or electrical compatibility for your installation. + + +CSV logging: wide format now records each field's observation age, blanks readings after `--max-age` (default 120 seconds) or a disconnected stream, and refuses to append a mismatched header. Start a new CSV after upgrading. Use long format for entities that appear after the initial snapshot; wide mode warns rather than silently dropping new columns. Adjust maximum age to the actual reporting cadence, not the desired irrigation interval. diff --git a/tests/calculator.test.js b/tests/calculator.test.js index a4c782b..0cc3020 100644 --- a/tests/calculator.test.js +++ b/tests/calculator.test.js @@ -1,19 +1,81 @@ -const test=require('node:test'),assert=require('node:assert/strict'); -const T=require('../tools/setup/calculator.js'),S=require('../tools/setup/substrates.js'); -const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`); -test('Hugo is based on real metric dimensions, not a perfect six-inch cube',()=>near(T.box(15,15,14.2),3.195)); -test('three Hugo blocks on 1m x 15cm x 7.5cm slab',()=>{const v=T.allocation(3.195,11.25,3,4);near(v.plant,6.945);near(v.unit,20.835);near(v.zone,83.34);assert.equal(v.plants,12);}); -test('100 x 20 x 10 shared slab counts once',()=>near(T.allocation(3.195,T.box(100,20,10),3,1).plant,9.861666666666666)); -test('cube only and shared coco allocation',()=>{near(T.allocation(3.195,0,1,1).plant,3.195);near(T.allocation(0,10,2,1).plant,5);}); -test('US and Imperial gallons stay distinct',()=>{near(T.litres(3,'USgal'),11.356235352);near(T.litres(3,'Impgal'),13.63827);}); -test('cylinder and truncated-cone volume',()=>{near(T.taperedPot(20,20,30),3*Math.PI);near(T.taperedPot(30,20,25),25*Math.PI*1900/12000);}); -test('weighed water volume and tare',()=>{const v=T.weighed(500,8000,11.25);near(v.waterMl,7500);near(v.vwc,66.66666666666667);near(T.weighed(600,8100,11.25).vwc,v.vwc);}); -test('weighed density correction',()=>near(T.weighed(100,1098,2,.998).vwc,50)); -test('shot uses emitter total flow per plant',()=>{const s=T.shot(6.945,2,2,2);near(s.ml,138.9);near(s.seconds,125.01);}); -test('dryback points differ from relative percent',()=>{const d=T.dryback(70,60);near(d.points,10);near(d.relative,100/7);}); -test('rising VWC produces negative dryback, not a hidden clamp',()=>near(T.dryback(60,70).points,-10)); -test('invalid geometry, fractions, missing values and impossible water rejected',()=>{ - for(const f of [()=>T.box(0,10,10),()=>T.allocation(1,2,0,1),()=>T.allocation(1,2,1.5,1),()=>T.allocation(0,0,1,1),()=>T.box(NaN,1,1),()=>T.weighed(500,400,1),()=>T.weighed(10,2010,1),()=>T.shot(1,2,0,2),()=>T.litres(3,'gallons'),()=>T.weighed(-1,500,1),()=>T.box('10',10,10),()=>T.dryback(0,0)])assert.throws(f); -}); -test('every preset has valid dimensions and unique key within its group',()=>{for(const entries of Object.values(S)){const ids=new Set;for(const [id,label,l,w,h] of entries){assert.ok(!ids.has(id));ids.add(id);assert.ok(label);assert.ok(T.box(l,w,h)>0);}}}); -test('CSV escapes quoted labels and neutralises formulas',()=>{assert.equal(T.escapeCsv('a"b'),'"a""b"');assert.equal(T.escapeCsv('=1+1'),'"\'=1+1"');assert.equal(T.escapeCsv('plain'),'"plain"');}); +const test = require("node:test"), + assert = require("node:assert/strict"); +const T = require("../tools/setup/calculator.js"), + S = require("../tools/setup/substrates.js"); +const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`); +test("Hugo is based on real metric dimensions, not a perfect six-inch cube", () => + near(T.box(15, 15, 14.2), 3.195)); +test("three Hugo blocks on 1m x 15cm x 7.5cm slab", () => { + const v = T.allocation(3.195, 11.25, 3, 4); + near(v.plant, 6.945); + near(v.unit, 20.835); + near(v.zone, 83.34); + assert.equal(v.plants, 12); +}); +test("100 x 20 x 10 shared slab counts once", () => + near(T.allocation(3.195, T.box(100, 20, 10), 3, 1).plant, 9.861666666666666)); +test("cube only and shared coco allocation", () => { + near(T.allocation(3.195, 0, 1, 1).plant, 3.195); + near(T.allocation(0, 10, 2, 1).plant, 5); +}); +test("US and Imperial gallons stay distinct", () => { + near(T.litres(3, "USgal"), 11.356235352); + near(T.litres(3, "Impgal"), 13.63827); +}); +test("cylinder and truncated-cone volume", () => { + near(T.taperedPot(20, 20, 30), 3 * Math.PI); + near(T.taperedPot(30, 20, 25), (25 * Math.PI * 1900) / 12000); +}); +test("weighed water volume and tare", () => { + const v = T.weighed(500, 8000, 11.25); + near(v.waterMl, 7500); + near(v.vwc, 66.66666666666667); + near(T.weighed(600, 8100, 11.25).vwc, v.vwc); +}); +test("weighed density correction", () => + near(T.weighed(100, 1098, 2, 0.998).vwc, 50)); +test("shot uses emitter total flow per plant", () => { + const s = T.shot(6.945, 2, 2, 2); + near(s.ml, 138.9); + near(s.seconds, 125.01); +}); +test("dryback points differ from relative percent", () => { + const d = T.dryback(70, 60); + near(d.points, 10); + near(d.relative, 100 / 7); +}); +test("rising VWC produces negative dryback, not a hidden clamp", () => + near(T.dryback(60, 70).points, -10)); +test("invalid geometry, fractions, missing values and impossible water rejected", () => { + for (const f of [ + () => T.box(0, 10, 10), + () => T.allocation(1, 2, 0, 1), + () => T.allocation(1, 2, 1.5, 1), + () => T.allocation(0, 0, 1, 1), + () => T.box(NaN, 1, 1), + () => T.weighed(500, 400, 1), + () => T.weighed(10, 2010, 1), + () => T.shot(1, 2, 0, 2), + () => T.litres(3, "gallons"), + () => T.weighed(-1, 500, 1), + () => T.box("10", 10, 10), + () => T.dryback(0, 0), + ]) + assert.throws(f); +}); +test("every preset has valid dimensions and unique key within its group", () => { + for (const entries of Object.values(S)) { + const ids = new Set(); + for (const [id, label, l, w, h] of entries) { + assert.ok(!ids.has(id)); + ids.add(id); + assert.ok(label); + assert.ok(T.box(l, w, h) > 0); + } + } +}); +test("CSV escapes quoted labels and neutralises formulas", () => { + assert.equal(T.escapeCsv('a"b'), '"a""b"'); + assert.equal(T.escapeCsv("=1+1"), '"\'=1+1"'); + assert.equal(T.escapeCsv("plain"), '"plain"'); +}); diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..0af130d --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,27 @@ +import importlib.util, tempfile, unittest, csv +from pathlib import Path +spec=importlib.util.spec_from_file_location('tdr_logger',Path(__file__).resolve().parents[1]/'tools/tdr_logger.py') +logger=importlib.util.module_from_spec(spec);spec.loader.exec_module(logger) + +class LoggerTests(unittest.TestCase): + def test_stale_and_disconnected_values_are_blank(self): + b=logger.ReadingBuffer();b.update('vwc',65,100) + self.assertEqual(b.snapshot(['vwc'],110,120),[65,10]) + self.assertEqual(b.snapshot(['vwc'],221,120),['',121]) + b.disconnect();self.assertEqual(b.snapshot(['vwc'],110,120),['',10]) + def test_reconnect_does_not_refresh_unseen_fields(self): + b=logger.ReadingBuffer();b.update('vwc',65,100);b.update('ec',1.3,100);b.disconnect() + b.update('vwc',64,300) + self.assertEqual(b.snapshot(['vwc','ec'],300,120),[64,0,'',200]) + def test_identical_new_value_refreshes_age(self): + b=logger.ReadingBuffer();b.update('vwc',65,100);b.update('vwc',65,300) + self.assertEqual(b.snapshot(['vwc','missing'],301,120),[65,1,'','']) + def test_mismatched_append_refused(self): + with tempfile.TemporaryDirectory() as d: + p=Path(d)/'test.csv';p.write_text('timestamp,vwc\n') + with self.assertRaises(ValueError):logger.validate_append_header(p,logger.wide_header(['vwc'])) + with p.open('w',newline='') as f:csv.writer(f).writerow(logger.wide_header(['vwc'])) + logger.validate_append_header(p,logger.wide_header(['vwc'])) + def test_numeric_zero_not_lost(self): + self.assertEqual(logger.numeric_value({'value':0,'state':'0.00'}),0) +if __name__=='__main__':unittest.main() diff --git a/tools/setup/calculator.js b/tools/setup/calculator.js index 7a6125a..7193da9 100644 --- a/tools/setup/calculator.js +++ b/tools/setup/calculator.js @@ -1,67 +1,107 @@ /* Offline calculations. The browser and node tests run this same code. */ (function (root, factory) { const api = factory(); - if (typeof module === 'object' && module.exports) module.exports = api; + if (typeof module === "object" && module.exports) module.exports = api; else root.TDR = api; -})(typeof globalThis !== 'undefined' ? globalThis : this, function () { - 'use strict'; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; function positive(n, label) { - if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) throw new Error(`${label} must be greater than zero.`); + if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) + throw new Error(`${label} must be greater than zero.`); return n; } function nonnegative(n, label) { - if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or greater.`); + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) + throw new Error(`${label} must be zero or greater.`); return n; } function whole(n, label) { positive(n, label); - if (!Number.isInteger(n)) throw new Error(`${label} must be a whole number.`); + if (!Number.isInteger(n)) + throw new Error(`${label} must be a whole number.`); return n; } - function box(l, w, h) { return positive(l, 'Length') * positive(w, 'Width') * positive(h, 'Height') / 1000; } + function box(l, w, h) { + return ( + (positive(l, "Length") * positive(w, "Width") * positive(h, "Height")) / + 1000 + ); + } function taperedPot(top, bottom, height) { - positive(top, 'Top diameter'); positive(bottom, 'Bottom diameter'); positive(height, 'Fill height'); - return Math.PI * height * (top * top + top * bottom + bottom * bottom) / 12000; + positive(top, "Top diameter"); + positive(bottom, "Bottom diameter"); + positive(height, "Fill height"); + return ( + (Math.PI * height * (top * top + top * bottom + bottom * bottom)) / 12000 + ); } function litres(value, unit) { - positive(value, 'Volume'); + positive(value, "Volume"); const factors = { L: 1, USgal: 3.785411784, Impgal: 4.54609 }; - if (!(unit in factors)) throw new Error('Choose litres, US gallons or Imperial gallons.'); + if (!(unit in factors)) + throw new Error("Choose litres, US gallons or Imperial gallons."); return value * factors[unit]; } function allocation(cubeL, baseL, plants, units) { - nonnegative(cubeL, 'Block volume'); nonnegative(baseL, 'Shared base volume'); - whole(plants, 'Plants per unit'); whole(units, 'Number of units'); + nonnegative(cubeL, "Block volume"); + nonnegative(baseL, "Shared base volume"); + whole(plants, "Plants per unit"); + whole(units, "Number of units"); const unit = baseL + plants * cubeL; - positive(unit, 'Total substrate'); - return { plant: unit / plants, unit, zone: unit * units, plants: plants * units }; + positive(unit, "Total substrate"); + return { + plant: unit / plants, + unit, + zone: unit * units, + plants: plants * units, + }; } function weighed(dry, wet, volumeL, density = 1) { - nonnegative(dry, 'Dry assembly mass'); positive(wet, 'Wet assembly mass'); - positive(volumeL, 'Sample volume'); positive(density, 'Water density'); - if (wet < dry) throw new Error('Wet mass cannot be below dry mass.'); + nonnegative(dry, "Dry assembly mass"); + positive(wet, "Wet assembly mass"); + positive(volumeL, "Sample volume"); + positive(density, "Water density"); + if (wet < dry) throw new Error("Wet mass cannot be below dry mass."); const waterMl = (wet - dry) / density; - const vwc = 100 * waterMl / (volumeL * 1000); - if (vwc > 100) throw new Error('Calculated VWC exceeds 100%. Check volume, tare and units.'); + const vwc = (100 * waterMl) / (volumeL * 1000); + if (vwc > 100) + throw new Error( + "Calculated VWC exceeds 100%. Check volume, tare and units.", + ); return { waterMl, vwc }; } function shot(volumeL, percent, emitters, flowLh) { - positive(volumeL, 'Allocated substrate volume'); positive(percent, 'Shot percent'); - if (percent > 100) throw new Error('Shot percent cannot exceed 100.'); - whole(emitters, 'Emitters per plant'); positive(flowLh, 'Measured emitter flow'); - const ml = volumeL * 1000 * percent / 100; - return { ml, seconds: ml / (emitters * flowLh * 1000 / 3600) }; + positive(volumeL, "Allocated substrate volume"); + positive(percent, "Shot percent"); + if (percent > 100) throw new Error("Shot percent cannot exceed 100."); + whole(emitters, "Emitters per plant"); + positive(flowLh, "Measured emitter flow"); + const ml = (volumeL * 1000 * percent) / 100; + return { ml, seconds: ml / ((emitters * flowLh * 1000) / 3600) }; } function dryback(peak, current) { - positive(peak, 'Peak VWC'); nonnegative(current, 'Current VWC'); - if (peak > 100 || current > 100) throw new Error('VWC cannot exceed 100%.'); - return { points: peak - current, relative: 100 * (peak - current) / peak }; + positive(peak, "Peak VWC"); + nonnegative(current, "Current VWC"); + if (peak > 100 || current > 100) throw new Error("VWC cannot exceed 100%."); + return { + points: peak - current, + relative: (100 * (peak - current)) / peak, + }; } function escapeCsv(value) { // Block spreadsheet formula execution in user-entered record labels. - let s = String(value ?? ''); + let s = String(value ?? ""); if (/^[\s]*[=+@-]/.test(s)) s = "'" + s; return '"' + s.replaceAll('"', '""') + '"'; } - return { box, taperedPot, litres, allocation, weighed, shot, dryback, escapeCsv }; + return { + box, + taperedPot, + litres, + allocation, + weighed, + shot, + dryback, + escapeCsv, + }; }); diff --git a/tools/setup/index.html b/tools/setup/index.html index 2d1aa8b..338ef84 100644 --- a/tools/setup/index.html +++ b/tools/setup/index.html @@ -1,24 +1,482 @@ - -TDR Sensor · Substrate & calibration desk - -
TDR SENSOR FIELD TOOLS / 03

Know the volume.
Measure the water.

A practical setup desk for blocks, shared slabs and coco. Everything runs on this device; entries stay here.

-
01 / YOUR ROOT ZONE

Substrate volume

Use physical growing-medium volume. A shared slab is counted once, then divided between its plants.

-
-
Block above each plant's root zone
-
Shared slab
- -
-
-
Optional shot-volume calculation

This converts an operator-selected shot percentage into volume and time. It does not recommend an irrigation target or predict the VWC rise.

-

Grodan block dimensions: Precision Irrigation guide, pages 5–6. The 1 m entries are geometric examples; check your Prestige label. Nominal six-inch Hugo volume is 3.195 L from 15 × 15 × 14.2 cm.

+ + + + + + TDR Sensor · Substrate & calibration desk + + + + + + +
+
TDR SENSOR FIELD TOOLS / 03
+

Know the volume.
Measure the water.

+

+ A practical setup desk for blocks, shared slabs and coco. Everything + runs on this device; entries stay here. +

+ +
+
+
+
01 / YOUR ROOT ZONE
+

Substrate volume

+

+ Use physical growing-medium volume. A shared slab is counted once, + then divided between its plants. +

+
+
+ +
+ Block above each plant's root zone + +
+ +
+
+
+ Shared slab + +
+ +
+
+ +
+ +
+
+ +
+
+ Optional shot-volume calculation +

+ This converts an operator-selected shot percentage into volume and + time. It does not recommend an irrigation target or predict the VWC + rise. +

+
+ +
+

+
+

+ Grodan block dimensions: + Precision Irrigation guide, pages 5–6. The 1 m entries are geometric examples; check your Prestige label. + Nominal six-inch Hugo volume is 3.195 L from 15 × 15 × 14.2 cm. +

+
-
02 / CALIBRATION BENCH

Wet reference or actual VWC?

Quick wet reference

Place the sensor, wet the medium uniformly, let free drainage settle and turn Calibration mode on. Wait for Capture ready, then press Save wet reference.

The index becomes 100. It is a repeatable instrument reference, not 100% water, field capacity, or water remaining. Turn calibration mode off after capture.

Weighed VWC calibration

Use a spare unplanted sample of the same medium and geometry. Capture two weighed moisture levels, A and B, bracketing the intended range. Check a third independent level C.

Headline VWC becomes available only after the check passes and calibration mode is off. Keep the sensor position fixed throughout the measurements.

-

This is the sample on the scale, NOT the allocated litres per plant above. Do not include cubes unless they are part of the weighed, representative calibration specimen.

Water density

1 is a practical water approximation. Consistent tare, retained solution salts and spatial gradients can introduce larger errors; record the method.

-
Dryback: percentage points versus percent

A slab reading represents a local sensing region. Multiplying it by the entire slab-plus-cubes volume assumes uniform moisture and can give a false whole-root-zone water balance.

+
+
02 / CALIBRATION BENCH
+

Wet reference or actual VWC?

+
+
+

Quick wet reference

+

+ Place the sensor, wet the medium uniformly, let free drainage + settle and turn Calibration mode on. Wait for + Capture ready, then press Save wet reference. +

+

+ The index becomes 100. It is a repeatable instrument reference, + not 100% water, field capacity, or water remaining. Turn + calibration mode off after capture. +

+
+
+

Weighed VWC calibration

+

+ Use a spare unplanted sample of the same medium and geometry. + Capture two weighed moisture levels, A and B, bracketing the + intended range. Check a third independent level C. +

+

+ Headline VWC becomes available only after the check passes and + calibration mode is off. Keep the sensor position fixed throughout + the measurements. +

+
+
+
+
+ +

+ This is the sample on the scale, NOT the allocated litres per + plant above. Do not include cubes unless they are part of the + weighed, representative calibration specimen. +

+
+ +
+
+ Water density + +

+ 1 is a practical water approximation. Consistent tare, retained + solution salts and spatial gradients can introduce larger + errors; record the method. +

+
+ +
+ +
+
+ Dryback: percentage points versus percent +
+ +
+

+

+ A slab reading represents a local sensing region. Multiplying it by + the entire slab-plus-cubes volume assumes uniform moisture and can + give a false whole-root-zone water balance. +

+
+
-
03 / POSITION & REPEAT

MT22 placement

For roots established in a slab, use the slab as the primary measurement. Keep a cube probe only when you want a separate upper-block reading. Starting flower week alone cannot prove rooting into the slab.

MT22 side insertion into a slab beside the middle of three blocks, with the long body level and all three 53 mm rods at one height. -

Placement for cubes, slabs and coco ↗ · Download 75 / 100 mm slab templates (PDF) ↗

-

Custom actual-size placement sheet

Enter the actual substrate height and your chosen rod height from the base. The suggested midpoint is a repeatable starting position, not an MT22 placement specification validated for every medium.

This A4 sheet supports heights 26–160 mm. For a taller container use a measured base datum and ruler. Pin pitch is not dimensioned by INFWIN: transfer the real probe pins onto the centreline, never guessed hole positions.

-
- +
+
03 / POSITION & REPEAT
+

MT22 placement

+

+ For roots established in a slab, use the slab as the primary + measurement. Keep a cube probe only when you want a separate + upper-block reading. Starting flower week alone cannot prove rooting + into the slab. +

+ MT22 side insertion into a slab beside the middle of three blocks, with the long body level and all three 53 mm rods at one height. +

+ Placement for cubes, slabs and coco ↗ + · + Download 75 / 100 mm slab templates (PDF) ↗ +

+
+

Custom actual-size placement sheet

+

+ Enter the actual substrate height and your chosen rod height from + the base. The suggested midpoint is a repeatable starting position, + not an MT22 placement specification validated for every medium. +

+
+ +
+

+ This A4 sheet supports heights 26–160 mm. For a taller container use + a measured base datum and ruler. Pin pitch is not dimensioned by + INFWIN: transfer the real probe pins onto the centreline, never + guessed hole positions. +

+ +
+
+ +
+ + + diff --git a/tools/setup/style.css b/tools/setup/style.css index b77308c..40d4b73 100644 --- a/tools/setup/style.css +++ b/tools/setup/style.css @@ -1 +1,291 @@ -:root{color-scheme:light;--ink:#182f36;--muted:#586d73;--green:#216f5c;--paper:#f4f3ee;--line:#d8dfd9}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.6 system-ui,sans-serif}header,main{max-width:1160px;margin:auto}header{padding:48px 28px 38px}.brand{letter-spacing:.16em;font-size:13px;font-weight:800}.brand span{float:right;font-size:11px;color:var(--muted)}h1{font-family:Georgia,serif;font-weight:400;line-height:1.04;font-size:clamp(40px,6.5vw,78px);letter-spacing:-.04em;margin:35px 0 22px}header p{max-width:630px;color:var(--muted);font-size:18px}nav{display:flex;flex-wrap:wrap;gap:25px;padding-top:14px}a{color:var(--green);text-underline-offset:4px}main{padding:0 28px}section{padding:38px 0;border-top:1px solid var(--line);scroll-margin-top:15px}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.15em;color:var(--green)}h2{font-family:Georgia,serif;font-weight:400;font-size:34px;margin:9px 0 12px;line-height:1.2}h3{font-size:19px;margin:0 0 12px}p{margin:10px 0 20px}.grid{display:grid;grid-template-columns:1.25fr 1fr;gap:24px;margin:25px 0}.panel,.cards article{padding:26px;background:white;border:1px solid var(--line);border-radius:10px}.result{padding:28px;background:#e2eee6;border-radius:10px;align-self:start}.big{font-size:clamp(38px,5vw,64px);font-weight:600;letter-spacing:-.04em;line-height:1.3;margin:14px 0 22px}.two,.three{display:grid;gap:15px}.two{grid-template-columns:repeat(2,minmax(0,1fr))}.three{grid-template-columns:repeat(3,minmax(0,1fr))}label{display:block;font-size:13px;font-weight:650;margin:0 0 17px}input,select{display:block;font:inherit;font-size:15px;font-weight:400;width:100%;min-height:44px;border:1px solid #acbdb5;border-radius:5px;background:white;padding:10px;margin-top:6px;color:var(--ink)}input:focus,select:focus,button:focus{outline:3px solid #89bdaa;outline-offset:2px}fieldset{padding:15px 0 0;margin:12px 0;border:0;border-top:1px solid var(--line)}legend{font-size:13px;font-weight:700;padding-right:14px}.small{font-size:12px;color:var(--muted)}dl{margin:0}dt{font-size:12px;color:var(--muted);margin-top:15px}dd{font-size:24px;margin:0}.note{padding-top:18px;border-top:1px solid #bbd4c6;font-size:13px}.error{color:#9b241a;font-size:14px;font-weight:650}.error:empty{display:none}details{margin:22px 0;padding:18px 0;border-top:1px solid var(--line)}summary{cursor:pointer;font-weight:650;margin-bottom:10px}button{cursor:pointer;border:0;background:var(--green);color:white;border-radius:5px;min-height:44px;padding:12px 18px;font:inherit;font-size:14px;font-weight:650;margin:8px 8px 8px 0}button.secondary{background:white;color:var(--green);border:1px solid var(--green)}button:disabled{opacity:.5;cursor:default}.diagram{width:100%;height:auto;background:white;border:1px solid var(--line);border-radius:8px;margin:20px 0}footer{padding:30px 0 55px;color:var(--muted);font-size:13px}[hidden],#print-sheet{display:none!important}@media(max-width:720px){.grid,.cards{grid-template-columns:1fr}header{padding-top:28px}.brand span{float:none;display:block;margin-top:8px}.panel,.result{padding:20px}.three{gap:8px}main,header{padding-left:18px;padding-right:18px}}@page{size:A4 portrait;margin:0}@media print{body>*:not(#print-sheet){display:none!important}#print-sheet{display:block!important;width:210mm;height:297mm;background:white;overflow:hidden}#print-sheet svg{display:block;width:210mm;height:297mm}} +:root { + color-scheme: light; + --ink: #182f36; + --muted: #586d73; + --green: #216f5c; + --paper: #f4f3ee; + --line: #d8dfd9; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 16px/1.6 system-ui, + sans-serif; +} +header, +main { + max-width: 1160px; + margin: auto; +} +header { + padding: 48px 28px 38px; +} +.brand { + letter-spacing: 0.16em; + font-size: 13px; + font-weight: 800; +} +.brand span { + float: right; + font-size: 11px; + color: var(--muted); +} +h1 { + font-family: Georgia, serif; + font-weight: 400; + line-height: 1.04; + font-size: clamp(40px, 6.5vw, 78px); + letter-spacing: -0.04em; + margin: 35px 0 22px; +} +header p { + max-width: 630px; + color: var(--muted); + font-size: 18px; +} +nav { + display: flex; + flex-wrap: wrap; + gap: 25px; + padding-top: 14px; +} +a { + color: var(--green); + text-underline-offset: 4px; +} +main { + padding: 0 28px; +} +section { + padding: 38px 0; + border-top: 1px solid var(--line); + scroll-margin-top: 15px; +} +.eyebrow { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.15em; + color: var(--green); +} +h2 { + font-family: Georgia, serif; + font-weight: 400; + font-size: 34px; + margin: 9px 0 12px; + line-height: 1.2; +} +h3 { + font-size: 19px; + margin: 0 0 12px; +} +p { + margin: 10px 0 20px; +} +.grid { + display: grid; + grid-template-columns: 1.25fr 1fr; + gap: 24px; + margin: 25px 0; +} +.panel, +.cards article { + padding: 26px; + background: white; + border: 1px solid var(--line); + border-radius: 10px; +} +.result { + padding: 28px; + background: #e2eee6; + border-radius: 10px; + align-self: start; +} +.big { + font-size: clamp(38px, 5vw, 64px); + font-weight: 600; + letter-spacing: -0.04em; + line-height: 1.3; + margin: 14px 0 22px; +} +.two, +.three { + display: grid; + gap: 15px; +} +.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +label { + display: block; + font-size: 13px; + font-weight: 650; + margin: 0 0 17px; +} +input, +select { + display: block; + font: inherit; + font-size: 15px; + font-weight: 400; + width: 100%; + min-height: 44px; + border: 1px solid #acbdb5; + border-radius: 5px; + background: white; + padding: 10px; + margin-top: 6px; + color: var(--ink); +} +input:focus, +select:focus, +button:focus { + outline: 3px solid #89bdaa; + outline-offset: 2px; +} +fieldset { + padding: 15px 0 0; + margin: 12px 0; + border: 0; + border-top: 1px solid var(--line); +} +legend { + font-size: 13px; + font-weight: 700; + padding-right: 14px; +} +.small { + font-size: 12px; + color: var(--muted); +} +dl { + margin: 0; +} +dt { + font-size: 12px; + color: var(--muted); + margin-top: 15px; +} +dd { + font-size: 24px; + margin: 0; +} +.note { + padding-top: 18px; + border-top: 1px solid #bbd4c6; + font-size: 13px; +} +.error { + color: #9b241a; + font-size: 14px; + font-weight: 650; +} +.error:empty { + display: none; +} +details { + margin: 22px 0; + padding: 18px 0; + border-top: 1px solid var(--line); +} +summary { + cursor: pointer; + font-weight: 650; + margin-bottom: 10px; +} +button { + cursor: pointer; + border: 0; + background: var(--green); + color: white; + border-radius: 5px; + min-height: 44px; + padding: 12px 18px; + font: inherit; + font-size: 14px; + font-weight: 650; + margin: 8px 8px 8px 0; +} +button.secondary { + background: white; + color: var(--green); + border: 1px solid var(--green); +} +button:disabled { + opacity: 0.5; + cursor: default; +} +.diagram { + width: 100%; + height: auto; + background: white; + border: 1px solid var(--line); + border-radius: 8px; + margin: 20px 0; +} +footer { + padding: 30px 0 55px; + color: var(--muted); + font-size: 13px; +} +[hidden], +#print-sheet { + display: none !important; +} +@media (max-width: 720px) { + .grid, + .cards { + grid-template-columns: 1fr; + } + header { + padding-top: 28px; + } + .brand span { + float: none; + display: block; + margin-top: 8px; + } + .panel, + .result { + padding: 20px; + } + .three { + gap: 8px; + } + main, + header { + padding-left: 18px; + padding-right: 18px; + } +} +@page { + size: A4 portrait; + margin: 0; +} +@media print { + body > *:not(#print-sheet) { + display: none !important; + } + #print-sheet { + display: block !important; + width: 210mm; + height: 297mm; + background: white; + overflow: hidden; + } + #print-sheet svg { + display: block; + width: 210mm; + height: 297mm; + } +} diff --git a/tools/setup/substrates.js b/tools/setup/substrates.js index 7ba33e4..db3a52f 100644 --- a/tools/setup/substrates.js +++ b/tools/setup/substrates.js @@ -4,30 +4,30 @@ */ const SUBSTRATES = { blocks: [ - ['custom', 'Custom measured block', 15, 15, 14.2], - ['gr4', 'Grodan GR4 / Delta: 7.5 × 7.5 × 6.5 cm', 7.5, 7.5, 6.5], - ['gr56', 'Grodan GR5.6: 7.5 × 7.5 × 10 cm', 7.5, 7.5, 10], - ['gr65', 'Grodan GR6.5: 10 × 10 × 6.5 cm', 10, 10, 6.5], - ['gr75', 'Grodan GR7.5: 10 × 10 × 7.5 cm', 10, 10, 7.5], - ['gr10', 'Grodan GR10: 10 × 10 × 10 cm', 10, 10, 10], - ['jumbo', 'Grodan Jumbo / GR22.5 (6 × 6 × 4 nominal)', 15, 15, 10], - ['hugo', 'Grodan Hugo / GR32 (6-inch nominal)', 15, 15, 14.2], - ['uniblock', 'Grodan Uniblock: 20 × 20 × 10 cm', 20, 20, 10], - ['bigmama', 'Grodan Big Mama: 20.3 × 20.3 × 20.3 cm', 20.3, 20.3, 20.3], - ['unislab', 'Grodan Uni-Slab: 24 × 19.5 × 10 cm', 24, 19.5, 10] + ["custom", "Custom measured block", 15, 15, 14.2], + ["gr4", "Grodan GR4 / Delta: 7.5 × 7.5 × 6.5 cm", 7.5, 7.5, 6.5], + ["gr56", "Grodan GR5.6: 7.5 × 7.5 × 10 cm", 7.5, 7.5, 10], + ["gr65", "Grodan GR6.5: 10 × 10 × 6.5 cm", 10, 10, 6.5], + ["gr75", "Grodan GR7.5: 10 × 10 × 7.5 cm", 10, 10, 7.5], + ["gr10", "Grodan GR10: 10 × 10 × 10 cm", 10, 10, 10], + ["jumbo", "Grodan Jumbo / GR22.5 (6 × 6 × 4 nominal)", 15, 15, 10], + ["hugo", "Grodan Hugo / GR32 (6-inch nominal)", 15, 15, 14.2], + ["uniblock", "Grodan Uniblock: 20 × 20 × 10 cm", 20, 20, 10], + ["bigmama", "Grodan Big Mama: 20.3 × 20.3 × 20.3 cm", 20.3, 20.3, 20.3], + ["unislab", "Grodan Uni-Slab: 24 × 19.5 × 10 cm", 24, 19.5, 10], ], slabs: [ - ['custom', 'Custom measured slab', 100, 15, 7.5], - ['s1001575', '1 m slab: 100 × 15 × 7.5 cm', 100, 15, 7.5], - ['s1001510', '1 m slab: 100 × 15 × 10 cm', 100, 15, 10], - ['s1002075', '1 m slab: 100 × 20 × 7.5 cm', 100, 20, 7.5], - ['s1002010', '1 m slab: 100 × 20 × 10 cm', 100, 20, 10], - ['s1003075', '1 m slab: 100 × 30 × 7.5 cm', 100, 30, 7.5], - ['s1003010', '1 m slab: 100 × 30 × 10 cm', 100, 30, 10], - ['s903', 'Grodan guide: 90 × 15 × 7.5 cm', 90, 15, 7.5], - ['s904', 'Grodan guide: 90 × 15 × 10 cm', 90, 15, 10], - ['s908', 'Grodan guide: 90 × 19.5 × 7.5 cm', 90, 19.5, 7.5], - ['s9012', 'Grodan guide: 90 × 30 × 7.5 cm', 90, 30, 7.5] - ] + ["custom", "Custom measured slab", 100, 15, 7.5], + ["s1001575", "1 m slab: 100 × 15 × 7.5 cm", 100, 15, 7.5], + ["s1001510", "1 m slab: 100 × 15 × 10 cm", 100, 15, 10], + ["s1002075", "1 m slab: 100 × 20 × 7.5 cm", 100, 20, 7.5], + ["s1002010", "1 m slab: 100 × 20 × 10 cm", 100, 20, 10], + ["s1003075", "1 m slab: 100 × 30 × 7.5 cm", 100, 30, 7.5], + ["s1003010", "1 m slab: 100 × 30 × 10 cm", 100, 30, 10], + ["s903", "Grodan guide: 90 × 15 × 7.5 cm", 90, 15, 7.5], + ["s904", "Grodan guide: 90 × 15 × 10 cm", 90, 15, 10], + ["s908", "Grodan guide: 90 × 19.5 × 7.5 cm", 90, 19.5, 7.5], + ["s9012", "Grodan guide: 90 × 30 × 7.5 cm", 90, 30, 7.5], + ], }; -if (typeof module === 'object' && module.exports) module.exports = SUBSTRATES; +if (typeof module === "object" && module.exports) module.exports = SUBSTRATES; diff --git a/tools/setup/ui.js b/tools/setup/ui.js index 6e0ee23..34eb35a 100644 --- a/tools/setup/ui.js +++ b/tools/setup/ui.js @@ -1,68 +1,219 @@ -'use strict'; -const $ = id => document.getElementById(id); -const value = id => { const s=$(id).value.trim(); return s === '' ? NaN : Number(s); }; -const fmt = (v, d=3) => Number(v).toLocaleString(undefined,{maximumFractionDigits:d}); -let allocation=null, weighed=null; -const records=[]; +"use strict"; +const $ = (id) => document.getElementById(id); +const value = (id) => { + const s = $(id).value.trim(); + return s === "" ? NaN : Number(s); +}; +const fmt = (v, d = 3) => + Number(v).toLocaleString(undefined, { maximumFractionDigits: d }); +let allocation = null, + weighed = null; +const records = []; function setPresets(id, entries, selected) { - for(const [key,label] of entries) $(id).add(new Option(label,key,key===selected,key===selected)); + for (const [key, label] of entries) + $(id).add(new Option(label, key, key === selected, key === selected)); } -setPresets('block-preset',SUBSTRATES.blocks,'hugo');setPresets('slab-preset',SUBSTRATES.slabs,'s1001575'); +setPresets("block-preset", SUBSTRATES.blocks, "hugo"); +setPresets("slab-preset", SUBSTRATES.slabs, "s1001575"); function loadPreset(id, entries, prefix) { - const entry=entries.find(x=>x[0]===$(id).value); - if(entry && entry[0]!=='custom') ['l','w','h'].forEach((x,i)=>$(prefix+x).value=entry[i+2]); + const entry = entries.find((x) => x[0] === $(id).value); + if (entry && entry[0] !== "custom") + ["l", "w", "h"].forEach((x, i) => ($(prefix + x).value = entry[i + 2])); calculate(); } -$('block-preset').addEventListener('change',()=>loadPreset('block-preset',SUBSTRATES.blocks,'b')); -$('slab-preset').addEventListener('change',()=>loadPreset('slab-preset',SUBSTRATES.slabs,'s')); -for(const prefix of ['b','s']) for(const axis of ['l','w','h']) $(prefix+axis).addEventListener('input',()=>$(prefix==='b'?'block-preset':'slab-preset').value='custom'); -$('pot-preset').addEventListener('change',()=>{if($('pot-preset').value!=='custom'){$('pv').value=$('pot-preset').value;$('pu').value='L';}calculate();}); -$('pv').addEventListener('input',()=>$('pot-preset').value='custom'); -$('pu').addEventListener('change',()=>$('pot-preset').value='custom'); -$('system').addEventListener('change',()=>{if(['cube','coco'].includes($('system').value))$('plants').value=1;else $('plants').value=3;calculate();}); +$("block-preset").addEventListener("change", () => + loadPreset("block-preset", SUBSTRATES.blocks, "b"), +); +$("slab-preset").addEventListener("change", () => + loadPreset("slab-preset", SUBSTRATES.slabs, "s"), +); +for (const prefix of ["b", "s"]) + for (const axis of ["l", "w", "h"]) + $(prefix + axis).addEventListener( + "input", + () => + ($(prefix === "b" ? "block-preset" : "slab-preset").value = "custom"), + ); +$("pot-preset").addEventListener("change", () => { + if ($("pot-preset").value !== "custom") { + $("pv").value = $("pot-preset").value; + $("pu").value = "L"; + } + calculate(); +}); +$("pv").addEventListener("input", () => ($("pot-preset").value = "custom")); +$("pu").addEventListener("change", () => ($("pot-preset").value = "custom")); +$("system").addEventListener("change", () => { + if (["cube", "coco"].includes($("system").value)) $("plants").value = 1; + else $("plants").value = 3; + calculate(); +}); function calculate() { - const mode=$('system').value, pot=$('pot-method').value; - $('block-fields').hidden=!['cube','stack'].includes(mode);$('slab-fields').hidden=!['slab','stack'].includes(mode);$('coco-fields').hidden=mode!=='coco'; - $('known-pot').hidden=pot!=='known';$('pot-dimensions').hidden=pot==='known'; - $('pot-a-label').textContent=pot==='box'?'Inside length · cm':'Top diameter · cm';$('pot-b-label').textContent=pot==='box'?'Inside width · cm':'Bottom diameter · cm'; - allocation=null;$('volume-error').textContent=''; - for(const id of ['per-plant','per-unit','per-zone','plant-total'])$(id).textContent='—'; - $('volume-detail').textContent='';$('shot-result').textContent=''; + const mode = $("system").value, + pot = $("pot-method").value; + $("block-fields").hidden = !["cube", "stack"].includes(mode); + $("slab-fields").hidden = !["slab", "stack"].includes(mode); + $("coco-fields").hidden = mode !== "coco"; + $("known-pot").hidden = pot !== "known"; + $("pot-dimensions").hidden = pot === "known"; + $("pot-a-label").textContent = + pot === "box" ? "Inside length · cm" : "Top diameter · cm"; + $("pot-b-label").textContent = + pot === "box" ? "Inside width · cm" : "Bottom diameter · cm"; + allocation = null; + $("volume-error").textContent = ""; + for (const id of ["per-plant", "per-unit", "per-zone", "plant-total"]) + $(id).textContent = "—"; + $("volume-detail").textContent = ""; + $("shot-result").textContent = ""; + try { + const block = ["cube", "stack"].includes(mode) + ? TDR.box(value("bl"), value("bw"), value("bh")) + : 0; + let base = ["slab", "stack"].includes(mode) + ? TDR.box(value("sl"), value("sw"), value("sh")) + : 0; + if (mode === "coco") + base = + pot === "known" + ? TDR.litres(value("pv"), $("pu").value) + : pot === "box" + ? TDR.box(value("pa"), value("pb"), value("ph")) + : TDR.taperedPot(value("pa"), value("pb"), value("ph")); + if (mode === "cube" && value("plants") !== 1) + throw new Error( + "For cubes only use one plant per unit; enter the number of cubes as units.", + ); + allocation = TDR.allocation(block, base, value("plants"), value("units")); + $("per-plant").textContent = fmt(allocation.plant) + " L"; + $("per-unit").textContent = fmt(allocation.unit) + " L"; + $("per-zone").textContent = fmt(allocation.zone) + " L"; + $("plant-total").textContent = fmt(allocation.plants, 0); + $("volume-detail").textContent = + mode === "stack" + ? `${fmt(block)} L cube + ${fmt(base)} L slab ÷ ${value("plants")} plants. This is an allocation, not a root boundary.` + : mode === "cube" + ? `${fmt(block)} L per measured block.` + : `${fmt(base)} L filled medium per unit, shared by ${value("plants")} plant(s).`; + try { + const s = TDR.shot( + allocation.plant, + value("shot-pct"), + value("emitters"), + value("flow"), + ); + $("shot-result").textContent = + `${fmt(s.ml, 1)} mL per plant · ${fmt(s.seconds, 1)} seconds at the entered flow. Measure delivery with a catch test; drainage and redistribution change the resulting VWC.`; + } catch (e) { + $("shot-result").textContent = e.message; + } + } catch (e) { + $("volume-error").textContent = e.message; + } + weighed = null; + $("weigh-error").textContent = ""; + $("weighed-vwc").textContent = "—"; + $("water-volume").textContent = ""; try { - const block=['cube','stack'].includes(mode)?TDR.box(value('bl'),value('bw'),value('bh')):0; - let base=['slab','stack'].includes(mode)?TDR.box(value('sl'),value('sw'),value('sh')):0; - if(mode==='coco')base=pot==='known'?TDR.litres(value('pv'),$('pu').value):pot==='box'?TDR.box(value('pa'),value('pb'),value('ph')):TDR.taperedPot(value('pa'),value('pb'),value('ph')); - if(mode==='cube' && value('plants')!==1)throw new Error('For cubes only use one plant per unit; enter the number of cubes as units.'); - allocation=TDR.allocation(block,base,value('plants'),value('units')); - $('per-plant').textContent=fmt(allocation.plant)+' L';$('per-unit').textContent=fmt(allocation.unit)+' L';$('per-zone').textContent=fmt(allocation.zone)+' L';$('plant-total').textContent=fmt(allocation.plants,0); - $('volume-detail').textContent=mode==='stack'?`${fmt(block)} L cube + ${fmt(base)} L slab ÷ ${value('plants')} plants. This is an allocation, not a root boundary.`:mode==='cube'?`${fmt(block)} L per measured block.`:`${fmt(base)} L filled medium per unit, shared by ${value('plants')} plant(s).`; - try {const s=TDR.shot(allocation.plant,value('shot-pct'),value('emitters'),value('flow'));$('shot-result').textContent=`${fmt(s.ml,1)} mL per plant · ${fmt(s.seconds,1)} seconds at the entered flow. Measure delivery with a catch test; drainage and redistribution change the resulting VWC.`;}catch(e){$('shot-result').textContent=e.message;} - }catch(e){$('volume-error').textContent=e.message;} - weighed=null;$('weigh-error').textContent='';$('weighed-vwc').textContent='—';$('water-volume').textContent=''; - try {weighed=TDR.weighed(value('dry'),value('wet'),value('sample-volume'),value('density'));$('weighed-vwc').textContent=fmt(weighed.vwc,2)+'%';$('water-volume').textContent=fmt(weighed.waterMl,1)+' mL of water in the weighed sample.';}catch(e){$('weigh-error').textContent=e.message;} - $('save-record').disabled=!weighed;$('download-record').disabled=records.length===0; - try{const d=TDR.dryback(value('peak'),value('current'));$('dryback-result').textContent=`${fmt(d.points,2)} percentage points = ${fmt(d.relative,2)}% of the peak VWC.`;}catch(e){$('dryback-result').textContent=e.message;} + weighed = TDR.weighed( + value("dry"), + value("wet"), + value("sample-volume"), + value("density"), + ); + $("weighed-vwc").textContent = fmt(weighed.vwc, 2) + "%"; + $("water-volume").textContent = + fmt(weighed.waterMl, 1) + " mL of water in the weighed sample."; + } catch (e) { + $("weigh-error").textContent = e.message; + } + $("save-record").disabled = !weighed; + $("download-record").disabled = records.length === 0; + try { + const d = TDR.dryback(value("peak"), value("current")); + $("dryback-result").textContent = + `${fmt(d.points, 2)} percentage points = ${fmt(d.relative, 2)}% of the peak VWC.`; + } catch (e) { + $("dryback-result").textContent = e.message; + } } -document.querySelectorAll('input,select').forEach(el=>el.addEventListener('input',calculate)); -$('save-record').addEventListener('click',()=>{ - calculate(); if(!weighed)return; - const raw=value('record-raw');if(!Number.isFinite(raw)||raw<=0||raw>4095){$('record-status').textContent='Enter a RAW average between 1 and 4095 before saving.';return;} - records.push([new Date().toISOString(),$('record-label').value,$('point').value,value('sample-volume'),value('dry'),value('wet'),value('density'),raw,weighed.vwc]); - $('record-status').textContent=`${records.length} record(s) added. Download before closing this page.`;calculate(); +document + .querySelectorAll("input,select") + .forEach((el) => el.addEventListener("input", calculate)); +$("save-record").addEventListener("click", () => { + calculate(); + if (!weighed) return; + const raw = value("record-raw"); + if (!Number.isFinite(raw) || raw <= 0 || raw > 4095) { + $("record-status").textContent = + "Enter a RAW average between 1 and 4095 before saving."; + return; + } + records.push([ + new Date().toISOString(), + $("record-label").value, + $("point").value, + value("sample-volume"), + value("dry"), + value("wet"), + value("density"), + raw, + weighed.vwc, + ]); + $("record-status").textContent = + `${records.length} record(s) added. Download before closing this page.`; + calculate(); }); -$('download-record').addEventListener('click',()=>{ - const header=['timestamp_utc','sample_sensor_reference','point','sample_volume_L','dry_assembly_g','current_assembly_g','density_g_ml','capture_raw','weighed_vwc_percent']; - const csv=[header,...records].map(row=>row.map(TDR.escapeCsv).join(',')).join('\r\n'); - const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='tdr-calibration-record.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000); +$("download-record").addEventListener("click", () => { + const header = [ + "timestamp_utc", + "sample_sensor_reference", + "point", + "sample_volume_L", + "dry_assembly_g", + "current_assembly_g", + "density_g_ml", + "capture_raw", + "weighed_vwc_percent", + ]; + const csv = [header, ...records] + .map((row) => row.map(TDR.escapeCsv).join(",")) + .join("\r\n"); + const url = URL.createObjectURL( + new Blob([csv], { type: "text/csv;charset=utf-8" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = "tdr-calibration-record.csv"; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); }); -function printTemplate(){ - const h=value('print-height'),z=value('print-center');$('print-error').textContent=''; - if(!Number.isFinite(h)||!Number.isFinite(z)||h<26||h>160||z<13||z>h-13){$('print-error').textContent='Height must be 26–160 mm; keep the full 26 mm contact face inside the substrate.';return false;} - const base=242,cy=base-z,top=base-h; +function printTemplate() { + const h = value("print-height"), + z = value("print-center"); + $("print-error").textContent = ""; + if ( + !Number.isFinite(h) || + !Number.isFinite(z) || + h < 26 || + h > 160 || + z < 13 || + z > h - 13 + ) { + $("print-error").textContent = + "Height must be 26–160 mm; keep the full 26 mm contact face inside the substrate."; + return false; + } + const base = 242, + cy = base - z, + top = base - h; // Every SVG coordinate is a physical millimetre on an A4 page. - $('print-sheet').innerHTML=`MT22 placement templateSubstrate ${h} mm · chosen rod centreline ${z} mm above the basePrint A4 / actual size / 100%. Disable Fit, Shrink and headers/footers.Measure BOTH 100 mm bars before using the sheet.User-selected position; not a manufacturer-validated depth.Transfer the actual pins to the line. Pin spacing is not specified.SUBSTRATE TOPBASE DATUM — bottom of growing medium, not gutter lip88 × 26 mm contact faceAll three rod centres on this lineFull rod insertion: 53 mm. Keep the long body level along the slab.100 mm100 mmINFWIN MT22 dimensions · verify your sensor revision · docs/PLACEMENT.md`; + $("print-sheet").innerHTML = + `MT22 placement templateSubstrate ${h} mm · chosen rod centreline ${z} mm above the basePrint A4 / actual size / 100%. Disable Fit, Shrink and headers/footers.Measure BOTH 100 mm bars before using the sheet.User-selected position; not a manufacturer-validated depth.Transfer the actual pins to the line. Pin spacing is not specified.SUBSTRATE TOPBASE DATUM — bottom of growing medium, not gutter lip88 × 26 mm contact faceAll three rod centres on this lineFull rod insertion: 53 mm. Keep the long body level along the slab.100 mm100 mmINFWIN MT22 dimensions · verify your sensor revision · docs/PLACEMENT.md`; return true; } -$('print-button').addEventListener('click',()=>{if(printTemplate())window.print();}); -window.addEventListener('beforeprint',printTemplate); +$("print-button").addEventListener("click", () => { + if (printTemplate()) window.print(); +}); +window.addEventListener("beforeprint", printTemplate); calculate(); diff --git a/tools/tdr_logger.py b/tools/tdr_logger.py index 90371c0..f4d5cb0 100644 --- a/tools/tdr_logger.py +++ b/tools/tdr_logger.py @@ -55,14 +55,21 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--port", type=int, default=80, help="Web server port (default 80)" ) - return p.parse_args() + p.add_argument("--max-age", type=float, default=120.0, + help="Blank wide readings older than this many seconds (default 120)") + args = p.parse_args() + if not __import__('math').isfinite(args.interval) or args.interval <= 0: + p.error("--interval must be a finite positive number") + if not __import__('math').isfinite(args.max_age) or args.max_age <= 0: + p.error("--max-age must be a finite positive number") + return args def now_iso() -> str: return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") -def iter_events(url: str): +def iter_events(url: str, on_disconnect=None): """Yield (event_name, data_dict) from an SSE stream. Reconnects on its own if the connection drops. @@ -87,7 +94,13 @@ def iter_events(url: str): except json.JSONDecodeError: continue yield event, data + # A clean EOF is still a disconnected stream. + if on_disconnect: + on_disconnect() + time.sleep(5) except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as err: + if on_disconnect: + on_disconnect() print(f"[{now_iso()}] connection lost ({err}), retrying in 5s", file=sys.stderr) time.sleep(5) @@ -129,49 +142,90 @@ def run_long(url: str, out_path: str) -> None: fh.flush() -def run_wide(url: str, out_path: str, interval: float) -> None: +class ReadingBuffer: + """Values retain observation times; a broken stream cannot look live.""" + def __init__(self): + self.latest = {} + self.online = False + + def update(self, key, value, received): + self.latest[key] = (value, received) + self.online = True + + def disconnect(self): + self.online = False + + def snapshot(self, columns, now, max_age): + row = [] + for key in columns: + value, received = self.latest.get(key, ("", None)) + age = None if received is None else max(0.0, now - received) + valid = self.online and age is not None and age <= max_age + row.extend([value if valid else "", round(age, 1) if age is not None else ""]) + return row + + +def wide_header(columns): + return ["timestamp", "stream_connected"] + [item for c in columns for item in (c, c + "__age_s")] + + +def validate_append_header(path, header): + """Do not append a different column order/meaning to an existing CSV.""" + import os + if os.path.exists(path) and os.path.getsize(path): + with open(path, newline="", encoding="utf-8") as fh: + if next(csv.reader(fh), None) != header: + raise ValueError("Existing CSV header differs. Choose a new --out file.") + + +def run_wide(url: str, out_path: str, interval: float, max_age: float = 120.0) -> None: import os import threading - latest: dict[str, object] = {} + readings = ReadingBuffer() lock = threading.Lock() + def disconnected(): + with lock: + readings.disconnect() + def reader() -> None: - for event, data in iter_events(url): + for event, data in iter_events(url, on_disconnect=disconnected): if event not in ("state", "message"): continue key = sensor_key(data) - if key is None: - continue - with lock: - latest[key] = numeric_value(data) - - t = threading.Thread(target=reader, daemon=True) - t.start() + if key is not None: + with lock: + readings.update(key, numeric_value(data), time.monotonic()) - # Wait for the first sweep of sensors so the header is complete. + threading.Thread(target=reader, daemon=True).start() print(f"[{now_iso()}] collecting sensors for {min(interval, 15):.0f}s...") time.sleep(min(interval, 15)) - with lock: - columns = sorted(latest.keys()) + columns = sorted(readings.latest) if not columns: - print("No sensors seen yet. Is the host right and the device up?", - file=sys.stderr) - + raise RuntimeError("No sensor events received. Check the host/connection and retry; no CSV was created.") + header = wide_header(columns) + validate_append_header(out_path, header) new_file = not os.path.exists(out_path) or os.path.getsize(out_path) == 0 + reported_new = set() with open(out_path, "a", newline="", encoding="utf-8") as fh: writer = csv.writer(fh) if new_file: - writer.writerow(["timestamp"] + columns) + writer.writerow(header) fh.flush() - print(f"[{now_iso()}] logging (wide) to {out_path} every {interval:.0f}s, " - f"Ctrl-C to stop") + print(f"[{now_iso()}] logging (wide) to {out_path}; readings older than {max_age:g}s are blank") while True: time.sleep(interval) with lock: - row = [latest.get(c, "") for c in columns] - writer.writerow([now_iso()] + row) + new_columns = set(readings.latest) - set(columns) - reported_new + connected = readings.online + row = readings.snapshot(columns, time.monotonic(), max_age) + if new_columns: + print("New entities are outside the fixed CSV header: " + ", ".join(sorted(new_columns)) + + ". Restart with a new file or use long format to include them.", file=sys.stderr) + reported_new.update(new_columns) + writer.writerow([now_iso(), int(connected)] + row) fh.flush() @@ -186,7 +240,7 @@ def handle_sigint(_sig, _frame): signal.signal(signal.SIGINT, handle_sigint) if args.wide: - run_wide(url, args.out, args.interval) + run_wide(url, args.out, args.interval, args.max_age) else: run_long(url, args.out) return 0