diff --git a/.claude/rules/esphome-yaml.md b/.claude/rules/esphome-yaml.md index ca1e032..c192995 100644 --- a/.claude/rules/esphome-yaml.md +++ b/.claude/rules/esphome-yaml.md @@ -29,10 +29,11 @@ paths: - Packages are fetched from GitHub by each build server — after pushing changes, run `scripts/esphome_fleet_update.py reset` (NAS + paired VPS builder) before compiling; a NAS-only purge leaves the VPS clone stale. - Production packages rely on ESPHome's native deep-sleep OTA guard (`>=2026.8.0`): an orderly `deep_sleep.enter` confirms the app image via - `on_safe_shutdown` -> `confirm_app_image_`, so `smart_plant_core.yaml` no - longer calls `safe_mode.mark_successful` (retired in `infra-3rr.47`). The - public `configuration.yaml` example keeps the explicit call because it targets - arbitrary ESPHome versions. + `on_safe_shutdown` -> `confirm_app_image_`. `smart_plant_core.yaml` and the + public `configuration.yaml` example also call `safe_mode.mark_successful` + before sleep (upstream-reviewed shared recovery; redundant and harmless on + `>=2026.8.0`; `infra-3rr.47` had retired it from the core, restored by the + upstream sync `infra-3rr.78`). - To read deployed fleet ESPHome version, trust the HA discovery config `dev.sw` field or the current function-only topic `/sensor/esphome_version/state`, cross-checked with `esphome/discover/`. To prove a package diff --git a/.claude/rules/nas-operations.md b/.claude/rules/nas-operations.md index 5b9a7bf..e79a88c 100644 --- a/.claude/rules/nas-operations.md +++ b/.claude/rules/nas-operations.md @@ -10,8 +10,9 @@ an OTA upload does not update the bootloader. - ESPHome's native guard (`>=2026.8.0`) confirms the app image on any orderly `deep_sleep.enter`, so a short measurement cycle is no longer misclassified as - a failed OTA boot; `smart_plant_core.yaml` no longer calls - `safe_mode.mark_successful` (retired in `infra-3rr.47`; the public - `configuration.yaml` example keeps it for version-agnostic safety). + a failed OTA boot; `smart_plant_core.yaml` calls + `safe_mode.mark_successful` again before sleep (redundant and harmless on + `>=2026.8.0`; `infra-3rr.47` retired it, restored by the upstream sync + `infra-3rr.78`). - OTA detection: `nc -z -w1 3232` — ICMP ping is unreliable on ESP32. - Flash: `docker exec esphome esphome upload /config/.yaml --device ` diff --git a/.gitattributes b/.gitattributes index 51dbb73..359c377 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ # Treat PDF documents as binary to preserve their encoded content. *.pdf binary -# Preserve deterministic recovered symbol libraries on Windows. +# Keep the generated symbol library identical to the recovery output on Windows. Design/*.kicad_sym text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1f91b32 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Changes to these paths need the maintainer's approval before merging. +# Firmware, examples, docs and tests are not listed, so collaborators can +# merge those PRs themselves once CI passes. + +# Hardware design and manufacturing outputs +/Design/ @JGAguado +/Enclosure/ @JGAguado +/Documentation/ @JGAguado + +# Repository governance: CI and this file +/.github/ @JGAguado diff --git a/.github/workflows/firmware-tests.yml b/.github/workflows/firmware-tests.yml index 5d44e1d..8a859f9 100644 --- a/.github/workflows/firmware-tests.yml +++ b/.github/workflows/firmware-tests.yml @@ -20,14 +20,14 @@ jobs: with: python-version: "3.12" - # Keep the native and package tests on the validated ESPHome release. + # The soil-voltage tests require this exact ESPHome release. - name: Install ESPHome run: pip install esphome==2026.9.0 pyyaml - name: Install Cairo build dependencies run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config - - name: Install label renderer dependencies + - name: Install label generator dependencies run: pip install -r examples/multi-device/label_maker/requirements.txt - name: Unit tests @@ -35,3 +35,6 @@ jobs: - name: Validate standalone configuration run: esphome config docs/source/files/configuration.yaml + + - name: Validate public API and MQTT examples + run: python scripts/validate_public_examples.py diff --git a/AGENTS.md b/AGENTS.md index 65ad787..3fcdee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,9 +47,10 @@ USB flash of `firmware.factory.bin`; OTA does not update the bootloader. - ESPHome `>=2026.8.0` natively confirms the app image on every orderly `deep_sleep.enter`, covering the normal cycle that finishes before ESPHome's - default 60-second OTA validation window, so production packages no longer call - `safe_mode.mark_successful` (retired in `infra-3rr.47`; the public - `configuration.yaml` example keeps it for version-agnostic safety). + default 60-second OTA validation window. The core and the public example still + call `safe_mode.mark_successful` before sleep (upstream-reviewed shared + recovery; redundant and harmless; `infra-3rr.47` had retired it from the core, + restored by the upstream sync `infra-3rr.78`). - Rollback for shared-package changes: merge a revert PR into `V2R1`, purge the package cache, and flash the last validated factory/OTA image. USB recovery is the final fallback. @@ -133,9 +134,9 @@ version/config hash live on the devices. Read them from the source every time: generic hardening changes. Read their live review state from GitHub/Beads. - MAX17048 native model — gate `infra-3rr.45` (defer 2026-10-15) on ESPHome PR #18594 reaching a stable release; implementation `infra-3rr.46` hard-blocked until then. - - `safe_mode.mark_successful` retired from `smart_plant_core.yaml` — `infra-3rr.47` - (native `>=2026.8.0` guard); the public `configuration.yaml` example keeps the - defensive call. Compile/explicit-IP upload split — `infra-3rr.27`. + - `safe_mode.mark_successful` was retired from `smart_plant_core.yaml` by + `infra-3rr.47` (native `>=2026.8.0` guard) and restored by the upstream sync + `infra-3rr.78`. Compile/explicit-IP upload split — `infra-3rr.27`. - **OTA**: Device Builder push only (`ota: platform: esphome` + `scripts/esphome_fleet_update.py`, maintenance-window gated by `ota_min_battery`). Pull-OTA removed 2026-09-03 (`infra-3rr.42`) as over-engineered for 8 devices. Historical eval `docs/pull-ota-eval.md` (`infra-3rr.22`). diff --git a/CLAUDE.md b/CLAUDE.md index 0f1fde7..2cfeffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,9 +91,10 @@ not evidence that every probe was calibrated individually. - **Old bootloader**: `Bootloader too old for OTA rollback` requires one USB factory flash. OTA does not update the bootloader. - **Deep-sleep OTA validation**: ESPHome `>=2026.8.0` natively confirms the app - image on every orderly `deep_sleep.enter`, so production packages no longer - call `safe_mode.mark_successful` (retired in `infra-3rr.47`; the public - `configuration.yaml` example keeps it for version-agnostic safety). + image on every orderly `deep_sleep.enter`. The upstream-reviewed shared recovery + calls `safe_mode.mark_successful` again before sleep, in the core and the public + example (redundant and harmless on `>=2026.8.0`; `infra-3rr.47` had retired it + from the core, restored by the upstream sync `infra-3rr.78`). - **OTA path**: Device Builder push only (`ota: platform: esphome` + `scripts/esphome_fleet_update.py`, triggered in the maintenance window). Pull-OTA was removed 2026-09-03 (`infra-3rr.42`) as over-engineered for an diff --git a/README.md b/README.md index 995ad95..e4f4146 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,99 @@ +# Smart Plant -[![Documentation Status][documentation-shield]][documentation] -[![License][license-shield]](LICENSE) -[![Project Maintenance][maintenance-shield]][maintenance] -[![buyit][buyit-shield]][buyit] +**See how your plant is doing — on its own e-paper label and in Home Assistant.** -# Smart Plant -The Smart Plant is a board that can be plugged directly on the soil, close to the plant, that allows the measurement of the environment (soil moisture, environment temperature & illuminance), and presents the information on an ePaper display. +Smart Plant measures soil moisture, light, temperature and humidity. Place it +in your plant pot, read the measurements on the 2.9-inch e-paper display, +and connect it to Home Assistant with ESPHome. + +![Five Smart Plant devices with custom plant labels and coloured enclosures](docs/source/_static/Slide%202.png) + +**[Get started](https://smart-plant.readthedocs.io/en/v2r1/getting_started.html)** +· **[Create your plant label](https://github.com/JGAguado/Label-maker)** +· **[Get a board](https://www.elecrow.com/smart-plant.html)** +· **[Read the documentation](https://smart-plant.readthedocs.io/en/v2r1/)** + +[![Documentation](https://readthedocs.org/projects/smart-plant/badge/?version=v2r1)](https://smart-plant.readthedocs.io/en/v2r1/) +[![Firmware tests](https://github.com/JGAguado/Smart_Plant/actions/workflows/firmware-tests.yml/badge.svg?branch=V2R1)](https://github.com/JGAguado/Smart_Plant/actions/workflows/firmware-tests.yml) +[![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/license-CC_BY--NC--SA_4.0-lightgrey)](LICENSE) + +## A useful label for every plant + +- **Check the soil and surroundings.** Read soil moisture, ambient light, + air temperature and relative humidity. +- **Read it at the pot.** The e-paper screen shows the last displayed readings + while the device sleeps. +- **Make it yours.** Give each plant its own name, illustration and gauge + background with the companion Label-maker project. +- **Connect to Home Assistant.** Choose the native ESPHome API or retained + MQTT readings for devices that spend most of their time asleep. +- **Run on battery.** The firmware sleeps between measurement cycles. + The hardware also supports an optional solar cell. + +## Start with one plant + +1. [Get a board](https://www.elecrow.com/smart-plant.html), or use the + [hardware design files](Design/) to explore the board. +2. Follow the [getting-started guide](https://smart-plant.readthedocs.io/en/v2r1/getting_started.html) + and [ESPHome programming guide](https://smart-plant.readthedocs.io/en/v2r1/programming.html). +3. Set your Wi-Fi credentials and calibrate the soil sensor for your setup. +4. Add your own label background, then install the firmware. + +The [standalone configuration](docs/source/files/configuration.yaml) is the +starting point for a single device. Read the +[safety instructions](https://smart-plant.readthedocs.io/en/v2r1/safety.html) +before assembly and flashing. + +## Create your plant label + +The companion **[Label-maker](https://github.com/JGAguado/Label-maker)** +generates printable plant labels and e-paper backgrounds from JSON or YAML +templates. Use it to add a plant name and illustration to your display. + +[![Example lemon-tree e-paper background](docs/source/images/programming/Lemon_tree_label_page_1.png)](https://github.com/JGAguado/Label-maker/tree/main/1_Plants) + +Follow the [step-by-step label guide](docs/source/labels.rst). +Start with the [plant examples and generator](https://github.com/JGAguado/Label-maker/tree/main/1_Plants), +then follow the programming guide to use the resulting background image. +The firmware draws the live readings over that image. -

- - -

+## Connect one plant or several -## Project tree +| Setup | Starting point | During deep sleep | +| --- | --- | --- | +| One device with the native ESPHome API | [Standalone configuration](docs/source/files/configuration.yaml) | No network response; entity availability depends on Home Assistant setup. | +| Several devices with retained MQTT readings | [MQTT example](examples/multi-device/my-lemon-tree.yaml) | Last published readings remain available. | +| Shared packages with the native ESPHome API | [API example](examples/multi-device/my-lemon-tree-api.yaml) | No network response; entity availability depends on Home Assistant setup. | -
  • Design
  • -
  • Documentation
  • +The [multi-device guide](examples/multi-device/README.md) explains the shared +hardware core and transport profiles. Each device sets its identity and +credentials. Calibration and label images use shared defaults unless overridden. -
  • README.md
  • +## Explore the hardware -## ESPHome firmware paths +Smart Plant combines an ESP32-S2, a soil-moisture probe, environmental sensors +and an e-paper display. The repository includes board design files and +printable enclosure models. -- The [programming guide](./docs/source/programming.rst) and its standalone - configuration are the simplest starting point for one device. -- The [multi-device example](./examples/multi-device/README.md) documents the - shared core plus native-API/MQTT profiles. Its inventory, generated metadata, - fleet updater, and NAS/VPS procedures are specific to the `flowcool` deployment - and are not required for a normal installation. +- [Board design](Design/) +- [Enclosure models](Enclosure/) +- [Schematics and supporting files](Documentation/) +- [Technical specifications](https://smart-plant.readthedocs.io/en/v2r1/specs.html) +- [Assembly guide](https://smart-plant.readthedocs.io/en/v2r1/assembly.html) +## Questions and contributions +Use [Discussions](https://github.com/JGAguado/Smart_Plant/discussions) for +questions and ideas. Report reproducible problems in +[Issues](https://github.com/JGAguado/Smart_Plant/issues). -[documentation-shield]: https://readthedocs.org/projects/smart-plant/badge/?version=v2r1&style=for-the-badge -[documentation]: https://smart-plant.readthedocs.io/ +Contributions to firmware, documentation and examples are welcome. Describe +the problem, the change and how you verified it in your pull request. -[license-shield]: https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg?style=for-the-badge +Created by [J. G. Aguado](https://github.com/JGAguado), with contributions +from the community. -[maintenance-shield]: https://img.shields.io/badge/maintainer-J.%20G.%20Aguado-2e48a7.svg?style=for-the-badge -[maintenance]: https://github.com/JGAguado +## License -[buyit-shield]: https://img.shields.io/badge/get%20it%20on-elecrow-399BD7.svg?style=for-the-badge -[buyit]: https://www.elecrow.com/smart-plant.html +This project uses [CC BY-NC-SA 4.0](LICENSE). See the license for attribution, +non-commercial use and sharing requirements. diff --git a/docs/source/faq.rst b/docs/source/faq.rst index ace6e2f..0f1067b 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -1,63 +1,115 @@ -💬 :term:`FAQ` -============================= - -When trying to flash the |Product| with `ESPHome Web `_ , I get a spinning ring with "connecting" for a few seconds and then it errors out with "Failed to initialize". - This is an issue related to `ESPHome Web `_ when trying to flash ESP32-S2 modules and (at the moment of writing this guide) is still unsolved. Please check alternative ways (on the :ref:`programming-esphome` programming section) of flashing the |Product|. - -After connecting the battery, I don't see the e-paper refresh or any LED on. - The two LEDs that are on the board (STDBY & CHRG) only indicate the state of the battery while charging (USB-C or solar panel). The way to - check if the SmartPlant is alive is pressing the Reset pushbutton and see that the e-paper refreshes correctly. If not, you will need to - charge the battery through the USB-C. - -After connecting the battery and the USB power I see the both LED (STDBY & CHRG) on. - Check the battery connection polarity: red with + and black/blue with -. - Since there is no standard relative to the JST connectors polarity, please pay - special attention when connecting any battery to the board. - -Can I upload ESPHome directly from the Home Assistance setup running on my Raspberry Pi? - Yes you can, and actually this is one of the simplest and more effective way to upload ESPHome. - -Can I upload firmware through the USB-C? - Yes, the ESP32-S2 includes a full-speed USB On-The-Go (OTG) interface to enable USB communication. - -When I try to compile the given YAML and upload it into the board I get some error messages saying that I miss some files (Audiowide.ttf, icon-map.h, etc). How to fix it? - Just the configuration YAML file isn’t enough and you might get an error asking for missing items (the fonts and the images). The reason is because - on the configuration we are asking for some files (the fonts that you want for the digits or the icons and the background image with the plant and the gauges). - - Therefore you need to upload into the folder with the configuration YAML file the required files. I strongly suggest to use Visual studio code and just drag and - drop the items according to the structure mentioned on the :ref:`programming-esphome` programming section. - -How is the time handled in the board? - The time is handled by the ESP32 internal RTC to keep the design compact and straightforward. Therefore the deep-sleep timing is not very - precise, I have observed a loss of around 1-2 minutes out of every hour, resulting in a deep sleep period that lasts around 58 minutes - instead of 60. However, for the kind of application this is not a quite big issue. - -Can I use it if there is no internet connection? - Yes, and based on tests I made, actually will improve the battery duration in almost one week. You just need to adjust the config YAML file - and remove the Wifi components. - -Can Smart Plant be used outdoor in garden? Is it weatherproof? And with the printed enclosure? - I strongly recommend you to avoid placing them outdoor in garden, directly under a water hose/ sprinkles zone - or heavy rain. Instead use them on indoor plant pots (or a covered balcony). - Despite I designed a 3d printable enclosure that would prevent most of the dirt particles and certain level of moisture from enter in - the electronics area, I cannot warranty fully waterproofness. - - However, one of my first backers embedded his board in resin (being careful to leave the reset pushbutton and the USB-C port accessible). - He lost access to changing the LiPo, but in addition to a pretty cool design, he gained UV and water resistance. - - Regarding sun exposure, the e-paper display degrades under direct UV light exposure. For this, there are some UV blocking films, - but at the moment are not mounted on the e-paper displays I sell with the Smart Plant. However they can be acquired on Amazon and - placed over the display. - -The illluminance sensor doesn't seem to present the right values. - Since the VEML7700 gains can be adjusted via software to optimize the illluminance measurement, please have a look at the `Adafruit's tutorial `_. - -What is battery life on single charge? - With the ESPHome configuration YAML, (10s working and e-paper update, and 1h on deep sleep mode) the expected battery life is around 40-50 days. - If you use it without the WiFi settings even more. - -Can I attach a solar panel to extend the battery? - Yes, there are two unsoldered pins marked as +/- where you can attach a 5V solar pannel of your choice. Just make sure the size is fitting - the rear cover's size so you can make two holes for passing the wires and glue it carefully to the surface. - When placing the SmartPlant in the pot, just make sure the sun exposed area is the solar panel and not the e-ink display. +Frequently asked questions +========================== +Flashing and setup +------------------ + +The serial port is missing or flashing stops at "connecting". What should I check? + Use a USB data cable, check access to the serial port and close other programs + that use it. Enter :ref:`flashing` mode again. With ESPHome Web, use a browser + that supports Web Serial. If that path fails, use Device Builder's host serial + upload or the ESPHome command-line tools in :doc:`programming`. A failed + connection alone does not identify a firmware or board fault. + +Do I need Home Assistant? + No. ESPHome can build and flash the firmware from its command-line tools. + The local display works without Home Assistant. Use Home Assistant if you + want to collect readings and manage entities there. + +Can I flash through USB-C? + Yes. Follow :ref:`flashing` and use a data cable. Disconnect the battery and + solar input while flashing, as described in the programming guide. + +The build cannot find an image or font. What should I copy? + Check the exact ``file`` entries in your YAML. The supplied example downloads + its background and Google Fonts during compilation, so the build computer + needs internet access. It does not require manually copying ``Audiowide.ttf`` + or ``icon-map.h``. For a local image, keep its path relative to the device + YAML and make sure that file exists. See :doc:`labels`. + +Display and measurements +------------------------ + +The LEDs are off. Is the device broken? + The STDBY and CHRG LEDs indicate charging state, not whether the firmware is + running. An e-paper image also remains visible without power. Check the first + boot over USB serial and confirm the measurement and sleep cycle. A new board + needs firmware before it can refresh the screen. + +The battery and USB are connected, and both charging LEDs are on. What should I do? + Disconnect power and check battery connector polarity against the board's + markings. JST connector polarity is not standardized. Read :doc:`safety` + before reconnecting the battery. + +The screen has not changed. Is it asleep? + It may be asleep, but the image alone cannot prove that. The firmware normally + refreshes once per measurement cycle, then sleeps. Check logs or the timestamp + of a new network reading. The standalone clock shows ``--`` when network + time was not available. Use sensor logs to investigate invalid readings. + +The soil percentage looks wrong. How do I calibrate it? + The default voltage mapping is a starting point. Use readings from your probe + in representative dry and wet conditions and adjust ``calibrate_linear``. + See :doc:`programming`. Changing a gauge's preferred range in Label-maker + does not change the sensor calibration. + +The light value looks wrong. What should I check? + Check that the enclosure and plant do not cover the light sensor. Compare + the logged illuminance value with the number on the display. A gauge needle + can reach its limit while the numeric value continues beyond that range. + Check the display scale before changing sensor settings. + +Why do the gauge needles not match my custom background? + The background geometry and ranges must match the YAML drawing code. The + Label-maker and firmware examples differ in some ranges. Follow the layout + checks in :doc:`labels` before generating your final image. + +Connectivity and sleep +---------------------- + +Why does Home Assistant show the device as unavailable? + The standalone native-API example sleeps between readings. It cannot answer + API requests while asleep. Home Assistant can retain the last values when + it recognizes deep-sleep support; behavior depends on the integration and + how the device was registered. An MQTT profile can retain its last published + readings too. Stored readings are not live measurements. Check their age. + Do not add an MQTT-profile device through the ESPHome integration as well. + +Does USB power keep the firmware awake for OTA? + No. The supplied example still enters deep sleep on USB power. Use serial + flashing or start OTA during an awake window. Check your selected YAML + before relying on any remote command to keep the device awake. + +Can it work without internet access? + Local measurements and the display do not need Home Assistant or an internet + service. However, the example uses network time, and compilation downloads + fonts and an image. For an offline installation, supply local build assets + and review the time and network-dependent actions in your configuration. + Removing only the Wi-Fi block is not a complete offline conversion. + +Why does the wake time drift? + Deep-sleep timing and the awake measurement period affect the interval between + readings. Do not treat the one-hour sleep setting as a precision clock. Use + observed timestamps if you need to estimate the next wake. + +Battery and placement +--------------------- + +How long does one charge last? + It depends on battery capacity and health, Wi-Fi conditions, awake duration, + measurement frequency, display behavior and temperature. The older guide + quoted 40-50 days for a cycle of about 10 seconds awake and one hour asleep, + but that is not a validated guarantee for the current firmware. Measure your + own setup before planning an unattended installation. + +Is Smart Plant waterproof? + No waterproof rating is provided. Keep the electronics away from rain, + sprinklers and watering. The printed enclosure does not make the device + waterproof. Protect the e-paper display from direct UV exposure. See + :doc:`safety` and :doc:`assembly`. + +Can I add a solar panel? + The board supports an optional solar input. Follow the voltage, size and + polarity requirements in :doc:`assembly`. Keep the panel exposed to light + while protecting the display and electronics. Solar input does not guarantee + that charging will cover your device's consumption. diff --git a/docs/source/files/configuration.yaml b/docs/source/files/configuration.yaml index 07209a7..683c0b9 100644 --- a/docs/source/files/configuration.yaml +++ b/docs/source/files/configuration.yaml @@ -401,6 +401,9 @@ script: else: - script.execute: handle_display_busy_fault + # With the default 30s budget, reset and sleep request take at most + # 30.510s plus event-loop latency. This ends at deep_sleep.enter; native + # shutdown hooks can still send the final display SPI sleep command. - id: display_recovery_budget mode: single then: @@ -436,7 +439,7 @@ script: # remain enabled, including the stock driver's final SPI sleep command. - id: force_sleep_after_busy_fault then: - # Keep this safeguard for standalone users on arbitrary ESPHome versions. + # Keep this safeguard for users on arbitrary ESPHome versions. - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc diff --git a/docs/source/files/lemon-tree-label.json b/docs/source/files/lemon-tree-label.json new file mode 100644 index 0000000..99feec8 --- /dev/null +++ b/docs/source/files/lemon-tree-label.json @@ -0,0 +1,157 @@ +{ + "_source": "Adapted from J. G. Aguado, JGAguado/Label-maker, 1_Plants/examples/Lemon_tree/Lemon_tree.json at 85648df6982435ec3ca0dfb3d29630e85afb0fc0", + "_changes": "Match standalone light and humidity scales; bound the illustrative preferred light range.", + "general": { + "size": [ + 296, + 128 + ], + "background": [ + 1, + 1, + 1 + ], + "radius": 10 + }, + "title": { + "value": "Lemon tree", + "font_type": "Audiowide", + "font_size": 20, + "position": [ + 5, + 20 + ] + }, + "subtitle": { + "value": "Citrus limon", + "font_type": "Audiowide", + "font_size": 15, + "position": [ + 5, + 35 + ] + }, + "image": { + "path": "examples/Lemon_tree/Lemon_tree.png", + "size": [ + 90, + 90 + ], + "position": [ + 10, + 37 + ] + }, + "bubble": { + "path": "../0_Resources/bubble.png", + "size": [ + 185, + 185 + ], + "position": [ + 60, + 38 + ] + }, + "life-cycle": { + "display": false, + "icon": "../0_Resources/sprout.png", + "span": "Jan-Mar" + }, + "synchronized": { + "display": false, + "icon": "../0_Resources/calendar-sync.png", + "position": [ + 20, + 280, + 15, + 15 + ] + }, + "parameters": { + "moisture": { + "rel_range": [ + 30, + 60 + ], + "abs_range": [ + 0, + 100 + ], + "recomended": "3/week", + "unit": "%", + "icon": "../0_Resources/cup-water.png", + "font_type": "Audiowide", + "font_size": 12, + "font_xoff": 5, + "position": [ + 80, + 50 + ], + "radius": 22 + }, + "light": { + "rel_range": [ + 600, + 3775 + ], + "abs_range": [ + 0, + 3775 + ], + "recomended": "High", + "unit": "lux", + "icon": "../0_Resources/white-balance-sunny.png", + "font_type": "Audiowide", + "font_size": 12, + "font_xoff": 0, + "position": [ + 134, + 70 + ], + "radius": 22 + }, + "temperature": { + "rel_range": [ + 18, + 29 + ], + "abs_range": [ + -10, + 50 + ], + "recomended": "15-25 C", + "unit": "C", + "icon": "../0_Resources/thermometer.png", + "font_type": "Audiowide", + "font_size": 12, + "font_xoff": 0, + "position": [ + 188, + 50 + ], + "radius": 22 + }, + "humidity": { + "rel_range": [ + 20, + 80 + ], + "abs_range": [ + 20, + 80 + ], + "recomended": "Normal", + "unit": "%", + "icon": "../0_Resources/water-percent.png", + "font_type": "Audiowide", + "font_size": 12, + "font_xoff": 0, + "position": [ + 242, + 70 + ], + "radius": 22 + } + } +} diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index c33ae87..33134bf 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -1,15 +1,23 @@ -🚀 Getting started -================== - - Trust me, I know what I'm doing... +Getting started +=============== - -- Every engineer while ignoring the instructions book. +Take your Smart Plant from the assembled board to its first readings. -Regardless of your professional background, I encourage you to have a quick read through the following pages in order to get your |Product| -onboarding smoother and minimize the risk of damaging the device and/or yourself. +1. Read :doc:`safety`. Check the battery connector polarity before applying power. +2. Follow :doc:`assembly` to connect the supported display and fit the enclosure. +3. Follow :doc:`programming` to configure Wi-Fi, flash ESPHome and verify one + measurement, display refresh and sleep cycle. +4. Follow :doc:`labels` to give your plant its own display background. +5. Use :doc:`faq` if flashing, readings or connectivity do not work as expected. + +Start with the supplied example for one device. After the first successful +cycle, choose whether you need Home Assistant and whether sleeping devices +should retain their readings there. The programming guide explains the API +and MQTT choices. .. toctree:: :maxdepth: 2 assembly programming + labels diff --git a/docs/source/index.rst b/docs/source/index.rst index 3da1e70..d818350 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,67 +1,65 @@ Smart Plant -############### +=========== -.. raw:: html - - +See how your plant is doing, on its e-paper label and in Home Assistant. +Smart Plant measures soil moisture, light, temperature and humidity. Its +2.9-inch display keeps the last image while the device sleeps between readings. +.. image:: images/getting_started/Enclosed.jpg + :alt: Five Smart Plant devices with custom labels and coloured enclosures + :width: 100% -Welcome to the documentation page of the |Product|, a device to monitor the environmental conditions of your favourite plant. - -With the |Product| you can measure a range of paramaters such as the soil moisture, ambient light and the air temperature & relative humidity. -In adition to the IoT capabilities of streaming the measured parameters into your favourite platform, like Home Assistant, you can read the data directly on -the integrated 2.9" e-paper display. - -As you can imagine, all this features would be useless if the design is not optimized to consume the lowest energy possible: thanks to the deep-sleep features of -the ESP32 microcontroller, a selection of low-consumption electronics (high-efficient LDOs in standby modes, power-switched sensors and partially refreshed e-paper) -and the optional solar cell. - -Everything packed in a quite compact but elegant device that can be directly plugged into your plant's pot. +Choose your next step +--------------------- +:doc:`getting_started` + Assemble the board, install ESPHome and verify your first readings. -Contents --------- +:doc:`labels` + Create a plant label with the companion Label-maker project. -:doc:`safety` - How to operate and manipulate the |Product|. +:doc:`programming` + Configure one device and choose how to connect it to Home Assistant. -🛒 `Buy `_ - Get your own |Product| kit today. +:doc:`faq` + Troubleshoot flashing, the display, sleep and connectivity. -:doc:`details` - A deeper explanation on the systems of the |Product|. +`Get a board `_ + Buy a Smart Plant kit. -:doc:`getting_started` - First steps for configuring and working with the |Product|. +Explore the design +------------------ -:doc:`design` - Design files of the |Product|. +Use :doc:`details` to understand the hardware, :doc:`specs` for its +specifications and :doc:`design` for board and enclosure files. +Battery operation and an optional solar input support installations away from +a USB supply. Battery life depends on your configuration and conditions; see +:doc:`faq` before planning an unattended installation. .. _support: -Technical support ------------------ +Questions and support +--------------------- -If you have technical problems or cannot find the information that you need in the provided documentation, -please contact me directly: +Ask questions and share ideas in +`GitHub Discussions `_. +Report reproducible problems in +`GitHub Issues `_. Include the +board revision, ESPHome version and relevant logs with credentials removed. :Author: |Author| :Contact: |Email| - :Board: |Product| :Revision: |Version| -:Date: |Date| - .. toctree:: :maxdepth: 3 :hidden: - safety - 🛒 Buy - details + safety getting_started - design + details + design specs glossary - faq + faq diff --git a/docs/source/labels.rst b/docs/source/labels.rst new file mode 100644 index 0000000..73d48d7 --- /dev/null +++ b/docs/source/labels.rst @@ -0,0 +1,133 @@ +Create your plant label +======================= + +Give your Smart Plant a name and illustration with the companion +`Label-maker project `_. +Its plant generator creates the static background. ESPHome draws the current +measurements and gauge needles over that image. + +.. image:: images/programming/Lemon_tree_label_page_1.png + :alt: Lemon-tree background with four gauges and space for live readings + :width: 592px + +Start with an existing background +--------------------------------- + +For a first hardware test, you can keep the supplied lemon-tree image. Its +preferred-range arcs may differ from your firmware scales. Before using them +to interpret readings, generate a matching background as described below. +To browse other ready-made labels, see the +`plant examples `_ +and their generated ``*_label_page_1.png`` files. Check their gauge ranges +before using them with your configuration. + +The display background must be **296 by 128 pixels**. Keep the gauge locations +aligned with the display code. The second generated page is not used by the +standalone example. + +Generate one label +------------------ + +The plant e-paper generator uses **JSON** input. Clone Label-maker, create a +Python virtual environment and install ``numpy`` and ``pycairo`` there. +Pycairo also needs the native Cairo library; follow the +`Pycairo installation instructions `_ +for your operating system. + +.. code-block:: console + + git clone https://github.com/JGAguado/Label-maker.git + cd Label-maker + python3 -m venv .venv + +Activate the environment with the command for your operating system, then run: + +.. code-block:: console + + python -m pip install numpy pycairo + cd 1_Plants + +Download :download:`the firmware-matched JSON ` +and save it as ``examples/Lemon_tree/My_plant.json`` in your Label-maker clone. +This local copy matches the standalone YAML: light uses ``[0, 3775]`` and +humidity uses ``[20, 80]``. Its preferred light region is ``[600, 3775]``, +within the firmware range. Preferred regions are illustrative, not care advice +for your plant. Review them before use. + +Generate the background from this local copy: + +.. code-block:: console + + python -c "from Plants_epaper import main; main('examples/Lemon_tree/My_plant.json', simulation=False, page=1)" + +This writes ``examples/Lemon_tree/My_plant_label_page_1.png``. +Run the generator from ``1_Plants`` because the example's resource paths are +relative to that directory. This command generates only the selected label; +running ``Plants_epaper.py`` directly processes every JSON example. + +To personalize the local JSON, edit ``title.value`` and ``subtitle.value``. +Set ``image.path`` to your illustration if needed. Run the command again to +regenerate the background. If you rename the JSON, update the command too; +the output takes the same filename stem. + +The JSON selects system fonts. Install its requested font, such as Audiowide, +on the generator computer if you want the same lettering. These system fonts +are separate from the Google Fonts fetched by ESPHome during compilation. + +Keep the gauges consistent +-------------------------- + +The original Label-maker examples do not use the firmware scales for all +gauges. The downloadable JSON above corrects those ranges. If you change it, +compare each JSON +``parameters`` entry with the corresponding gauge drawing code in your YAML. +The standalone firmware currently uses: + +.. list-table:: Standalone display layout + :header-rows: 1 + + * - JSON parameter + - Range in the YAML + - Position in the JSON + - Radius + * - ``moisture`` + - 0 to 100% + - ``[80, 50]`` + - 22 + * - ``light`` + - 0 to 3775 lx + - ``[134, 70]`` + - 22 + * - ``temperature`` + - -10 to 50 degrees C + - ``[188, 50]`` + - 22 + * - ``humidity`` + - 20 to 80% + - ``[242, 70]`` + - 22 + +Set each JSON ``abs_range`` to match the YAML range. Keep ``rel_range`` within +that range; it marks the preferred region on the static background and does +not calibrate the sensor. Choose that region for your plant. If you change a +gauge position, radius or range in the background, update the YAML drawing +code to match. + +Install your background +----------------------- + +Copy the PNG into an ``images`` directory next to your device YAML. In the +existing ``image:`` entry, replace only its ``file`` value: + +.. code-block:: yaml + + file: "images/my-plant.png" + +Keep ``id: page_1_background`` and the other image settings. The display code +refers to that ID. See the `ESPHome image component +`_ if you need a different image source. + +Validate and rebuild your device configuration, then install it as described +in :doc:`programming`. Changing a PNG on the build computer does not update a +sleeping device. Compare the displayed needles and values with the background +after the next successful flash and measurement cycle. diff --git a/docs/source/programming.rst b/docs/source/programming.rst index c9d6067..b1154f2 100644 --- a/docs/source/programming.rst +++ b/docs/source/programming.rst @@ -1,151 +1,175 @@ -💻 Programming -=============== +Programming +=========== -There are two main programming methods supported and tested with the |Product|: +Start with the ESPHome example for one Smart Plant. It measures the sensors, +refreshes the display once and sleeps for one hour. You can use the display +without Home Assistant. Home Assistant is optional for viewing readings over +the network. - * ESPHome - * Arduino +.. _programming-esphome: -.. _flashing: +1. Prepare ESPHome +------------------ -In both scenarios, and if you are using the USB port or the Serial port for programming it, you -will first need to enter the board into flashing mode: press and hold the *Flash* pushbutton -while you reset the board (pressing once the *Reset* pushbutton). +Use `ESPHome Device Builder in Home Assistant +`_ or the +`ESPHome command-line tools +`_. You need a +USB data cable and access to the computer that will flash the board. -.. Caution:: - When flashing the board, make sure its only powered by the USB/Serial port. +Download :download:`configuration.yaml ` into your +ESPHome configuration directory and give your copy a unique filename, such as +``my-plant.yaml``. This example already selects the ESP32-S2 board and its pins. +Keep those hardware settings when adapting it. -.. _programming-esphome: +2. Configure your plant +----------------------- -ESPHome ---------- -`ESPHome `_ is a well known platform for programming ESP-based devices -with a very little effort. It is configured via YAML files and supports a wide range of functionalities -and sensors. +Edit these values in your copy: -.. Hint:: - For using ESPHome, and all its funcionalities, you need to have a `Home Assistant `_ instance running - in the same network as your |Product|. +* ``device_name``: a unique lowercase device name. +* ``friendly_name``: the name shown in sensor labels. +* ``timezone``: your IANA timezone, such as ``Europe/Paris``. +* ``ap_pwd``: a new password for the fallback Wi-Fi hotspot. - -The |Product| comes raw, without any firmware by default, therefore, you will need to flash it for first time. There are many ways to flash -your ESPHome device (`locally `_, `ESPHome Web `_), but -the one I strongly recommend is the one through the `ESPHome Add-on for Home Assistant `_: +Add your network credentials to the existing ``wifi:`` block. Keep its other +settings. Do not add a second ``wifi:`` block. -.. Error:: - For the last months, the `ESPHome Web `_ web has been having some issues when trying to flash ESP32-S2 modules. Therefore the best way to flash it for first time is with the ESPHome HA (Home Assistant) add-on running on a Raspberry Pi and the Smart Powermeter connected to the RPi. +.. code-block:: yaml + wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + # Keep the example's fast_connect, power_save_mode and ap settings here. -1. Make sure your ESPHome Add-on for HA is up to date and working. -2. Add a new device, enter the name you want (like *Smart-Plant*), and skip the next step. -3. Select the *ESP32-S2* as the device type, skip the last step (installation). You will have created a provisional first configuration YAML file. +Put the matching values in ``secrets.yaml`` next to your device file: -.. figure:: images/getting_started/esphome_1.png - :align: center - :figwidth: 400px +.. code-block:: yaml -4. Open the recently created file and replace the content with the example configuration ``smart-plant.yaml`` + wifi_ssid: "Your network name" + wifi_password: "Your network password" -.. Note:: You might need to keep the encription keys *OTA* and *API* +Do not publish your secrets. If adapting an existing device configuration, +preserve its API encryption key and OTA credentials. Configure OTA +authentication for your selected ESPHome version; see the `ESPHome OTA component +`_. -.. literalinclude:: files/configuration.yaml - :language: yaml - :linenos: +Use the supplied lemon-tree background for the first test. Then follow +:doc:`labels` to create your own image. The example fetches its image and Google +Fonts during compilation, so the build computer needs internet access. +.. Note:: + Invalid sensor readings show ``--``. The device waits for the e-paper + to finish refreshing. If it does not finish, bounded recovery resets + the panel before the device requests deep sleep. -.. Important:: - This example performs one bounded measurement/display cycle and then enters - deep sleep. Battery charge no longer keeps it awake for OTA. To install an - update, power the board through USB-C or start the upload during its short - awake window; multi-device deployments can instead use the retained MQTT - Maintenance workflow documented under ``examples/multi-device``. +.. _flashing: - -.. Note:: - Missing or nonfinite sensor values show ``--``. Numeric readings retain - their measured value; only gauge needles and battery icons are clamped. - Before normal sleep, the firmware observes the e-paper BUSY line. A late - BUSY signal starts recovery with one shared 30-second budget, including - retries during the final sleep delay. Terminal handling resets the panel - and requests sleep within 30.510 seconds of the first recovery start, plus - event-loop scheduling latency. It starts no new display refresh. - - This bound ends at ``deep_sleep.enter``, not physical sleep. Native ESPHome - shutdown hooks remain active. The stock driver sends its final SPI sleep - command and can add a BUSY wait. Both normal and fault paths retain explicit - OTA image validation for standalone users on older ESPHome versions. +3. Flash through USB +-------------------- -.. Note:: - The Smart Plant integrates a *battery gauge* sensor (MAX17043/MAX17048). - ESPHome includes a **native** ``max17043`` component — no ``external_components`` - are needed. The example configuration above already uses it. - - The gauge is powered permanently from the battery (VBAT), so it keeps - tracking the state of charge across deep sleep. No host action is required - to save power during sleep: the MAX17048 enters its **automatic hibernate** - mode on its own when cell activity is low (a few microamps) and wakes - automatically. Do **not** try to force it asleep from the - ``consider_deep_sleep`` script — the native ``max17043`` component's - ``sleep_mode`` action does not set the MAX17048 ``MODE.EnSleep`` bit and is - a silent no-op on that part until upstream ESPHome PR #18594 is released - stable; cutting the switched sensor rail beforehand also removes the I²C - pull-ups the gauge needs to answer. +Read :doc:`safety` and :doc:`assembly` first. +.. caution:: -.. Note:: - ``Lemon_tree_label_page_1.png`` is the background image that will be displayed on the e-paper. For having always a styled background image, I made a `python script `_ that generates the image of the plant, the title and the parameter - gauges out of a JSON config file. Alternativelly, you can use any photo editor of your choice, but keep in mind the display size - (296x128 pixel) and the center of each gauges (indicated in the YAML code). In this example, it is obtained from an URL, but you can upload yours locally following the `ESPHome image guide `_ + Power the board only through USB while flashing. Disconnect the battery and + solar input before this step. Check battery polarity before reconnecting it. + +Connect Smart Plant to the computer that will perform the serial upload. +Hold **Flash**, press and release **Reset**, then release **Flash** to enter +flashing mode. + +In Device Builder, choose **Install** and the serial installation option that +matches where the USB cable is connected. A browser-connected board and a board +connected to the Device Builder host use different upload paths. Follow the +current ESPHome installation prompts. + +With the command-line tools, run this from your configuration directory: + +.. code-block:: console + + esphome config my-plant.yaml + esphome run my-plant.yaml + +Select the connected serial port when prompted. After the upload completes, +press **Reset** once to start the firmware. - .. image:: images/programming/Lemon_tree_label_page_1.png - :width: 150px +If the port is missing or flashing fails, use :doc:`faq`. There is no need to +assume that every ESP32-S2 is incompatible with browser flashing. +4. Verify the first cycle +------------------------- +Keep the USB serial logs open during the first boot. Check that: -5. Click on install, make sure that the the board is connected via the USB-C (and into `flashing`_ mode ) to the device running the Home Assistant (in my case a Raspberry Pi) before selecting the mode of installation. +* The device connects to your Wi-Fi network. +* Temperature, humidity, light and soil readings appear in the logs. +* The display refreshes with readings. If a value is missing or implausible, + inspect the sensor logs before trusting the display. +* The device enters deep sleep after the measurement and display cycle. -.. figure:: images/getting_started/esphome_2.png - :align: center - :figwidth: 400px +The e-paper keeps its last image during sleep. An unchanged screen does not +prove that the device is awake or that its readings are recent. -6. Select the Serial port and let it run, it might take some minutes. -7. Once it's done, you will have to exit the flashing mode: press the *Reset* pushbutton once. +The standalone example uses the native ESPHome API. If you use Home Assistant, +add the device through its ESPHome integration. The short awake window can make +initial discovery or connection difficult; sleeping devices cannot answer API +requests. Home Assistant may keep the last values when it recognizes +deep-sleep support. Its availability display depends on your integration setup. +For an MQTT transport with retained readings, consider the +`MQTT package example +`_. +Do not add an MQTT-profile device through the ESPHome integration as well. -Now, your ESPHome-based |Product| should be ready to log data and stream it to your Home Assistant. Note that the current configuration is just an example and you can customize it at your will, including the calibration. +5. Calibrate the soil reading +----------------------------- +The example maps two ADC voltages to 0% and 100% with ``calibrate_linear``. +These are starting values, not a universal calibration for every plant or soil. +Observe your probe in representative dry and wet conditions, record the raw +voltage, then adjust the two mapping points. Keep water away from the electronics. +The displayed percentage is a relative calibration, not a laboratory soil-water +measurement. -.. Tip:: - A very easy way to upload and copy files (code or even images) into your ESPHome folder hosted in your HA instance is - with the help of the Visual Studio Code integration for HA. This way you can just drag and drop the files over the folder - on the Home Assistant’s Visual Studio Code navigation panel on your left. +6. Install later updates +------------------------ +The example sleeps between cycles. USB power alone does **not** disable deep +sleep or create a permanent OTA window. Use serial flashing for a reliable +update, or start OTA while the device is awake. Do not change the sleep policy +without considering battery use. -Flash Tools -^^^^^^^^^^^^ -If you want to deploy an ESPHome already compiled *.bin* image, you can use Espressif's official `Flash Download Tools `_ to upload it into your Smart Powermeter. -As an example (and test) you can use this :download:`smart-areca-v2r1-offline.bin ` image with the address `0x0`, make sure `DoNotChgBin` is checked: +For the MQTT packages, follow the multi-device guide. Do not assume that the +standalone example provides remote commands to keep a sleeping device awake. -.. image:: images/programming/Flash_tool.png - :width: 3100% +Firmware reference +------------------ +The complete :download:`example YAML ` contains the +pin mapping, sensor definitions, display layout and sleep scripts. -Arduino --------- -If you are still interested in programming directly with the Arduino IDE, the procedure is no -different than with any other ESP32 devices: +The battery gauge uses ESPHome's native ``max17043`` component. The MAX17048 +stays powered from the battery and hibernates automatically. Do not add a forced +sleep action without checking model support in your selected ESPHome version. -1. Open the Arduino IDE and go to File -> Preferences option. -2. Add to the *Additional Boards Manager URSLs* the url: +The standalone example uses a fixed delay after requesting a display update. +The shared packages have their own display-completion handling. Inspect the +configuration you select rather than assuming that all examples have identical +refresh and sleep behavior. -.. parsed-literal:: +Other programming options +------------------------- - https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json +For browser installation, see `ESPHome Web `_. Use a +browser that supports Web Serial and a USB data cable. -3. Close the preferences and open in the menu Tools -> Board -> Boards Manager. -4. Search for *esp32* and install it. This might take some time. -5. Now you can select the board *ESP32 Dev Module* as the target board. Leave the rest of parameters - by default. -6. Select the correct port and remember to enter the board into flashing mode before uploading the sketch. +For Arduino development, start with the official +`Arduino-ESP32 documentation `_ +and select a target that matches the ESP32-S2. Use the :ref:`pinout` for this +board. An Arduino sketch must implement the sensors, display and sleep behavior; +it does not inherit them from the ESPHome example. -.. Tip:: - Remember to check out the full :ref:`pinout` +The repository also contains a historical +:download:`offline firmware image `. +It does not represent your customized YAML. Build your own configuration when +you need current behavior, your credentials or your own label. diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 2eaf2b7..1fdc3a6 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -8,10 +8,11 @@ defaults for upstream users. ## Transport examples -- [`my-lemon-tree.yaml`](my-lemon-tree.yaml) selects retained MQTT telemetry. -- [`my-lemon-tree-api.yaml`](my-lemon-tree-api.yaml) selects the native ESPHome - API. Its entities are unavailable between deep-sleep wakes. It has no remote - Maintenance or Storage commands. +Start from `my-lemon-tree.yaml` for MQTT or `my-lemon-tree-api.yaml` for the +native API, set its substitutions and compose the core with exactly one +transport profile. The API profile clears a Storage latch left by the MQTT +profile and keeps the common low-battery latch. Keep credentials in the local `secrets.yaml`; remote package +files contain no secrets. Both examples compose the shared core with one transport profile. Set the referenced secrets before building. Use the fleet inventory and maintenance @@ -303,6 +304,132 @@ wake/sleep cycle, and only then resume OTA maintenance. ESPHome 2026.8.0 and later confirm the application image during every orderly `deep_sleep.enter`, including wake cycles shorter than the default OTA -validation window. The production packages therefore do not call -`safe_mode.mark_successful`; the public standalone `configuration.yaml` keeps -the defensive call because it is intended to remain version-agnostic. +validation window. The core package and the public standalone +`configuration.yaml` still call `safe_mode.mark_successful` before sleep, as the +upstream-reviewed shared recovery does, so they stay version-agnostic. On +ESPHome 2026.8.0 and later the call is redundant and harmless. +Both profiles use the released `waveshare_epaper` driver and wait for the +panel's physical BUSY cycle before deep sleep. They deliberately keep the +captive portal and Improv Serial recovery paths from the original example; the +captive portal also enables ESPHome's browser-based recovery upload surface. +Deployments that do not need local fallback provisioning can remove +`captive_portal`, `improv_serial`, and the Wi-Fi `ap` block together. + +## Low-battery protection + +Both transport profiles use the same battery policy. Below 30%, the normal +page changes to white marks on a black background. At or below 15%, the device +shows a critical battery page and sleeps for 24 hours. It keeps this protection +until a valid reading reaches 22% or more. The latch survives deep sleep and +power loss. Charging or changing the battery releases it on the next valid +reading. Missing and non-finite readings do not change the latch. + +Override the defaults in the device substitutions: + +```yaml +substitutions: + low_batt_crit_pct: "15" + low_batt_exit_pct: "22" + low_batt_warn_pct: "30" + low_batt_sleep_duration: "24h" +``` + +Thresholds must satisfy `0 <= critical < exit < warning <= 100`. An invalid +order fails compilation. The long sleep also applies after a panel BUSY fault. +The normal sleep remains one hour. Both paths keep OTA image confirmation and +the existing display completion or emergency reset sequence before sleep. +This feature does not require MQTT or Home Assistant commands. + +## Validate the public examples + +The examples fetch both packages from `JGAguado/Smart_Plant@V2R1`. Keep the +core and transport on the same revision. The fork is not required. + +With ESPHome 2026.9.0 installed, run from the repository root: + +```sh +python scripts/validate_public_examples.py +``` + +This command validates both actual example files with synthetic secrets, a +local public background and packages from the current checkout. It does not +compile or contact a device or MQTT broker. ESPHome can download Google Fonts. +CI runs the same command so a PR is checked against its own package source. + +## Retained MQTT controls + +These controls belong to the MQTT profile. The API profile keeps the normal +measurement cycle. Compose the core and profile from the same revision. + +Publish `ON` or `OFF` with retain enabled and QoS 1: + +| Control | Command topic | Result topic | +| --- | --- | --- | +| Maintenance | `/cmd/maintenance` | `/status/maintenance` | +| Storage | `/cmd/storage_mode` | `/status/storage_mode` | + +`` is `mqtt_topic_prefix`. Command state expresses the requested mode. +Result state expresses the accepted mode. Home Assistant discovers both +controls and their diagnostic result entities. + +Maintenance has priority over Storage. Admission requires a finite battery +reading at or above `ota_min_battery` (50% by default). A lower reading returns +`REJECTED_LOW_BATTERY`; a missing or non-finite reading returns +`BATTERY_UNAVAILABLE`. Both results clear the retained Maintenance command. +A rejected request resumes Storage or acquires fresh plant readings for Normal +mode. The rejection result remains visible through that cycle. +Maintenance samples only the battery. Its page shows the window end time when +the clock is valid. A missing clock delays admission by at most ten seconds. + +Wait for Maintenance result `ON` before uploading firmware. This result follows +an observed panel BUSY HIGH-to-LOW refresh cycle and its settling guard. A +physical LOW without an observed assertion does not acknowledge readiness. +A repeated `ON` renews the window. `OFF` or the watchdog clears the retained +command, restores the selected mode and refreshes the display. Normal mode +first acquires fresh plant measurements. Changed requests received during +acquisition or display settlement cancel the older work; the latest requested +state wins. An echoed Maintenance `OFF` after rejection or exit starts no new +cycle. Equal Storage requests do not restart pending work. Maintenance `ON` +continues to renew its timed window. + +A successful OTA transfer clears the Maintenance request and cancels its +watchdog before a two-second MQTT flush. It starts no new display refresh. +When Storage was active, a persisted flag requests its page on the next boot. +A failed transfer leaves the current window under its watchdog. + +Storage persists in NVS. Each Storage wake samples only the battery, refreshes +the Storage page and sleeps for 24 hours. It keeps this behavior if the broker +is unavailable. Retained `OFF` restores normal acquisition on the next wake; +this can take up to the configured Storage sleep interval. A Storage command +received during Maintenance takes effect when Maintenance ends. + +Low-battery protection keeps priority over the Storage page and sleep duration. +The common core remains independent of MQTT commands. Both normal and emergency +sleep paths explicitly confirm the OTA image before shutdown. + +```yaml +substitutions: + ota_min_battery: "50" + maintenance_timeout_minutes: "25" + storage_sleep_duration: "24h" +``` + +Panel recovery uses one total 30-second budget across BUSY retries. A terminal +fault increments a persisted counter, resets the panel, waits for the reset +guard and enters emergency sleep. The MQTT profile publishes the counter at +`/status/display_fault_count`. A new refresh cancels all delayed work +from the previous refresh. ESPHome still runs its native shutdown handlers. + +Run the offline package tests with `python3 -B -m unittest discover -s tests -v`. +They execute the package actions and compile the policy lambdas as C++ with +simulated sensors, time and GPIO. Firmware compilation checks ESPHome schemas +and generated code. Neither check proves physical panel or network behavior. + +## Optional label generation + +Use the [generic label generator](label_maker/README.md) to keep recommendation +bands and firmware needle scales aligned. Its example uses synthetic values. +It does not read Home Assistant or any device inventory. + +The `flowcool` fleet uses a separate, inventory-driven variant that reads the Home +Assistant threshold snapshot: see [`label_maker/FLEET.md`](label_maker/FLEET.md). diff --git a/examples/multi-device/label_maker/FLEET.md b/examples/multi-device/label_maker/FLEET.md new file mode 100644 index 0000000..15c3e3e --- /dev/null +++ b/examples/multi-device/label_maker/FLEET.md @@ -0,0 +1,50 @@ +# E-paper gauge inputs + +`ha-thresholds-snapshot.json` holds the accepted recommendation bands. Use +`by_device_mac6..thresholds`. The manifest records extraction time, +Home Assistant version and configuration-entry hash. The Rhipsalis record also +records its accepted correction. `number_entity_states_raw` is historical +evidence. It is not a rendering input. No automatic HA refresh runs here. + +`fleet-gauge-contract.yaml` defines display geometry and full scales. The two +generators consume the contract through `scripts/smartplant_fleet_gauge_contract.py`. +Humidity and moisture use 0–100%. Temperature uses −10–50°C. Each light scale +ends at the next 5000 lx step that contains its accepted maximum, with a minimum +full scale of 5000 lx. These are display scales, not sensor calibration. + +The clockwise screen angle starts at 135° and spans 270°: + +```text +angle = 135° + 270° * (clamped_value - minimum) / (maximum - minimum) +``` + +The filled segment ends at the recommendation midpoint. The runtime needle +uses the same scale and angle. Its number shows the raw finite reading. +The tests compile the runtime C++ lambda and check its coordinates against +the renderer for all 32 device/metric combinations. Core geometry remains +validated against this contract. Generated metadata supplies per-device scales. +Two devices may share a label file only when their recommendation bands and +scales match. The generator checks all filenames before rendering. + +Regenerate from the repository root: + +```bash +python3 -m pip install -r examples/multi-device/label_maker/requirements.txt +python3 scripts/generate_device_metadata.py +python3 scripts/smartplant_fleet_label_maker.py \ + --snapshot examples/multi-device/label_maker/ha-thresholds-snapshot.json \ + --plants examples/multi-device/plants.yaml \ + --illustrations examples/multi-device/label_maker/illustrations \ + --icons examples/multi-device/label_maker/icons \ + --out examples/multi-device/plant_labels +python3 scripts/generate_device_metadata.py --check +python3 -m unittest discover -s tests -v +``` + +Run these commands in a Python virtual environment. Rendering needs PyYAML, +Pycairo and Pillow. The runtime test also needs `g++`. +The suite checks PNG bytes against committed outputs and renders twice. +Changing the renderer dependencies can change PNG bytes. Inspect image changes +before accepting a new rendering environment. Tests do not access devices or +compile ESPHome firmware. A real canary build and deployment need separate +authorization. diff --git a/examples/multi-device/label_maker/LICENSE.Label-maker.txt b/examples/multi-device/label_maker/LICENSE.Label-maker.txt new file mode 100644 index 0000000..c450b31 --- /dev/null +++ b/examples/multi-device/label_maker/LICENSE.Label-maker.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 J.G.Aguado @JGAguado + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/multi-device/label_maker/README.md b/examples/multi-device/label_maker/README.md index bd2e43b..7a4eecd 100644 --- a/examples/multi-device/label_maker/README.md +++ b/examples/multi-device/label_maker/README.md @@ -1,50 +1,58 @@ -# E-paper gauge inputs +# Generic e-paper label generator -`ha-thresholds-snapshot.json` holds the accepted recommendation bands. Use -`by_device_mac6..thresholds`. The manifest records extraction time, -Home Assistant version and configuration-entry hash. The Rhipsalis record also -records its accepted correction. `number_entity_states_raw` is historical -evidence. It is not a rendering input. No automatic HA refresh runs here. +This is an optional adaptation of JGAguado's [Label-maker](https://github.com/JGAguado/Label-maker). +The original renderer already covers label creation. This extraction adds a +shared scale contract and fixes the recommendation endpoint to use the same +135-degree start angle as Smart Plant's runtime needle. The original MIT +notice is retained in `LICENSE.Label-maker.txt`. -`gauge-contract.yaml` defines display geometry and full scales. The two -generators consume the contract through `scripts/smartplant_gauge_contract.py`. -Humidity and moisture use 0–100%. Temperature uses −10–50°C. Each light scale -ends at the next 5000 lx step that contains its accepted maximum, with a minimum -full scale of 5000 lx. These are display scales, not sensor calibration. +`example.yaml` contains synthetic recommendation bands. It contains no device +identity, Home Assistant snapshot or plant artwork. `gauge-contract.yaml` +defines the four positions and default scales from the current shared core. +The defaults preserve humidity 20-80 and light 0-3775. They are display ranges, +not calibration or botanical advice. Change each `band` to the intended range. +An optional `scale` must contain that band. -The clockwise screen angle starts at 135° and spans 270°: +Run from the repository root in a Python virtual environment: -```text -angle = 135° + 270° * (clamped_value - minimum) / (maximum - minimum) +```sh +python3 -m pip install -r examples/multi-device/label_maker/requirements.txt +python3 scripts/smartplant_label_maker.py \ + --config examples/multi-device/label_maker/example.yaml \ + --image tmp/labels/example.png \ + --substitutions tmp/labels/display-scales.yaml +``` + +The command writes a 1-bit 296x128 PNG and matching ESPHome display substitutions. +Validate the image before use. Place both generated files beside the local +device configuration. Include `display-scales.yaml` as a package after the +core package. Remove conflicting device-level gauge substitutions; they take +precedence over package values. Set `label_image` to `example.png` in the +device substitutions. Use the +same YAML input to regenerate both outputs after a scale change. Moisture is +fixed at 0-100 in firmware. Light starts at 0. The tool rejects unsupported or +nonfinite ranges before writing either output. + +The filled band ends at the recommendation midpoint. The renderer and live +needle use `135deg + 270deg * (value-min)/(max-min)`. The displayed measurement +remains raw; only the needle clamps. Neither generation nor these local tests +contacts devices or changes firmware: + +```sh +python3 -m unittest discover -s tests -p test_generic_labels.py -v ``` -The filled segment ends at the recommendation midpoint. The runtime needle -uses the same scale and angle. Its number shows the raw finite reading. -The tests compile the runtime C++ lambda and check its coordinates against -the renderer for all 32 device/metric combinations. Core geometry remains -validated against this contract. Generated metadata supplies per-device scales. -Two devices may share a label file only when their recommendation bands and -scales match. The generator checks all filenames before rendering. +The tests execute the actual runtime C++ gauge, compare arc endpoints and +scales, reject invalid inputs and render twice to check deterministic output. +They need `g++` plus the listed Python packages. PNG bytes can change with +rendering-library versions. New hardware or fleet validation remains a +separate operation. Revert this isolated optional-tool contribution to undo it. -Regenerate from the repository root: +With ESPHome 2026.9.0 installed, also run: -```bash -python3 -m pip install -r examples/multi-device/label_maker/requirements.txt -python3 scripts/generate_device_metadata.py -python3 scripts/smartplant_label_maker.py \ - --snapshot examples/multi-device/label_maker/ha-thresholds-snapshot.json \ - --plants examples/multi-device/plants.yaml \ - --illustrations examples/multi-device/label_maker/illustrations \ - --icons examples/multi-device/label_maker/icons \ - --out examples/multi-device/plant_labels -python3 scripts/generate_device_metadata.py --check -python3 -m unittest discover -s tests -v +```sh +python3 -m unittest discover -s tests -p test_label_packages.py -v ``` -Run these commands in a Python virtual environment. Rendering needs PyYAML, -Pycairo and Pillow. The runtime test also needs `g++`. -The suite checks PNG bytes against committed outputs and renders twice. -Changing the renderer dependencies can change PNG bytes. Inspect image changes -before accepting a new rendering environment. Tests do not access devices or -compile ESPHome firmware. A real canary build and deployment need separate -authorization. +These tests check the real package loader with custom scales, reversed package +order and conflicting device substitutions. diff --git a/examples/multi-device/label_maker/example.yaml b/examples/multi-device/label_maker/example.yaml new file mode 100644 index 0000000..22037dd --- /dev/null +++ b/examples/multi-device/label_maker/example.yaml @@ -0,0 +1,19 @@ +name: Example plant +subtitle: Synthetic recommendation bands +metrics: + moisture: + band: + - 20 + - 60 + illuminance: + band: + - 500 + - 2500 + temperature: + band: + - 15 + - 30 + humidity: + band: + - 30 + - 70 diff --git a/examples/multi-device/label_maker/fleet-gauge-contract.yaml b/examples/multi-device/label_maker/fleet-gauge-contract.yaml new file mode 100644 index 0000000..e2ae003 --- /dev/null +++ b/examples/multi-device/label_maker/fleet-gauge-contract.yaml @@ -0,0 +1,24 @@ +# Display coordinates and full scales. Recommendations stay in the HA snapshot. +# Runtime constants and call sites are checked against this contract by tests. +size: [296, 128] +angle_degrees: 270 +radius: 22 +arc_thickness: 5 +metrics: + moisture: + position: [80, 50] + icon: cup-water.png + range: [0, 100] + illuminance: + position: [134, 70] + icon: white-balance-sunny.png + minimum: 0 + maximum_step: 5000 + temperature: + position: [188, 50] + icon: thermometer.png + range: [-10, 50] + humidity: + position: [242, 70] + icon: water-percent.png + range: [0, 100] diff --git a/examples/multi-device/label_maker/gauge-contract.yaml b/examples/multi-device/label_maker/gauge-contract.yaml index e2ae003..c9b1b33 100644 --- a/examples/multi-device/label_maker/gauge-contract.yaml +++ b/examples/multi-device/label_maker/gauge-contract.yaml @@ -1,24 +1,39 @@ -# Display coordinates and full scales. Recommendations stay in the HA snapshot. -# Runtime constants and call sites are checked against this contract by tests. -size: [296, 128] +size: +- 296 +- 128 angle_degrees: 270 radius: 22 arc_thickness: 5 metrics: moisture: - position: [80, 50] - icon: cup-water.png - range: [0, 100] + position: + - 80 + - 50 + label: SOIL + scale: + - 0 + - 100 illuminance: - position: [134, 70] - icon: white-balance-sunny.png - minimum: 0 - maximum_step: 5000 + position: + - 134 + - 70 + label: LUX + scale: + - 0 + - 3775 temperature: - position: [188, 50] - icon: thermometer.png - range: [-10, 50] + position: + - 188 + - 50 + label: TEMP + scale: + - -10 + - 50 humidity: - position: [242, 70] - icon: water-percent.png - range: [0, 100] + position: + - 242 + - 70 + label: RH + scale: + - 20 + - 80 diff --git a/examples/multi-device/my-lemon-tree.yaml b/examples/multi-device/my-lemon-tree.yaml index e8cb556..5a2e019 100644 --- a/examples/multi-device/my-lemon-tree.yaml +++ b/examples/multi-device/my-lemon-tree.yaml @@ -25,6 +25,12 @@ substitutions: # Use secret references for new installations. Existing live credentials do # not need to be migrated solely to follow this example. + wifi_ssid: !secret wifi_ssid + wifi_password: !secret wifi_password + mqtt_broker: !secret mqtt_broker + mqtt_username: !secret mqtt_username + mqtt_password: !secret mqtt_password + api_encryption_key: !secret lemon_tree_api_encryption_key ota_password: !secret lemon_tree_ota_password # Plant label image (place PNG in esphome/plant_labels/) diff --git a/examples/multi-device/packages/generated/ceropegia-woodii-54a8f2.yaml b/examples/multi-device/packages/generated/ceropegia-woodii-54a8f2.yaml index df6f09a..848ac94 100644 --- a/examples/multi-device/packages/generated/ceropegia-woodii-54a8f2.yaml +++ b/examples/multi-device/packages/generated/ceropegia-woodii-54a8f2.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: ceropegia-woodii-54a8f2) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/ceropegia-woodii-54a99c.yaml b/examples/multi-device/packages/generated/ceropegia-woodii-54a99c.yaml index 984dab3..534467a 100644 --- a/examples/multi-device/packages/generated/ceropegia-woodii-54a99c.yaml +++ b/examples/multi-device/packages/generated/ceropegia-woodii-54a99c.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: ceropegia-woodii-54a99c) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/cyperus-papyrus-54a9b2.yaml b/examples/multi-device/packages/generated/cyperus-papyrus-54a9b2.yaml index 5fc6cd7..68a56d7 100644 --- a/examples/multi-device/packages/generated/cyperus-papyrus-54a9b2.yaml +++ b/examples/multi-device/packages/generated/cyperus-papyrus-54a9b2.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: cyperus-papyrus-54a9b2) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/equisetum-hyemale-54a994.yaml b/examples/multi-device/packages/generated/equisetum-hyemale-54a994.yaml index d13c4ab..f12d80b 100644 --- a/examples/multi-device/packages/generated/equisetum-hyemale-54a994.yaml +++ b/examples/multi-device/packages/generated/equisetum-hyemale-54a994.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: equisetum-hyemale-54a994) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/oxalis-triangularis-5326ba.yaml b/examples/multi-device/packages/generated/oxalis-triangularis-5326ba.yaml index b07c4db..bc1e39a 100644 --- a/examples/multi-device/packages/generated/oxalis-triangularis-5326ba.yaml +++ b/examples/multi-device/packages/generated/oxalis-triangularis-5326ba.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: oxalis-triangularis-5326ba) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/peperomia-tetraphylla-54a940.yaml b/examples/multi-device/packages/generated/peperomia-tetraphylla-54a940.yaml index a528464..0bc1e9e 100644 --- a/examples/multi-device/packages/generated/peperomia-tetraphylla-54a940.yaml +++ b/examples/multi-device/packages/generated/peperomia-tetraphylla-54a940.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: peperomia-tetraphylla-54a940) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/pilea-peperomioides-54a8e4.yaml b/examples/multi-device/packages/generated/pilea-peperomioides-54a8e4.yaml index f09c695..5bac102 100644 --- a/examples/multi-device/packages/generated/pilea-peperomioides-54a8e4.yaml +++ b/examples/multi-device/packages/generated/pilea-peperomioides-54a8e4.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: pilea-peperomioides-54a8e4) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/generated/rhipsalis-baccifera-54a936.yaml b/examples/multi-device/packages/generated/rhipsalis-baccifera-54a936.yaml index 23d299f..c02d3a7 100644 --- a/examples/multi-device/packages/generated/rhipsalis-baccifera-54a936.yaml +++ b/examples/multi-device/packages/generated/rhipsalis-baccifera-54a936.yaml @@ -1,7 +1,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: rhipsalis-baccifera-54a936) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index fa647e7..17f2202 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -1,18 +1,14 @@ # ============================================================================= -# Smart Plant — transport-agnostic CORE package +# Smart Plant — transport-independent core package # ============================================================================= -# Composition-only: never flashed standalone. A device composes this together -# with exactly one transport profile: +# Compose this package with exactly one transport profile: # # packages: -# core: github://flowcool/Smart_Plant/examples/multi-device/packages/smart_plant_core.yaml@V2R1 -# transport: github://flowcool/Smart_Plant/examples/multi-device/packages/smart_plant_profile_mqtt.yaml@V2R1 -# # or smart_plant_profile_api.yaml for the simple native-API profile +# core: github://JGAguado/Smart_Plant/examples/multi-device/packages/smart_plant_core.yaml@V2R1 +# transport: github://JGAguado/Smart_Plant/examples/multi-device/packages/smart_plant_profile_mqtt.yaml@V2R1 # -# Holds hardware, sensors, display (all pages), the e-paper BUSY-settle + -# deep-sleep lifecycle, and low-battery protection. NO mqtt:, NO on_boot, NO -# maintenance/storage logic, NO mqtt.publish. Cross-references decide_sleep and -# sleep_after_display are provided by the transport profile. +# This file owns hardware, sensors, display, Wi-Fi and the sleep decision only. +# Transport setup and boot orchestration belong to the selected profile. # ============================================================================= # Default values — override any of these in your device file. @@ -43,7 +39,7 @@ substitutions: # OTA password (leave empty string for no password) ota_password: "" - # WiFi/MQTT credentials — overridden per-device via !secret in the local + # Wi-Fi credentials — overridden per-device via !secret in the local # device file. Must stay plain substitutions here (no !secret): this file # is fetched as a remote package, and the remote-build bundler refuses to # ship secrets.yaml to packages pulled from a github:// source. @@ -52,7 +48,6 @@ substitutions: mqtt_broker: "" mqtt_username: "" mqtt_password: "" - # Address used by ESPHome CLI/dashboard for OTA and log connections. # Default: device hostname via mDNS. Set to static IP for reliability. use_address: "${device_name}" @@ -72,50 +67,31 @@ substitutions: hum_min: "20" hum_max: "80" - # OTA maintenance is accepted only above this battery level and always times - # out. A full battery permits maintenance; it never keeps the device awake by - # itself. + # Low-battery policy: enter protection at/below critical, release at/above + # exit, and invert the normal page below warning. Thresholds are SoC percent. + low_batt_crit_pct: "15" + low_batt_exit_pct: "22" + low_batt_warn_pct: "30" + low_batt_sleep_duration: "24h" ota_min_battery: "50" maintenance_timeout_minutes: "25" maintenance_timeout: "${maintenance_timeout_minutes}min" storage_sleep_duration: "24h" - # Low-battery protection (SoC %, per-device overridable). WARN inverts the - # e-paper below warn_pct (no behaviour change); CRITIQUE hibernates at/below - # crit_pct and exits at/above exit_pct (hysteresis). See epic infra-3rr.25. - low_batt_crit_pct: "15" - low_batt_exit_pct: "22" - low_batt_warn_pct: "30" - low_batt_sleep_duration: "${storage_sleep_duration}" + + # The legacy Waveshare driver returns before the active-high BUSY signal has + # completed every physical refresh. These bounded windows prevent sleep from + # cutting power while the panel is still updating. display_busy_gpio: "14" display_reset_gpio: "15" - # The legacy driver returns immediately after MASTER ACTIVATION. Observe the - # panel's active-high BUSY transition directly instead of guessing a delay. display_busy_assert_timeout: "2s" display_busy_clear_timeout: "30s" - # Total recovery budget shared across all retries and final sleep delays. - # From first recovery: timeout + reset(10ms) + guard(500ms) + notification - # (MQTT 1s / API 500ms), plus event-loop scheduling latency, reaches the - # deep_sleep.enter action. Stock driver shutdown still sends its sleep SPI - # command; this is not a physical-sleep deadline or a driver-hook bypass. display_busy_recovery_timeout: "30s" globals: - # storage_active: NVS-backed seasonal state; read by the core display and - # enter_deep_sleep, written only by the MQTT profile (enter/exit_storage). - id: storage_active type: bool restore_value: true initial_value: 'false' - # Low-battery hysteresis latch. NVS-backed (restore_value): survives deep sleep - # AND power loss, so a battery swap does NOT reset it — evaluate_low_batt clears - # it on the first cycle at >= exit_pct (self-correction is the hysteresis exit, - # not a reset). NVS dirty-check skips no-op writes, so flash wear is negligible. - - id: low_batt_active - type: bool - restore_value: true - initial_value: 'false' - # Read by the core page_maintenance display; written only by the MQTT profile - # (decide_sleep). Stay -1 in the API profile (page never shown there). - id: maintenance_end_hour type: int restore_value: false @@ -124,31 +100,32 @@ globals: type: int restore_value: false initial_value: '-1' - - id: display_settle_ok + - id: display_busy_fault_count + type: int + restore_value: true + initial_value: '0' + # Persist hysteresis across deep sleep and power loss. A fresh reading at the + # exit threshold releases a restored latch after charging or battery exchange. + - id: low_batt_active type: bool - restore_value: false + restore_value: true initial_value: 'false' - id: display_busy_seen type: bool restore_value: false initial_value: 'false' - - id: display_safe_to_sleep + - id: display_settle_ok type: bool restore_value: false - # No refresh is in flight at boot. prepare_display_refresh resets this before every - # activation and only restores it after BUSY is observed low. - initial_value: 'true' - # Release RST if a new refresh cancels an asynchronous terminal reset pulse. + initial_value: 'false' - id: display_reset_pending type: bool restore_value: false initial_value: 'false' - # NVS-backed cumulative count of permanent BUSY faults that triggered the - # last-resort emergency sleep path. Survives deep sleep and power cycles. - - id: display_busy_fault_count - type: int - restore_value: true - initial_value: '0' + - id: display_safe_to_sleep + type: bool + restore_value: false + initial_value: 'true' esphome: name: "${configured_name}" @@ -253,15 +230,15 @@ sensor: battery_voltage: id: batvolt name: "Battery voltage" - internal: true accuracy_decimals: 2 + internal: true battery_level: id: batpercent device_class: "battery" unit_of_measurement: "%" name: "Battery" - force_update: true accuracy_decimals: 0 + force_update: true - platform: aht10 id: aht20_sensor @@ -269,18 +246,18 @@ sensor: i2c_id: bus_a temperature: name: "Temperature" + accuracy_decimals: 1 id: temp icon: "mdi:thermometer" device_class: temperature force_update: true - accuracy_decimals: 1 humidity: name: "Air Humidity" + accuracy_decimals: 0 id: hum icon: "mdi:water-percent" device_class: humidity force_update: true - accuracy_decimals: 0 update_interval: never - platform: veml7700 @@ -289,51 +266,42 @@ sensor: update_interval: never ambient_light: name: "Ambient light" + accuracy_decimals: 0 id: light icon: "mdi:white-balance-sunny" device_class: illuminance force_update: true - accuracy_decimals: 0 actual_gain: name: "Actual gain" internal: true - # Raw soil ADC voltage, published to Home Assistant as a diagnostic so the - # per-device wet-immersion reference (infra-3rr.44.1) can be captured in - # continuous operation without a throwaway calibration firmware. This is the - # median+clamped voltage BEFORE the moisture % mapping. acquire_normal drives - # this source sensor; the moisture % is derived from it by the copy sensor. - platform: adc pin: GPIO1 name: "Soil Voltage" id: soil_voltage device_class: voltage state_class: measurement - unit_of_measurement: "V" entity_category: diagnostic update_interval: never + unit_of_measurement: "V" attenuation: 12db force_update: true - accuracy_decimals: 3 filters: - # send_first_at=5 (with window_size=send_every=5) publishes a single - # median voltage only after five samples, so acquire_normal's five soil - # updates yield exactly one publication with no transient value at the - # first sample (default send_first_at is 1). - median: window_size: 5 send_every: 5 send_first_at: 5 + # Reject disconnected/shorted readings before applying the percentage + # calibration. An invalid voltage therefore remains unavailable instead + # of becoming a plausible-looking 0% or 100% value. - clamp: min_value: 0.5 max_value: 3.3 ignore_out_of_range: true + accuracy_decimals: 3 - # Soil moisture %: derived from the raw voltage by the same linear mapping as - # before. calibrate_linear pivots the line around the (per-device or shared) - # wet endpoint; the dry endpoint stays a shared out-of-ADC-range approximation - # (infra-3rr.44.1). copy republishes once per source publication, so the single - # median voltage yields exactly one moisture value with no transient. + # Derive moisture from the single filtered ADC publication, without a second + # sampling loop or an intermediate unfiltered percentage. - platform: copy source_id: soil_voltage name: "Soil Moisture" @@ -342,21 +310,18 @@ sensor: device_class: moisture unit_of_measurement: "%" force_update: true - accuracy_decimals: 0 filters: - calibrate_linear: - ${soil_v_wet} -> 100.00 - ${soil_v_dry} -> 0.00 - lambda: if (x < 1) return 0; else if (x > 100) return 100; return (x); + accuracy_decimals: 0 -# Running ESPHome core version. Function-only entity name like every other -# exposed entity; Home Assistant cleanup owns legacy collision removal. +# Publish the running firmware version and native effective-configuration hash. text_sensor: - platform: version name: "ESPHome Version" hide_timestamp: true - # Keep the native config hash: unlike a manually stamped Git revision, it - # identifies the effective per-device configuration compiled into the image. hide_hash: false entity_category: diagnostic @@ -365,7 +330,7 @@ display: cs_pin: GPIO10 dc_pin: GPIO13 busy_pin: GPIO${display_busy_gpio} - reset_pin: GPIO15 + reset_pin: GPIO${display_reset_gpio} rotation: 270 model: 2.90inv2 id: my_display @@ -374,14 +339,10 @@ display: pages: - id: page1 lambda: |- - // WARN signalling: below warn_pct the whole page renders in negative - // (fill black, draw white). Same layout, glanceable state change. The - // label image is drawn inverted via its (on,off) colour args = (fg,bg). - bool bat_valid = id(batpercent).has_state() && std::isfinite(id(batpercent).state); - float battery_perc = bat_valid ? id(batpercent).state : 0.0f; - bool low = bat_valid && battery_perc < ${low_batt_warn_pct}; - auto fg = low ? COLOR_OFF : COLOR_ON; - auto bg = low ? COLOR_ON : COLOR_OFF; + bool battery_valid = id(batpercent).has_state() && std::isfinite(id(batpercent).state); + const bool warning = battery_valid && id(batpercent).state < ${low_batt_warn_pct}; + const auto fg = warning ? COLOR_OFF : COLOR_ON; + const auto bg = warning ? COLOR_ON : COLOR_OFF; it.fill(bg); it.image(0, 0, id(page_1_background), fg, bg); @@ -390,9 +351,9 @@ display: // touches only these substitutions, no image regeneration. it.printf(2, 0, id(font_title), fg, TextAlign::TOP_LEFT, "${display_name}"); it.printf(2, 22, id(font_subtitle), fg, TextAlign::TOP_LEFT, "${secondary_name}"); - - if (bat_valid) { - int battery_range = (int)(battery_perc / 16.0f); + if (battery_valid) { + float battery_perc = id(batpercent).state; + int battery_range = static_cast(battery_perc / 16.0f); battery_range = (battery_range > 6) ? 6 : battery_range; battery_range = (battery_range < 0) ? 0 : battery_range; const char* battery_icon_map[] = { @@ -419,12 +380,12 @@ display: int radius = 22; int thick = 7; - auto draw_gauge = [&](float value, bool has_val, float vmin, float vmax, int xc, int yc, const char* fmt) { - if (!has_val || !std::isfinite(value)) { + auto draw_gauge = [&](float value, bool valid, float vmin, float vmax, int xc, int yc, const char* fmt) { + if (!valid || !std::isfinite(value)) { it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), fg, TextAlign::TOP_CENTER, "--"); return; } - // Keep the sensor reading intact for the numeric label. + // Keep the measured number; clamp only the needle coordinates. float needle_value = value; if (needle_value < vmin) needle_value = vmin; if (needle_value > vmax) needle_value = vmax; @@ -441,10 +402,10 @@ display: it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), fg, TextAlign::TOP_CENTER, fmt, value); }; - draw_gauge(id(soil).state, id(soil).has_state(), 0, 100, 80, 50, "%.0f%%"); - draw_gauge(id(light).state, id(light).has_state(), 0, ${light_max}, 134, 70, "%.0flx"); - draw_gauge(id(temp).state, id(temp).has_state(), ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); - draw_gauge(id(hum).state, id(hum).has_state(), ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); + draw_gauge(id(soil).state, id(soil).has_state(), 0, 100, 80, 50, "%.0f%%"); + draw_gauge(id(light).state, id(light).has_state(), 0, ${light_max}, 134, 70, "%.0flx"); + draw_gauge(id(temp).state, id(temp).has_state(), ${temp_min}, ${temp_max}, 188, 50, "%.0f°C"); + draw_gauge(id(hum).state, id(hum).has_state(), ${hum_min}, ${hum_max}, 242, 70, "%.0f%%"); - id: page_maintenance lambda: |- @@ -463,25 +424,24 @@ display: it.printf(148, 4, id(font_subtitle), TextAlign::TOP_CENTER, "${display_name}"); it.printf(148, 31, id(font_title), TextAlign::TOP_CENTER, "STORAGE MODE"); it.printf(148, 68, id(font_subtitle), TextAlign::TOP_CENTER, "DAILY CHECK"); - if (id(batpercent).has_state() && !isnan(id(batpercent).state)) { + if (id(batpercent).has_state() && std::isfinite(id(batpercent).state)) { it.printf(148, 91, id(font_title), TextAlign::TOP_CENTER, "BATTERY %.0f%%", id(batpercent).state); } else { it.printf(148, 91, id(font_title), TextAlign::TOP_CENTER, "BATTERY --%%"); } - # Critical low-battery page, rendered in negative (fill black, text white). - # Shows STORAGE MODE as a small secondary line when the plant is also in - # Storage, so the alarm is unmistakable without hiding the storage context. + # Critical protection is transport-independent and takes precedence over + # the normal page until the persisted hysteresis latch releases. - id: page_low_batt lambda: |- it.fill(COLOR_ON); - it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${display_name}"); - it.printf(148, 26, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "LOW BATT !"); + it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${display_name}"); + it.printf(148, 26, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "LOW BATT !"); if (id(storage_active)) { it.printf(148, 60, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "STORAGE MODE"); } - if (id(batpercent).has_state() && !isnan(id(batpercent).state)) { + if (id(batpercent).has_state() && std::isfinite(id(batpercent).state)) { it.printf(148, 88, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "BATTERY %.0f%%", id(batpercent).state); } else { @@ -493,10 +453,69 @@ deep_sleep: sleep_duration: 1h script: - # Waveshare 2.9 V2 BUSY is active high. ESPHome 2026.7.4 waits for idle - # before writing but returns immediately after MASTER ACTIVATION, so every - # caller must observe the subsequent HIGH -> LOW transition before sleep or - # another lifecycle boundary. + # Each sensor is triggered only as often as its own conversion/filter needs. + # All waits are bounded, so a failed sensor cannot keep the device awake. + - id: acquire_normal + mode: restart + then: + - switch.turn_on: exc + - component.update: max17043_id + - wait_until: + condition: + lambda: 'return id(batpercent).has_state() && std::isfinite(id(batpercent).state);' + timeout: 3s + - component.update: aht20_sensor + - component.update: veml7700_sensor + - wait_until: + condition: + lambda: |- + return id(temp).has_state() && std::isfinite(id(temp).state) && + id(hum).has_state() && std::isfinite(id(hum).state); + timeout: 3s + - wait_until: + condition: + lambda: 'return id(light).has_state() && std::isfinite(id(light).state);' + timeout: 5s + - repeat: + count: 5 + then: + - component.update: soil_voltage + - delay: 3s + - wait_until: + condition: + lambda: 'return id(soil).has_state() && std::isfinite(id(soil).state);' + timeout: 3s + - wait_until: + condition: + lambda: 'return id(esptime).now().is_valid();' + timeout: 10s + - script.execute: decide_sleep + + - id: render_measurement_page + mode: restart + then: + - lambda: |- + static_assert(0 <= ${low_batt_crit_pct} && + ${low_batt_crit_pct} < ${low_batt_exit_pct} && + ${low_batt_exit_pct} < ${low_batt_warn_pct} && + ${low_batt_warn_pct} <= 100, + "Low-battery thresholds must satisfy 0 <= critical < exit < warning <= 100"); + const bool battery_valid = id(batpercent).has_state() && std::isfinite(id(batpercent).state); + if (battery_valid) { + if (id(low_batt_active)) { + if (id(batpercent).state >= ${low_batt_exit_pct}) id(low_batt_active) = false; + } else if (id(batpercent).state <= ${low_batt_crit_pct}) { + id(low_batt_active) = true; + } + } + - display.page.show: !lambda |- + if (id(low_batt_active)) return id(page_low_batt); + return id(storage_active) ? id(page_storage) : id(page1); + - script.execute: prepare_display_refresh + - component.update: my_display + - script.execute: settle_display + - script.wait: settle_display + - id: prepare_display_refresh then: # Run before any new display SPI writes. A new refresh invalidates any @@ -581,6 +600,9 @@ script: else: - script.execute: handle_display_busy_fault + # With the default 30s budget, reset and sleep request take at most + # 30.510s plus event-loop latency. This ends at deep_sleep.enter; native + # shutdown hooks can still send the final display SPI sleep command. - id: display_recovery_budget mode: single then: @@ -615,18 +637,15 @@ script: - delay: 500ms - script.execute: force_sleep_after_busy_fault - # Emergency sleep request for a permanent BUSY fault. Bypasses our final - # BUSY check; stock ESPHome shutdown hooks still run (including display SPI). + # No new display refresh follows a terminal fault. Native shutdown hooks + # remain enabled, including the stock driver's final SPI sleep command. # Battery-priority and storage durations are preserved. - # The panel may show a corrupted image until the next normal boot. - id: force_sleep_after_busy_fault then: - script.execute: on_display_fault - script.wait: on_display_fault - # No safe_mode.mark_successful: on ESPHome >=2026.8.0 an orderly - # deep_sleep.enter natively confirms the app image (on_safe_shutdown -> - # confirm_app_image_), making the manual call redundant. Retired in - # infra-3rr.47. The public configuration.yaml example keeps the call. + # Keep this safeguard for users on arbitrary ESPHome versions. + - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc - if: @@ -657,91 +676,9 @@ script: condition: lambda: |- return id(batpercent).has_state() && - !isnan(id(batpercent).state); - timeout: 3s - - # Acquisition is decoupled per sensor: each is triggered as often as it needs - # and given an explicit bounded readiness wait, instead of a shared six-pass - # loop that only existed to satisfy the soil median cadence. The soil filter - # (median window_size=5/send_every=5/send_first_at=5) now publishes exactly - # one filtered value at the fifth sample, so no transient unfiltered soil - # value reaches MQTT. Total soil dwell ~15s < the previous ~18s. - - id: acquire_normal - mode: restart - then: - - switch.turn_on: exc - # MAX17048: one wake/config update, then a bounded wait for a finite SoC. - - component.update: max17043_id - - wait_until: - condition: - lambda: |- - return id(batpercent).has_state() && - !isnan(id(batpercent).state); - timeout: 3s - # AHT20 + VEML7700 are one-shot digital sensors. Trigger each once; their - # async work (AHT ~80-240ms; VEML auto-gain may span several integration - # times) completes without re-polling. Wait bounded for finite values. - - component.update: aht20_sensor - - component.update: veml7700_sensor - - wait_until: - condition: - lambda: |- - return id(temp).has_state() && !isnan(id(temp).state) && - id(hum).has_state() && !isnan(id(hum).state); - timeout: 3s - - wait_until: - condition: - lambda: 'return id(light).has_state() && !isnan(id(light).state);' - timeout: 5s - # Soil ADC: exactly five spaced samples feed the median so a single - # filtered value is published. Inter-measurement spacing is preserved - # (not synchronous back-to-back ADC reads). - - repeat: - count: 5 - then: - - component.update: soil_voltage - - delay: 3s - - wait_until: - condition: - lambda: 'return id(soil).has_state() && !isnan(id(soil).state);' + std::isfinite(id(batpercent).state); timeout: 3s - # Give SNTP a bounded window so a slow first sync does not leave stale - # time for one hour. - - wait_until: - condition: - lambda: 'return id(esptime).now().is_valid();' - timeout: 10s - - script.execute: decide_sleep - - # Single source of truth for the measurement refresh: evaluate the low-battery - # hysteresis latch, then show low-batt > storage > normal, refresh, and settle. - # Every non-maintenance page.show routes through here so no path can display a - # stale page at a critical battery level. Callers must have sampled the battery. - - id: render_measurement_page - then: - # Inline (not a sub-script) so the latch update completes before page select. - # NVS-backed low_batt_active; a restored-true latch self-corrects at exit_pct. - - lambda: |- - bool bat_ok = id(batpercent).has_state() && !isnan(id(batpercent).state); - if (id(low_batt_active)) { - if (bat_ok && id(batpercent).state >= ${low_batt_exit_pct}) { - id(low_batt_active) = false; - } - } else { - if (bat_ok && id(batpercent).state <= ${low_batt_crit_pct}) { - id(low_batt_active) = true; - } - } - - display.page.show: !lambda |- - if (id(low_batt_active)) return id(page_low_batt); - return id(storage_active) ? id(page_storage) : id(page1); - - script.execute: prepare_display_refresh - - component.update: my_display - - script.execute: settle_display - - script.wait: settle_display - # Give retained status/command publications a short flush window before the - # Wi-Fi and sensor rail disappear. Repeated calls are intentionally ignored. - id: enter_deep_sleep mode: restart then: @@ -753,11 +690,8 @@ script: digitalRead(${display_busy_gpio}) == LOW; then: - script.stop: display_recovery_budget - # No safe_mode.mark_successful: the sub-60s wake cycle that used to - # need a manual mark before ESPHome's 60-second OTA window is now - # covered natively (>=2026.8.0 confirms the image on any orderly - # deep_sleep.enter). Retired in infra-3rr.47; the public - # configuration.yaml example keeps the call. + # Preserve version-independent OTA validation on normal sleep too. + - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc - if: diff --git a/scripts/generate_device_metadata.py b/scripts/generate_device_metadata.py index 3347c4f..641e2f4 100644 --- a/scripts/generate_device_metadata.py +++ b/scripts/generate_device_metadata.py @@ -27,9 +27,9 @@ import yaml if __package__: - from .smartplant_gauge_contract import device_scales + from .smartplant_fleet_gauge_contract import device_scales else: - from smartplant_gauge_contract import device_scales + from smartplant_fleet_gauge_contract import device_scales ROOT = Path(__file__).resolve().parent.parent INVENTORY = ROOT / "examples/multi-device/plants.yaml" @@ -39,7 +39,7 @@ # ============================================================================= # GENERATED FILE - DO NOT EDIT. # Sources: examples/multi-device/plants.yaml (key: {key}) -# label_maker/gauge-contract.yaml + ha-thresholds-snapshot.json +# label_maker/fleet-gauge-contract.yaml + ha-thresholds-snapshot.json # Regenerate: python3 scripts/generate_device_metadata.py # Drift check (CI): python3 scripts/generate_device_metadata.py --check # ----------------------------------------------------------------------------- diff --git a/scripts/smartplant_fleet_gauge_contract.py b/scripts/smartplant_fleet_gauge_contract.py new file mode 100644 index 0000000..4c5639c --- /dev/null +++ b/scripts/smartplant_fleet_gauge_contract.py @@ -0,0 +1,50 @@ +"""Shared e-paper geometry and snapshot-derived display scales. + +The snapshot's by_device_mac6.thresholds mapping is the accepted recommendation +source. number_entity_states_raw is historical evidence, not rendering input. +""" +import json +import math +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +INPUTS = ROOT / "examples/multi-device/label_maker" +CONTRACT = yaml.safe_load((INPUTS / "fleet-gauge-contract.yaml").read_text(encoding="utf-8")) +SNAPSHOT = INPUTS / "ha-thresholds-snapshot.json" + + +def full_scale(metric, recommended_min, recommended_max): + if not (math.isfinite(recommended_min) and math.isfinite(recommended_max) + and recommended_min <= recommended_max): + raise ValueError(f"invalid recommendation for {metric}") + settings = CONTRACT["metrics"][metric] + if "range" in settings: + minimum, maximum = settings["range"] + else: + step = settings["maximum_step"] + minimum = settings["minimum"] + maximum = max(step, math.ceil(recommended_max / step) * step) + if not minimum <= recommended_min <= recommended_max <= maximum or minimum >= maximum: + raise ValueError(f"recommendation outside full scale for {metric}") + return [minimum, maximum] + + +def angle(value, scale): + """Clockwise screen angle in radians, from +x; clamp only the pointer.""" + minimum, maximum = scale + sweep = math.radians(CONTRACT["angle_degrees"]) + start = math.pi / 2 + (2 * math.pi - sweep) / 2 + return start + (min(max(value, minimum), maximum) - minimum) / (maximum - minimum) * sweep + + +def load_thresholds(snapshot=SNAPSHOT): + return json.loads(Path(snapshot).read_text(encoding="utf-8"))["by_device_mac6"] + + +def device_scales(key, snapshot=None): + snapshot = load_thresholds() if snapshot is None else snapshot + thresholds = snapshot[key.rsplit("-", 1)[-1]]["thresholds"] + return {metric: full_scale(metric, thresholds[metric]["min"], thresholds[metric]["max"]) + for metric in CONTRACT["metrics"]} diff --git a/scripts/smartplant_fleet_label_maker.py b/scripts/smartplant_fleet_label_maker.py new file mode 100644 index 0000000..3d001d8 --- /dev/null +++ b/scripts/smartplant_fleet_label_maker.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SmartPlant name-free e-paper label generator (infra-zdxz.3). + +Regenerates the 296x128 `page_1_background` PNGs so that: + - the plant NAME is NOT baked in (ESPHome renders display_name/secondary_name + as dynamic text on the normal page); the background carries only the + hand-drawn illustration + the four recommended-range gauge arcs; + - the arcs are reproducible from the infra-kl21.4 HA threshold snapshot + (rel_range = HA-authoritative thresholds), not from lost per-plant JSON. + +Arc rendering derives from JGAguado/Label-maker Plants_labels.py. Its endpoint +angle is corrected to use the same start angle as the runtime needle. +Geometry and full scales come from label_maker/fleet-gauge-contract.yaml, which also +drives generated per-device firmware scales. The accepted HA snapshot remains +the recommendation source; its historical raw-entity appendix is not input. +""" +import math +import argparse +from pathlib import Path + +import cairo + +if __package__: + from .smartplant_fleet_gauge_contract import CONTRACT, angle as gauge_angle, full_scale, load_thresholds +else: + from smartplant_fleet_gauge_contract import CONTRACT, angle as gauge_angle, full_scale, load_thresholds + +SIZE = tuple(CONTRACT["size"]) +GAUGE_ANGLE_DEG = CONTRACT["angle_degrees"] +GAUGE_RADIUS = CONTRACT["radius"] +GAUGE_THICK = CONTRACT["arc_thickness"] +GAUGES = CONTRACT["metrics"] + +INK = (0.0, 0.0, 0.0) # single ink; panel is 1-bit BINARY, colour is cosmetic + + +# Arc renderer derived from JGAguado/Label-maker Plants_labels.py. +def draw_image(ctx, image, top, left, height, width, rot=0): + image_surface = cairo.ImageSurface.create_from_png(image) + img_height = image_surface.get_height() + img_width = image_surface.get_width() + width_ratio = float(width) / float(img_width) + height_ratio = float(height) / float(img_height) + scale_xy = min(height_ratio, width_ratio) + ctx.save() + ctx.rotate(rot * math.pi / 180) + ctx.translate(left, top) + ctx.scale(scale_xy, scale_xy) + ctx.set_source_surface(image_surface) + ctx.paint() + ctx.restore() + + +def plot_parameter(ctx, parameter, thick=GAUGE_THICK): + alpha = math.radians(GAUGE_ANGLE_DEG) + beta = 2 * math.pi - alpha + + rel_range = parameter["rel_range"] + abs_r = parameter["abs_range"] + left = parameter["position"][0] + top = parameter["position"][1] + radius = parameter["radius"] + + if rel_range[0] < abs_r[0] or rel_range[1] > abs_r[1]: + raise ValueError(f"rel_range {rel_range} not within abs_range {abs_r}") + + endpoint = gauge_angle(sum(rel_range) / 2, abs_r) + + # Icon (centred in the ring) + size = radius + draw_image(ctx, parameter["icon"], + top + radius - size / 2, left + radius - size / 2, size, size) + + # Filled segment: gauge start -> midpoint of recommended band + ctx.set_line_width(1) + ctx.set_source_rgb(*INK) + ctx.move_to(left + radius + (radius - thick) * math.cos(math.pi / 2 + beta / 2), + top + radius + (radius - thick) * math.sin(math.pi / 2 + beta / 2)) + ctx.arc(left + radius, top + radius, radius, math.pi / 2 + beta / 2, endpoint) + ctx.line_to(left + radius + radius * math.cos(endpoint), + top + radius + radius * math.sin(endpoint)) + ctx.arc_negative(left + radius, top + radius, radius - thick, endpoint, math.pi / 2 + beta / 2) + ctx.fill() + ctx.stroke() + + # Full 270deg contour outline + ctx.set_source_rgb(*INK) + ctx.move_to(left + radius + (radius - thick) * math.cos(math.pi / 2 + beta / 2), + top + radius + (radius - thick) * math.sin(math.pi / 2 + beta / 2)) + ctx.arc(left + radius, top + radius, radius, math.pi / 2 + beta / 2, math.pi / 2 - beta / 2) + ctx.line_to(left + radius + radius * math.cos(math.pi / 2 - beta / 2), + top + radius + radius * math.sin(math.pi / 2 - beta / 2)) + ctx.arc_negative(left + radius, top + radius, radius - thick, math.pi / 2 - beta / 2, math.pi / 2 + beta / 2) + ctx.stroke() + + +# --------------------------------------------------------------------------- +def render_label(out_path, illustration, icons_dir, thresholds, + illo=(5, 37), illsize=(75, 88)): + """Compose one name-free 296x128 background PNG.""" + surface = cairo.ImageSurface(cairo.FORMAT_RGB24, *SIZE) + ctx = cairo.Context(surface) + # White background (BINARY: off pixels show the panel's white) + ctx.rectangle(0, 0, *SIZE) + ctx.set_source_rgb(1, 1, 1) + ctx.fill() + + # Hand-drawn illustration, left region (below the dynamic name area) + draw_image(ctx, str(illustration), illo[1], illo[0], illsize[1], illsize[0]) + + # Four recommended-range arcs + for metric, g in GAUGES.items(): + rmin = thresholds[metric]["min"] + rmax = thresholds[metric]["max"] + plot_parameter(ctx, { + "rel_range": [rmin, rmax], + "abs_range": full_scale(metric, rmin, rmax), + "position": g["position"], + "radius": GAUGE_RADIUS, + "icon": str(Path(icons_dir) / g["icon"]), + }) + + surface.write_to_png(str(out_path)) + # Flatten cairo's antialiased RGB to a clean 1-bit image: the panel is a + # 2.90inv2 BINARY e-paper, so grey edge pixels are noise. Done in code (not + # by hand) to keep the output reproducible. Threshold 160 matches the + # hand-quantised reference within 99% of pixels. + from PIL import Image + Image.open(out_path).convert("L").point( + lambda p: 255 if p >= 160 else 0, mode="1").save(out_path) + + +def load_plants(plants_yaml): + import yaml + with open(plants_yaml) as f: + return yaml.safe_load(f)["plants"] + + +def main(): + ap = argparse.ArgumentParser(description="SmartPlant name-free label generator") + ap.add_argument("--snapshot", required=True, help="kl21.4 HA threshold snapshot JSON") + ap.add_argument("--plants", required=True, help="plants.yaml identity registry") + ap.add_argument("--illustrations", required=True, help="dir of .png illustrations") + ap.add_argument("--icons", required=True, help="dir of gauge icon PNGs") + ap.add_argument("--out", required=True, help="output dir for generated PNGs") + args = ap.parse_args() + + snap = load_thresholds(args.snapshot) + plants = load_plants(args.plants) + illu_dir = Path(args.illustrations) + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + # index snapshot by mac6 suffix of the device key + jobs = {} + for key, p in plants.items(): + mac6 = key.rsplit("-", 1)[-1] + if mac6 not in snap: + raise SystemExit(f"no snapshot thresholds for {key} (mac6={mac6})") + # Join illustration on the label_image stem, not botanical_name: + # both derive from the same original baked PNG, whereas botanical_name + # is null/cultivar-mismatched for some devices (e.g. Peperomia_Hope). + stem = Path(p["label_image"]).name.replace("_label_page_1.png", "") + illustration = illu_dir / f"{stem}.png" + if not illustration.exists(): + raise SystemExit(f"missing illustration {illustration} for {key}") + out_png = out / Path(p["label_image"]).name + thresholds = snap[mac6]["thresholds"] + signature = [(metric, thresholds[metric], full_scale( + metric, thresholds[metric]["min"], thresholds[metric]["max"])) + for metric in GAUGES] + if out_png.name in jobs and jobs[out_png.name][0] != signature: + raise SystemExit(f"conflicting thresholds/scales for shared image {out_png.name}") + jobs[out_png.name] = (signature, illustration, thresholds) + print(f"OK {key:32s} <- {stem:24s} -> {out_png.name}") + + # Validate all shared filenames before writing any output. + for filename, (_, illustration, thresholds) in jobs.items(): + render_label(out / filename, illustration, args.icons, thresholds) + print(f"\n{len(jobs)} unique labels for {len(plants)} devices generated in {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/smartplant_gauge_contract.py b/scripts/smartplant_gauge_contract.py index e41247b..1cfaf1d 100644 --- a/scripts/smartplant_gauge_contract.py +++ b/scripts/smartplant_gauge_contract.py @@ -1,50 +1,56 @@ -"""Shared e-paper geometry and snapshot-derived display scales. - -The snapshot's by_device_mac6.thresholds mapping is the accepted recommendation -source. number_entity_states_raw is historical evidence, not rendering input. -""" -import json +"""Explicit label geometry and display scales, without fleet-owned data.""" import math from pathlib import Path import yaml -ROOT = Path(__file__).resolve().parent.parent -INPUTS = ROOT / "examples/multi-device/label_maker" -CONTRACT = yaml.safe_load((INPUTS / "gauge-contract.yaml").read_text(encoding="utf-8")) -SNAPSHOT = INPUTS / "ha-thresholds-snapshot.json" - - -def full_scale(metric, recommended_min, recommended_max): - if not (math.isfinite(recommended_min) and math.isfinite(recommended_max) - and recommended_min <= recommended_max): - raise ValueError(f"invalid recommendation for {metric}") - settings = CONTRACT["metrics"][metric] - if "range" in settings: - minimum, maximum = settings["range"] - else: - step = settings["maximum_step"] - minimum = settings["minimum"] - maximum = max(step, math.ceil(recommended_max / step) * step) - if not minimum <= recommended_min <= recommended_max <= maximum or minimum >= maximum: - raise ValueError(f"recommendation outside full scale for {metric}") - return [minimum, maximum] +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = yaml.safe_load((ROOT / "examples/multi-device/label_maker/gauge-contract.yaml").read_text()) + + +def pair(value, name): + if not isinstance(value, list) or len(value) != 2: + raise ValueError(f"{name} must contain two numbers") + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in value): + raise ValueError(f"{name} must contain finite numbers") + return list(map(float, value)) + + +def resolve_metrics(config): + if not isinstance(config, dict) or not isinstance(config.get("metrics"), dict): + raise ValueError("configuration needs a metrics mapping") + if set(config["metrics"]) != set(CONTRACT["metrics"]): + raise ValueError("configuration needs exactly moisture, illuminance, temperature and humidity") + resolved = {} + for metric, geometry in CONTRACT["metrics"].items(): + spec = config["metrics"][metric] + if not isinstance(spec, dict): + raise ValueError(f"{metric} needs a mapping") + scale = pair(spec.get("scale", geometry["scale"]), f"{metric} scale") + band = pair(spec.get("band"), f"{metric} band") + if not scale[0] <= band[0] <= band[1] <= scale[1] or scale[0] >= scale[1]: + raise ValueError(f"{metric} band is outside its increasing display scale") + if metric == "moisture" and scale != [0, 100]: + raise ValueError("the firmware moisture scale is fixed at 0-100") + if metric == "illuminance" and scale[0] != 0: + raise ValueError("the firmware illuminance scale starts at zero") + resolved[metric] = {"scale": scale, "band": band} + return resolved def angle(value, scale): - """Clockwise screen angle in radians, from +x; clamp only the pointer.""" + """Clockwise screen angle, identical to the runtime's 270-degree needle.""" minimum, maximum = scale sweep = math.radians(CONTRACT["angle_degrees"]) start = math.pi / 2 + (2 * math.pi - sweep) / 2 return start + (min(max(value, minimum), maximum) - minimum) / (maximum - minimum) * sweep -def load_thresholds(snapshot=SNAPSHOT): - return json.loads(Path(snapshot).read_text(encoding="utf-8"))["by_device_mac6"] - - -def device_scales(key, snapshot=None): - snapshot = load_thresholds() if snapshot is None else snapshot - thresholds = snapshot[key.rsplit("-", 1)[-1]]["thresholds"] - return {metric: full_scale(metric, thresholds[metric]["min"], thresholds[metric]["max"]) - for metric in CONTRACT["metrics"]} +def substitutions(metrics): + return { + "light_max": str(metrics["illuminance"]["scale"][1]), + "temp_min": str(metrics["temperature"]["scale"][0]), + "temp_max": str(metrics["temperature"]["scale"][1]), + "hum_min": str(metrics["humidity"]["scale"][0]), + "hum_max": str(metrics["humidity"]["scale"][1]), + } diff --git a/scripts/smartplant_label_maker.py b/scripts/smartplant_label_maker.py index 63387a6..0fae5c5 100644 --- a/scripts/smartplant_label_maker.py +++ b/scripts/smartplant_label_maker.py @@ -1,42 +1,33 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -SmartPlant name-free e-paper label generator (infra-zdxz.3). - -Regenerates the 296x128 `page_1_background` PNGs so that: - - the plant NAME is NOT baked in (ESPHome renders display_name/secondary_name - as dynamic text on the normal page); the background carries only the - hand-drawn illustration + the four recommended-range gauge arcs; - - the arcs are reproducible from the infra-kl21.4 HA threshold snapshot - (rel_range = HA-authoritative thresholds), not from lost per-plant JSON. - -Arc rendering derives from JGAguado/Label-maker Plants_labels.py. Its endpoint -angle is corrected to use the same start angle as the runtime needle. -Geometry and full scales come from label_maker/gauge-contract.yaml, which also -drives generated per-device firmware scales. The accepted HA snapshot remains -the recommendation source; its historical raw-entity appendix is not input. +"""Render a generic 296x128 label and matching display substitutions. + +Gauge arcs derive from JGAguado/Label-maker Plants_labels.py. The corrected +endpoint uses the same 135-degree start angle as Smart Plant runtime needles. +Only the explicit YAML input is read; no inventory or Home Assistant access. """ -import math + +# Original arc renderer: Copyright (c) 2021 J.G.Aguado @JGAguado. +# MIT notice: examples/multi-device/label_maker/LICENSE.Label-maker.txt import argparse +import math from pathlib import Path import cairo +from PIL import Image +import yaml if __package__: - from .smartplant_gauge_contract import CONTRACT, angle as gauge_angle, full_scale, load_thresholds + from .smartplant_gauge_contract import CONTRACT, angle as gauge_angle, resolve_metrics, substitutions else: - from smartplant_gauge_contract import CONTRACT, angle as gauge_angle, full_scale, load_thresholds + from smartplant_gauge_contract import CONTRACT, angle as gauge_angle, resolve_metrics, substitutions SIZE = tuple(CONTRACT["size"]) GAUGE_ANGLE_DEG = CONTRACT["angle_degrees"] GAUGE_RADIUS = CONTRACT["radius"] GAUGE_THICK = CONTRACT["arc_thickness"] GAUGES = CONTRACT["metrics"] +INK = (0.0, 0.0, 0.0) -INK = (0.0, 0.0, 0.0) # single ink; panel is 1-bit BINARY, colour is cosmetic - - -# Arc renderer derived from JGAguado/Label-maker Plants_labels.py. def draw_image(ctx, image, top, left, height, width, rot=0): image_surface = cairo.ImageSurface.create_from_png(image) img_height = image_surface.get_height() @@ -69,9 +60,10 @@ def plot_parameter(ctx, parameter, thick=GAUGE_THICK): endpoint = gauge_angle(sum(rel_range) / 2, abs_r) # Icon (centred in the ring) - size = radius - draw_image(ctx, parameter["icon"], - top + radius - size / 2, left + radius - size / 2, size, size) + if parameter.get("icon"): + size = radius + draw_image(ctx, parameter["icon"], + top + radius - size / 2, left + radius - size / 2, size, size) # Filled segment: gauge start -> midpoint of recommended band ctx.set_line_width(1) @@ -97,89 +89,52 @@ def plot_parameter(ctx, parameter, thick=GAUGE_THICK): # --------------------------------------------------------------------------- -def render_label(out_path, illustration, icons_dir, thresholds, - illo=(5, 37), illsize=(75, 88)): - """Compose one name-free 296x128 background PNG.""" + +def render_label(out_path, config): + metrics = resolve_metrics(config) surface = cairo.ImageSurface(cairo.FORMAT_RGB24, *SIZE) ctx = cairo.Context(surface) - # White background (BINARY: off pixels show the panel's white) - ctx.rectangle(0, 0, *SIZE) ctx.set_source_rgb(1, 1, 1) - ctx.fill() - - # Hand-drawn illustration, left region (below the dynamic name area) - draw_image(ctx, str(illustration), illo[1], illo[0], illsize[1], illsize[0]) - - # Four recommended-range arcs - for metric, g in GAUGES.items(): - rmin = thresholds[metric]["min"] - rmax = thresholds[metric]["max"] - plot_parameter(ctx, { - "rel_range": [rmin, rmax], - "abs_range": full_scale(metric, rmin, rmax), - "position": g["position"], - "radius": GAUGE_RADIUS, - "icon": str(Path(icons_dir) / g["icon"]), - }) - + ctx.paint() + ctx.set_source_rgb(*INK) + ctx.select_font_face("Sans") + ctx.set_font_size(15) + ctx.move_to(2, 18) + ctx.show_text(str(config.get("name", "Smart Plant"))) + ctx.set_font_size(10) + ctx.move_to(2, 33) + ctx.show_text(str(config.get("subtitle", ""))) + for metric, settings in GAUGES.items(): + spec = metrics[metric] + plot_parameter(ctx, {"rel_range": spec["band"], "abs_range": spec["scale"], + "position": settings["position"], "radius": GAUGE_RADIUS}) + ctx.set_font_size(8) + ctx.move_to(settings["position"][0] + 8, settings["position"][1] + GAUGE_RADIUS + 3) + ctx.show_text(settings["label"]) surface.write_to_png(str(out_path)) - # Flatten cairo's antialiased RGB to a clean 1-bit image: the panel is a - # 2.90inv2 BINARY e-paper, so grey edge pixels are noise. Done in code (not - # by hand) to keep the output reproducible. Threshold 160 matches the - # hand-quantised reference within 99% of pixels. - from PIL import Image - Image.open(out_path).convert("L").point( - lambda p: 255 if p >= 160 else 0, mode="1").save(out_path) - - -def load_plants(plants_yaml): - import yaml - with open(plants_yaml) as f: - return yaml.safe_load(f)["plants"] + with Image.open(out_path) as image: + image.convert("L").point(lambda p: 255 if p >= 160 else 0, mode="1").save(out_path) + return substitutions(metrics) def main(): - ap = argparse.ArgumentParser(description="SmartPlant name-free label generator") - ap.add_argument("--snapshot", required=True, help="kl21.4 HA threshold snapshot JSON") - ap.add_argument("--plants", required=True, help="plants.yaml identity registry") - ap.add_argument("--illustrations", required=True, help="dir of .png illustrations") - ap.add_argument("--icons", required=True, help="dir of gauge icon PNGs") - ap.add_argument("--out", required=True, help="output dir for generated PNGs") - args = ap.parse_args() - - snap = load_thresholds(args.snapshot) - plants = load_plants(args.plants) - illu_dir = Path(args.illustrations) - out = Path(args.out) - out.mkdir(parents=True, exist_ok=True) - - # index snapshot by mac6 suffix of the device key - jobs = {} - for key, p in plants.items(): - mac6 = key.rsplit("-", 1)[-1] - if mac6 not in snap: - raise SystemExit(f"no snapshot thresholds for {key} (mac6={mac6})") - # Join illustration on the label_image stem, not botanical_name: - # both derive from the same original baked PNG, whereas botanical_name - # is null/cultivar-mismatched for some devices (e.g. Peperomia_Hope). - stem = Path(p["label_image"]).name.replace("_label_page_1.png", "") - illustration = illu_dir / f"{stem}.png" - if not illustration.exists(): - raise SystemExit(f"missing illustration {illustration} for {key}") - out_png = out / Path(p["label_image"]).name - thresholds = snap[mac6]["thresholds"] - signature = [(metric, thresholds[metric], full_scale( - metric, thresholds[metric]["min"], thresholds[metric]["max"])) - for metric in GAUGES] - if out_png.name in jobs and jobs[out_png.name][0] != signature: - raise SystemExit(f"conflicting thresholds/scales for shared image {out_png.name}") - jobs[out_png.name] = (signature, illustration, thresholds) - print(f"OK {key:32s} <- {stem:24s} -> {out_png.name}") - - # Validate all shared filenames before writing any output. - for filename, (_, illustration, thresholds) in jobs.items(): - render_label(out / filename, illustration, args.icons, thresholds) - print(f"\n{len(jobs)} unique labels for {len(plants)} devices generated in {out}") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, type=Path) + parser.add_argument("--image", required=True, type=Path) + parser.add_argument("--substitutions", required=True, type=Path) + args = parser.parse_args() + if args.image.resolve() == args.substitutions.resolve(): + parser.error("image and substitutions need distinct paths") + if args.config.resolve() in (args.image.resolve(), args.substitutions.resolve()): + parser.error("output paths must differ from the input configuration") + config = yaml.safe_load(args.config.read_text(encoding="utf-8")) + # Validate all ranges before writing either output. + resolve_metrics(config) + for path in (args.image, args.substitutions): + path.parent.mkdir(parents=True, exist_ok=True) + overrides = render_label(args.image, config) + args.substitutions.write_text(yaml.safe_dump({"substitutions": overrides}, sort_keys=False), encoding="utf-8") + print(f"Wrote {args.image} and {args.substitutions}") if __name__ == "__main__": diff --git a/scripts/validate_public_examples.py b/scripts/validate_public_examples.py new file mode 100644 index 0000000..73c0e5d --- /dev/null +++ b/scripts/validate_public_examples.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Validate public API/MQTT examples against this checkout with synthetic secrets. + +Only schema validation runs. No compiler, device, broker or live service is used. +ESPHome may download the examples' Google Fonts. The public background is local. +""" +from pathlib import Path +import re +import subprocess +import sys +import tempfile + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "examples/multi-device" +PACKAGE_URL = re.compile( + r"github://(?:JGAguado|flowcool)/Smart_Plant/examples/multi-device/packages/" + r"(smart_plant_(?:core|profile_api|profile_mqtt)\.yaml)@V2R1" +) + + +def validate(): + secrets = { + "wifi_ssid": "offline-example", + "wifi_password": "offline-example-password", + "mqtt_broker": "192.0.2.1", + "mqtt_username": "offline-example", + "mqtt_password": "offline-example-password", + "lemon_tree_api_encryption_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + "lemon_tree_ota_password": "offline-example-ota", + "lemon_tree_api_ota_password": "offline-example-api-ota", + } + label = ROOT / "docs/source/images/programming/Lemon_tree_label_page_1.png" + with tempfile.TemporaryDirectory(prefix="smart-plant-examples-") as temporary: + directory = Path(temporary) + (directory / "secrets.yaml").write_text(yaml.safe_dump(secrets), encoding="utf-8") + for name in ("my-lemon-tree-api.yaml", "my-lemon-tree.yaml"): + # Keep the real example's substitutions, credentials references and + # overrides. Resolve its two public packages from this checkout so + # CI checks the PR source rather than a previously merged revision. + source = (EXAMPLES / name).read_text(encoding="utf-8") + source, count = PACKAGE_URL.subn( + lambda match: "!include " + yaml.safe_dump( + str(EXAMPLES / "packages" / match[1]), default_style='"' + ).strip(), source + ) + if count != 2 or "github://" in source: + raise ValueError(f"{name}: expected two upstream package references") + fixture = directory / name + fixture.write_text(source, encoding="utf-8") + result = subprocess.run( + [sys.executable, "-m", "esphome", "-s", "label_image", str(label), + "config", str(fixture)], capture_output=True, text=True, timeout=180 + ) + if result.returncode: + # Inputs are synthetic. Keep failure output bounded for CI logs. + print((result.stdout + result.stderr)[-12000:], file=sys.stderr) + raise SystemExit(result.returncode) + print(f"PASS {name}: current-checkout packages and synthetic secrets", flush=True) + + +if __name__ == "__main__": + validate() diff --git a/tests/display_automation.py b/tests/display_automation.py index 115d3ea..5667769 100644 --- a/tests/display_automation.py +++ b/tests/display_automation.py @@ -1,7 +1,6 @@ """Offline executor for the display scripts, read directly from package YAML. -Only the action primitives used by these scripts are supported. Control tests -record publications and sensor updates; they never contact a broker or device. C++ lambdas +Only the action primitives used by these scripts are supported. C++ lambdas run unchanged in a native library with fake globals, time and GPIO. Script restart/stop cancels the caller's continuation and waits, not called scripts, matching ESPHome 2026.9.0 script.h / automation.h / base_automation.h. @@ -40,7 +39,7 @@ def __init__(self, root, profile=None, standalone=False, control=False): (packages / f"smart_plant_profile_{profile}.yaml").read_text(encoding="utf-8"), PackageLoader ) self.substitutions = dict(core["substitutions"], **transport.get("substitutions", {})) - available = {s["id"]: s for p in (core, transport) for s in p["script"]} + available = {s["id"]: s for p in (core, transport) for s in p.get("script", [])} if control: for switch in transport.get("switch", []): for direction in ("on", "off"): @@ -112,17 +111,14 @@ def visit(node): fields.append(("esptime", "Clock", "0")) if control: fields += [("batpercent", "Sensor", None), ("temp", "Sensor", None), - ("hum", "Sensor", None), ("light", "Sensor", None), - ("soil", "Sensor", None), ("soil_voltage", "Sensor", None), + ("hum", "Sensor", None), ("light", "Sensor", None), ("soil", "Sensor", None), ("esptime", "Clock", None), ("selected_page", "int", "0"), ("mqtt_connected", "bool", "true")] fields += [(page["id"], "int", str(index)) for index, page in enumerate(core["display"][0]["pages"], 1)] types = {"bool": ctypes.c_bool, "int": ctypes.c_int, "uint32_t": ctypes.c_uint32} - class Sensor(ctypes.Structure): _fields_ = [("state", ctypes.c_float), ("available", ctypes.c_bool)] - class Clock(ctypes.Structure): _fields_ = [("hour", ctypes.c_int), ("minute", ctypes.c_int), ("valid", ctypes.c_bool)] types.update(Sensor=Sensor, Clock=Clock) @@ -135,7 +131,7 @@ class State(ctypes.Structure): else Clock(0, 0, False) if kind == "Clock" else value == "true" if kind == "bool" else int(value) for name, kind, value in fields} source = ["#include \n#include \n#include \n", - "using std::isnan; struct Sensor { float state; bool available; bool has_state() const { return available; } };", + "struct Sensor { float state; bool available; bool has_state() const { return available; } };", "struct Clock { int hour; int minute; bool valid; bool is_valid() const { return valid; } Clock now() const { return *this; } };", "struct State {" + "".join(f"{kind} {name};" for name, kind, _ in fields) + "};", "constexpr int HIGH=1, LOW=0, OUTPUT=1;", @@ -230,7 +226,7 @@ def condition(self, condition): return self.state.mqtt_connected if key == "api.connected": if value != {"state_subscription_only": True}: - raise ValueError("Only the production subscriber condition is supported") + raise ValueError("Only state subscriber readiness is supported") return self.api_subscriber raise ValueError(f"Unsupported condition: {key}") @@ -257,12 +253,12 @@ def actions(self, actions, task): for action in actions: if not task["alive"] or self.sleep: return - if isinstance(action, str): - key, value = action, None - elif len(action) == 1: - key, value = next(iter(action.items())) - else: + if action == "safe_mode.mark_successful": + self.events.append((self.state.now, action, None)) + continue + if len(action) != 1: raise ValueError(f"Unsupported action: {action}") + key, value = next(iter(action.items())) if key == "lambda": self.program.functions[value](ctypes.byref(self.state)) elif key == "if": @@ -299,7 +295,7 @@ def actions(self, actions, task): self.sleep = (self.state.now, self.program.duration(duration)) self.events.append((self.state.now, key, duration)) elif key in ("deep_sleep.allow", "deep_sleep.prevent", "switch.turn_off", "switch.turn_on", - "safe_mode.mark_successful", "switch.template.publish", "logger.log", "mqtt.publish", "text_sensor.template.publish"): + "switch.template.publish", "logger.log", "mqtt.publish", "text_sensor.template.publish"): self.events.append((self.state.now, key, value)) else: raise ValueError(f"Unsupported action: {key}") diff --git a/tests/test_gauge_contract.py b/tests/test_fleet_gauge_contract.py similarity index 97% rename from tests/test_gauge_contract.py rename to tests/test_fleet_gauge_contract.py index 6436273..b229bd2 100644 --- a/tests/test_gauge_contract.py +++ b/tests/test_fleet_gauge_contract.py @@ -13,8 +13,8 @@ import yaml from scripts import generate_device_metadata as metadata -from scripts import smartplant_gauge_contract as contract -from scripts import smartplant_label_maker as labels +from scripts import smartplant_fleet_gauge_contract as contract +from scripts import smartplant_fleet_label_maker as labels import test_display_gauge as runtime @@ -23,7 +23,7 @@ def label_command(output, snapshot=contract.SNAPSHOT): - return [sys.executable, str(ROOT / "scripts/smartplant_label_maker.py"), + return [sys.executable, str(ROOT / "scripts/smartplant_fleet_label_maker.py"), "--snapshot", str(snapshot), "--plants", str(BASE / "plants.yaml"), "--illustrations", str(BASE / "label_maker/illustrations"), "--icons", str(BASE / "label_maker/icons"), "--out", str(output)] diff --git a/tests/test_generic_labels.py b/tests/test_generic_labels.py new file mode 100644 index 0000000..b08390f --- /dev/null +++ b/tests/test_generic_labels.py @@ -0,0 +1,97 @@ +"""Verify generated bands against executed firmware needles with synthetic inputs.""" +import copy +import math +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock + +from PIL import Image +import yaml + +from scripts import smartplant_gauge_contract as contract +from scripts import smartplant_label_maker as labels +import test_display_gauge as runtime + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLE = ROOT / "examples/multi-device/label_maker/example.yaml" + + +class GenericLabelTests(unittest.TestCase): + setUpClass = classmethod(runtime.DisplayGaugeTests.setUpClass.__func__) + + def test_default_scales_positions_and_band_endpoints_match_actual_firmware(self): + config = yaml.safe_load(EXAMPLE.read_text()) + metrics = contract.resolve_metrics(config) + core = yaml.safe_load((ROOT / "examples/multi-device/packages/smart_plant_core.yaml").read_text().replace("!lambda", "")) + page = core["display"][0]["pages"][0]["lambda"] + calls = re.findall(r"draw_gauge\(id\((\w+)\)\.state,.*?has_state\(\),\s*(.+?),\s*(.+?),\s*(\d+),\s*(\d+),", page) + self.assertEqual(len(calls), 4) + for (metric, geometry), call in zip(contract.CONTRACT["metrics"].items(), calls): + def resolve(value): + return float(core["substitutions"][value[2:-1]] if value.startswith("${") else value) + self.assertEqual([resolve(call[1]), resolve(call[2])], metrics[metric]["scale"]) + self.assertEqual(tuple(map(int, call[3:5])), tuple(geometry["position"])) + midpoint = sum(metrics[metric]["band"]) / 2 + ctx = Mock() + labels.plot_parameter(ctx, {"rel_range": metrics[metric]["band"], "abs_range": metrics[metric]["scale"], + "position": geometry["position"], "radius": contract.CONTRACT["radius"]}) + result = subprocess.run([str(self.executable), str(midpoint), "1", *map(str, metrics[metric]["scale"]), + "%.1f", *map(str, geometry["position"])], check=True, capture_output=True, text=True) + self.assertAlmostEqual(float(result.stderr), ctx.arc.call_args_list[0].args[-1], delta=1e-6) + self.assertAlmostEqual(ctx.arc.call_args_list[0].args[-2], contract.angle(metrics[metric]["scale"][0], metrics[metric]["scale"])) + + def test_explicit_scales_generate_matching_overrides(self): + config = yaml.safe_load(EXAMPLE.read_text()) + config["metrics"]["humidity"]["scale"] = [0, 100] + config["metrics"]["illuminance"]["scale"] = [0, 10000] + metrics = contract.resolve_metrics(config) + overrides = contract.substitutions(metrics) + self.assertEqual(overrides["hum_min"], "0.0") + self.assertEqual(overrides["hum_max"], "100.0") + self.assertEqual(overrides["light_max"], "10000.0") + for metric in ("humidity", "illuminance"): + midpoint = sum(metrics[metric]["band"]) / 2 + position = contract.CONTRACT["metrics"][metric]["position"] + result = subprocess.run([str(self.executable), str(midpoint), "1", *map(str, metrics[metric]["scale"]), + "%.1f", *map(str, position)], check=True, capture_output=True, text=True) + self.assertAlmostEqual(float(result.stderr), contract.angle(midpoint, metrics[metric]["scale"]), delta=1e-6) + + def test_invalid_or_unsupported_ranges_fail_before_any_output(self): + config = yaml.safe_load(EXAMPLE.read_text()) + for metric, field, value in [("humidity", "band", [70, 30]), ("humidity", "scale", [50, 50]), + ("humidity", "band", [0, 100]), ("moisture", "scale", [-10, 100]), + ("illuminance", "scale", [1, 5000]), ("temperature", "band", [math.nan, 25])]: + with self.subTest(metric=metric, field=field, value=value), tempfile.TemporaryDirectory() as temporary: + candidate = copy.deepcopy(config) + candidate["metrics"][metric][field] = value + path = Path(temporary) + (path / "input.yaml").write_text(yaml.safe_dump(candidate)) + result = subprocess.run([sys.executable, str(ROOT / "scripts/smartplant_label_maker.py"), + "--config", str(path / "input.yaml"), "--image", str(path / "label.png"), + "--substitutions", str(path / "display.yaml")], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((path / "label.png").exists()) + self.assertFalse((path / "display.yaml").exists()) + + def test_cli_is_deterministic_and_outputs_one_bit_image_and_usable_overrides(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) + command = [sys.executable, str(ROOT / "scripts/smartplant_label_maker.py"), "--config", str(EXAMPLE), + "--image", str(path / "label.png"), "--substitutions", str(path / "display.yaml")] + subprocess.run(command, check=True, capture_output=True) + first = (path / "label.png").read_bytes(), (path / "display.yaml").read_bytes() + subprocess.run(command, check=True, capture_output=True) + self.assertEqual(first, ((path / "label.png").read_bytes(), (path / "display.yaml").read_bytes())) + with Image.open(path / "label.png") as image: + self.assertEqual(image.mode, "1") + self.assertEqual(image.size, (296, 128)) + self.assertEqual(yaml.safe_load((path / "display.yaml").read_text())["substitutions"], + contract.substitutions(contract.resolve_metrics(yaml.safe_load(EXAMPLE.read_text())))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_label_packages.py b/tests/test_label_packages.py new file mode 100644 index 0000000..557fac3 --- /dev/null +++ b/tests/test_label_packages.py @@ -0,0 +1,48 @@ +"""Check generated scale precedence with the exact pinned ESPHome package loader.""" +import copy +import importlib.metadata +import importlib.util +from pathlib import Path +import unittest + +import yaml + +from scripts import smartplant_gauge_contract as contract + +ROOT = Path(__file__).resolve().parents[1] + + +class LabelPackageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if importlib.util.find_spec("esphome") is None: + raise unittest.SkipTest("Run with pinned ESPHome 2026.9.0") + if importlib.metadata.version("esphome") != "2026.9.0": + raise RuntimeError("Package precedence scenarios require ESPHome 2026.9.0") + from esphome.components.packages import do_packages_pass + cls.expand = staticmethod(do_packages_pass) + core = yaml.safe_load((ROOT / "examples/multi-device/packages/smart_plant_core.yaml").read_text().replace("!lambda", "")) + config = yaml.safe_load((ROOT / "examples/multi-device/label_maker/example.yaml").read_text()) + config["metrics"]["humidity"]["scale"] = [0, 100] + config["metrics"]["illuminance"]["scale"] = [0, 10000] + cls.overrides = contract.substitutions(contract.resolve_metrics(config)) + cls.packages = {"core": {"substitutions": core["substitutions"]}, + "display_scales": {"substitutions": cls.overrides}} + + def test_scale_package_after_core_supplies_all_generated_values(self): + resolved = self.expand({"packages": copy.deepcopy(self.packages)}) + for name, value in self.overrides.items(): + self.assertEqual(resolved["substitutions"][name], value) + + def test_reversed_package_order_loses_custom_values(self): + resolved = self.expand({"packages": dict(reversed(copy.deepcopy(self.packages).items()))}) + self.assertEqual(float(resolved["substitutions"]["light_max"]), 3775) + self.assertEqual(float(resolved["substitutions"]["hum_max"]), 80) + + def test_device_substitutions_take_precedence_over_generated_package(self): + resolved = self.expand({"substitutions": {"light_max": "5000"}, "packages": copy.deepcopy(self.packages)}) + self.assertEqual(resolved["substitutions"]["light_max"], "5000") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_low_battery.py b/tests/test_low_battery.py new file mode 100644 index 0000000..874fa6c --- /dev/null +++ b/tests/test_low_battery.py @@ -0,0 +1,210 @@ +"""Execute the package's real C++ lambdas and inspect both sleep action paths.""" + +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +CORE = ROOT / "examples/multi-device/packages/smart_plant_core.yaml" + + +class PackageLoader(yaml.SafeLoader): + pass + + +PackageLoader.add_constructor("!lambda", lambda loader, node: loader.construct_scalar(node)) +PACKAGE = yaml.load(CORE.read_text(), Loader=PackageLoader) +SCRIPTS = {script["id"]: script for script in PACKAGE["script"]} +PAGES = {page["id"]: page["lambda"] for page in PACKAGE["display"][0]["pages"]} +SUBSTITUTIONS = PACKAGE["substitutions"] + + +def resolve(source, overrides=None): + values = dict(SUBSTITUTIONS, **(overrides or {})) + for name, value in values.items(): + source = source.replace("${" + name + "}", str(value)) + return source + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#define id(x) x +struct Sensor { float state = 50; bool available = true; bool has_state() { return available; } }; +Sensor batpercent, soil, light, temp, hum; +bool low_batt_active = false; +bool storage_active = false; +int page_storage = 3; +int page1 = 1, page_low_batt = 2; +int font_title, font_subtitle, font_parameters, font_icon, page_1_background; +enum Color { COLOR_OFF, COLOR_ON }; +enum class TextAlign { TOP_LEFT, TOP_RIGHT, TOP_CENTER }; +struct Time { bool valid = true; bool is_valid() { return valid; } }; +struct Clock { Time value; Time now() { return value; } } esptime; +struct Display { + Color background = COLOR_OFF, image_fg = COLOR_ON, image_bg = COLOR_OFF; + std::vector foregrounds; + std::vector text; + void fill(Color color) { background = color; } + void image(int, int, int, Color fg, Color bg) { image_fg = fg; image_bg = bg; } + void line(int, int, int, int, Color color) { foregrounds.push_back(color); } + void printf(int, int, int, Color color, TextAlign, const char *format, ...) { + char value[128]; + va_list args; + va_start(args, format); + std::vsnprintf(value, sizeof(value), format, args); + va_end(args); + foregrounds.push_back(color); text.emplace_back(value); + } + void strftime(int, int, int, Color color, TextAlign, const char *, Time) { + foregrounds.push_back(color); + } +}; +void evaluate() { EVALUATE } +int selected_page() { SELECT_PAGE } +void normal_page(Display &it) { NORMAL_PAGE } +void critical_page(Display &it) { CRITICAL_PAGE } +int main() { + // Exact thresholds and their neighboring float values, with either restored latch. + for (bool restored : {false, true}) { + for (float value : {0.0f, 14.999f, 15.0f, 15.001f, 21.999f, 22.0f, 22.001f, 100.0f}) { + batpercent = {value, true}; low_batt_active = restored; evaluate(); + assert(low_batt_active == (restored ? value < 22.0f : value <= 15.0f)); + assert(selected_page() == (low_batt_active ? page_low_batt : page1)); + } + for (float value : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + batpercent = {value, true}; low_batt_active = restored; evaluate(); + assert(low_batt_active == restored); + } + for (float value : {0.0f, 100.0f}) { + batpercent = {value, false}; low_batt_active = restored; evaluate(); + assert(low_batt_active == restored); + } + } + // Charge recovery and another discharge, across successive measurement cycles. + low_batt_active = false; + const float recovery_values[] = {15.0f, 20.0f, 22.0f, 20.0f, 15.0f}; + const bool recovery_expected[] = {true, true, false, false, true}; + for (int index = 0; index < 5; ++index) { + batpercent = {recovery_values[index], true}; evaluate(); + assert(low_batt_active == recovery_expected[index]); + } + // Both valid and missing gauge/time paths must use the selected foreground. + for (bool available : {false, true}) { + soil.available = light.available = temp.available = hum.available = available; + esptime.value.valid = available; + for (float value : {29.999f, 30.0f, 30.001f}) { + batpercent = {value, true}; Display display; normal_page(display); + Color fg = value < 30.0f ? COLOR_OFF : COLOR_ON; + Color bg = value < 30.0f ? COLOR_ON : COLOR_OFF; + assert(display.background == bg && display.image_fg == fg && display.image_bg == bg); + assert(!display.foregrounds.empty()); + for (Color color : display.foregrounds) assert(color == fg); + } + } + for (float value : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) { + batpercent = {value, true}; Display display; normal_page(display); + assert(display.background == COLOR_OFF && display.image_fg == COLOR_ON); + for (Color color : display.foregrounds) assert(color == COLOR_ON); + } + batpercent = {10, false}; Display unknown; critical_page(unknown); + assert(unknown.background == COLOR_ON && unknown.text.back() == "BATTERY --%"); + for (Color color : unknown.foregrounds) assert(color == COLOR_OFF); + batpercent = {15, true}; Display critical; critical_page(critical); + assert(critical.text.back() == "BATTERY 15%"); +} +''' + + +class LowBatteryTests(unittest.TestCase): + def source(self, overrides=None): + evaluation = SCRIPTS["render_measurement_page"]["then"][0]["lambda"] + selection = SCRIPTS["render_measurement_page"]["then"][1]["display.page.show"] + source = HARNESS + for marker, body in (("EVALUATE", evaluation), ("SELECT_PAGE", selection), + ("NORMAL_PAGE", PAGES["page1"]), ("CRITICAL_PAGE", PAGES["page_low_batt"])): + source = source.replace(marker, resolve(body, overrides)) + return source + + def compile(self, directory, overrides=None): + cpp = Path(directory) / "low_battery.cpp" + binary = Path(directory) / "low_battery" + cpp.write_text(self.source(overrides)) + result = subprocess.run(["g++", "-std=c++17", "-Wformat", "-Werror=format-security", "-Werror=format", str(cpp), "-o", str(binary)], + capture_output=True, text=True) + return result, binary + + @unittest.skipUnless(shutil.which("g++"), "g++ required for real package lambdas") + def test_real_lambdas_at_thresholds_and_rendering_branches(self): + with tempfile.TemporaryDirectory() as directory: + result, binary = self.compile(directory) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(binary)], check=True) + + @unittest.skipUnless(shutil.which("g++"), "g++ required for threshold validation") + def test_invalid_threshold_overrides_fail_at_compile_time(self): + for overrides in ({"low_batt_crit_pct": "-1"}, {"low_batt_exit_pct": "15"}, + {"low_batt_warn_pct": "22"}, {"low_batt_warn_pct": "101"}): + with self.subTest(overrides=overrides), tempfile.TemporaryDirectory() as directory: + result, _ = self.compile(directory, overrides) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Low-battery thresholds", result.stderr) + + def test_latch_is_persistent_and_core_has_no_transport_policy(self): + latch = next(item for item in PACKAGE["globals"] if item["id"] == "low_batt_active") + self.assertTrue(latch["restore_value"]) + self.assertEqual(latch["initial_value"], "false") + self.assertNotIn("mqtt", PACKAGE) + self.assertNotIn("mqtt.publish", CORE.read_text()) + for profile in ("api", "mqtt"): + text = (CORE.parent / f"smart_plant_profile_{profile}.yaml").read_text() + self.assertIn("script.execute: acquire_normal", text) + self.assertIn({"script.execute": "decide_sleep"}, SCRIPTS["acquire_normal"]["then"]) + + def test_normal_and_emergency_sleep_keep_confirmation_and_battery_duration(self): + paths = [] + + def find(value): + if isinstance(value, list): + if "safe_mode.mark_successful" in value: + paths.append(value) + for child in value: + find(child) + elif isinstance(value, dict): + for child in value.values(): + find(child) + + for name in ("enter_deep_sleep", "force_sleep_after_busy_fault"): + find(SCRIPTS[name]) + self.assertEqual(len(paths), 2) + self.assertEqual(SUBSTITUTIONS["low_batt_sleep_duration"], "24h") + for actions in paths: + confirm = actions.index("safe_mode.mark_successful") + allow = actions.index({"deep_sleep.allow": "deep_sleep_control"}) + rail_off = actions.index({"switch.turn_off": "exc"}) + self.assertLess(confirm, allow) + self.assertLess(allow, rail_off) + decision = actions[rail_off + 1]["if"] + self.assertEqual(decision["condition"]["lambda"], "return id(low_batt_active);") + self.assertEqual(decision["then"], [{"deep_sleep.enter": { + "id": "deep_sleep_control", "sleep_duration": "${low_batt_sleep_duration}"}}]) + storage = decision["else"][0]["if"] + self.assertEqual(storage["condition"]["lambda"], "return id(storage_active);") + self.assertEqual(storage["else"], [{"deep_sleep.enter": "deep_sleep_control"}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py index 5c95e76..d940ecf 100644 --- a/tests/test_maintenance_storage.py +++ b/tests/test_maintenance_storage.py @@ -359,7 +359,7 @@ def test_rejected_maintenance_acquires_initially_missing_plant_states(self): sensor.available = False sensor.state = math.nan readings = {"aht20_sensor": {"temp": 21, "hum": 45}, - "veml7700_sensor": {"light": 123}, "soil_voltage": {"soil": 42, "soil_voltage": 1.9}} + "veml7700_sensor": {"light": 123}, "soil_voltage": {"soil": 42}} def update(component, state): for name, value in readings.get(component, {}).items(): sensor = getattr(state, name) diff --git a/tests/test_soil_voltage.py b/tests/test_soil_voltage.py new file mode 100644 index 0000000..2ca1e47 --- /dev/null +++ b/tests/test_soil_voltage.py @@ -0,0 +1,161 @@ +"""Execute pinned sensor filters with the package's real soil configuration. + +Run with ESPHome 2026.9.0. Only storage and filter plumbing are local scaffolding; +median scheduling, median computation, rejection and calibration use its source. +No ADC, broker or device is used. +""" +import importlib.metadata +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def function(source, signature): + start = source.index(signature) + brace = source.index("{", start) + depth = 1 + end = brace + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +class SoilVoltageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.find_spec("esphome") + if spec is None: + raise unittest.SkipTest("Run with pinned ESPHome 2026.9.0") + if importlib.metadata.version("esphome") != "2026.9.0": + raise RuntimeError("Soil filter scenarios require ESPHome 2026.9.0") + source = (Path(spec.origin).parent / "components/sensor/filter.cpp").read_text() + cls.config = yaml.safe_load((ROOT / "examples/multi-device/packages/smart_plant_core.yaml").read_text().replace("!lambda", "")) + voltage = next(s for s in cls.config["sensor"] if s.get("id") == "soil_voltage") + moisture = next(s for s in cls.config["sensor"] if s.get("id") == "soil") + if voltage["platform"] != "adc" or moisture["platform"] != "copy" or moisture["source_id"] != "soil_voltage": + raise AssertionError("Only one ADC source may drive the moisture copy") + median, clamp = voltage["filters"] + calibration, final = moisture["filters"] + median, clamp = median["median"], clamp["clamp"] + if not clamp["ignore_out_of_range"]: + raise AssertionError("Invalid voltages must be rejected before calibration") + points = [] + for point in calibration["calibrate_linear"]: + for name, value in cls.config["substitutions"].items(): + point = point.replace("${" + name + "}", value) + points.append(tuple(map(float, point.split("->")))) + (x0, y0), (x1, y1) = points + slope = (y1 - y0) / (x1 - x0) + intercept = y0 - slope * x0 + code = r''' +#include +#include +#include +#include +#include +#include +#include +using std::optional; +#define ESP_LOGVV(...) +template struct FixedVector : std::vector { void init(size_t n) { this->reserve(n); } }; +struct Ring : std::vector { + size_t capacity; + void push_overwrite(float v) { if (size() == capacity) erase(begin()); push_back(v); } +}; +struct SlidingWindowFilter { + Ring window_; uint16_t send_every_, send_at_; + optional new_value(float); + virtual float compute_result() = 0; +}; +struct SortedWindowFilter : SlidingWindowFilter { FixedVector get_window_values_(); }; +struct MedianFilter : SortedWindowFilter { float compute_result() override; }; +struct ClampFilter { + float min_, max_; bool ignore_out_of_range_; + optional new_value(float); +}; +// PINNED +int main(int argc, char **argv) { + MedianFilter median; + median.window_.capacity = WINDOW; + median.send_every_ = EVERY; + median.send_at_ = EVERY - FIRST; + ClampFilter clamp{MINIMUM, MAXIMUM, true}; + std::array calibration{SLOPE, INTERCEPT, INFINITY}; + for (int i = 1; i < argc; ++i) { + auto value = median.new_value(std::stof(argv[i])); + if (!value) continue; + value = clamp.new_value(*value); + if (!value) continue; + const float x = *calibrate_linear_compute(&calibration, 1, *value); + const float percent = *[&]() -> optional { FINAL; }(); + std::cout << i << ' ' << *value << ' ' << percent << '\n'; + } +} +''' + signatures = ["optional SlidingWindowFilter::new_value", "FixedVector SortedWindowFilter::get_window_values_", + "float MedianFilter::compute_result", "optional ClampFilter::new_value", + "optional calibrate_linear_compute"] + code = code.replace("// PINNED", "\n".join(function(source, signature) for signature in signatures)) + replacements = {"WINDOW": median["window_size"], "EVERY": median["send_every"], "FIRST": median["send_first_at"], + "MINIMUM": clamp["min_value"], "MAXIMUM": clamp["max_value"], "SLOPE": slope, + "INTERCEPT": intercept, "FINAL": final["lambda"]} + for key, value in replacements.items(): + code = code.replace(key, str(value)) + cls.temp = tempfile.TemporaryDirectory(prefix="smartplant-soil-") + cls.addClassCleanup(cls.temp.cleanup) + cpp = Path(cls.temp.name) / "soil.cpp" + cls.executable = cpp.with_suffix("") + cpp.write_text(code) + compiled = subprocess.run(["g++", "-std=c++17", str(cpp), "-o", str(cls.executable)], text=True, capture_output=True) + if compiled.returncode: + raise AssertionError(compiled.stderr) + + def process(self, values): + result = subprocess.run([str(self.executable), *map(str, values)], check=True, text=True, capture_output=True) + return [tuple(map(float, line.split())) for line in result.stdout.splitlines()] + + @property + def calibration(self): + wet = float(self.config["substitutions"]["soil_v_wet"]) + dry = float(self.config["substitutions"]["soil_v_dry"]) + return wet, (wet + dry) / 2, dry + + def test_single_filtered_publication_and_copy_percentage(self): + wet, middle, dry = self.calibration + self.assertEqual(self.process([wet] * 4), []) + for voltage, percentage in [(wet, 100), (middle, 50), (dry, 0)]: + result = self.process([voltage] * 10) + self.assertEqual([r[0] for r in result], [5, 10]) + for _, actual_voltage, actual_percent in result: + self.assertAlmostEqual(actual_voltage, voltage, places=4) + self.assertAlmostEqual(actual_percent, percentage, places=3) + + def test_median_rejects_spikes_and_invalid_windows(self): + _, middle, _ = self.calibration + result = self.process([0.1, middle, middle, middle, 4.0]) + self.assertEqual(len(result), 1) + self.assertEqual(result[0][0], 5) + self.assertAlmostEqual(result[0][1], middle, places=4) + self.assertAlmostEqual(result[0][2], 50, places=3) + for invalid in (0.1, 4.0, "nan", "inf", "-inf"): + with self.subTest(invalid=invalid): + self.assertEqual(self.process([invalid] * 5), []) + + def test_calibration_and_three_second_cadence_are_preserved(self): + # Fork fleet default: shared immersion calibration (the upstream default is 1.25). + self.assertEqual(self.config["substitutions"]["soil_v_wet"], "1.36") + self.assertEqual(self.config["substitutions"]["soil_v_dry"], "2.8") + acquire = next(s for s in self.config["script"] if s["id"] == "acquire_normal") + repeat = next(action["repeat"] for action in acquire["then"] if "repeat" in action) + self.assertEqual(repeat, {"count": 5, "then": [{"component.update": "soil_voltage"}, {"delay": "3s"}]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py index 21522ab..b4191a8 100644 --- a/tests/test_standalone_display.py +++ b/tests/test_standalone_display.py @@ -1,4 +1,4 @@ -"""Execute the public renderer with optional UBSan and its real YAML automation.""" +"""Execute the public renderer and real display automation in both entry points.""" from pathlib import Path import subprocess import os @@ -120,10 +120,14 @@ def test_out_of_range_text_stays_raw_and_needles_match_endpoints(self): self.assertEqual(text[position], [expected]) -class StandaloneFlowTests(unittest.TestCase): +class DisplayFlowScenarios: + standalone = True + profile = None + # Profile on_display_fault hook (log or MQTT flush) before the sleep request. + fault_hook_ms = 0 @classmethod def setUpClass(cls): - cls.program = DisplayProgram(ROOT, standalone=True) + cls.program = DisplayProgram(ROOT, profile=cls.profile, standalone=cls.standalone) cls.addClassCleanup(cls.program.close) def runtime(self, busy, reverse=False): @@ -138,12 +142,18 @@ def assert_valid_sleep(self, runtime): self.assertEqual(keys.count("safe_mode.mark_successful"), 1) self.assertLess(keys.index("safe_mode.mark_successful"), keys.index("deep_sleep.enter")) - def test_boot_after_setup_and_normal_refresh_observes_busy(self): - package = yaml.load(PUBLIC.read_text(), PackageLoader) - self.assertLess(package["esphome"]["on_boot"]["priority"], 400) + def test_normal_refresh_observes_busy(self): for reverse in (False, True): - runtime = self.runtime(lambda ms: int(10000 <= ms < 12000), reverse) - runtime.execute("consider_deep_sleep") + if self.standalone: + package = yaml.load(PUBLIC.read_text(encoding="utf-8"), PackageLoader) + self.assertLess(package["esphome"]["on_boot"]["priority"], 400) + runtime = self.runtime(lambda ms: int(10000 <= ms < 12000), reverse) + runtime.execute("consider_deep_sleep") + else: + runtime = self.runtime(lambda ms: int(ms < 2000), reverse) + runtime.refresh() + runtime.run_until(3000) + runtime.execute("sleep_after_display") runtime.run_until(15000) self.assert_valid_sleep(runtime) self.assertTrue(runtime.state.display_busy_seen) @@ -158,9 +168,9 @@ def test_fault_and_oscillation_reach_one_bounded_sleep_request(self): runtime = self.runtime(signal, reverse) runtime.state.display_safe_to_sleep = False runtime.execute("recover_display_busy") - runtime.run_until(32000) + runtime.run_until(32000 + self.fault_hook_ms) self.assert_valid_sleep(runtime) - self.assertLessEqual(runtime.sleep[0], 30530) + self.assertLessEqual(runtime.sleep[0], 30530 + self.fault_hook_ms) self.assertEqual(runtime.state.reset_low_count, 1) self.assertEqual(runtime.state.reset_high_count, 1) self.assertFalse([e for e in runtime.events if e[1] == "component.update"]) @@ -173,6 +183,26 @@ def test_late_high_after_settle_enters_recovery(self): self.assert_valid_sleep(runtime) self.assertEqual(runtime.state.reset_low_count, 1) + def test_new_refresh_starts_a_fresh_recovery_budget(self): + for reverse in (False, True): + runtime = self.runtime(lambda ms: 1, reverse) + runtime.state.display_safe_to_sleep = False + runtime.execute("recover_display_busy") + runtime.run_until(1000) + runtime.busy = lambda ms: int(ms < 2000) + runtime.refresh() + runtime.run_until(4000) + runtime.busy = lambda ms: 1 + runtime.state.busy = 1 + runtime.state.display_safe_to_sleep = False + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNone(runtime.sleep) + runtime.run_until(35000 + self.fault_hook_ms) + self.assert_valid_sleep(runtime) + self.assertGreaterEqual(runtime.sleep[0], 34000) + self.assertLessEqual(runtime.sleep[0], 34530 + self.fault_hook_ms) + def test_new_refresh_cancels_wait_guard_sleep_reset_and_old_notification(self): for cancel, signal in [(100, lambda ms: 1), (250, lambda ms: 0), (2000, lambda ms: int(ms < 1000)), @@ -192,5 +222,81 @@ def test_new_refresh_cancels_wait_guard_sleep_reset_and_old_notification(self): self.assertEqual(runtime.state.reset_low_count, runtime.state.reset_high_count) +class CoreBatterySleepTests(unittest.TestCase): + def test_normal_and_fault_sleep_preserve_configurable_protection_duration(self): + for profile in ("api", "mqtt"): + program = DisplayProgram(ROOT, profile=profile) + self.addCleanup(program.close) + program.substitutions["low_batt_sleep_duration"] = "12h" + for fault in (False, True): + for reverse in (False, True): + runtime = DisplayRuntime(program, lambda ms: int(fault), reverse) + runtime.state.busy = int(fault) + runtime.state.display_safe_to_sleep = not fault + runtime.state.low_batt_active = True + runtime.execute("recover_display_busy" if fault else "enter_deep_sleep") + runtime.run_until(32000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.sleep[1], 12 * 3600000) + events = [event[1] for event in runtime.events] + self.assertEqual(events.count("safe_mode.mark_successful"), 1) + self.assertLess(events.index("safe_mode.mark_successful"), events.index("deep_sleep.enter")) + + +class RecoveryConsistencyTests(unittest.TestCase): + # Multi-device extensions on top of the shared recovery: fault hook and + # persisted counter, logs, and the MQTT status entity. Everything else must + # stay identical to the public standalone example. + EXTENSION_ACTIONS = ("logger.log", "text_sensor.template.publish") + + @classmethod + def without_extensions(cls, node): + if isinstance(node, list): + return [cls.without_extensions(child) for child in node + if not (isinstance(child, dict) and + (child.keys() & set(cls.EXTENSION_ACTIONS) or + child.get("script.stop") == "on_display_fault"))] + if isinstance(node, dict): + return {key: cls.without_extensions(value) for key, value in node.items()} + if isinstance(node, str): + return "\n".join(line for line in node.split("\n") + if "display_busy_fault_count" not in line) + return node + + def test_shared_recovery_actions_match_standalone(self): + public = yaml.load(PUBLIC.read_text(encoding="utf-8"), PackageLoader) + public_scripts = {script["id"]: script for script in public["script"]} + packages = ROOT / "examples/multi-device/packages" + loaded = {name: yaml.load((packages / f"smart_plant_{name}.yaml").read_text(encoding="utf-8"), + PackageLoader) for name in ("core", "profile_api", "profile_mqtt")} + for name in ("prepare_display_refresh", "settle_display", "recover_display_busy", + "display_recovery_budget", "handle_display_busy_fault"): + with self.subTest(script=name): + core = {script["id"]: script for script in loaded["core"]["script"]}[name] + self.assertEqual(self.without_extensions(core), + self.without_extensions(public_scripts[name])) + for profile in ("profile_api", "profile_mqtt"): + with self.subTest(script="sleep_after_display", profile=profile): + scripts = {script["id"]: script for script in loaded[profile]["script"]} + self.assertEqual(self.without_extensions(scripts["sleep_after_display"]), + self.without_extensions(public_scripts["sleep_after_display"])) + + +class StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): + pass + + +class CoreApiFlowTests(DisplayFlowScenarios, unittest.TestCase): + standalone = False + profile = "api" + fault_hook_ms = 500 + + +class CoreMqttFlowTests(DisplayFlowScenarios, unittest.TestCase): + standalone = False + profile = "mqtt" + fault_hook_ms = 1000 + + if __name__ == "__main__": unittest.main()