From da4a2f0977590501129887d79b2f1c8956027454 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 01/23] feat: expose the running ESPHome version and native config hash --- examples/multi-device/packages/smart_plant_core.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 5670b17..d25146d 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -233,6 +233,14 @@ sensor: - lambda: if (x < 1) return 0; else if (x > 100) return 100; return (x); accuracy_decimals: 0 +# Publish the running firmware version and native effective-configuration hash. +text_sensor: + - platform: version + name: "${friendly_name} ESPHome Version" + hide_timestamp: true + hide_hash: false + entity_category: diagnostic + display: - platform: waveshare_epaper cs_pin: GPIO10 From 872485a8961d4147e7955dc48216767728a2d403 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 02/23] feat: expose the filtered soil voltage without changing calibration --- .../packages/smart_plant_core.yaml | 26 ++- tests/test_soil_voltage.py | 149 ++++++++++++++++++ 2 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 tests/test_soil_voltage.py diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 5670b17..485eb78 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -207,12 +207,13 @@ sensor: - platform: adc pin: GPIO1 - name: "${friendly_name} Soil Moisture" - id: soil - icon: "mdi:cup-water" - device_class: moisture + name: "${friendly_name} Soil Voltage" + id: soil_voltage + device_class: voltage + state_class: measurement + entity_category: diagnostic update_interval: never - unit_of_measurement: "%" + unit_of_measurement: "V" attenuation: 12db force_update: true filters: @@ -227,6 +228,19 @@ sensor: min_value: 0.5 max_value: 3.3 ignore_out_of_range: true + accuracy_decimals: 3 + + # 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: "${friendly_name} Soil Moisture" + id: soil + icon: "mdi:cup-water" + device_class: moisture + unit_of_measurement: "%" + force_update: true + filters: - calibrate_linear: - ${soil_v_wet} -> 100.00 - ${soil_v_dry} -> 0.00 @@ -335,7 +349,7 @@ script: - repeat: count: 5 then: - - component.update: soil + - component.update: soil_voltage - delay: 3s - wait_until: condition: diff --git a/tests/test_soil_voltage.py b/tests/test_soil_voltage.py new file mode 100644 index 0000000..507deb8 --- /dev/null +++ b/tests/test_soil_voltage.py @@ -0,0 +1,149 @@ +"""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()] + + def test_single_filtered_publication_and_copy_percentage(self): + self.assertEqual(self.process([1.25] * 4), []) + for voltage, percentage in [(1.25, 100), (2.025, 50), (2.8, 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): + result = self.process([0.1, 2.025, 2.025, 2.025, 4.0]) + self.assertEqual(result, [(5, 2.025, 50)]) + 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): + self.assertEqual(self.config["substitutions"]["soil_v_wet"], "1.25") + 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() From beb95c6c4f8b6e398fc2538d151ac42467680e8e Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 03/23] fix: preserve raw gauge labels while clamping needles --- .../packages/smart_plant_core.yaml | 8 +- tests/test_display_gauge.py | 148 ++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/test_display_gauge.py diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 5670b17..bbe3be7 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -284,9 +284,11 @@ display: it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, "--"); return; } - if (value < vmin) value = vmin; - if (value > vmax) value = vmax; - float val = (value - vmin) / abs(vmax - vmin) * alpha; + // 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; + float val = (needle_value - vmin) / abs(vmax - vmin) * alpha; int x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); int y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); int x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); diff --git a/tests/test_display_gauge.py b/tests/test_display_gauge.py new file mode 100644 index 0000000..a539cf8 --- /dev/null +++ b/tests/test_display_gauge.py @@ -0,0 +1,148 @@ +"""Execute the production gauge lambda with a minimal C++ display recorder. + +Run: python3 -m unittest discover -s tests -p test_display_gauge.py +Requires g++; no ESPHome build or hardware access is needed. +""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +CORE = ROOT / "examples/multi-device/packages/smart_plant_core.yaml" + +STUB = r""" +#include +#include +#include +#include +#include +#include +#include +#include + +enum class TextAlign { TOP_CENTER }; +#define id(name) name + +struct Display { + std::string label; + double needle_angle = NAN; + std::vector> lines; + void line(int x0, int y0, int x1, int y1, int = 1) { + lines.push_back({x0, y0, x1, y1}); + } + void printf(int x, int y, int font, TextAlign align, const char *fmt, float value) { + printf(x, y, font, 1, align, fmt, value); + } + void printf(int x, int y, int font, TextAlign align, const char *fmt) { + printf(x, y, font, 1, align, fmt); + } + void printf(int, int, int, int, TextAlign, const char *fmt, ...) { + char buffer[128]; + va_list args; + va_start(args, fmt); + vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + label = buffer; + } +}; + +int main(int argc, char **argv) { + if (argc != 8) return 2; + Display it; + int font_parameters = 0; + int fg = 1; + // PRODUCTION_LAMBDA + draw_gauge(std::stof(argv[1]), std::string(argv[2]) == "1", + std::stof(argv[3]), std::stof(argv[4]), + std::stoi(argv[6]), std::stoi(argv[7]), argv[5]); + std::cout << it.label << '\n'; + std::cerr << std::setprecision(10) << it.needle_angle; + for (const auto &line : it.lines) { + for (int coordinate : line) std::cout << coordinate << ' '; + std::cout << '\n'; + } + return 0; +} +""" + + +class DisplayGaugeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") + if compiler is None: + raise RuntimeError("g++ is required to execute the production gauge") + source = CORE.read_text(encoding="utf-8") + start = source.index(" float pi =", source.index("- id: page1")) + end = source.index(" };", start) + len(" };") + gauge = textwrap.dedent(source[start:end]) + if "auto draw_gauge =" not in gauge: + raise AssertionError("production draw_gauge lambda was not extracted") + # Observe its angle before integer pixel rounding. Keep the production + # value calculation and all drawing operations intact. + gauge = gauge.replace("int x0 =", "it.needle_angle = pi / 2 + beta / 2 + val;\nint x0 =", 1) + cls.temp = tempfile.TemporaryDirectory(prefix="smartplant-gauge-") + cls.addClassCleanup(cls.temp.cleanup) + path = Path(cls.temp.name) + cpp = path / "gauge.cpp" + cls.executable = path / "gauge" + cpp.write_text(STUB.replace("// PRODUCTION_LAMBDA", gauge), encoding="utf-8") + subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", str(cpp), + "-o", str(cls.executable)], + check=True, capture_output=True, text=True, + ) + + def render(self, value, minimum, maximum, fmt, has_state=True, position=(80, 50)): + result = subprocess.run( + [str(self.executable), str(value), "1" if has_state else "0", + str(minimum), str(maximum), fmt, *map(str, position)], + check=True, capture_output=True, text=True, + ).stdout.splitlines() + return result[0], [tuple(map(int, line.split())) for line in result[1:]] + + def test_raw_numbers_and_clamped_needles(self): + cases = [ + (10000, 0, 3775, "%.0flx", "10000lx"), + (90, 20, 80, "%.0f%%", "90%"), + (-15, -10, 50, "%.0f°C", "-15°C"), + (-5, 0, 100, "%.0f%%", "-5%"), + (120, 0, 100, "%.0f%%", "120%"), + (52.5, -10, 50, "%.1f°C", "52.5°C"), + ] + for value, minimum, maximum, fmt, expected in cases: + with self.subTest(value=value, fmt=fmt): + label, needle = self.render(value, minimum, maximum, fmt) + endpoint = min(max(value, minimum), maximum) + _, endpoint_needle = self.render(endpoint, minimum, maximum, fmt) + self.assertEqual(label, expected) + self.assertEqual(len(needle), 3) + self.assertEqual(needle, endpoint_needle) + + def test_in_range_and_boundaries(self): + expected = { + 0: [(86, 87, 79, 90), (79, 90, 83, 94), (83, 94, 86, 87)], + 50: [(102, 50, 104, 43), (104, 43, 99, 43), (99, 43, 102, 50)], + 100: [(117, 87, 120, 94), (120, 94, 124, 90), (124, 90, 117, 87)], + } + for value, needle in expected.items(): + with self.subTest(value=value): + label, actual = self.render(value, 0, 100, "%.0f%%") + self.assertEqual(label, f"{value}%") + self.assertEqual(actual, needle) + + def test_invalid_or_missing_values_have_no_needle(self): + for value, has_state in [(42, False), ("nan", True), ("inf", True), + ("-inf", True), ("nan", False)]: + with self.subTest(value=value, has_state=has_state): + self.assertEqual( + self.render(value, 0, 100, "%.0f%%", has_state), ("--", []) + ) + + +if __name__ == "__main__": + unittest.main() From da8d0c92f5cb9fa75f474c3316ad5fc970fe2570 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 04/23] fix: recover the missing KiCad symbol library from schematic caches --- Design/Smart_Plant-eagle-import.kicad_sym | 8712 +++++++++++++++++++++ docs/kicad-symbol-recovery.md | 21 + scripts/recover_kicad_symbol_library.py | 129 + tests/test_kicad_symbol_library.py | 55 + 4 files changed, 8917 insertions(+) create mode 100644 Design/Smart_Plant-eagle-import.kicad_sym create mode 100644 docs/kicad-symbol-recovery.md create mode 100644 scripts/recover_kicad_symbol_library.py create mode 100644 tests/test_kicad_symbol_library.py diff --git a/Design/Smart_Plant-eagle-import.kicad_sym b/Design/Smart_Plant-eagle-import.kicad_sym new file mode 100644 index 0000000..9369b43 --- /dev/null +++ b/Design/Smart_Plant-eagle-import.kicad_sym @@ -0,0 +1,8712 @@ +(kicad_symbol_lib + (version 20251024) + (generator "recover_kicad_symbol_library") + (generator_version "1.0") + (symbol "3.3V" + (power global) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#SUPPLY" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at 0 2.794 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify bottom) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "3.3V Supply Symbol\n\nPower supply symbol for a specifically-stated 3.3V source." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "3.3V_1_0" + (polyline + (pts + (xy 0 2.54) (xy -0.762 1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 1.27) (xy 0 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at 0 0 90) + (length 2.54) + (name "3.3V" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "555DNE" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at -7.62 10.922 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -7.62 -12.827 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:SO08" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "TIMER" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "POPULARITY" "31" + (at 0 -14.351 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "555DNE_1_0" + (polyline + (pts + (xy -7.62 10.16) (xy -7.62 -10.16) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -7.62 -10.16) (xy 7.62 -10.16) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 7.62 10.16) (xy -7.62 10.16) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 7.62 -10.16) (xy 7.62 10.16) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at -10.16 -7.62 0) + (length 2.54) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -10.16 7.62 0) + (length 2.54) + (name "TR" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin output line + (at 10.16 7.62 180) + (length 2.54) + (name "Q" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input inverted + (at -10.16 2.54 0) + (length 2.54) + (name "R" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -10.16 -2.54 0) + (length 2.54) + (name "CV" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at 10.16 -2.54 180) + (length 2.54) + (name "THR" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at 10.16 2.54 180) + (length 2.54) + (name "DIS" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin power_in line + (at 10.16 -7.62 180) + (length 2.54) + (name "V+" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "A4L-LOC" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#FRAME" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "FRAME\n\nDIN A4, landscape with location and doc. field" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "DRAWING_NAME" "" + (at 217.17 15.24 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 2.54 2.159) + ) + (justify left bottom) + ) + ) + (property "LAST_DATE_TIME" "" + (at 217.17 10.16 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 2.286 1.9431) + ) + (justify left bottom) + ) + ) + (property "SHEET" "" + (at 230.505 5.08 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 2.54 2.159) + ) + (justify left bottom) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "A4L-LOC_1_0" + (polyline + (pts + (xy 0 179.07) (xy 260.35 179.07) (xy 260.35 0) (xy 0 0) (xy 0 179.07) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 134.3025) (xy 3.81 134.3025) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 89.535) (xy 3.81 89.535) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 44.7675) (xy 3.81 44.7675) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 175.26) (xy 3.81 3.81) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 43.3917 179.07) (xy 43.3917 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 43.3917 3.81) (xy 43.3917 0) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 86.7833 179.07) (xy 86.7833 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 86.7833 3.81) (xy 86.7833 0) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 130.175 179.07) (xy 130.175 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 130.175 3.81) (xy 130.175 0) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 161.29 24.13) (xy 215.265 24.13) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 161.29 3.81) (xy 161.29 24.13) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 173.5667 179.07) (xy 173.5667 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 173.5667 3.81) (xy 173.5667 0) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 24.13) (xy 256.54 24.13) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 19.05) (xy 215.265 24.13) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 19.05) (xy 256.54 19.05) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 13.97) (xy 215.265 19.05) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 13.97) (xy 256.54 13.97) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 8.89) (xy 215.265 13.97) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 215.265 8.89) (xy 215.265 3.81) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 216.9583 179.07) (xy 216.9583 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 216.9583 3.81) (xy 216.9583 0) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 246.38 8.89) (xy 215.265 8.89) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 246.38 8.89) (xy 256.54 8.89) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 246.38 3.81) (xy 246.38 8.89) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 175.26) (xy 3.81 175.26) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 175.26) (xy 256.54 3.81) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 134.3025) (xy 260.35 134.3025) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 89.535) (xy 260.35 89.535) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 44.7675) (xy 260.35 44.7675) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 19.05) (xy 256.54 24.13) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 13.97) (xy 256.54 19.05) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 8.89) (xy 256.54 13.97) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 3.81) (xy 3.81 3.81) + ) + (stroke + (width 0) + (type default) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 256.54 3.81) (xy 256.54 8.89) + ) + (stroke + (width 0.1016) + (type solid) + ) + (fill + (type none) + ) + ) + (text "A" + (at 1.905 156.6863 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "B" + (at 1.905 111.9188 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "C" + (at 1.905 67.1513 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "D" + (at 1.905 22.3838 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "1" + (at 21.6958 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "1" + (at 21.6958 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "2" + (at 65.0875 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "2" + (at 65.0875 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "3" + (at 108.4792 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "3" + (at 108.4792 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "4" + (at 151.8708 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "4" + (at 151.8708 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "5" + (at 195.2625 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "5" + (at 195.2625 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "Sheet:" + (at 216.916 4.953 0) + (effects + (font + (size 2.54 2.159) + ) + (justify left bottom) + ) + ) + (text "6" + (at 238.6542 177.165 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "6" + (at 238.6542 1.905 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "A" + (at 258.445 156.6863 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "B" + (at 258.445 111.9188 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "C" + (at 258.445 67.1513 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + (text "D" + (at 258.445 22.3838 0) + (effects + (font + (size 2.54 2.286) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "AHT20" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at 24.13 7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Value" "" + (at 24.13 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Footprint" "Smart_Plant:AHT20" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Adafruit AHT20 - Temperature & Humidity Sensor Breakout Board - STEMMA QT / Qwiic\n\nSource: https://cdn-learn.adafruit.com/assets/assets/000/091/676/original/AHT20-datasheet-2020-4-16.pdf?1591047915 Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "HEIGHT" "1.1mm" + (at 24.13 -1.524 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_NAME" "Adafruit" + (at 24.13 -3.048 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_PART_NUMBER" "AHT20" + (at 24.13 -4.572 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "AHT20_1_0" + (polyline + (pts + (xy 5.08 2.54) (xy 5.08 -7.62) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 5.08 2.54) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -7.62) (xy 5.08 -7.62) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -7.62) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin bidirectional line + (at 0 0 0) + (length 5.08) + (name "NC_1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -2.54 0) + (length 5.08) + (name "VDD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -5.08 0) + (length 5.08) + (name "SCL" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -5.08 180) + (length 5.08) + (name "SDA" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -2.54 180) + (length 5.08) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 0 180) + (length 5.08) + (name "NC_2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "BAT_CONN-JST-2MM-SMT" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "J" + (at -2.54 3.302 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -2.54 -7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:JST-2-SMD" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Multi connection point. Often used as Generic Header-pin footprint for 0.1 inch spaced/style header connections\n\nOn any of the 0.1 inch spaced packages, you can populate with these:\n\n• https://www.sparkfun.com/products/116 Break Away Headers - Straight (PRT-00116)\n• https://www.sparkfun.com/products/553 Break Away Male Headers - Right Angle (PRT-00553)\n• https://www.sparkfun.com/products/115 Female Headers (PRT-00115)\n• https://www.sparkfun.com/products/117 Break Away Headers - Machine Pin (PRT-00117)\n• https://www.sparkfun.com/products/743 Break Away Female Headers - Swiss Machine Pin (PRT-00743)\n\nFor SCREWTERMINALS and SPRING TERMINALS visit here:\n\n• https://www.sparkfun.com/search/results?term=Screw+Terminals Screw Terimnals on SparkFun.com (5mm/3.5mm/2.54mm spacing)\n\nThis device is also useful as a general connection point to wire up your design to another part of your project. Our various solder wires solder well into these plated through hole pads.\n\n• https://www.sparkfun.com/products/11375 Hook-Up Wire - Assortment (Stranded, 22 AWG) (PRT-11375)\n• https://www.sparkfun.com/products/11367 Hook-Up Wire - Assortment (Solid Core, 22 AWG) (PRT-11367)\n• https://www.sparkfun.com/categories/141 View the entire wire category on our website here\n\nSpecial notes: Molex polarized connector foot print use with: PRT-08233 with associated crimp pins and housings.\n\n2.54_SCREWTERM for use with PRT-10571.\n\n3.5mm Screw Terminal footprints for PRT-08084\n\n5mm Screw Terminal footprints for use with PRT-08432" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC" "C3029440" + (at 0 -9.144 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "BAT_CONN-JST-2MM-SMT_1_0" + (polyline + (pts + (xy -2.54 2.54) (xy -2.54 -5.08) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -2.54 2.54) (xy 3.81 2.54) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 2.54 0) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 -2.54) (xy 2.54 -2.54) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 -5.08) (xy -2.54 -5.08) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 -5.08) (xy 3.81 2.54) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at 7.62 0 180) + (length 5.08) + (name "+" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin passive line + (at 7.62 -2.54 180) + (length 5.08) + (name "-" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "C-USC0603" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "C" + (at 1.016 0.635 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at 1.016 -4.191 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:C0603" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "CAPACITOR, American symbol" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "POPULARITY" "37" + (at 1.016 -5.715 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "SPICEPREFIX" "C" + (at 1.016 -7.239 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "C-USC0603_1_0" + (polyline + (pts + (xy -2.54 0) (xy 2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start 0 -1) + (mid 1.3158 -1.2195) + (end 2.4892 -1.8541) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start -2.4668 -1.8504) + (mid -1.302 -1.2303) + (end 0 -1.0161) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 -1.016) (xy 0 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at 0 2.54 270) + (length 2.54) + (name "1" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 0 -5.08 90) + (length 2.54) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "CONN_04PTH" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "J" + (at -5.08 8.128 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -5.08 -7.366 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:1X04" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Multi connection point. Often used as Generic Header-pin footprint for 0.1 inch spaced/style header connections\n\nOn any of the 0.1 inch spaced packages, you can populate with these:\n\n• https://www.sparkfun.com/products/116 Break Away Headers - Straight (PRT-00116)\n• https://www.sparkfun.com/products/553 Break Away Male Headers - Right Angle (PRT-00553)\n• https://www.sparkfun.com/products/115 Female Headers (PRT-00115)\n• https://www.sparkfun.com/products/117 Break Away Headers - Machine Pin (PRT-00117)\n• https://www.sparkfun.com/products/743 Break Away Female Headers - Swiss Machine Pin (PRT-00743)\n\nFor SCREWTERMINALS and SPRING TERMINALS visit here:\n\n• https://www.sparkfun.com/search/results?term=Screw+Terminals Screw Terimnals on SparkFun.com (5mm/3.5mm/2.54mm spacing)\n\nThis device is also useful as a general connection point to wire up your design to another part of your project. Our various solder wires solder well into these plated through hole pads.\n\n• https://www.sparkfun.com/products/11375 Hook-Up Wire - Assortment (Stranded, 22 AWG) (PRT-11375)\n• https://www.sparkfun.com/products/11367 Hook-Up Wire - Assortment (Solid Core, 22 AWG) (PRT-11367)\n• https://www.sparkfun.com/categories/141 View the entire wire category on our website here\n\nSpecial notes:\n\nMolex polarized connector foot print use with SKU : PRT-08231 with associated crimp pins and housings. 1MM SMD Version SKU: PRT-10208" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROD_ID" "CONN-09696" + (at 0 -8.89 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "CONN_04PTH_1_0" + (polyline + (pts + (xy -5.08 7.62) (xy -5.08 -5.08) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -5.08 7.62) (xy 1.27 7.62) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 5.08) (xy 0 5.08) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 2.54) (xy 0 2.54) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 0) (xy 0 0) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 -2.54) (xy 0 -2.54) + ) + (stroke + (width 0.6096) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 -5.08) (xy -5.08 -5.08) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 -5.08) (xy 1.27 7.62) + ) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at 5.08 -2.54 180) + (length 5.08) + (name "1" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin passive line + (at 5.08 0 180) + (length 5.08) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin passive line + (at 5.08 2.54 180) + (length 5.08) + (name "3" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin passive line + (at 5.08 5.08 180) + (length 5.08) + (name "4" + (effects + (font + (size 0 0) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "DIODE-SCHOTTKYSS14" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "D" + (at -2.286 1.905 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "1A/40V/500mV" + (at -2.286 -3.429 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:SMA-DIODE" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Schottky diode\n\nA Schottky diode is a semiconductor diode which has a low forward voltage drop and a very fast switching action." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROD_ID" "DIO-08053" + (at 0 -4.953 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "DIODE-SCHOTTKYSS14_1_0" + (polyline + (pts + (xy -1.27 1.27) (xy -1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 0) (xy -2.54 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 0) (xy -1.27 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 -1.27) (xy 1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.635 -1.016) (xy 0.635 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 1.27) (xy 1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy -1.27 1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 1.27 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 2.54 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 -1.27) (xy 0.635 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.905 1.27) (xy 1.27 1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.905 1.27) (xy 1.905 1.016) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -2.54 0 0) + (length 0) + (name "A" + (effects + (font + (size 0 0) + ) + ) + ) + (number "A" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 0 180) + (length 0) + (name "C" + (effects + (font + (size 0 0) + ) + ) + ) + (number "C" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "E-PAPER-FPC" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "" + (at -2.54 3.81 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -2.54 -63.5 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:FPC_24_0.5MM" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC" "C2856831" + (at 0 -65.024 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "E-PAPER-FPC_1_0" + (polyline + (pts + (xy -2.54 2.54) (xy 10.16 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -2.54 -60.96) (xy -2.54 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 10.16 2.54) (xy 10.16 -60.96) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 10.16 -60.96) (xy -2.54 -60.96) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (text "GDR" + (at 0 0 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "RESE" + (at 0 -2.54 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "TSCL" + (at 0 -5.08 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "TSDA" + (at 0 -7.62 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "BUSY" + (at 0 -12.7 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "RES" + (at 0 -15.24 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "DC" + (at 0 -17.78 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "CS" + (at 0 -20.32 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "SCK" + (at 0 -22.86 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "SDIO" + (at 0 -25.4 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VGL" + (at 0 -27.94 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VSH2" + (at 0 -30.48 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "BSI" + (at 0 -33.02 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VDDIO" + (at 0 -35.56 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VCI" + (at 0 -38.1 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VSS" + (at 0 -40.64 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VDD" + (at 0 -43.18 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VPP" + (at 0 -45.72 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VSH1" + (at 0 -48.26 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VGH" + (at 0 -50.8 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VSL" + (at 0 -53.34 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VGL" + (at 0 -55.88 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (text "VCOM" + (at 0 -58.42 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left) + ) + ) + (pin input line + (at -5.08 0 0) + (length 2.54) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -2.54 0) + (length 2.54) + (name "3" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -27.94 0) + (length 2.54) + (name "4" + (effects + (font + (size 0 0) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -30.48 0) + (length 2.54) + (name "5" + (effects + (font + (size 0 0) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -5.08 0) + (length 2.54) + (name "6" + (effects + (font + (size 0 0) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -7.62 0) + (length 2.54) + (name "7" + (effects + (font + (size 0 0) + ) + ) + ) + (number "7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -33.02 0) + (length 2.54) + (name "8" + (effects + (font + (size 0 0) + ) + ) + ) + (number "8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -12.7 0) + (length 2.54) + (name "9" + (effects + (font + (size 0 0) + ) + ) + ) + (number "9" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -15.24 0) + (length 2.54) + (name "10" + (effects + (font + (size 0 0) + ) + ) + ) + (number "10" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -17.78 0) + (length 2.54) + (name "11" + (effects + (font + (size 0 0) + ) + ) + ) + (number "11" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -20.32 0) + (length 2.54) + (name "12" + (effects + (font + (size 0 0) + ) + ) + ) + (number "12" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -22.86 0) + (length 2.54) + (name "13" + (effects + (font + (size 0 0) + ) + ) + ) + (number "13" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -25.4 0) + (length 2.54) + (name "14" + (effects + (font + (size 0 0) + ) + ) + ) + (number "14" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -35.56 0) + (length 2.54) + (name "15" + (effects + (font + (size 0 0) + ) + ) + ) + (number "15" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -38.1 0) + (length 2.54) + (name "16" + (effects + (font + (size 0 0) + ) + ) + ) + (number "16" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -40.64 0) + (length 2.54) + (name "17" + (effects + (font + (size 0 0) + ) + ) + ) + (number "17" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -43.18 0) + (length 2.54) + (name "18" + (effects + (font + (size 0 0) + ) + ) + ) + (number "18" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -45.72 0) + (length 2.54) + (name "19" + (effects + (font + (size 0 0) + ) + ) + ) + (number "19" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -48.26 0) + (length 2.54) + (name "20" + (effects + (font + (size 0 0) + ) + ) + ) + (number "20" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -50.8 0) + (length 2.54) + (name "21" + (effects + (font + (size 0 0) + ) + ) + ) + (number "21" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -53.34 0) + (length 2.54) + (name "22" + (effects + (font + (size 0 0) + ) + ) + ) + (number "22" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -55.88 0) + (length 2.54) + (name "23" + (effects + (font + (size 0 0) + ) + ) + ) + (number "23" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin input line + (at -5.08 -58.42 0) + (length 2.54) + (name "24" + (effects + (font + (size 0 0) + ) + ) + ) + (number "24" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "ESP32-S2-MINI-1-N4R2" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at 29.21 7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Value" "" + (at 29.21 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Footprint" "Smart_Plant:ESP32S2MINI1N4R2" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "WiFi Modules - 802.11 SMD module, ESP32-S2FN4R2 with 4 MB flash die and 2 MB PSRAM die inside, PCB antenna\n\nSource: https://cz.mouser.com/datasheet/2/891/esp32_s2_mini_1_esp32_s2_mini_1u_datasheet_en-1928084.pdf Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "HEIGHT" "2.55mm" + (at 29.21 -1.524 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_NAME" "Espressif Systems" + (at 29.21 -3.048 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_PART_NUMBER" "ESP32-S2-MINI-1-N4R2" + (at 29.21 -4.572 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MOUSER_PART_NUMBER" "356-ESP32S2MINI1N4R2" + (at 29.21 -6.096 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MOUSER_PRICE-STOCK" "https://www.mouser.co.uk/ProductDetail/Espressif-Systems/ESP32-S2-MINI-1-N4R2?qs=iLbezkQI%252Bsgb61FCBnw%252B9A%3D%3D" + (at 29.21 -7.62 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "ESP32-S2-MINI-1-N4R2_1_0" + (polyline + (pts + (xy 5.08 2.54) (xy 5.08 -93.98) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 5.08 2.54) (xy 27.94 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 27.94 -93.98) (xy 5.08 -93.98) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 27.94 -93.98) (xy 27.94 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin bidirectional line + (at 0 0 0) + (length 5.08) + (name "GND_1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -2.54 0) + (length 5.08) + (name "GND_2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -5.08 0) + (length 5.08) + (name "3V3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -7.62 0) + (length 5.08) + (name "IO0" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -10.16 0) + (length 5.08) + (name "IO1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -12.7 0) + (length 5.08) + (name "IO2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -15.24 0) + (length 5.08) + (name "IO3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -17.78 0) + (length 5.08) + (name "IO4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -20.32 0) + (length 5.08) + (name "IO5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "9" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -22.86 0) + (length 5.08) + (name "IO6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "10" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -25.4 0) + (length 5.08) + (name "IO7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "11" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -27.94 0) + (length 5.08) + (name "IO8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "12" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -30.48 0) + (length 5.08) + (name "IO9" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "13" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -33.02 0) + (length 5.08) + (name "IO10" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "14" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -35.56 0) + (length 5.08) + (name "IO11" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "15" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -38.1 0) + (length 5.08) + (name "IO12" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "16" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -40.64 0) + (length 5.08) + (name "IO13" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "17" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -43.18 0) + (length 5.08) + (name "IO14" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "18" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -45.72 0) + (length 5.08) + (name "IO15" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "19" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -48.26 0) + (length 5.08) + (name "IO16" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "20" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -50.8 0) + (length 5.08) + (name "IO17" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "21" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -53.34 0) + (length 5.08) + (name "IO18" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "22" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -55.88 0) + (length 5.08) + (name "IO19" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "23" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -58.42 0) + (length 5.08) + (name "IO20" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "24" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -60.96 0) + (length 5.08) + (name "IO21" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "25" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -63.5 0) + (length 5.08) + (name "IO26" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "26" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin no_connect line + (at 0 -66.04 0) + (length 5.08) + (name "NC" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "27" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -68.58 0) + (length 5.08) + (name "IO33" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "28" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -71.12 0) + (length 5.08) + (name "IO34" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "29" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -73.66 0) + (length 5.08) + (name "GND_3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "30" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -76.2 0) + (length 5.08) + (name "IO35" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "31" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -78.74 0) + (length 5.08) + (name "IO36" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "32" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -81.28 0) + (length 5.08) + (name "IO37" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "33" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -83.82 0) + (length 5.08) + (name "IO38" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "34" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -86.36 0) + (length 5.08) + (name "IO39" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "35" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -88.9 0) + (length 5.08) + (name "IO40" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "36" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -91.44 0) + (length 5.08) + (name "IO41" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "37" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 0 180) + (length 5.08) + (name "IO42" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "38" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -2.54 180) + (length 5.08) + (name "TXD0" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "39" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -5.08 180) + (length 5.08) + (name "RXD0" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "40" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -7.62 180) + (length 5.08) + (name "IO45" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "41" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -10.16 180) + (length 5.08) + (name "GND_4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "42" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -12.7 180) + (length 5.08) + (name "GND_5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "43" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -15.24 180) + (length 5.08) + (name "IO46" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "44" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -17.78 180) + (length 5.08) + (name "EN" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "45" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -20.32 180) + (length 5.08) + (name "GND_6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "46" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -22.86 180) + (length 5.08) + (name "GND_7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "47" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -25.4 180) + (length 5.08) + (name "GND_8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "48" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -27.94 180) + (length 5.08) + (name "GND_9" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "49" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -30.48 180) + (length 5.08) + (name "GND_10" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "50" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -33.02 180) + (length 5.08) + (name "GND_11" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "51" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -35.56 180) + (length 5.08) + (name "GND_12" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "52" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -38.1 180) + (length 5.08) + (name "GND_13" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "53" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -40.64 180) + (length 5.08) + (name "GND_14" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "54" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -43.18 180) + (length 5.08) + (name "GND_15" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "55" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -45.72 180) + (length 5.08) + (name "GND_16" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "56" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -48.26 180) + (length 5.08) + (name "GND_17" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "57" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -50.8 180) + (length 5.08) + (name "GND_18" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "58" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -53.34 180) + (length 5.08) + (name "GND_19" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "59" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -55.88 180) + (length 5.08) + (name "GND_20" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "60" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -58.42 180) + (length 5.08) + (name "GND_21" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "61" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -60.96 180) + (length 5.08) + (name "GND_22" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "62" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -63.5 180) + (length 5.08) + (name "GND_23" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "63" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -66.04 180) + (length 5.08) + (name "GND_24" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "64" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -68.58 180) + (length 5.08) + (name "GND_25" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "65" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -71.12 180) + (length 5.08) + (name "GND_26" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "66" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -73.66 180) + (length 5.08) + (name "GND_27" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "67" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -76.2 180) + (length 5.08) + (name "GND_28" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "68" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -78.74 180) + (length 5.08) + (name "GND_29" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "69" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -81.28 180) + (length 5.08) + (name "GND_30" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "70" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -83.82 180) + (length 5.08) + (name "GND_31" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "71" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -86.36 180) + (length 5.08) + (name "GND_32" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "72" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 33.02 -88.9 180) + (length 5.08) + (name "GND_33" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "73" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "GND" + (power global) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#GND" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at -2.54 -2.54 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "SUPPLY SYMBOL" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "GND_1_0" + (polyline + (pts + (xy -1.905 0) (xy 1.905 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at 0 2.54 270) + (length 2.54) + (name "GND" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "INDUCTORNR401" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "L" + (at 0 2.54 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.0795) + ) + ) + ) + (property "Value" "" + (at 0 -1.54 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.0795) + ) + ) + ) + (property "Footprint" "Smart_Plant:INDUCTOR_4X4MM_NR401" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Inductors\n\n0603:\n\n• 10nH Ceramic Inductor - microwire antenna/RF (Digikey: 712-1434-2-ND)\n\n0805:\n\n• 560nH Ceramic Inductor - RF (Digikey: 553-1047-1-ND)\n• 270nH Ceramic Inductor - Q = 40@250MHz, +-5%, 1 Ohm DC Resistance (Digikey: 535-10506-2-ND)\n\n1007 (2518 Metric) SMT:\n\n• 10µH 10% 250mOhm 820mA 1007 Inductor (Digikey: 587-2189-1-ND)\n• 22µH 10% 500mOhm 580mA 1007 Inductor (Digikey: 587-2190-1-ND)\n• 47µH 10% 950mOhm 420mA 1007 Inductor (Digikey: 587-2191-1-ND)\n• 47µH 5% 100mOhm 1A 1008 Inductor (Digikey: 732-1816-1-ND)\n\n5.0x5.0mm (Taiyo Yuden NR5040 Series)\n\n• 6.8µH 20% 2.3A 64mOhm Inductor (Digikey: 587-2374-2-ND)\n\n5.0x5.0mm (TDK VLC5045 Series)\n\n• 4.7µH 20% 3.3A 34mOhm Inductor (Digikey: 445-6526-1-ND)\n• 6.8µH 20% 2.7A 46mOhm Inductor (Digikey: 445-6527-1-ND)\n\n6.0x6.0mm (TDK VLC6045 Series)\n\n• 4.7µH 20% 3.8A 27mOhm Inductor (Digikey: 445-6538-2-ND)\n• 6.8µH 20% 3A 41mOhm Inductor (Digikey: 445-6540-1-ND)\n• 6.2µH 30% 2.2A 45mOhm Inductor (Digikey: SRR5028-6R2YCT-ND) - Not TDK VLC6045 but footprint should match\n\n6.0x6.0mm (TDK SLF6045 Series)\n\n• 6.8uH 30% 2A 33mOhm Inductor (Digikey: 445-4572-1-ND, Mouser: 810-SLF645T6R8N2R0PF)\n\n6.0x6.0mm (Sumida CDRH5D28 Series)\n\n• 6.2uH 30% 1.8A 45mOhm Inductor (Digikey: 308-1542-1-ND)\n\nTaiyo Yuden NR3015T2R2M - 2.2uH 20% 1.5A (Digikey: 587-1648-2-ND, Mouser: 963-NR3015T2R2M) - Used with TI TPS62172\n\nCoilCraft RFID Transponder Coil - MA5532-AE (For use with AS3935 Lightning Sensor)\n\nTaiyo Yuden NRH2412T2R2MNGH - 2.2uH 20% 1AA (Digikey: 587-3443-1-ND) - Used with TI LM3671\n\nVishay IHLP\n\n• 1.0uH Molded Inductor - 24mOhm DCR, 5A, +/-20%, -55°C-125°C (Digikey: 541-1319-1-ND)" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "INDUCTORNR401_1_0" + (polyline + (pts + (xy -2.54 0.3175) (xy -2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start -2.54 0.3175) + (mid -1.9049 0.9524) + (end -1.27 0.3173) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start -1.27 0.3175) + (mid -0.6349 0.9524) + (end 0 0.3173) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start 0 0.3175) + (mid 0.6351 0.9524) + (end 1.27 0.3173) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (arc + (start 1.27 0.3175) + (mid 1.9051 0.9524) + (end 2.54 0.3173) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 0.3175) (xy 2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -5.08 0 0) + (length 2.54) + (name "1" + (effects + (font + (size 0 0) + ) + ) + ) + (number "P$1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 5.08 0 180) + (length 2.54) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "P$2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "IRLML2060TRPBF" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "Q" + (at 5.08 0.635 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.0795) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at 5.08 -1.27 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.0795) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:SOT23-3" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "IRLML2060TRPBF_1_0" + (polyline + (pts + (xy 0 2.54) (xy 0 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 3.175) (xy 0.762 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 2.54) (xy 0.762 1.905) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 2.54) (xy 3.81 2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 0.762) (xy 0.762 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 0) (xy 0.762 -0.762) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 0) (xy 2.54 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 -1.905) (xy 0.762 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 -2.54) (xy 0.762 -3.175) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.016 0) (xy 2.032 0.762) (xy 2.032 -0.762) (xy 1.016 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type outline) + ) + ) + (circle + (center 2.54 2.54) + (radius 0.3592) + (stroke + (width -0.0001) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 2.54 0) (xy 2.54 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 -2.54) (xy 0.762 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 -2.54) (xy 3.81 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (circle + (center 2.54 -2.54) + (radius 0.3592) + (stroke + (width -0.0001) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 3.302 0.508) (xy 3.048 0.254) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 2.54) (xy 3.81 0.508) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 0.508) (xy 3.302 0.508) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 0.508) (xy 3.81 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 3.81 0.508) (xy 3.302 -0.254) (xy 4.318 -0.254) (xy 3.81 0.508) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 4.318 0.508) (xy 3.81 0.508) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 4.572 0.762) (xy 4.318 0.508) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (text "G" + (at -1.27 -1.905 0) + (effects + (font + (size 0.8128 0.6908) + ) + (justify left bottom) + ) + ) + (text "D" + (at 3.175 3.175 0) + (effects + (font + (size 0.8128 0.6908) + ) + (justify left bottom) + ) + ) + (text "S" + (at 3.175 -3.81 0) + (effects + (font + (size 0.8128 0.6908) + ) + (justify left bottom) + ) + ) + (pin bidirectional line + (at -2.54 -2.54 0) + (length 2.54) + (name "G" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 2.54 -5.08 90) + (length 2.54) + (name "S" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 2.54 5.08 270) + (length 2.54) + (name "D" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "LED0603" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "D" + (at 3.556 -4.572 90) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at 5.715 -4.572 90) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:LED_0603" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "LED (Generic)\n\nStandard schematic elements and footprints for 5mm, 3mm, 0402, 0603 , 0805 and 1206 sized LEDs. Generic LEDs with no color specified." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "LED0603_1_0" + (polyline + (pts + (xy -3.429 -2.159) (xy -3.048 -1.27) (xy -2.54 -1.778) (xy -3.429 -2.159) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy -3.302 -3.302) (xy -2.921 -2.413) (xy -2.413 -2.921) (xy -3.302 -3.302) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy -2.032 -0.762) (xy -3.429 -2.159) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.905 -1.905) (xy -3.302 -3.302) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 0) (xy -1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 -2.54) (xy -1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 -2.54) (xy -1.27 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 0 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 0 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 -2.54) (xy 0 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at 0 2.54 270) + (length 2.54) + (name "A" + (effects + (font + (size 0 0) + ) + ) + ) + (number "A" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 0 -5.08 90) + (length 2.54) + (name "C" + (effects + (font + (size 0 0) + ) + ) + ) + (number "C" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "LIGHT_SENSOR-TR" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at 5.08 7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Value" "" + (at 5.08 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Footprint" "Smart_Plant:VEML7700-TR" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "High Accuracy Ambient Light Sensor with I2C Interface\n\nSource: http://www.vishay.com/docs/84286/veml7700.pdf Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "LIGHT_SENSOR-TR_1_0" + (polyline + (pts + (xy 5.08 2.54) (xy 5.08 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 5.08 2.54) (xy 12.7 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 12.7 -10.16) (xy 5.08 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 12.7 -10.16) (xy 12.7 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin bidirectional line + (at 0 0 0) + (length 5.08) + (name "SCL" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin power_in line + (at 0 -2.54 0) + (length 5.08) + (name "VDD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin power_in line + (at 0 -5.08 0) + (length 5.08) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -7.62 0) + (length 5.08) + (name "SDA" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "LL4148" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "D" + (at -2.54 2.54 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -2.54 -3.81 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:LL-34" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Diode Ultra Fast 100V 0.2A 2SOD-80\n\nSource: https://www.onsemi.com/pub/Collateral/LL4148-D.pdf Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ARROW_PART_NUMBER" "LL4148" + (at 0 -5.334 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ARROW_PRICE-STOCK" "https://www.arrow.com/en/products/ll4148/on-semiconductor?region=europe" + (at 0 -6.858 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "HEIGHT" "1mm" + (at 0 -8.382 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC" "C9808" + (at 0 -9.906 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_NAME" "onsemi" + (at 0 -11.43 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_PART_NUMBER" "LL4148" + (at 0 -12.954 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MOUSER_PART_NUMBER" "512-LL4148" + (at 0 -14.478 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MOUSER_PRICE-STOCK" "https://www.mouser.co.uk/ProductDetail/ON-Semiconductor-Fairchild/LL4148?qs=z%2Fr5wmDnQfY5jXgsdLPkRg%3D%3D" + (at 0 -16.002 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "LL4148_1_0" + (polyline + (pts + (xy -1.27 1.27) (xy -1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 0) (xy -2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 0) (xy -1.27 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.27 -1.27) (xy 1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 1.27) (xy 1.27 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy -1.27 1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 1.27 -1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.27 0) (xy 2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -2.54 0 0) + (length 0) + (name "A" + (effects + (font + (size 0 0) + ) + ) + ) + (number "A" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 0 180) + (length 0) + (name "C" + (effects + (font + (size 0 0) + ) + ) + ) + (number "C" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "MAX17048G+T10" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at 26.67 7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Value" "" + (at 26.67 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Footprint" "Smart_Plant:SON50P200X200X80-9N" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Battery Management Host-Side Modelgauge (1-Cell)\n\nSource: https://datasheets.maximintegrated.com/en/ds/MAX17048-MAX17049.pdf Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC" "C2682616" + (at 26.67 -1.524 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "MAX17048G+T10_1_0" + (polyline + (pts + (xy 5.08 2.54) (xy 5.08 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 5.08 2.54) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -10.16) (xy 5.08 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -10.16) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin bidirectional line + (at 0 -5.08 0) + (length 5.08) + (name "CTG" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -2.54 0) + (length 5.08) + (name "CELL" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 0 0) + (length 5.08) + (name "VDD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -7.62 0) + (length 5.08) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -7.62 180) + (length 5.08) + (name "~{ALRT}" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -5.08 180) + (length 5.08) + (name "QSTRT" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -2.54 180) + (length 5.08) + (name "SCL" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 0 180) + (length 5.08) + (name "SDA" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -7.62 0) + (length 5.08) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "9" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "MOMENTARY-SWITCH-SPST-SMD-5.2MM-TALL" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "S" + (at 0 1.524 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify bottom) + ) + ) + (property "Value" "" + (at 0 -0.508 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify top) + ) + ) + (property "Footprint" "Smart_Plant:TACTILE_SWITCH_SMD_5.2MM" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Momentary Switch (Pushbutton) - SPST\n\nNormally-open (NO) SPST momentary switches (buttons, pushbuttons).\n\nVariants PTH-12MM - 12mm square, through-hole\n\n• https://www.sparkfun.com/products/9190 Momentary Pushbutton Switch - 12mm Square (COM-09190)\n\nPTH-6.0MM, PTH-6.0MM-KIT - 6.0mm square, through-hole\n\n• https://www.sparkfun.com/products/97 Mini Pushbutton Switch (COM-00097)\n• KIT package intended for soldering kit's - only one side of pads' copper is exposed.\n\nPTH-RIGHT-ANGLE-KIT - Right-angle, through-hole\n\n• https://www.sparkfun.com/products/10791 Right Angle Tactile Button - Used on https://www.sparkfun.com/products/11734 SparkFun BigTime Watch Kit\n\nSMD-12MM - 12mm square, surface-mount\n\n• https://www.sparkfun.com/products/12993 Tactile Button - SMD (12mm) (COM-12993)\n• Used on https://www.sparkfun.com/products/11888 SparkFun PicoBoard\n\nSMD-4.5MM - 4.5mm Square Trackball Switch\n\n• Used on https://www.sparkfun.com/products/13169 SparkFun Blackberry Trackballer Breakout\n\nSMD-4.6MMX2.8MM - 4.60mm x 2.80mm, surface mount\n\n• Used on https://www.sparkfun.com/products/17717 SparkFun Pro Micro - RP2040\n\nSMD-5.2MM, SMD-5.2-REDUNDANT - 5.2mm square, surface-mount\n\n• https://www.sparkfun.com/products/8720 Mini Pushbutton Switch - SMD (COM-08720)\n• Used on https://www.sparkfun.com/products/11114 Arduino Pro Mini\n• REDUNDANT package connects both switch circuits together\n\nSMD-6.0X3.5MM - 6.0 x 3.5mm, surface mount\n\n• https://www.sparkfun.com/products/8229 Momentary Reset Switch SMD (COM-08229)\n\nSMD-6.2MM-TALL - 6.2mm square, surface mount\n\n• https://www.sparkfun.com/products/12992 Tactile Button - SMD (6mm)\n• Used on https://www.sparkfun.com/products/12651 SparkFun Digital Sandbox\n\nSMD-RIGHT-ANGLE - Right-angle, surface mount\n\n• Used on https://www.sparkfun.com/products/13036 SparkFun Block for Intel® Edison - Arduino" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC" " C318884" + (at 0 -2.032 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROD_ID" "SWCH-14139" + (at 0 -3.556 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "MOMENTARY-SWITCH-SPST-SMD-5.2MM-TALL_1_0" + (circle + (center -2.54 0) + (radius 0.127) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -2.54 0) (xy 1.905 1.27) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.905 0) (xy 2.54 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (circle + (center 2.54 0) + (radius 0.127) + (stroke + (width 0.4064) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -5.08 0 0) + (length 2.54) + (name "1" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 5.08 0 180) + (length 2.54) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "R-US_R0603" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "R" + (at -3.81 1.4986 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at -3.81 -3.302 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:R0603" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "RESISTOR, American symbol" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "POPULARITY" "77" + (at 0 -4.826 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "SPICEPREFIX" "R" + (at 0 -6.35 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "R-US_R0603_1_0" + (polyline + (pts + (xy -2.54 0) (xy -2.159 1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -2.159 1.016) (xy -1.524 -1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.524 -1.016) (xy -0.889 1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -0.889 1.016) (xy -0.254 -1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -0.254 -1.016) (xy 0.381 1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.381 1.016) (xy 1.016 -1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.016 -1.016) (xy 1.651 1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.651 1.016) (xy 2.286 -1.016) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.286 -1.016) (xy 2.54 0) + ) + (stroke + (width 0.2032) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -5.08 0 0) + (length 2.54) + (name "1" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 5.08 0 180) + (length 2.54) + (name "2" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "RT9080-33GJ5" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "IC" + (at 24.13 7.62 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Value" "" + (at 24.13 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left) + ) + ) + (property "Footprint" "Smart_Plant:SOT94P280X100-5N" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "IC REG LIN 3.3V 600MA TSOT23-5\n\nSource: https://www.richtek.com/assets/product_file/RT9080/DS9080-05.pdf Datasheet" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "HEIGHT" "1mm" + (at 24.13 -1.524 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_NAME" "RICHTEK" + (at 24.13 -3.048 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "MANUFACTURER_PART_NUMBER" "RT9080-33GJ5" + (at 24.13 -4.572 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "RT9080-33GJ5_1_0" + (polyline + (pts + (xy 5.08 2.54) (xy 5.08 -7.62) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 5.08 2.54) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -7.62) (xy 5.08 -7.62) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 22.86 -7.62) (xy 22.86 2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin bidirectional line + (at 0 0 0) + (length 5.08) + (name "VIN" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -2.54 0) + (length 5.08) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 0 -5.08 0) + (length 5.08) + (name "EN" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 0 180) + (length 5.08) + (name "SNS" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 27.94 -2.54 180) + (length 5.08) + (name "VOUT" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "SparkFun-PowerSymbols_530_GND" + (power global) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#GND" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at 0 -0.254 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify top) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Ground Supply Symbol\n\nGeneric signal ground supply symbol." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "SparkFun-PowerSymbols_530_GND_1_0" + (polyline + (pts + (xy -1.905 0) (xy 1.905 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at 0 2.54 270) + (length 2.54) + (name "GND" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "SparkFun-PowerSymbols_GND" + (power global) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#GND" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at 0 -0.254 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify top) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Ground Supply Symbol\n\nGeneric signal ground supply symbol." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "SparkFun-PowerSymbols_GND_1_0" + (polyline + (pts + (xy -1.905 0) (xy 1.905 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at 0 2.54 270) + (length 2.54) + (name "GND" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "TP4056" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "" + (at -7.62 -12.7 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Footprint" "Smart_Plant:SOT23-8" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "TP4056\n\nhttp://goo.gl/J7k8TF Data sheet\n\nDave.Calaway https://goo.gl/Muy1Tr Eagle Library" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "TP4056_1_0" + (polyline + (pts + (xy -7.62 10.16) (xy 12.7 10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -7.62 -10.16) (xy -7.62 10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 12.7 10.16) (xy 12.7 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 12.7 -10.16) (xy -7.62 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (text "TP4056" + (at -7.62 -15.24 0) + (effects + (font + (size 1.27 1.0795) + ) + (justify left bottom) + ) + ) + (pin bidirectional line + (at -10.16 7.62 0) + (length 2.54) + (name "TEMP" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at -10.16 2.54 0) + (length 2.54) + (name "PROG" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at -10.16 -2.54 0) + (length 2.54) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "3" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at -10.16 -7.62 0) + (length 2.54) + (name "VCC" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "4" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 15.24 -7.62 180) + (length 2.54) + (name "BAT" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "5" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 15.24 -2.54 180) + (length 2.54) + (name "~{STDBY}" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "6" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 15.24 2.54 180) + (length 2.54) + (name "~{CHRG}" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "7" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + (pin bidirectional line + (at 15.24 7.62 180) + (length 2.54) + (name "CE" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "8" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "TPB1,27" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "TP" + (at -1.27 1.27 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Value" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Footprint" "Smart_Plant:B1,27" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Test pad" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "TP_SIGNAL_NAME" "" + (at 1.27 -1.27 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "POPULARITY" "19" + (at 1.27 -2.794 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "TPB1,27_1_0" + (polyline + (pts + (xy -0.762 -0.762) (xy 0 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 0) (xy 0.762 -0.762) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0 -1.524) (xy -0.762 -0.762) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 0.762 -0.762) (xy 0 -1.524) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin input line + (at 0 -2.54 90) + (length 2.54) + (name "TP" + (effects + (font + (size 0 0) + ) + ) + ) + (number "TP" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "T_NPN__SOT23-BECMMBT2222A-1P_CHANGJ" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "T" + (at 1.27 4.826 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify right top) + ) + ) + (property "Value" "" + (at 1.27 -3.302 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify right top) + ) + ) + (property "Footprint" "Smart_Plant:SOT23" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Transistor NPN" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_DATASHEET" "https://datasheet.lcsc.com/lcsc/1811081957_Changjiang-Electronics-Tech--CJ-MMBT2222A-1P_C8512.pdf" + (at 1.27 -4.826 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_PART" "C8512" + (at 1.27 -6.35 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_PRICE1_EUR" "0.023149606" + (at 1.27 -7.874 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_ROTATION_CCW" "180" + (at 1.27 -9.398 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROPERTIES" "0.3W NPN 0.6A 40V SOT-23(SOT-23-3) Bipolar Transistors - BJT ROHS" + (at 1.27 -10.922 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "T_NPN__SOT23-BECMMBT2222A-1P_CHANGJ_1_0" + (rectangle + (start -0.254 -2.54) + (end 0.508 2.54) + (stroke + (width -0.0001) + (type default) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 1.27 -2.54) (xy 1.778 -1.524) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.54 -2.0399) (xy 0.3081 -1.4239) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 1.778 -1.524) (xy 2.54 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 2.54) (xy 0.508 1.524) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 -2.54) (xy 1.27 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -2.54 0 0) + (length 2.54) + (name "B" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 -5.08 90) + (length 2.54) + (name "E" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 5.08 270) + (length 2.54) + (name "C" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "T_P-MOSFET__SOT23-GSDLBSS84LT1G_LRC" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "T" + (at 1.016 5.08 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify right top) + ) + ) + (property "Value" "" + (at 1.016 -3.302 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify right top) + ) + ) + (property "Footprint" "Smart_Plant:SOT23" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "Transistor P-MOSFET" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_DATASHEET" "https://datasheet.lcsc.com/lcsc/1811031711_LRC-LBSS84LT1G_C8492.pdf" + (at 1.016 -4.826 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_PART" "C8492" + (at 1.016 -6.35 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_PRICE1_EUR" "0.031496063" + (at 1.016 -7.874 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "LCSC_ROTATION_CCW" "180" + (at 1.016 -9.398 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROPERTIES" "2V 250uA 10Ohm 100mA,5V 0.13A 0.225W P Channel 50V SOT-23(SOT-23-3) MOSFETs ROHS" + (at 1.016 -10.922 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "T_P-MOSFET__SOT23-GSDLBSS84LT1G_LRC_1_0" + (polyline + (pts + (xy -1.1176 2.54) (xy -2.54 2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -1.016 2.54) (xy -1.016 -2.54) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (rectangle + (start -0.254 -2.54) + (end 0.508 2.54) + (stroke + (width -0.0001) + (type default) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 2.54 2.032) (xy 0 2.032) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 2.032) (xy 2.54 2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (circle + (center 2.54 2.032) + (radius 0.127) + (stroke + (width 0.3048) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 0) (xy 0 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 0) (xy 2.54 2.032) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 0) (xy 1.524 0.508) (xy 1.524 -0.508) (xy 2.54 0) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type outline) + ) + ) + (polyline + (pts + (xy 2.54 -2.032) (xy 0 -2.032) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 2.54 -2.032) (xy 2.54 -2.54) + ) + (stroke + (width 0.1524) + (type solid) + ) + (fill + (type none) + ) + ) + (pin passive line + (at -5.08 2.54 0) + (length 2.54) + (name "G" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 5.08 270) + (length 2.54) + (name "S" + (effects + (font + (size 0 0) + ) + ) + ) + (number "2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin passive line + (at 2.54 -5.08 90) + (length 2.54) + (name "D" + (effects + (font + (size 0 0) + ) + ) + ) + (number "3" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "USB_C_4-LAYER-ISOLATED" + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "J" + (at -2.54 10.414 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Value" "USB Female Type C Connector" + (at -2.54 -12.446 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.778) + ) + (justify left bottom) + ) + ) + (property "Footprint" "Smart_Plant:USB-C-16P-4LAYER-ISOLATED" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "USB Type C 16Pin Connector Super Speed pins not available on the 16-pin purely SMD connector so this part is best for USB 2.0 implementations. D1 and D2 are tied together enabling D+/- no matter which way the cable is plugged into the connector. The two channel configuration pins (CC1/2) are exposed. These are normally connected to ground via 5.1k resistors but can be reconfigured for high current/high power applications. **4-LAYER BOARD WARNING!!** If designing 4-layer board, make sure to select the variant _4-LAYER-ISOLATED. The linked footprint (USB-C-16-4LAYER-ISOLATED) includes some very necessary cutout polygons in the inner layers (2 and 15). This prevents the PTH mounting shield legs on the USB connector from electrically connecting any other polygons on the inner layers (usually VCC and GND)." + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "PROD_ID" "CONN-14122" + (at 0 -13.97 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "USB_C_4-LAYER-ISOLATED_1_0" + (polyline + (pts + (xy -2.54 -10.16) (xy -2.54 10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy -2.54 -10.16) (xy 10.16 -10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 10.16 10.16) (xy -2.54 10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (polyline + (pts + (xy 10.16 -10.16) (xy 10.16 10.16) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (text "USB-C" + (at 0 0 900) + (effects + (font + (size 2.54 2.159) + ) + ) + ) + (pin bidirectional line + (at 12.7 0 180) + (length 2.54) + (name "CC1" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "A5" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 5.08 180) + (length 2.54) + (name "D+" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "A6" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 2.54 180) + (length 2.54) + (name "D-" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "A7" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -2.54 180) + (length 2.54) + (name "CC2" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "B5" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 5.08 180) + (length 2.54) + (name "D+" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "B6" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 2.54 180) + (length 2.54) + (name "D-" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "B7" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -7.62 180) + (length 2.54) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "GND" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -7.62 180) + (length 2.54) + (name "GND" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "GND2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD3" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD4" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD5" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD6" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD7" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 -5.08 180) + (length 2.54) + (name "SHLD" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "SHLD8" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 7.62 180) + (length 2.54) + (name "VBUS" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "VBUS1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + (pin bidirectional line + (at 12.7 7.62 180) + (length 2.54) + (name "VBUS" + (effects + (font + (size 1.524 1.524) + ) + ) + ) + (number "VBUS2" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) + (symbol "supply1_371_GND" + (power global) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (in_pos_files yes) + (duplicate_pin_numbers_are_jumpers no) + (property "Reference" "#GND" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Value" "" + (at -2.54 -2.54 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.778 1.5113) + ) + (justify left bottom) + ) + ) + (property "Footprint" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Datasheet" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "Description" "SUPPLY SYMBOL" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (hide yes) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (property "ki_locked" "" + (at 0 0 0) + (show_name no) + (do_not_autoplace no) + (effects + (font + (size 1.27 1.27) + ) + ) + ) + (symbol "supply1_371_GND_1_0" + (polyline + (pts + (xy -1.905 0) (xy 1.905 0) + ) + (stroke + (width 0.254) + (type solid) + ) + (fill + (type none) + ) + ) + (pin power_in line + (at 0 2.54 270) + (length 2.54) + (name "GND" + (effects + (font + (size 0 0) + ) + ) + ) + (number "1" + (effects + (font + (size 0 0) + ) + ) + ) + ) + ) + (embedded_fonts no) + ) +) diff --git a/docs/kicad-symbol-recovery.md b/docs/kicad-symbol-recovery.md new file mode 100644 index 0000000..c6604b6 --- /dev/null +++ b/docs/kicad-symbol-recovery.md @@ -0,0 +1,21 @@ +# Recovered KiCad symbol library + +The KiCad schematics reference `Smart_Plant-eagle-import`. Their symbol table +points to a local library that was missing. The restored library contains the +28 symbols embedded in the two checked-in schematic caches. + +Run `python3 scripts/recover_kicad_symbol_library.py --check` to compare the +library with both caches and check reference coverage. Run the script without +`--check` to regenerate the library deterministically. It rejects conflicting +cache definitions and unresolved references. Only the outer namespace and +indentation change; body tokens, pin definitions and geometry are preserved. + +Run `python3 -m unittest discover -s tests -p test_kicad_symbol_library.py -v` +for source equality, parser boundaries, conflicting cache and missing-reference +checks. The library uses the KiCad10 symbol format version `20251024`, matching +the source schematics. No schematic, PCB or manufacturing output is changed. + +These checks prove source recovery. They do not prove loading or ERC with +KiCad10. That application was unavailable during recovery; the installed KiCad9 +cannot validate this newer format. No fabrication or electrical validation is +claimed. Revert the isolated library contribution to undo this source change. diff --git a/scripts/recover_kicad_symbol_library.py b/scripts/recover_kicad_symbol_library.py new file mode 100644 index 0000000..544b311 --- /dev/null +++ b/scripts/recover_kicad_symbol_library.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Recover the local symbol library from the two authoritative schematic caches. + +Run with --check to verify the committed library without writing it. This checks +source equality and reference coverage, not KiCad loading, ERC or fabrication. +Only the outer symbol's library namespace and indentation change. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LIBRARY = "Smart_Plant-eagle-import" +OUTPUT = ROOT / "Design" / f"{LIBRARY}.kicad_sym" +SCHEMATICS = tuple(sorted((ROOT / "Design").glob("*.kicad_sch"))) +# KiCad 10.0 eeschema/sch_file_versions.h, blob +# 2ad78c104d6dee673359ab0f7b7c91b2944e3f50: SEXPR_SYMBOL_LIB_FILE_VERSION. +# https://github.com/KiCad/kicad-source-mirror/blob/10.0/eeschema/sch_file_versions.h +LIBRARY_VERSION = "20251024" +TOKEN = re.compile(r'\s+|"(?:\\.|[^"\\])*"|[()]|[^()\s"]+') + + +def tokens(source: str) -> list[tuple[str, int, int]]: + result = [] + offset = 0 + while offset < len(source): + match = TOKEN.match(source, offset) + if match is None: + raise ValueError(f"invalid token at offset {offset}") + value = match.group() + if not value.isspace(): + result.append((value, offset, match.end())) + offset = match.end() + return result + + +def forms(source: str) -> list[tuple[int, int, int, str]]: + """Return balanced forms as (start, end, depth, head), including nested ones.""" + stream = tokens(source) + stack = [] + result = [] + for index, (value, start, end) in enumerate(stream): + if value == "(": + if index + 1 == len(stream) or stream[index + 1][0] in ("(", ")"): + raise ValueError("missing form head") + stack.append((start, len(stack), stream[index + 1][0])) + elif value == ")": + if not stack: + raise ValueError("unmatched closing parenthesis") + begin, depth, head = stack.pop() + result.append((begin, end, depth, head)) + elif not stack: + raise ValueError("token outside a form") + if stack: + raise ValueError("unclosed form") + return sorted(result) + + +def token_values(source: str) -> list[str]: + return [value for value, _, _ in tokens(source)] + + +def recover(schematics: tuple[Path, ...]) -> dict[str, str]: + if not schematics: + raise ValueError("no source schematics") + symbols: dict[str, str] = {} + references = set() + for path in schematics: + source = path.read_text(encoding="utf-8") + parsed = forms(source) + caches = [(a, b) for a, b, depth, head in parsed if depth == 1 and head == "lib_symbols"] + if len(caches) != 1: + raise ValueError(f"{path}: expected one lib_symbols cache") + cache_start, cache_end = caches[0] + for start, end, depth, head in parsed: + if head == "lib_id": + references.add(token_values(source[start:end])[2].strip('"')) + if not (depth == 2 and head == "symbol" and cache_start < start < cache_end): + continue + block = source[start:end] + stream = tokens(block) + qualified = stream[2][0] + prefix = f'"{LIBRARY}:' + if not qualified.startswith(prefix): + raise ValueError(f"{path}: unexpected library {qualified}") + name = qualified[len(prefix):-1] + # Keep every body token and all string escapes exactly as exported. + local = block[:stream[2][1]] + f'"{name}"' + block[stream[2][2]:] + # Cache symbols sit one indentation level deeper than library symbols. + local = "\n".join(line[1:] if line.startswith("\t") else line for line in local.split("\n")) + if name in symbols and token_values(symbols[name]) != token_values(local): + raise ValueError(f"{path}: conflicting cached symbol {name}") + symbols.setdefault(name, local) + missing = references - {f"{LIBRARY}:{name}" for name in symbols} + if missing: + raise ValueError(f"unresolved symbol references: {sorted(missing)}") + return symbols + + +def render(schematics: tuple[Path, ...] = SCHEMATICS) -> str: + symbols = recover(schematics) + body = "\n".join("\t" + symbols[name] for name in sorted(symbols)) + return ( + f"(kicad_symbol_lib\n\t(version {LIBRARY_VERSION})\n" + '\t(generator "recover_kicad_symbol_library")\n' + '\t(generator_version "1.0")\n' + + body + "\n)\n" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + expected = render() + if args.check: + if not OUTPUT.exists() or OUTPUT.read_bytes() != expected.encode("utf-8"): + parser.exit(1, "symbol library is missing or differs from schematic caches\n") + else: + OUTPUT.write_bytes(expected.encode("utf-8")) + print(f"{len(recover(SCHEMATICS))} symbols; all schematic lib_id references resolved") + + +if __name__ == "__main__": + main() diff --git a/tests/test_kicad_symbol_library.py b/tests/test_kicad_symbol_library.py new file mode 100644 index 0000000..c370726 --- /dev/null +++ b/tests/test_kicad_symbol_library.py @@ -0,0 +1,55 @@ +"""Verify recovered library content without claiming KiCad 10 validation.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).parents[1] / "scripts/recover_kicad_symbol_library.py" +SPEC = importlib.util.spec_from_file_location("recover_kicad_symbol_library", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class RecoveredSymbolLibraryTests(unittest.TestCase): + def test_committed_library_matches_both_caches(self) -> None: + self.assertEqual(MODULE.OUTPUT.read_bytes(), MODULE.render().encode("utf-8")) + self.assertEqual(len(MODULE.recover(MODULE.SCHEMATICS)), 28) + + def test_all_body_tokens_match_original_symbols(self) -> None: + symbols = MODULE.recover(MODULE.SCHEMATICS) + for path in MODULE.SCHEMATICS: + source = path.read_text(encoding="utf-8") + for start, end, depth, head in MODULE.forms(source): + if depth == 2 and head == "symbol": + original = MODULE.token_values(source[start:end]) + name = original[2].removeprefix(f'"{MODULE.LIBRARY}:')[:-1] + recovered = MODULE.token_values(symbols[name]) + original[2] = f'"{name}"' + self.assertEqual(original, recovered, (path.name, name)) + + def test_quotes_escapes_and_parentheses_remain_opaque(self) -> None: + source = r'(symbol "a" (property "Value" "escaped \" quote (and parens)"))' + parsed = MODULE.forms(source) + self.assertEqual(len(parsed), 2) + self.assertEqual(source[parsed[0][0]:parsed[0][1]], source) + for invalid in ('(symbol "unterminated)', '(symbol "a"', '(symbol "a"))'): + with self.subTest(source=invalid), self.assertRaises(ValueError): + MODULE.forms(invalid) + + def test_conflicting_caches_and_missing_references_fail(self) -> None: + with tempfile.TemporaryDirectory() as directory: + paths = tuple(Path(directory) / name for name in ("one.kicad_sch", "two.kicad_sch")) + prefix = f'{MODULE.LIBRARY}:' + paths[0].write_text(f'(kicad_sch (lib_symbols (symbol "{prefix}a" (pin 1))))') + paths[1].write_text(f'(kicad_sch (lib_symbols (symbol "{prefix}a" (pin 2))))') + with self.assertRaisesRegex(ValueError, "conflicting cached symbol"): + MODULE.recover(paths) + paths[1].write_text(f'(kicad_sch (lib_symbols) (symbol (lib_id "{prefix}missing")))') + with self.assertRaisesRegex(ValueError, "unresolved symbol references"): + MODULE.recover(paths) + + +if __name__ == "__main__": + unittest.main() From 186d4775812394220c216de2d5da970861e42ad8 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 05/23] feat: add transport-independent low-battery protection --- examples/multi-device/README.md | 59 +++++ .../packages/smart_plant_core.yaml | 93 ++++++-- tests/test_low_battery.py | 202 ++++++++++++++++++ 3 files changed, 338 insertions(+), 16 deletions(-) create mode 100644 tests/test_low_battery.py diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 7771ebc..f2ef509 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -19,9 +19,68 @@ The MQTT example preserves an explicit topic prefix. Treat that prefix as a stable identity after Home Assistant discovery, because changing it creates new entities. +## Build the checked-out contribution + +The example device files fetch remote packages from the production fork's +`V2R1` branch. They do not select this staging contribution automatically. +To validate this checkout, replace the `packages` block in your local device +configuration with local includes. Keep the core and transport from the same +checkout: + +```yaml +packages: + core: !include packages/smart_plant_core.yaml + transport: !include packages/smart_plant_profile_api.yaml +``` + +For MQTT, use `packages/smart_plant_profile_mqtt.yaml` as the transport instead. +Keep the device file beside the `packages` directory. Supply its local secrets +and label image before validation. Keep those settings out of commits. + +```sh +esphome config examples/multi-device/my-lemon-tree-api.yaml +esphome compile examples/multi-device/my-lemon-tree-api.yaml +``` + +Use `my-lemon-tree.yaml` for the MQTT variant. These commands validate and build +the selected local packages. They do not upload firmware. + 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. + +This upstream staging contribution keeps the baseline's explicit +`safe_mode.mark_successful` action before both sleep paths. It does not declare +the production fork's ESPHome `>=2026.8.0` requirement. Production can rely on +native image confirmation under that requirement. The offline tests check the +explicit action's ordering; they do not simulate bootloader confirmation. +Complete local API and MQTT builds verify action availability with ESPHome +2026.9.0. Older ESPHome versions were not compiled in this review. diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 5670b17..792b75a 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -49,6 +49,13 @@ substitutions: hum_min: "20" hum_max: "80" + # 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" + # 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. @@ -59,6 +66,12 @@ substitutions: display_busy_recovery_timeout: "30s" globals: + # 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: true + initial_value: 'false' - id: display_busy_seen type: bool restore_value: false @@ -247,9 +260,12 @@ display: pages: - id: page1 lambda: |- - it.image(0, 0, id(page_1_background)); - 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); if (battery_valid) { float battery_perc = id(batpercent).state; int battery_range = static_cast(battery_perc / 16.0f); @@ -259,19 +275,19 @@ display: "\U0000ebdc", "\U0000ebd9", "\U0000ebe0", "\U0000ebdd", "\U0000ebe2", "\U0000ebd4", "\U0000e1a4" }; - it.printf(278, 1, id(font_icon), TextAlign::TOP_LEFT, battery_icon_map[battery_range]); - it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, "%3.0f%%", battery_perc); + it.printf(278, 1, id(font_icon), fg, TextAlign::TOP_LEFT, battery_icon_map[battery_range]); + it.printf(278, 1, id(font_subtitle), fg, TextAlign::TOP_RIGHT, "%3.0f%%", battery_perc); } else { - it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, " --%%"); + it.printf(278, 1, id(font_subtitle), fg, TextAlign::TOP_RIGHT, " --%%"); } auto now = id(esptime).now(); if (now.is_valid()) { - it.strftime(278, 18, id(font_subtitle), TextAlign::TOP_RIGHT, "%H:%M %d/%m", now); + it.strftime(278, 18, id(font_subtitle), fg, TextAlign::TOP_RIGHT, "%H:%M %d/%m", now); } else { - it.printf(278, 18, id(font_subtitle), TextAlign::TOP_RIGHT, "--:-- --/--"); + it.printf(278, 18, id(font_subtitle), fg, TextAlign::TOP_RIGHT, "--:-- --/--"); } - it.printf(278, 18, id(font_icon), TextAlign::TOP_LEFT, "\U0000e627"); + it.printf(278, 18, id(font_icon), fg, TextAlign::TOP_LEFT, "\U0000e627"); float pi = 3.141592653589793; float alpha = 4.71238898038469; @@ -281,7 +297,7 @@ display: 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), TextAlign::TOP_CENTER, "--"); + it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), fg, TextAlign::TOP_CENTER, "--"); return; } if (value < vmin) value = vmin; @@ -293,10 +309,10 @@ display: int y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); int x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); int y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); - it.line(x0, y0, x1, y1); - it.line(x1, y1, x2, y2); - it.line(x2, y2, x0, y0); - it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, fmt, value); + it.line(x0, y0, x1, y1, fg); + it.line(x1, y1, x2, y2, fg); + it.line(x2, y2, x0, y0, fg); + 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%%"); @@ -304,6 +320,20 @@ display: 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%%"); + # 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, "${friendly_name}"); + it.printf(148, 26, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "LOW BATT !"); + 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 { + it.printf(148, 88, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "BATTERY --%%"); + } + deep_sleep: id: deep_sleep_control sleep_duration: 1h @@ -350,7 +380,22 @@ script: - id: render_and_sleep mode: restart then: - - display.page.show: page1 + - 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 |- + return id(low_batt_active) ? id(page_low_batt) : id(page1); - component.update: my_display - script.execute: settle_display - script.wait: settle_display @@ -437,7 +482,15 @@ script: - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(low_batt_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${low_batt_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control - id: enter_deep_sleep mode: restart @@ -455,7 +508,15 @@ script: - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(low_batt_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${low_batt_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control else: - deep_sleep.prevent: deep_sleep_control - script.execute: recover_display_busy diff --git a/tests/test_low_battery.py b/tests/test_low_battery.py new file mode 100644 index 0000000..47a81e8 --- /dev/null +++ b/tests/test_low_battery.py @@ -0,0 +1,202 @@ +"""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 +#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; +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); } + template + void printf(int, int, int, Color color, TextAlign, const char *format, Args... args) { + char value[128]; std::snprintf(value, sizeof(value), format, 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_and_sleep"]["then"][0]["lambda"] + selection = SCRIPTS["render_and_sleep"]["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", "-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("storage_active", 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": "render_and_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", "recover_display_busy"): + 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}"}}]) + self.assertEqual(decision["else"], [{"deep_sleep.enter": "deep_sleep_control"}]) + + +if __name__ == "__main__": + unittest.main() From 2d97269e9fe719d8192d7b6ea023d1fc22e9827f Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 06/23] fix: bound standalone display recovery and invalid rendering --- docs/source/files/configuration.yaml | 386 ++++++++++++++++----------- docs/source/programming.rst | 14 + tests/display_automation.py | 305 +++++++++++++++++++++ tests/test_standalone_display.py | 181 +++++++++++++ 4 files changed, 733 insertions(+), 153 deletions(-) create mode 100644 tests/display_automation.py create mode 100644 tests/test_standalone_display.py diff --git a/docs/source/files/configuration.yaml b/docs/source/files/configuration.yaml index 8dd3acf..07209a7 100644 --- a/docs/source/files/configuration.yaml +++ b/docs/source/files/configuration.yaml @@ -6,6 +6,11 @@ substitutions: ap_pwd: "smartplant" # IANA timezone string — find yours at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones timezone: "UTC" + display_busy_gpio: "14" + display_reset_gpio: "15" + display_busy_assert_timeout: "2s" + display_busy_clear_timeout: "30s" + display_busy_recovery_timeout: "30s" esphome: name: "${device_name}" @@ -16,7 +21,8 @@ esphome: # Run one bounded boot workflow. It waits briefly for valid time, refreshes the # display once, marks the OTA image valid, and enters deep sleep. on_boot: - priority: 600 + # Run after sensor setup (600) and display setup (400). + priority: 399.0 then: - script.execute: consider_deep_sleep @@ -192,8 +198,8 @@ display: - platform: waveshare_epaper cs_pin: GPIO10 dc_pin: GPIO13 - busy_pin: GPIO14 - reset_pin: GPIO15 + busy_pin: GPIO${display_busy_gpio} + reset_pin: GPIO${display_reset_gpio} rotation: 270 model: 2.90inv2 id: my_display @@ -213,26 +219,24 @@ display: it.image(0, 0, id(page_1_background)); - // // Battery - float battery_perc = id(batpercent).state; - int battery_range = battery_perc / 16 ; - battery_range = (battery_range > 6) ? 6 : battery_range; - battery_range = (battery_range < 0) ? 0 : battery_range; - - const char* battery_icon_map[] = { - "\U0000ebdc", // battery empty - "\U0000ebd9", // battery 1 bar - "\U0000ebe0", // battery 2 bar - "\U0000ebdd", // battery 3 bar - "\U0000ebe2", // battery 4 bar - "\U0000ebd4", // battery 5 bar - "\U0000e1a4" // battery full - }; + // Validate before any float-to-int conversion or icon lookup. + const bool battery_valid = id(batpercent).has_state() && + std::isfinite(id(batpercent).state); + if (battery_valid) { + const float battery_perc = id(batpercent).state; + // Even finite readings can exceed int range. Clamp only the icon. + const float icon_percent = std::fmin(100.0f, std::fmax(0.0f, battery_perc)); + const int battery_range = static_cast(icon_percent / 16.0f); + const char* battery_icon_map[] = { + "\U0000ebdc", "\U0000ebd9", "\U0000ebe0", "\U0000ebdd", + "\U0000ebe2", "\U0000ebd4", "\U0000e1a4" + }; + it.printf(278, 1, id(font_icon), TextAlign::TOP_LEFT, battery_icon_map[battery_range]); + it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, "%3.0f%%", battery_perc); + } else { + it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, " --%%"); + } - it.printf(278, 1, id(font_icon), TextAlign::TOP_LEFT, battery_icon_map[battery_range]); - it.printf(278, 1, id(font_subtitle), TextAlign::TOP_RIGHT, - "%3.0f%%", battery_perc); - // Date — guard against SNTP not yet synced (avoids displaying 1970) auto now = id(esptime).now(); if (now.is_valid()) { @@ -244,138 +248,62 @@ display: it.printf(278, 18, id(font_icon), TextAlign::TOP_LEFT, "\U0000e627"); - // Parameters - // Drawing the marker over the gauge float pi = 3.141592653589793; - float alpha = 4.71238898038469; // Defined as the gauge angle in radians (270deg) + float alpha = 4.71238898038469; float beta = 2*pi - alpha; - int radius = 22; // Radius of the gauge in pixels - int thick = 7; // Size of the marker - - // *** Moisture *** - int min_range = 0; - int max_range = 100; - int xc = 80; - int yc = 50; - - float measured = id(soil).state; - - if (measured < min_range) { - measured = min_range; - } - if (measured > max_range) { - measured = max_range; - } - - float val = (measured - min_range) / abs(max_range - min_range) * alpha; - - int x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); - int y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); - int x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); - int y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); - int x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); - int y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); - it.line(x0, y0, x1, y1); - it.line(x1, y1, x2, y2); - it.line(x2, y2, x0, y0); - - it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, - "%.0f%%", id(soil).state); - - // *** Light *** - min_range = 0; - max_range = 3775; - xc = 134; - yc = 70; - - measured = id(light).state; - - if (measured < min_range) { - measured = min_range; - } - if (measured > max_range) { - measured = max_range; - } - - val = (measured - min_range) / abs(max_range - min_range) * alpha; - x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); - y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); - x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); - y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); - x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); - y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); - it.line(x0, y0, x1, y1); - it.line(x1, y1, x2, y2); - it.line(x2, y2, x0, y0); - - it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, - "%.0flx", id(light).state); - - - // *** Temperature *** - min_range = -10; - max_range = 50; - xc = 188; - yc = 50; - - measured = id(temp).state; - - if (measured < min_range) { - measured = min_range; - } - if (measured > max_range) { - measured = max_range; - } - - val = (measured - min_range) / abs(max_range - min_range) * alpha; - x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); - y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); - x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); - y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); - x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); - y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); - it.line(x0, y0, x1, y1); - it.line(x1, y1, x2, y2); - it.line(x2, y2, x0, y0); - - it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, - "%.0f°C", id(temp).state); - + 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)) { + it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, "--"); + return; + } + // Keep the sensor reading intact for the numeric label. + float needle_value = value; + if (needle_value < vmin) needle_value = vmin; + if (needle_value > vmax) needle_value = vmax; + float val = (needle_value - vmin) / abs(vmax - vmin) * alpha; + int x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); + int y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); + int x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); + int y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); + int x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); + int y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); + it.line(x0, y0, x1, y1); + it.line(x1, y1, x2, y2); + it.line(x2, y2, x0, y0); + it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), 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, 3775, 134, 70, "%.0flx"); + draw_gauge(id(temp).state, id(temp).has_state(), -10, 50, 188, 50, "%.0f°C"); + draw_gauge(id(hum).state, id(hum).has_state(), 20, 80, 242, 70, "%.0f%%"); - // *** Humidity *** - min_range = 20; - max_range = 80; - xc = 242; - yc = 70; - - measured = id(hum).state; - - if (measured < min_range) { - measured = min_range; - } - if (measured > max_range) { - measured = max_range; - } - - val = (measured - min_range) / abs(max_range - min_range) * alpha; - x0 = static_cast(xc + radius + radius * cos(pi / 2 + beta / 2 + val)); - y0 = static_cast(yc + radius + radius * sin(pi / 2 + beta / 2 + val)); - x1 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val + 0.1)); - y1 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val + 0.1)); - x2 = static_cast(xc + radius + (radius+thick) * cos(pi / 2 + beta / 2 + val - 0.1)); - y2 = static_cast(yc + radius + (radius+thick) * sin(pi / 2 + beta / 2 + val - 0.1)); - it.line(x0, y0, x1, y1); - it.line(x1, y1, x2, y2); - it.line(x2, y2, x0, y0); - - it.printf(xc + radius, yc + 1.7*radius, id(font_parameters), TextAlign::TOP_CENTER, - "%.0f%%", id(hum).state); - deep_sleep: id: deep_sleep_control # run_duration: 5s sleep_duration: 1h +globals: + - id: display_busy_seen + type: bool + restore_value: false + initial_value: 'false' + - id: display_settle_ok + type: bool + restore_value: false + initial_value: 'false' + - id: display_safe_to_sleep + type: bool + restore_value: false + initial_value: 'true' + - id: display_reset_pending + type: bool + restore_value: false + initial_value: 'false' + script: - id: consider_deep_sleep mode: single @@ -384,13 +312,165 @@ script: condition: lambda: 'return id(esptime).now().is_valid();' timeout: 10s + - script.execute: prepare_display_refresh - component.update: my_display - - delay: 5s - # A normal cycle can finish before ESPHome's default 60-second OTA - # validation window. Mark the image valid before deep sleep resets it. + - script.execute: settle_display + - script.wait: settle_display + - script.execute: sleep_after_display + + - id: prepare_display_refresh + then: + # Run before any new display SPI writes. A new refresh invalidates any + # delayed recovery from an older one. + - script.stop: settle_display + - script.stop: recover_display_busy + - script.stop: display_recovery_budget + - script.stop: enter_deep_sleep + - script.stop: sleep_after_display + - script.stop: handle_display_busy_fault + - script.stop: force_sleep_after_busy_fault + - lambda: |- + // Cancellation during the asynchronous reset pulse must release RST. + if (id(display_reset_pending)) { + digitalWrite(${display_reset_gpio}, HIGH); + id(display_reset_pending) = false; + } + id(display_settle_ok) = false; + id(display_busy_seen) = false; + id(display_safe_to_sleep) = false; + + - id: settle_display + mode: restart + then: + - delay: 100ms + - wait_until: + condition: + lambda: 'return digitalRead(${display_busy_gpio}) == HIGH;' + timeout: ${display_busy_assert_timeout} + - if: + condition: + lambda: 'return digitalRead(${display_busy_gpio}) == HIGH;' + then: + - lambda: 'id(display_busy_seen) = true;' + - wait_until: + condition: + lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' + timeout: ${display_busy_clear_timeout} + - lambda: |- + const bool busy_low = digitalRead(${display_busy_gpio}) == LOW; + id(display_safe_to_sleep) = busy_low; + id(display_settle_ok) = id(display_busy_seen) && busy_low; + - if: + condition: + lambda: 'return id(display_settle_ok);' + then: + - delay: 500ms + else: + - logger.log: + level: ERROR + format: "E-paper refresh not confirmed (asserted=%s, safe_low=%s)" + args: + - 'id(display_busy_seen) ? "true" : "false"' + - 'id(display_safe_to_sleep) ? "true" : "false"' + + - id: recover_display_busy + mode: restart + then: + # A separate single-mode timer survives LOW/HIGH retries and the final + # 1s sleep delay. Only a new refresh or successful sleep cancels it. + - if: + condition: + not: + script.is_running: display_recovery_budget + then: + - script.execute: display_recovery_budget + - wait_until: + condition: + lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' + timeout: ${display_busy_recovery_timeout} + - delay: 500ms + - if: + condition: + lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' + then: + - lambda: 'id(display_safe_to_sleep) = true;' + - logger.log: + level: WARN + format: "E-paper BUSY cleared after recovery window; resuming safe sleep" + - script.execute: enter_deep_sleep + else: + - script.execute: handle_display_busy_fault + + - id: display_recovery_budget + mode: single + then: + - delay: ${display_busy_recovery_timeout} + - script.execute: handle_display_busy_fault + + # Shared terminal path for a failed guard or the total budget. Stop pending + # normal sleep/recovery before reset. No display/SPI action occurs here. + - id: handle_display_busy_fault + mode: single + then: + - script.stop: display_recovery_budget + - script.stop: recover_display_busy + - script.stop: enter_deep_sleep + - script.stop: sleep_after_display + - lambda: |- + id(display_safe_to_sleep) = false; + - logger.log: + level: ERROR + format: "E-paper BUSY recovery failed or budget exhausted; reset then emergency sleep" + - lambda: |- + pinMode(${display_reset_gpio}, OUTPUT); + id(display_reset_pending) = true; + digitalWrite(${display_reset_gpio}, LOW); + - delay: 10ms + - lambda: |- + digitalWrite(${display_reset_gpio}, HIGH); + id(display_reset_pending) = false; + - delay: 500ms + - script.execute: force_sleep_after_busy_fault + + # No new display refresh follows a terminal fault. Native shutdown hooks + # 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. - safe_mode.mark_successful - - switch.turn_off: exc # Cut sensor power before sleep - # The MAX17048 fuel gauge stays powered from VBAT and hibernates - # automatically; no host action is needed (max17043.sleep_mode is a no-op - # on the MAX17048 until explicit model support ships in stable ESPHome). + - deep_sleep.allow: deep_sleep_control + - switch.turn_off: exc - deep_sleep.enter: deep_sleep_control + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + - deep_sleep.prevent: deep_sleep_control + - script.execute: recover_display_busy + + - id: enter_deep_sleep + mode: restart + then: + - delay: 1s + - if: + condition: + lambda: |- + return id(display_safe_to_sleep) && + digitalRead(${display_busy_gpio}) == LOW; + then: + - script.stop: display_recovery_budget + # Preserve version-independent OTA validation on normal sleep too. + - safe_mode.mark_successful + - deep_sleep.allow: deep_sleep_control + - switch.turn_off: exc + # MAX17048 stays powered from VBAT and hibernates automatically. + - deep_sleep.enter: deep_sleep_control + else: + - lambda: 'id(display_safe_to_sleep) = false;' + - script.execute: sleep_after_display diff --git a/docs/source/programming.rst b/docs/source/programming.rst index 2058915..9e6e9bc 100644 --- a/docs/source/programming.rst +++ b/docs/source/programming.rst @@ -61,6 +61,20 @@ the one I strongly recommend is the one through the `ESPHome Add-on for Home Ass can use the MQTT package under ``examples/multi-device``. +.. 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. + .. Note:: The Smart Plant integrates a *battery gauge* sensor (MAX17043/MAX17048). ESPHome includes a **native** ``max17043`` component — no ``external_components`` diff --git a/tests/display_automation.py b/tests/display_automation.py new file mode 100644 index 0000000..901b4ce --- /dev/null +++ b/tests/display_automation.py @@ -0,0 +1,305 @@ +"""Offline executor for the display scripts, read directly from package YAML. + +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. +This checks automation flow up to deep_sleep.enter. The driver shutdown hook +can still send SPI; physical sleep timing is outside this executor. +""" + +import ctypes +from pathlib import Path +import re +import subprocess +import tempfile + +import yaml + + +class PackageLoader(yaml.SafeLoader): + pass + + +PackageLoader.add_multi_constructor( + "!", lambda loader, tag, node: loader.construct_scalar(node) +) + + +class DisplayProgram: + def __init__(self, root, profile=None, standalone=False, control=False): + if standalone: + core = yaml.load((Path(root) / "docs/source/files/configuration.yaml").read_text(), PackageLoader) + transport = {"script": []} + else: + packages = Path(root) / "examples/multi-device/packages" + core = yaml.load((packages / "smart_plant_core.yaml").read_text(), PackageLoader) + transport = yaml.load( + (packages / f"smart_plant_profile_{profile}.yaml").read_text(), PackageLoader + ) + self.substitutions = dict(core["substitutions"], **transport.get("substitutions", {})) + available = {s["id"]: s for p in (core, transport) for s in p["script"]} + if control: + for switch in transport.get("switch", []): + for direction in ("on", "off"): + available[f'{switch["id"]}_{direction}'] = { + "then": switch[f"turn_{direction}_action"]} + if "on_end" in transport["ota"][0]: + available["ota_end"] = {"then": transport["ota"][0]["on_end"]["then"]} + available["boot"] = {"then": transport["esphome"]["on_boot"][-1]["then"]} + # Extract the actual refresh boundary at every caller, including the + # maintenance path. Surrounding plant/page decisions are irrelevant to + # cancellation; keep the real prepare/update/settle/wait action order. + self.refresh_sequences = [] + + def refreshes(node): + if isinstance(node, list): + for index, child in enumerate(node): + if child == {"component.update": "my_display"}: + start = index - 1 if index and "script.execute" in node[index - 1] else index + self.refresh_sequences.append(node[start:index + 3]) + refreshes(child) + elif isinstance(node, dict): + for value in node.values(): + refreshes(value) + + for package in (core, transport): + refreshes(package.get("script", [])) + self.scripts = {} + pending = ["settle_display", "enter_deep_sleep", "sleep_after_display"] + if standalone: + pending.append("consider_deep_sleep") + if control: + pending += [name for name in ("prepare_cycle", "decide_sleep", "ota_end", "boot", + "maintenance_control_on", "maintenance_control_off", + "storage_control_on", "storage_control_off") if name in available] + for sequence in self.refresh_sequences: + for action in sequence: + if "script.execute" in action: + pending.append(action["script.execute"]) + snippets = set() + + def visit(node): + if isinstance(node, list): + for child in node: + visit(child) + elif isinstance(node, dict): + for key, value in node.items(): + if key in ("logger.log", "mqtt.publish"): + continue # Outputs are recorded, not sent or formatted. + if key == "lambda": + snippets.add(value) + elif key == "display.page.show" and "return" in value: + snippets.add(value) + elif key in ("script.execute", "script.stop", "script.wait", "script.is_running"): + pending.append(value) + else: + visit(value) + + while pending: + name = pending.pop() + if name not in self.scripts: + self.scripts[name] = available[name] + visit(available[name]["then"]) + + fields = [(g["id"], g["type"], g["initial_value"]) + for p in (core, transport) for g in p.get("globals", [])] + fields += [("busy", "int", "0"), ("reset_low_count", "int", "0"), + ("reset_high_count", "int", "0"), ("now", "uint32_t", "0")] + if standalone: + fields.append(("esptime", "Clock", "0")) + if control: + fields += [("batpercent", "Sensor", None), ("temp", "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) + + class State(ctypes.Structure): + _fields_ = [(name, types[kind]) for name, kind, _ in fields] + + self.State = State + self.initial = {name: (Sensor(80, True) if kind == "Sensor" else Clock(12, 0, True)) if value is None + 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", + "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;", + "#define id(name) state->name", + "template double invoke(F f) { if constexpr " + "(std::is_void_v) { f(); return 0; } else return f(); }"] + self.functions = {} + for index, snippet in enumerate(sorted(snippets)): + body = self.expand(snippet) + source.append( + f'extern "C" double run_{index}(State *state) {{' + "auto digitalRead = [&](int) { return state->busy; };" + "auto pinMode = [](int, int) {};" + "auto digitalWrite = [&](int, int level) {" + "if (level == LOW) state->reset_low_count++; else state->reset_high_count++; };" + "auto millis = [&]() { return state->now; };" + f"return invoke([&]() {{ {body} }}); }}" + ) + self.functions[snippet] = f"run_{index}" + self.temp = tempfile.TemporaryDirectory(prefix="smart-plant-display-") + native = Path(self.temp.name) + (native / "lambdas.cpp").write_text("\n".join(source)) + subprocess.run(["c++", "-std=c++20", "-shared", "-fPIC", "-O0", + str(native / "lambdas.cpp"), "-o", str(native / "lambdas.so")], check=True) + self.library = ctypes.CDLL(str(native / "lambdas.so")) + for snippet, name in self.functions.items(): + function = getattr(self.library, name) + function.argtypes = [ctypes.POINTER(State)] + function.restype = ctypes.c_double + self.functions[snippet] = function + + def close(self): + self.temp.cleanup() + + def expand(self, value): + return re.sub(r"\$\{([^}]+)\}", lambda m: self.expand(self.substitutions[m[1]]), value) + + def duration(self, value): + value = self.expand(str(value)) + match = re.fullmatch(r"([\d.]+)(ms|s|min|h)", value) + if not match: + raise ValueError(f"Unsupported duration: {value}") + return round(float(match[1]) * {"ms": 1, "s": 1000, "min": 60000, "h": 3600000}[match[2]]) + + +class DisplayRuntime: + TICK = 10 + + def __init__(self, program, busy=lambda ms: 0, reverse_schedule=False, sensor_update=None): + self.program = program + self.state = program.State(**program.initial) + self.busy = busy + self.reverse_schedule = reverse_schedule + self.sensor_update = sensor_update + self.tasks = {} + self.events = [] + self.sleep = None + + def refresh(self, index=0): + name = "__refresh_caller" + task = {"alive": True, "name": name} + task["actions"] = self.actions(self.program.refresh_sequences[index], task) + self.tasks[name] = task + self.resume(task) + + def condition(self, condition): + if len(condition) != 1: + raise ValueError(f"Unsupported condition: {condition}") + key, value = next(iter(condition.items())) + if key == "lambda": + return bool(self.program.functions[value](ctypes.byref(self.state))) + if key == "script.is_running": + return value in self.tasks + if key == "not": + return not self.condition(value) + if key == "mqtt.connected": + return self.state.mqtt_connected + raise ValueError(f"Unsupported condition: {key}") + + def stop(self, name): + task = self.tasks.pop(name, None) + if task: + task["alive"] = False + + def execute(self, name): + script = self.program.scripts[name] + if name in self.tasks: + if script.get("mode", "single") == "single": + return + if script["mode"] != "restart": + raise ValueError("Only single/restart scripts are supported") + self.stop(name) + self.events.append((self.state.now, "script.execute", name)) + task = {"alive": True, "name": name} + task["actions"] = self.actions(script["then"], task) + self.tasks[name] = task + self.resume(task) + + def actions(self, actions, task): + for action in actions: + if not task["alive"] or self.sleep: + return + 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": + branch = "then" if self.condition(value["condition"]) else "else" + yield from self.actions(value.get(branch, []), task) + elif key == "script.execute": + self.execute(value) + elif key == "script.stop": + self.stop(value) + elif key == "script.wait": + if value in self.tasks: + yield ("script.wait", value, None) + elif key == "delay": + yield ("delay", None, self.state.now + self.program.duration(value)) + elif key == "wait_until": + if not self.condition(value["condition"]): + yield ("wait_until", value["condition"], + self.state.now + self.program.duration(value["timeout"])) + elif key == "component.update": + if value == "my_display" and self.state.reset_low_count != self.state.reset_high_count: + raise AssertionError("Display SPI update occurred while RST was LOW") + self.events.append((self.state.now, key, value)) + if self.sensor_update is not None: + self.sensor_update(value, self.state) + elif key == "display.page.show": + self.state.selected_page = (int(self.program.functions[value](ctypes.byref(self.state))) + if value in self.program.functions else getattr(self.state, value)) + self.events.append((self.state.now, key, self.state.selected_page)) + elif key == "repeat": + for _ in range(value["count"]): + yield from self.actions(value["then"], task) + elif key == "deep_sleep.enter": + duration = value.get("sleep_duration", "1h") if isinstance(value, dict) else "1h" + 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", + "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}") + + def resume(self, task): + try: + task["wait"] = next(task["actions"]) + except StopIteration: + if task["alive"]: + self.tasks.pop(task["name"], None) + task["alive"] = False + + def run_until(self, ms): + while self.state.now < ms and not self.sleep: + self.state.now += self.TICK + self.state.busy = self.busy(self.state.now) + for task in list(self.tasks.values())[:: -1 if self.reverse_schedule else 1]: + if not task["alive"]: + continue + kind, value, deadline = task["wait"] + ready = deadline is not None and self.state.now >= deadline + if kind == "wait_until": + ready = ready or self.condition(value) + elif kind == "script.wait": + ready = value not in self.tasks + if ready: + self.resume(task) diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py new file mode 100644 index 0000000..bd27453 --- /dev/null +++ b/tests/test_standalone_display.py @@ -0,0 +1,181 @@ +"""Execute the public renderer with UBSan and its real YAML automation.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml +from display_automation import DisplayProgram, DisplayRuntime, PackageLoader + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC = ROOT / "docs/source/files/configuration.yaml" + +STUB = r""" +#include +#include +#include +#include +#include +#include +#include +using std::abs; +#define id(name) name +struct Sensor { float state; bool present; bool has_state() const { return present; } }; +struct Now { bool is_valid() const { return false; } }; +struct Clock { Now now() { return {}; } }; +enum class TextAlign { TOP_CENTER, TOP_LEFT, TOP_RIGHT }; +struct Display { + void image(int,int,int) {} + void line(int x0,int y0,int x1,int y1) { std::cout << "line " << x0 << ' ' << y0 << ' ' << x1 << ' ' << y1 << '\n'; } + void printf(int x,int y,int,TextAlign,const char *fmt,...) { + char result[256]; va_list args; va_start(args,fmt); + vsnprintf(result,sizeof result,fmt,args); va_end(args); + std::cout << "text " << x << ' ' << y << ' ' << result << '\n'; + } + void strftime(int,int,int,TextAlign,const char *,Now) {} +}; +int main(int argc,char **argv) { + if(argc != 11) return 2; + Sensor batpercent{strtof(argv[1],nullptr),atoi(argv[2]) != 0}; + Sensor soil{strtof(argv[3],nullptr),atoi(argv[4]) != 0}; + Sensor light{strtof(argv[5],nullptr),atoi(argv[6]) != 0}; + Sensor temp{strtof(argv[7],nullptr),atoi(argv[8]) != 0}; + Sensor hum{strtof(argv[9],nullptr),atoi(argv[10]) != 0}; + Clock esptime; Display it; + int page_1_background=0,font_title=0,font_subtitle=0,font_parameters=0,font_icon=0; + // ACTUAL_PAGE +} +""" + + +class StandaloneRendererTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + package = yaml.load(PUBLIC.read_text(), PackageLoader) + page = package["display"][0]["pages"][0]["lambda"] + cls.temp = tempfile.TemporaryDirectory(prefix="smartplant-public-renderer-") + cls.addClassCleanup(cls.temp.cleanup) + path = Path(cls.temp.name) + source = path / "renderer.cpp" + cls.executable = path / "renderer" + source.write_text(STUB.replace("// ACTUAL_PAGE", page)) + subprocess.run(["c++", "-std=c++20", "-fsanitize=undefined,float-cast-overflow", + "-fno-sanitize-recover=all", str(source), "-o", str(cls.executable)], check=True) + + def render(self, values, present=None): + present = present or [True] * 5 + args = [item for value, exists in zip(values, present) for item in (str(value), str(int(exists)))] + result = subprocess.run([str(self.executable), *args], check=True, text=True, capture_output=True) + text, lines = {}, [] + for line in result.stdout.splitlines(): + if line.startswith("text "): + _, x, y, label = line.split(" ", 3) + text.setdefault((int(x), int(y)), []).append(label) + else: + lines.append(tuple(map(int, line.split()[1:]))) + return text, lines + + def test_missing_nonfinite_and_extreme_battery_never_cast_invalid_values(self): + for value, present in [("nan", True), ("inf", True), ("-inf", True), (42, False)]: + text, lines = self.render([value, 50, 1000, 25, 50], [present, True, True, True, True]) + self.assertEqual([label.strip() for label in text[(278, 1)]], ["--%"]) + self.assertEqual(len(lines), 12) + for value in ("1e30", "-1e30", -5, 120): + text, _ = self.render([value, 50, 1000, 25, 50]) + self.assertEqual(len(text[(278, 1)]), 2) + + def test_each_missing_or_nonfinite_gauge_has_placeholder_and_no_needle(self): + positions = [(102, 87), (156, 107), (210, 87), (264, 107)] + for index, position in enumerate(positions, 1): + for value, exists in [(42, False), ("nan", True), ("inf", True), ("-inf", True)]: + values, present = [50, 50, 1000, 25, 50], [True] * 5 + values[index], present[index] = value, exists + text, lines = self.render(values, present) + self.assertEqual(text[position], ["--"]) + self.assertEqual(len(lines), 9) + + def test_out_of_range_text_stays_raw_and_needles_match_endpoints(self): + raw = [75, 120, 10000, -15, 90] + clamped = [75, 100, 3775, -10, 80] + text, lines = self.render(raw) + _, endpoint_lines = self.render(clamped) + self.assertEqual(lines, endpoint_lines) + for position, expected in [((102, 87), "120%"), ((156, 107), "10000lx"), + ((210, 87), "-15°C"), ((264, 107), "90%")]: + self.assertEqual(text[position], [expected]) + + +class StandaloneFlowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.program = DisplayProgram(ROOT, standalone=True) + cls.addClassCleanup(cls.program.close) + + def runtime(self, busy, reverse=False): + runtime = DisplayRuntime(self.program, busy, reverse) + runtime.state.busy = busy(0) + return runtime + + def assert_valid_sleep(self, runtime): + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.sleep[1], 3600000) + keys = [event[1] for event in runtime.events] + 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) + for reverse in (False, True): + runtime = self.runtime(lambda ms: int(10000 <= ms < 12000), reverse) + runtime.execute("consider_deep_sleep") + runtime.run_until(15000) + self.assert_valid_sleep(runtime) + self.assertTrue(runtime.state.display_busy_seen) + self.assertTrue(runtime.state.display_settle_ok) + self.assertEqual(sum(e[1] == "component.update" for e in runtime.events), 1) + + def test_fault_and_oscillation_reach_one_bounded_sleep_request(self): + signals = (lambda ms: 1, lambda ms: int(ms % 2000 >= 600), + lambda ms: int(ms < 29900), lambda ms: int(ms % 600 >= 300)) + for signal in signals: + for reverse in (False, True): + runtime = self.runtime(signal, reverse) + runtime.state.display_safe_to_sleep = False + runtime.execute("recover_display_busy") + runtime.run_until(32000) + self.assert_valid_sleep(runtime) + self.assertLessEqual(runtime.sleep[0], 30530) + 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"]) + + def test_late_high_after_settle_enters_recovery(self): + for reverse in (False, True): + runtime = self.runtime(lambda ms: int(ms >= 500), reverse) + runtime.execute("enter_deep_sleep") + runtime.run_until(33000) + self.assert_valid_sleep(runtime) + self.assertEqual(runtime.state.reset_low_count, 1) + + 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)), + (30000, lambda ms: 1), (30200, lambda ms: 1)]: + for reverse in (False, True): + runtime = self.runtime(signal, reverse) + runtime.state.display_safe_to_sleep = False + runtime.execute("recover_display_busy") + runtime.run_until(cancel) + runtime.busy = lambda ms: int(ms < cancel + 1000) + runtime.state.busy = 1 + runtime.refresh() + runtime.run_until(cancel + 4000) + self.assertIsNone(runtime.sleep) + self.assertFalse(runtime.tasks) + self.assertTrue(runtime.state.display_safe_to_sleep) + self.assertEqual(runtime.state.reset_low_count, runtime.state.reset_high_count) + + +if __name__ == "__main__": + unittest.main() From 47ff160f2a9c589cb56ae81c50a481f355e4c328 Mon Sep 17 00:00:00 2001 From: "J.G.Aguado" Date: Wed, 21 Oct 2026 09:11:56 +0200 Subject: [PATCH 07/23] ci: add firmware tests workflow and CODEOWNERS Run the unit tests and validate the standalone ESPHome configuration on every PR. CODEOWNERS keeps hardware, documentation and repository governance paths under maintainer review. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 11 +++++++++++ .github/workflows/firmware-tests.yml | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/firmware-tests.yml 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 new file mode 100644 index 0000000..e7ced08 --- /dev/null +++ b/.github/workflows/firmware-tests.yml @@ -0,0 +1,26 @@ +name: Firmware tests + +on: + pull_request: + push: + branches: [V2R1, V2R2] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # The soil-voltage tests require this exact ESPHome release. + - name: Install ESPHome + run: pip install esphome==2026.9.0 pyyaml + + - name: Unit tests + run: python -B -m unittest discover -s tests -v + + - name: Validate standalone configuration + run: esphome config docs/source/files/configuration.yaml From e6b91759fa17cec644837279ee80019495087771 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:24:12 +0200 Subject: [PATCH 08/23] docs: keep low-battery guide focused on user behavior --- examples/multi-device/README.md | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index f2ef509..75531cb 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -19,32 +19,6 @@ The MQTT example preserves an explicit topic prefix. Treat that prefix as a stable identity after Home Assistant discovery, because changing it creates new entities. -## Build the checked-out contribution - -The example device files fetch remote packages from the production fork's -`V2R1` branch. They do not select this staging contribution automatically. -To validate this checkout, replace the `packages` block in your local device -configuration with local includes. Keep the core and transport from the same -checkout: - -```yaml -packages: - core: !include packages/smart_plant_core.yaml - transport: !include packages/smart_plant_profile_api.yaml -``` - -For MQTT, use `packages/smart_plant_profile_mqtt.yaml` as the transport instead. -Keep the device file beside the `packages` directory. Supply its local secrets -and label image before validation. Keep those settings out of commits. - -```sh -esphome config examples/multi-device/my-lemon-tree-api.yaml -esphome compile examples/multi-device/my-lemon-tree-api.yaml -``` - -Use `my-lemon-tree.yaml` for the MQTT variant. These commands validate and build -the selected local packages. They do not upload firmware. - 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 @@ -77,10 +51,3 @@ 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. -This upstream staging contribution keeps the baseline's explicit -`safe_mode.mark_successful` action before both sleep paths. It does not declare -the production fork's ESPHome `>=2026.8.0` requirement. Production can rely on -native image confirmation under that requirement. The offline tests check the -explicit action's ordering; they do not simulate bootloader confirmation. -Complete local API and MQTT builds verify action availability with ESPHome -2026.9.0. Older ESPHome versions were not compiled in this review. From 18b89572a4e1d1b1c6648bbf5146251fa202fd0e Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:24:24 +0200 Subject: [PATCH 09/23] docs: remove trailing empty line from battery guide --- examples/multi-device/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 75531cb..214def8 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -50,4 +50,3 @@ 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. - From 68e5b847cd1013bb744698605ab451527f6e534b Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:26:22 +0200 Subject: [PATCH 10/23] fix: keep recovered KiCad symbol libraries LF on Windows --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f4b132 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep the generated symbol library identical to the recovery output on Windows. +Design/*.kicad_sym text eol=lf From f46ad6934b69a17b2e9b15e4a20f4a88845fee5e Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:27:09 +0200 Subject: [PATCH 11/23] fix: align core display recovery and support native tests without UBSan --- docs/source/files/configuration.yaml | 3 + docs/source/programming.rst | 15 +- .../packages/smart_plant_core.yaml | 150 +++++++++++++----- tests/display_automation.py | 31 +++- tests/test_standalone_display.py | 65 ++++++-- 5 files changed, 191 insertions(+), 73 deletions(-) diff --git a/docs/source/files/configuration.yaml b/docs/source/files/configuration.yaml index 07209a7..38c163b 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: diff --git a/docs/source/programming.rst b/docs/source/programming.rst index 9e6e9bc..23007b3 100644 --- a/docs/source/programming.rst +++ b/docs/source/programming.rst @@ -62,18 +62,9 @@ the one I strongly recommend is the one through the `ESPHome Add-on for Home Ass .. 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. + 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. .. Note:: The Smart Plant integrates a *battery gauge* sensor (MAX17043/MAX17048). diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 5d9f8cc..24d404d 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -63,6 +63,14 @@ globals: type: bool restore_value: false initial_value: 'false' + - id: display_settle_ok + type: bool + restore_value: false + initial_value: 'false' + - id: display_reset_pending + type: bool + restore_value: false + initial_value: 'false' - id: display_safe_to_sleep type: bool restore_value: false @@ -375,6 +383,7 @@ script: mode: restart then: - display.page.show: page1 + - script.execute: prepare_display_refresh - component.update: my_display - script.execute: settle_display - script.wait: settle_display @@ -387,17 +396,30 @@ script: - deep_sleep.prevent: deep_sleep_control - script.execute: recover_display_busy - # Observe the panel's physical active-high BUSY cycle. A fixed delay is not - # evidence that the e-paper refresh has completed. - - id: settle_display - mode: restart + - id: prepare_display_refresh then: - # A new refresh invalidates recovery and delayed sleep from any older one. + # Run before any new display SPI writes. A new refresh invalidates any + # delayed recovery from an older one. + - script.stop: settle_display - script.stop: recover_display_busy + - script.stop: display_recovery_budget - script.stop: enter_deep_sleep + - script.stop: sleep_after_display + - script.stop: handle_display_busy_fault + - script.stop: force_sleep_after_busy_fault - lambda: |- + // Cancellation during the asynchronous reset pulse must release RST. + if (id(display_reset_pending)) { + digitalWrite(${display_reset_gpio}, HIGH); + id(display_reset_pending) = false; + } + id(display_settle_ok) = false; id(display_busy_seen) = false; id(display_safe_to_sleep) = false; + + - id: settle_display + mode: restart + then: - delay: 100ms - wait_until: condition: @@ -413,61 +435,109 @@ script: lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' timeout: ${display_busy_clear_timeout} - lambda: |- - id(display_safe_to_sleep) = id(display_busy_seen) && - digitalRead(${display_busy_gpio}) == LOW; + const bool busy_low = digitalRead(${display_busy_gpio}) == LOW; + id(display_safe_to_sleep) = busy_low; + id(display_settle_ok) = id(display_busy_seen) && busy_low; - if: condition: - lambda: 'return id(display_safe_to_sleep);' + lambda: 'return id(display_settle_ok);' then: - delay: 500ms else: - logger.log: level: ERROR - format: "E-paper refresh did not complete inside the BUSY window" + format: "E-paper refresh not confirmed (asserted=%s, safe_low=%s)" + args: + - 'id(display_busy_seen) ? "true" : "false"' + - 'id(display_safe_to_sleep) ? "true" : "false"' - # If BUSY clears late, resume normal sleep. A permanently asserted signal is - # bounded too: reset the panel, then sleep rather than draining the battery. - id: recover_display_busy mode: restart then: + # A separate single-mode timer survives LOW/HIGH retries and the final + # 1s sleep delay. Only a new refresh or successful sleep cancels it. + - if: + condition: + not: + script.is_running: display_recovery_budget + then: + - script.execute: display_recovery_budget - wait_until: condition: lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' timeout: ${display_busy_recovery_timeout} + - delay: 500ms - if: condition: lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' then: - - delay: 500ms - # Re-read after the guard: a newer refresh may have asserted BUSY. - - if: - condition: - lambda: 'return digitalRead(${display_busy_gpio}) == LOW;' - then: - - lambda: 'id(display_safe_to_sleep) = true;' - - script.execute: enter_deep_sleep - else: - - script.execute: recover_display_busy - else: + - lambda: 'id(display_safe_to_sleep) = true;' - logger.log: - level: ERROR - format: "E-paper BUSY stuck; resetting panel before emergency sleep" - - lambda: |- - pinMode(${display_reset_gpio}, OUTPUT); - digitalWrite(${display_reset_gpio}, LOW); - - delay: 10ms - - lambda: 'digitalWrite(${display_reset_gpio}, HIGH);' - - delay: 500ms - - safe_mode.mark_successful - - deep_sleep.allow: deep_sleep_control - - switch.turn_off: exc - - deep_sleep.enter: deep_sleep_control + level: WARN + format: "E-paper BUSY cleared after recovery window; resuming safe sleep" + - script.execute: enter_deep_sleep + 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: + - delay: ${display_busy_recovery_timeout} + - script.execute: handle_display_busy_fault + + # Shared terminal path for a failed guard or the total budget. Stop pending + # normal sleep/recovery before reset. No display/SPI action occurs here. + - id: handle_display_busy_fault + mode: single + then: + - script.stop: display_recovery_budget + - script.stop: recover_display_busy + - script.stop: enter_deep_sleep + - script.stop: sleep_after_display + - lambda: |- + id(display_safe_to_sleep) = false; + - logger.log: + level: ERROR + format: "E-paper BUSY recovery failed or budget exhausted; reset then emergency sleep" + - lambda: |- + pinMode(${display_reset_gpio}, OUTPUT); + id(display_reset_pending) = true; + digitalWrite(${display_reset_gpio}, LOW); + - delay: 10ms + - lambda: |- + digitalWrite(${display_reset_gpio}, HIGH); + id(display_reset_pending) = false; + - delay: 500ms + - script.execute: force_sleep_after_busy_fault + + # No new display refresh follows a terminal fault. Native shutdown hooks + # 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. + - safe_mode.mark_successful + - deep_sleep.allow: deep_sleep_control + - switch.turn_off: exc + - deep_sleep.enter: deep_sleep_control + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + - deep_sleep.prevent: deep_sleep_control + - script.execute: recover_display_busy - id: enter_deep_sleep mode: restart then: - # Let the selected transport flush its final sensor states before the - # Wi-Fi and switched sensor rail disappear. - delay: 1s - if: condition: @@ -475,11 +545,13 @@ script: return id(display_safe_to_sleep) && digitalRead(${display_busy_gpio}) == LOW; then: - # Version-agnostic OTA rollback protection for short wake cycles. + - script.stop: display_recovery_budget + # Preserve version-independent OTA validation on normal sleep too. - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control - switch.turn_off: exc + # MAX17048 stays powered from VBAT and hibernates automatically. - deep_sleep.enter: deep_sleep_control else: - - deep_sleep.prevent: deep_sleep_control - - script.execute: recover_display_busy + - lambda: 'id(display_safe_to_sleep) = false;' + - script.execute: sleep_after_display diff --git a/tests/display_automation.py b/tests/display_automation.py index 901b4ce..f22366d 100644 --- a/tests/display_automation.py +++ b/tests/display_automation.py @@ -9,6 +9,7 @@ """ import ctypes +import os from pathlib import Path import re import subprocess @@ -29,16 +30,16 @@ class PackageLoader(yaml.SafeLoader): class DisplayProgram: def __init__(self, root, profile=None, standalone=False, control=False): if standalone: - core = yaml.load((Path(root) / "docs/source/files/configuration.yaml").read_text(), PackageLoader) + core = yaml.load((Path(root) / "docs/source/files/configuration.yaml").read_text(encoding="utf-8"), PackageLoader) transport = {"script": []} else: packages = Path(root) / "examples/multi-device/packages" - core = yaml.load((packages / "smart_plant_core.yaml").read_text(), PackageLoader) + core = yaml.load((packages / "smart_plant_core.yaml").read_text(encoding="utf-8"), PackageLoader) transport = yaml.load( - (packages / f"smart_plant_profile_{profile}.yaml").read_text(), PackageLoader + (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"): @@ -152,10 +153,16 @@ class State(ctypes.Structure): self.functions[snippet] = f"run_{index}" self.temp = tempfile.TemporaryDirectory(prefix="smart-plant-display-") native = Path(self.temp.name) - (native / "lambdas.cpp").write_text("\n".join(source)) - subprocess.run(["c++", "-std=c++20", "-shared", "-fPIC", "-O0", - str(native / "lambdas.cpp"), "-o", str(native / "lambdas.so")], check=True) - self.library = ctypes.CDLL(str(native / "lambdas.so")) + (native / "lambdas.cpp").write_text("\n".join(source), encoding="utf-8") + library_path = native / ("lambdas.dll" if os.name == "nt" else "lambdas.so") + try: + subprocess.run(["c++", "-std=c++20", "-shared", "-O0", + *([] if os.name == "nt" else ["-fPIC"]), + str(native / "lambdas.cpp"), "-o", str(library_path)], check=True) + self.library = ctypes.CDLL(str(library_path)) + except BaseException: + self.temp.cleanup() + raise for snippet, name in self.functions.items(): function = getattr(self.library, name) function.argtypes = [ctypes.POINTER(State)] @@ -163,6 +170,14 @@ class State(ctypes.Structure): self.functions[snippet] = function def close(self): + if self.library is not None: + self.functions.clear() + if os.name == "nt": + # Windows locks loaded DLLs. Release the native handle before + # TemporaryDirectory removes the library file. + import _ctypes + _ctypes.FreeLibrary(self.library._handle) + self.library = None self.temp.cleanup() def expand(self, value): diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py index bd27453..4e1b889 100644 --- a/tests/test_standalone_display.py +++ b/tests/test_standalone_display.py @@ -1,6 +1,8 @@ -"""Execute the public renderer with 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 +import warnings import tempfile import unittest @@ -51,21 +53,34 @@ class StandaloneRendererTests(unittest.TestCase): @classmethod def setUpClass(cls): - package = yaml.load(PUBLIC.read_text(), PackageLoader) + package = yaml.load(PUBLIC.read_text(encoding="utf-8"), PackageLoader) page = package["display"][0]["pages"][0]["lambda"] cls.temp = tempfile.TemporaryDirectory(prefix="smartplant-public-renderer-") cls.addClassCleanup(cls.temp.cleanup) path = Path(cls.temp.name) source = path / "renderer.cpp" - cls.executable = path / "renderer" - source.write_text(STUB.replace("// ACTUAL_PAGE", page)) - subprocess.run(["c++", "-std=c++20", "-fsanitize=undefined,float-cast-overflow", - "-fno-sanitize-recover=all", str(source), "-o", str(cls.executable)], check=True) + cls.executable = path / ("renderer.exe" if os.name == "nt" else "renderer") + source.write_text(STUB.replace("// ACTUAL_PAGE", page), encoding="utf-8") + # Probe a separate valid program. A renderer error must still fail the + # real build; it must never trigger a silent unsanitized retry. + probe = path / "sanitizer_probe.cpp" + probe.write_text("int main() { return 0; }\n", encoding="utf-8") + probe_executable = path / ("sanitizer_probe.exe" if os.name == "nt" else "sanitizer_probe") + flags = ["-fsanitize=undefined,float-cast-overflow", "-fno-sanitize-recover=all"] + result = subprocess.run(["c++", "-std=c++20", *flags, str(probe), "-o", str(probe_executable)], + capture_output=True) + if result.returncode == 0: + result = subprocess.run([str(probe_executable)], capture_output=True) + if result.returncode: + warnings.warn("UBSan is unavailable; renderer assertions still run without it", RuntimeWarning) + flags = [] + subprocess.run(["c++", "-std=c++20", "-finput-charset=UTF-8", "-fexec-charset=UTF-8", + *flags, str(source), "-o", str(cls.executable)], check=True) def render(self, values, present=None): present = present or [True] * 5 args = [item for value, exists in zip(values, present) for item in (str(value), str(int(exists)))] - result = subprocess.run([str(self.executable), *args], check=True, text=True, capture_output=True) + result = subprocess.run([str(self.executable), *args], check=True, text=True, encoding="utf-8", capture_output=True) text, lines = {}, [] for line in result.stdout.splitlines(): if line.startswith("text "): @@ -105,10 +120,12 @@ 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 @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): @@ -123,12 +140,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) @@ -177,5 +200,19 @@ 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 StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): + pass + + +class CoreApiFlowTests(DisplayFlowScenarios, unittest.TestCase): + standalone = False + profile = "api" + + +class CoreMqttFlowTests(DisplayFlowScenarios, unittest.TestCase): + standalone = False + profile = "mqtt" + + if __name__ == "__main__": unittest.main() From 9828f6e8c4a2e57dd919a72a3d9efb06bb762fa2 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:28:59 +0200 Subject: [PATCH 12/23] test: guard shared recovery parity and fresh timer budgets --- tests/test_standalone_display.py | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py index 4e1b889..d4a5df7 100644 --- a/tests/test_standalone_display.py +++ b/tests/test_standalone_display.py @@ -181,6 +181,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.assert_valid_sleep(runtime) + self.assertGreaterEqual(runtime.sleep[0], 34000) + self.assertLessEqual(runtime.sleep[0], 34530) + 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)), @@ -200,6 +220,19 @@ 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 RecoveryConsistencyTests(unittest.TestCase): + def test_shared_recovery_actions_match_standalone(self): + public = yaml.load(PUBLIC.read_text(encoding="utf-8"), PackageLoader) + core_path = ROOT / "examples/multi-device/packages/smart_plant_core.yaml" + core = yaml.load(core_path.read_text(encoding="utf-8"), PackageLoader) + public_scripts = {script["id"]: script for script in public["script"]} + core_scripts = {script["id"]: script for script in core["script"]} + for name in ("prepare_display_refresh", "settle_display", "recover_display_busy", + "display_recovery_budget", "handle_display_busy_fault", "sleep_after_display"): + with self.subTest(script=name): + self.assertEqual(core_scripts[name], public_scripts[name]) + + class StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): pass From 59e55f7352c5ddccb3000adeb5d215d85f8da56e Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:29:52 +0200 Subject: [PATCH 13/23] test: make battery display recorder format-security safe --- tests/test_low_battery.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_low_battery.py b/tests/test_low_battery.py index 47a81e8..06c00cf 100644 --- a/tests/test_low_battery.py +++ b/tests/test_low_battery.py @@ -35,6 +35,7 @@ def resolve(source, overrides=None): #include #include #include +#include #include #include #include @@ -55,9 +56,12 @@ def resolve(source, overrides=None): 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); } - template - void printf(int, int, int, Color color, TextAlign, const char *format, Args... args) { - char value[128]; std::snprintf(value, sizeof(value), format, args...); + 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) { @@ -137,7 +141,7 @@ 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", "-Werror=format", str(cpp), "-o", str(binary)], + 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 From 56fa1f1ede78ae48a04ba490284ec2d7c44e07b7 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 09:37:08 +0200 Subject: [PATCH 14/23] docs: keep recovery compatibility comments shared between entry points --- docs/source/files/configuration.yaml | 2 +- examples/multi-device/packages/smart_plant_core.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/files/configuration.yaml b/docs/source/files/configuration.yaml index 38c163b..683c0b9 100644 --- a/docs/source/files/configuration.yaml +++ b/docs/source/files/configuration.yaml @@ -439,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/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 24d404d..00ada4a 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -517,7 +517,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 From 5f8f955f2751c44290e0a01dc6f86411e72f3fa3 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 11:18:53 +0200 Subject: [PATCH 15/23] fix: make public examples self-contained upstream and validate both transports --- .github/workflows/firmware-tests.yml | 3 + examples/multi-device/README.md | 16 +++++ examples/multi-device/my-lemon-tree-api.yaml | 4 +- examples/multi-device/my-lemon-tree.yaml | 4 +- .../packages/smart_plant_core.yaml | 4 +- scripts/validate_public_examples.py | 62 +++++++++++++++++++ 6 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 scripts/validate_public_examples.py diff --git a/.github/workflows/firmware-tests.yml b/.github/workflows/firmware-tests.yml index e7ced08..16ecabc 100644 --- a/.github/workflows/firmware-tests.yml +++ b/.github/workflows/firmware-tests.yml @@ -24,3 +24,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/examples/multi-device/README.md b/examples/multi-device/README.md index 214def8..7236215 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -50,3 +50,19 @@ 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. diff --git a/examples/multi-device/my-lemon-tree-api.yaml b/examples/multi-device/my-lemon-tree-api.yaml index 308a42f..a5e78af 100644 --- a/examples/multi-device/my-lemon-tree-api.yaml +++ b/examples/multi-device/my-lemon-tree-api.yaml @@ -21,8 +21,8 @@ substitutions: light_max: "8000" 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_api.yaml@V2R1 + 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_api.yaml@V2R1 wifi: manual_ip: diff --git a/examples/multi-device/my-lemon-tree.yaml b/examples/multi-device/my-lemon-tree.yaml index 8e40d03..b071d70 100644 --- a/examples/multi-device/my-lemon-tree.yaml +++ b/examples/multi-device/my-lemon-tree.yaml @@ -37,8 +37,8 @@ substitutions: light_max: "8000" 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 + 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 # Static IP — merges on top of the core Wi-Fi block. Remove this block for DHCP. wifi: diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 3a4b4dd..bcddb03 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -4,8 +4,8 @@ # 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 +# 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 # # This file owns hardware, sensors, display, Wi-Fi and the sleep decision only. # Transport setup and boot orchestration belong to the selected profile. diff --git a/scripts/validate_public_examples.py b/scripts/validate_public_examples.py new file mode 100644 index 0000000..3fe5bee --- /dev/null +++ b/scripts/validate_public_examples.py @@ -0,0 +1,62 @@ +#!/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/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=", + } + 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() From a73f123d5a76637c54b05b61aaeac8187c8fae96 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 11:38:04 +0200 Subject: [PATCH 16/23] Limit test CI checkout credentials --- .github/workflows/firmware-tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/firmware-tests.yml b/.github/workflows/firmware-tests.yml index 16ecabc..cf4c368 100644 --- a/.github/workflows/firmware-tests.yml +++ b/.github/workflows/firmware-tests.yml @@ -1,5 +1,8 @@ name: Firmware tests +permissions: + contents: read + on: pull_request: push: @@ -10,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: From 1c7b0550136e974f77aed4a0a7758efacb08ff45 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 17/23] feat: add retained MQTT maintenance and storage modes --- examples/multi-device/README.md | 72 ++- .../packages/smart_plant_core.yaml | 123 ++++- .../packages/smart_plant_profile_api.yaml | 41 ++ .../packages/smart_plant_profile_mqtt.yaml | 497 +++++++++++++++++- tests/test_display_recovery.py | 71 +++ tests/test_display_recovery_budget.py | 126 +++++ tests/test_low_battery.py | 14 +- tests/test_maintenance_storage.py | 431 +++++++++++++++ 8 files changed, 1338 insertions(+), 37 deletions(-) create mode 100644 tests/test_display_recovery.py create mode 100644 tests/test_display_recovery_budget.py create mode 100644 tests/test_maintenance_storage.py diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 7236215..bf5b900 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -12,7 +12,8 @@ Assistant transport: 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. Keep credentials in the local `secrets.yaml`; remote package +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. The MQTT example preserves an explicit topic prefix. Treat that prefix as a @@ -66,3 +67,72 @@ 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. diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index dc32cac..1ff6260 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -55,6 +55,10 @@ substitutions: 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" # The legacy Waveshare driver returns before the active-high BUSY signal has # completed every physical refresh. These bounded windows prevent sleep from @@ -66,6 +70,30 @@ substitutions: display_busy_recovery_timeout: "30s" globals: + - id: storage_active + type: bool + restore_value: true + initial_value: 'false' + - id: maintenance_end_hour + type: int + restore_value: false + initial_value: '-1' + - id: maintenance_end_minute + type: int + restore_value: false + initial_value: '-1' + - id: display_settle_ok + type: bool + restore_value: false + initial_value: 'false' + - id: display_reset_pending + type: bool + restore_value: false + initial_value: 'false' + - 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 @@ -352,6 +380,30 @@ display: 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: |- + it.printf(148, 8, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 38, id(font_title), TextAlign::TOP_CENTER, "MAINTENANCE"); + if (id(maintenance_end_hour) >= 0) { + it.printf(148, 82, id(font_title), TextAlign::TOP_CENTER, "ENDS AT %02d:%02d", + id(maintenance_end_hour), id(maintenance_end_minute)); + } else { + it.printf(148, 82, id(font_title), TextAlign::TOP_CENTER, + "ENDS IN ${maintenance_timeout_minutes} MIN"); + } + + - id: page_storage + lambda: |- + it.printf(148, 4, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_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() && 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 protection is transport-independent and takes precedence over # the normal page until the persisted hysteresis latch releases. - id: page_low_batt @@ -359,6 +411,9 @@ display: it.fill(COLOR_ON); it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${friendly_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() && std::isfinite(id(batpercent).state)) { it.printf(148, 88, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "BATTERY %.0f%%", id(batpercent).state); @@ -407,9 +462,9 @@ script: condition: lambda: 'return id(esptime).now().is_valid();' timeout: 10s - - script.execute: render_and_sleep + - script.execute: decide_sleep - - id: render_and_sleep + - id: render_measurement_page mode: restart then: - lambda: |- @@ -427,19 +482,12 @@ script: } } - display.page.show: !lambda |- - return id(low_batt_active) ? id(page_low_batt) : id(page1); + 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 - - if: - condition: - lambda: 'return id(display_safe_to_sleep);' - then: - - script.execute: enter_deep_sleep - else: - - deep_sleep.prevent: deep_sleep_control - - script.execute: recover_display_busy - id: prepare_display_refresh then: @@ -452,6 +500,7 @@ script: - script.stop: sleep_after_display - script.stop: handle_display_busy_fault - script.stop: force_sleep_after_busy_fault + - script.stop: on_display_fault - lambda: |- // Cancellation during the asynchronous reset pulse must release RST. if (id(display_reset_pending)) { @@ -544,9 +593,12 @@ script: - script.stop: sleep_after_display - lambda: |- id(display_safe_to_sleep) = false; + id(display_busy_fault_count) += 1; - logger.log: level: ERROR - format: "E-paper BUSY recovery failed or budget exhausted; reset then emergency sleep" + format: "E-paper BUSY recovery failed or budget exhausted - fault #%d; reset then emergency sleep" + args: + - 'id(display_busy_fault_count)' - lambda: |- pinMode(${display_reset_gpio}, OUTPUT); id(display_reset_pending) = true; @@ -560,8 +612,11 @@ script: # 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. - id: force_sleep_after_busy_fault then: + - script.execute: on_display_fault + - script.wait: on_display_fault # Keep this safeguard for users on arbitrary ESPHome versions. - safe_mode.mark_successful - deep_sleep.allow: deep_sleep_control @@ -574,19 +629,28 @@ script: id: deep_sleep_control sleep_duration: ${low_batt_sleep_duration} else: - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(storage_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${storage_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control - - id: sleep_after_display + - id: sample_battery mode: restart then: - - if: + - switch.turn_on: exc + - delay: 250ms + - component.update: max17043_id + - wait_until: condition: - lambda: 'return id(display_safe_to_sleep);' - then: - - script.execute: enter_deep_sleep - else: - - deep_sleep.prevent: deep_sleep_control - - script.execute: recover_display_busy + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state); + timeout: 3s - id: enter_deep_sleep mode: restart @@ -607,11 +671,26 @@ script: condition: lambda: 'return id(low_batt_active);' then: + # Protective hibernation: extend the cycle so residual drain + # cannot walk the cell toward deep-discharge damage while low. - deep_sleep.enter: id: deep_sleep_control sleep_duration: ${low_batt_sleep_duration} else: - - deep_sleep.enter: deep_sleep_control + - if: + condition: + lambda: 'return id(storage_active);' + then: + - deep_sleep.enter: + id: deep_sleep_control + sleep_duration: ${storage_sleep_duration} + else: + - deep_sleep.enter: deep_sleep_control else: + - logger.log: + level: WARN + format: "E-paper became BUSY before sleep; cancelling shutdown" + # The live pin overrides the cached result from settle/recovery. + # Both profiles must enter recovery instead of retrying the 1s delay. - lambda: 'id(display_safe_to_sleep) = false;' - script.execute: sleep_after_display diff --git a/examples/multi-device/packages/smart_plant_profile_api.yaml b/examples/multi-device/packages/smart_plant_profile_api.yaml index f42a3de..15127db 100644 --- a/examples/multi-device/packages/smart_plant_profile_api.yaml +++ b/examples/multi-device/packages/smart_plant_profile_api.yaml @@ -16,6 +16,8 @@ esphome: - lambda: 'delay(250);' - priority: 399.0 then: + # Storage belongs to MQTT. Clear a latch restored from an older profile. + - lambda: 'id(storage_active) = false;' - script.execute: acquire_normal api: @@ -25,3 +27,42 @@ api: ota: - platform: esphome password: "${ota_password}" + +script: + - id: on_display_fault + then: + - logger.log: + level: ERROR + format: "Display BUSY permanent fault #%d — emergency sleep" + args: + - 'id(display_busy_fault_count)' + - delay: 500ms + + # Simple sleep decision: no maintenance/storage. acquire_normal (core) ends by + # calling this; render the measurement page and sleep. + + - id: decide_sleep + mode: restart + then: + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + # API variant of sleep_after_display: same BUSY-safety logic as the MQTT + # profile, but the fault status is logged instead of published to an MQTT + # diagnostic entity (which does not exist in this profile). + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + - deep_sleep.prevent: deep_sleep_control + - logger.log: + level: ERROR + format: "E-paper remains BUSY; starting bounded recovery" + - script.execute: recover_display_busy diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index a7d807a..9cf64c3 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -1,10 +1,14 @@ # ============================================================================= -# Smart Plant — retained-MQTT transport profile +# Smart Plant — MQTT transport profile (advanced) # ============================================================================= -# Compose with smart_plant_core.yaml. Retained discovery/state keeps the latest -# readings visible while the device sleeps. The native API is enabled only for -# ESPHome dashboard metadata; do not also add the device through the ESPHome -# Home Assistant integration, which would create duplicate entities. +# Compose with smart_plant_core.yaml. Adds the MQTT data path (retained values +# survive deep sleep), retained remote control (Maintenance / Storage Mode), +# native ESPHome OTA (Device Builder push), and the boot orchestration. Requires +# deep-sleep-friendly retained state. +# +# packages: +# core: !include packages/smart_plant_core.yaml +# transport: !include packages/smart_plant_profile_mqtt.yaml # ============================================================================= substitutions: @@ -13,30 +17,81 @@ substitutions: mqtt_username: "" mqtt_password: "" +globals: + - id: maintenance_requested + type: bool + restore_value: false + initial_value: 'false' + - id: boot_sequence_complete + type: bool + restore_value: false + initial_value: 'false' + - id: maintenance_active + type: bool + restore_value: false + initial_value: 'false' + # The accepted seasonal state is the only Storage Mode value persisted. + # The requested state starts from it and is overridden by retained MQTT when + # the broker is reachable. + - id: storage_requested + type: bool + restore_value: false + initial_value: 'false' + - id: storage_display_refresh_pending + type: bool + restore_value: true + initial_value: 'false' + - id: preserve_maintenance_status + type: bool + restore_value: false + initial_value: 'false' + esphome: on_boot: + # GPIO switches initialize at HARDWARE priority 800 and MAX17043 at DATA + # priority 600. Block setup between them so the switched I2C rail is stable + # before the fuel gauge performs its one supported wake/config setup. - priority: 700 then: - lambda: 'delay(250);' + # Retained MQTT state handling suspends until MQTT is connected; this keeps + # cycle preparation after the remaining component setup work. - priority: 399.0 then: + - lambda: 'id(storage_requested) = id(storage_active);' - wait_until: condition: mqtt.connected: timeout: 30s - - script.execute: acquire_normal + # Retained desired-state commands arrive immediately after MQTT connect, + # but connection itself does not guarantee their callbacks have run. + - delay: 1s + - lambda: 'id(boot_sequence_complete) = true;' + - script.execute: prepare_cycle +# MQTT — required for deep-sleep compatibility: retained values survive sleep +# so Home Assistant sees fresh data on wake without "unavailable" state. +# MQTT is kept as the data source for Home Assistant (retained values survive +# deep sleep). The native API below is enabled ONLY so the ESPHome dashboard can +# read rich device metadata (mac / config_hash / version) via the _esphomelib._tcp +# mDNS record — MQTT-only devices advertise _http._tcp with `version` alone. +# Do NOT add these devices to Home Assistant via the ESPHome (API) integration, +# or you get duplicate entities that go unavailable during sleep. Keep them on MQTT. mqtt: + id: mqtt_client + topic_prefix: "${mqtt_topic_prefix}" broker: "${mqtt_broker}" username: "${mqtt_username}" password: "${mqtt_password}" discovery: true discovery_retain: true - birth_message: + birth_message: # disabled — prevents HA marking device offline during sleep will_message: - log_topic: - topic_prefix: "${mqtt_topic_prefix}" + log_topic: # disabled — saves bandwidth on wake +# Native API — dashboard metadata only (see note above). reboot_timeout: 0s so a +# sleeping device with no API client connected doesn't reboot itself. +# Supply api_encryption_key from the device's local secrets.yaml. api: encryption: key: "${api_encryption_key}" @@ -45,3 +100,427 @@ api: ota: - platform: esphome password: "${ota_password}" + # on_end runs only after the image has been transferred successfully and + # immediately before the OTA reboot. Clear the one-shot retained request so + # the new firmware performs a normal measurement/display/sleep cycle. + on_end: + then: + # The old deadline must not start a display refresh during this final + # retained-message flush before the OTA reboot. + - script.stop: maintenance_watchdog + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: 'return id(storage_active);' + then: + # Never start a physical refresh immediately before OTA reboot. + # Persist a one-shot request that the new image services after + # boot, where BUSY can be observed through completion. + - lambda: 'id(storage_display_refresh_pending) = true;' + - delay: 2s + +switch: + # Native MQTT-discovered control: ESPHome associates it with the same device + # as the plant sensors. Keep the established retained command topic so + # existing operators and automations remain compatible. + - platform: template + id: maintenance_control + name: "Maintenance" + icon: "mdi:wrench-cog" + optimistic: true + command_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + command_retain: true + subscribe_qos: 1 + qos: 1 + retain: true + turn_on_action: + - lambda: 'id(maintenance_requested) = true;' + # A repeated ON renews the window. The old deadline must not fire while + # the new battery sample or display refresh is still in progress. + - script.stop: maintenance_watchdog + - if: + condition: + lambda: 'return id(boot_sequence_complete);' + then: + - script.execute: prepare_cycle + turn_off_action: + - lambda: 'id(maintenance_requested) = false;' + - if: + condition: + lambda: 'return id(boot_sequence_complete);' + then: + - if: + condition: + lambda: 'return id(maintenance_active);' + then: + - script.stop: prepare_cycle + - script.execute: decide_sleep + else: + # Cancel pending admission. Ignore OFF echoes after rejection + # or exit, which must not start a second acquisition cycle. + - if: + condition: + script.is_running: prepare_cycle + then: + - script.execute: prepare_cycle + + - platform: template + id: storage_control + name: "Storage Mode" + icon: "mdi:archive-outline" + optimistic: true + command_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/storage_mode";' + command_retain: true + subscribe_qos: 1 + qos: 1 + retain: true + # Storage has no renewable window. Equal desired-state commands, including + # our retained OFF echo, must not restart pending or completed cycle work. + turn_on_action: + - if: + condition: + lambda: 'return !id(storage_requested);' + then: + - lambda: 'id(storage_requested) = true;' + - if: + condition: + lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + then: + - script.execute: prepare_cycle + turn_off_action: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - lambda: 'id(storage_requested) = false;' + - if: + condition: + lambda: 'return id(boot_sequence_complete) && !id(maintenance_active);' + then: + - script.execute: prepare_cycle + +text_sensor: + # Publish the actual state-machine result through a native entity so Home + # Assistant attaches it to the plant's MQTT device. REJECTED_LOW_BATTERY is + # intentionally distinct from the requested ON/OFF control state. + - platform: template + id: maintenance_status + name: "Maintenance Status" + icon: "mdi:information-outline" + entity_category: diagnostic + update_interval: never + state_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/maintenance";' + qos: 1 + retain: true + + - platform: template + id: storage_status + name: "Storage Mode Status" + icon: "mdi:archive-check-outline" + entity_category: diagnostic + update_interval: never + state_topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/storage_mode";' + qos: 1 + retain: true + +script: + # Called by force_sleep_after_busy_fault (core) when a permanent BUSY fault + # triggers the emergency sleep path. Publishes the NVS-backed fault counter + # retained so Home Assistant reflects the current count after each wake. + - id: on_display_fault + then: + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/status/display_fault_count";' + payload: !lambda 'return to_string(id(display_busy_fault_count));' + qos: 1 + retain: true + - delay: 1s + + - id: sleep_after_display + mode: restart + then: + - if: + condition: + lambda: 'return id(display_safe_to_sleep);' + then: + - script.execute: enter_deep_sleep + else: + # Recover within one total budget. A terminal timeout resets the + # panel and enters the bounded emergency sleep path. + - deep_sleep.prevent: deep_sleep_control + - text_sensor.template.publish: + id: maintenance_status + state: "DISPLAY_BUSY_TIMEOUT" + - logger.log: + level: ERROR + format: "E-paper remains BUSY; starting bounded recovery" + - script.execute: recover_display_busy + + # Choose the smallest acquisition path only after retained MQTT commands have + # had a chance to override the persisted Storage Mode state. + - id: prepare_cycle + mode: restart + then: + # A newer retained command owns the whole pending cycle. Cancel children + # as well as this restartable parent before choosing the next acquisition. + - script.stop: decide_sleep + - script.stop: enter_storage + - script.stop: exit_storage + - script.stop: exit_maintenance + - script.stop: acquire_normal + - script.stop: render_measurement_page + - script.stop: sample_battery + - script.execute: prepare_display_refresh + - if: + condition: + lambda: 'return id(maintenance_requested);' + then: + - script.execute: sample_battery + - script.wait: sample_battery + - wait_until: + condition: + lambda: 'return id(esptime).now().is_valid();' + timeout: 10s + - script.execute: decide_sleep + else: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + # Every daily Storage wake updates the only operational + # sensor that matters while the plant sensors are gated. + - script.execute: sample_battery + - script.wait: sample_battery + - script.execute: decide_sleep + else: + - script.execute: acquire_normal + + - id: decide_sleep + mode: restart + then: + - if: + condition: + lambda: 'return id(maintenance_requested);' + then: + - if: + condition: + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state) && + id(batpercent).state >= ${ota_min_battery}; + then: + - lambda: |- + id(maintenance_active) = true; + auto now = id(esptime).now(); + if (now.is_valid()) { + int end_minutes = now.hour * 60 + now.minute + + ${maintenance_timeout_minutes}; + id(maintenance_end_hour) = (end_minutes / 60) % 24; + id(maintenance_end_minute) = end_minutes % 60; + } else { + id(maintenance_end_hour) = -1; + id(maintenance_end_minute) = -1; + } + - deep_sleep.prevent: deep_sleep_control + - display.page.show: page_maintenance + - script.execute: prepare_display_refresh + - component.update: my_display + - script.execute: settle_display + - script.wait: settle_display + - if: + condition: + lambda: 'return id(display_settle_ok);' + then: + # ON is the OTA readiness handshake and is emitted only + # after an observed BUSY HIGH -> LOW refresh cycle. + - text_sensor.template.publish: + id: maintenance_status + state: "ON" + - script.execute: maintenance_watchdog + else: + - text_sensor.template.publish: + id: maintenance_status + state: "DISPLAY_REFRESH_FAILED" + - script.execute: sleep_after_display + else: + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + id(preserve_maintenance_status) = true; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - if: + condition: + lambda: |- + return id(batpercent).has_state() && + std::isfinite(id(batpercent).state); + then: + - text_sensor.template.publish: + id: maintenance_status + state: "REJECTED_LOW_BATTERY" + else: + - text_sensor.template.publish: + id: maintenance_status + state: "BATTERY_UNAVAILABLE" + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - script.execute: enter_storage + else: + # Admission samples only the battery. Acquire the plant + # states before returning to the normal display cycle. + - script.execute: acquire_normal + else: + - script.stop: maintenance_watchdog + - if: + condition: + lambda: 'return id(maintenance_active);' + then: + - script.execute: exit_maintenance + else: + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - script.execute: enter_storage + else: + - if: + condition: + lambda: 'return id(storage_active);' + then: + - script.execute: exit_storage + else: + - if: + condition: + lambda: 'return !id(preserve_maintenance_status);' + then: + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - text_sensor.template.publish: + id: storage_status + state: "OFF" + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + - id: maintenance_watchdog + mode: restart + then: + - delay: ${maintenance_timeout} + - script.execute: exit_maintenance + + - id: enter_storage + mode: single + then: + - lambda: |- + id(storage_active) = true; + id(storage_display_refresh_pending) = true; + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - if: + condition: + lambda: 'return id(display_settle_ok);' + then: + - lambda: 'id(storage_display_refresh_pending) = false;' + - switch.template.publish: + id: storage_control + state: ON + - text_sensor.template.publish: + id: storage_status + state: "ON" + - if: + condition: + lambda: 'return !id(preserve_maintenance_status);' + then: + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: |- + return !id(storage_display_refresh_pending) && + id(display_safe_to_sleep); + then: + - script.execute: enter_deep_sleep + else: + - script.execute: sleep_after_display + + - id: exit_storage + mode: single + then: + - lambda: 'id(storage_active) = false;' + - switch.template.publish: + id: storage_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/storage_mode";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: storage_status + state: "OFF" + - script.execute: render_measurement_page + - script.wait: render_measurement_page + - script.execute: sleep_after_display + + # The e-paper keeps its last image without power. Replace the maintenance + # page with the latest plant readings before an explicit OFF or watchdog + # sleep, so the physical display cannot contradict the retained MQTT state. + - id: exit_maintenance + mode: single + then: + - lambda: |- + id(maintenance_requested) = false; + id(maintenance_active) = false; + - switch.template.publish: + id: maintenance_control + state: OFF + - mqtt.publish: + topic: !lambda 'return id(mqtt_client).get_topic_prefix() + "/cmd/maintenance";' + payload: "OFF" + qos: 1 + retain: true + - text_sensor.template.publish: + id: maintenance_status + state: "OFF" + - if: + condition: + lambda: 'return id(storage_requested);' + then: + - lambda: 'id(storage_display_refresh_pending) = true;' + - script.execute: enter_storage + else: + - if: + condition: + lambda: 'return id(storage_active);' + then: + - script.execute: exit_storage + else: + # Plant sensors are never sampled during maintenance, so their + # states are still NaN here. Run the full normal cycle so the + # post-maintenance e-paper refresh shows real readings instead + # of "nan%/nanlx/nan°C/nan%". acquire_normal ends with + # decide_sleep, which now takes the plain-sleep branch. + - script.execute: acquire_normal diff --git a/tests/test_display_recovery.py b/tests/test_display_recovery.py new file mode 100644 index 0000000..fb52210 --- /dev/null +++ b/tests/test_display_recovery.py @@ -0,0 +1,71 @@ +from pathlib import Path +import unittest + +from display_automation import DisplayProgram, DisplayRuntime + + +ROOT = Path(__file__).resolve().parents[1] + + +class LateBusyRecoveryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.programs = {profile: DisplayProgram(ROOT, profile) for profile in ("api", "mqtt")} + + @classmethod + def tearDownClass(cls): + for program in cls.programs.values(): + program.close() + + def runtimes(self, busy): + for profile, program in self.programs.items(): + for reverse in (False, True): + runtime = DisplayRuntime(program, busy, reverse) + runtime.state.busy = busy(0) + yield runtime + + def test_late_permanent_high_reaches_emergency_sleep(self): + for runtime in self.runtimes(lambda ms: int(ms >= 500)): + runtime.execute("enter_deep_sleep") + runtime.run_until(34000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + self.assertEqual(runtime.state.reset_low_count, 1) + self.assertEqual(runtime.state.reset_high_count, 1) + self.assertIn("recover_display_busy", [e[2] for e in runtime.events if e[1] == "script.execute"]) + + def test_late_transient_high_resumes_safe_sleep(self): + for runtime in self.runtimes(lambda ms: int(500 <= ms < 2000)): + runtime.execute("enter_deep_sleep") + runtime.run_until(5000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.sleep[1], 3600000) + self.assertEqual(runtime.state.display_busy_fault_count, 0) + self.assertLess(runtime.sleep[0], 4000) + + def test_recovery_to_sleep_race_returns_to_recovery(self): + for runtime in self.runtimes(lambda ms: int(500 <= ms < 2000 or ms >= 3000)): + runtime.execute("enter_deep_sleep") + runtime.run_until(37000) + starts = [t for t, key, name in runtime.events + if key == "script.execute" and name == "recover_display_busy"] + self.assertEqual(starts, [1000, 3500]) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + + def test_normal_sleep_preserves_battery_and_storage_priority(self): + for runtime in self.runtimes(lambda ms: 0): + for low_battery, storage, duration in ((False, False, 3600000), + (False, True, 86400000), + (True, True, 86400000)): + trial = DisplayRuntime(runtime.program) + trial.state.low_batt_active = low_battery + trial.state.storage_active = storage + trial.execute("enter_deep_sleep") + trial.run_until(2000) + self.assertEqual(trial.sleep, (1000, duration)) + self.assertEqual(trial.state.display_busy_fault_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_display_recovery_budget.py b/tests/test_display_recovery_budget.py new file mode 100644 index 0000000..e80166d --- /dev/null +++ b/tests/test_display_recovery_budget.py @@ -0,0 +1,126 @@ +from pathlib import Path +import unittest + +from display_automation import DisplayProgram, DisplayRuntime + + +ROOT = Path(__file__).resolve().parents[1] + + +class RecoveryBudgetTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.programs = {profile: DisplayProgram(ROOT, profile) for profile in ("api", "mqtt")} + + @classmethod + def tearDownClass(cls): + for program in cls.programs.values(): + program.close() + + def runtimes(self, busy): + for program in self.programs.values(): + for reverse in (False, True): + for refresh_index in range(len(program.refresh_sequences)): + runtime = DisplayRuntime(program, busy, reverse) + runtime.refresh_index = refresh_index + runtime.state.busy = busy(0) + runtime.state.display_safe_to_sleep = False + yield runtime + + def test_repeated_low_then_high_during_sleep_delay_has_one_budget(self): + # Every LOW lasts long enough to pass the 500ms guard, but HIGH returns + # before the final 1s sleep delay finishes. Each individual wait succeeds. + for runtime in self.runtimes(lambda ms: int(ms % 2000 >= 600)): + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNotNone(runtime.sleep) + self.assertLessEqual(runtime.sleep[0], 31530) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + self.assertEqual(runtime.state.reset_low_count, 1) + self.assertEqual(runtime.state.reset_high_count, 1) + self.assertFalse([event for event in runtime.events if event[1] == "component.update"]) + + def test_permanent_high_late_low_and_guard_oscillation_terminate(self): + signals = (lambda ms: 1, + lambda ms: int(ms < 29900), + lambda ms: int(ms % 600 >= 300)) + for busy in signals: + for runtime in self.runtimes(busy): + runtime.execute("recover_display_busy") + runtime.run_until(33000) + self.assertIsNotNone(runtime.sleep) + self.assertLessEqual(runtime.sleep[0], 31530) + self.assertEqual(runtime.state.display_busy_fault_count, 1) + + def test_terminal_sleep_retains_distinct_battery_and_storage_durations(self): + for runtime in self.runtimes(lambda ms: 1): + old = runtime.program.substitutions["low_batt_sleep_duration"] + runtime.program.substitutions["low_batt_sleep_duration"] = "12h" + try: + for battery, storage, duration in ((False, False, 3600000), + (False, True, 86400000), + (True, True, 43200000)): + trial = DisplayRuntime(runtime.program, lambda ms: 1) + trial.state.busy = 1 + trial.state.display_safe_to_sleep = False + trial.state.low_batt_active = battery + trial.state.storage_active = storage + trial.execute("recover_display_busy") + trial.run_until(33000) + self.assertIsNotNone(trial.sleep) + self.assertEqual(trial.sleep[1], duration) + finally: + runtime.program.substitutions["low_batt_sleep_duration"] = old + + def test_new_refresh_cancels_recovery_guard_sleep_and_terminal_work(self): + # Cancel across wait, recovery guard, final sleep delay, reset pulse and + # fault notification. A later LOW completes the new settle normally. + stages = ((100, lambda ms: 1), + (250, lambda ms: 0), + (2000, lambda ms: int(ms < 1000)), + (30000, lambda ms: 1), + (30200, lambda ms: 1), + (30800, lambda ms: 1)) + for cancel_at, signal in stages: + for runtime in self.runtimes(signal): + runtime.execute("recover_display_busy") + runtime.run_until(cancel_at) + # A fresh refresh asserts BUSY, then clears within its own wait. + runtime.busy = lambda ms: int(ms < cancel_at + 1000) + runtime.state.busy = 1 + runtime.refresh(runtime.refresh_index) + runtime.run_until(cancel_at + 4000) + self.assertIsNone(runtime.sleep) + self.assertTrue(runtime.state.display_safe_to_sleep) + self.assertFalse(runtime.tasks) + self.assertEqual(runtime.state.reset_low_count, runtime.state.reset_high_count) + + def test_new_refresh_starts_a_fresh_budget(self): + for runtime in self.runtimes(lambda ms: 1): + runtime.execute("recover_display_busy") + runtime.run_until(1000) + runtime.busy = lambda ms: int(ms < 2000) + runtime.refresh(runtime.refresh_index) + 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(36000) + self.assertIsNotNone(runtime.sleep) + self.assertGreaterEqual(runtime.sleep[0], 35010) + self.assertLessEqual(runtime.sleep[0], 35530) + + def test_successful_recovery_cancels_budget_before_normal_sleep(self): + for runtime in self.runtimes(lambda ms: int(ms < 1000)): + runtime.execute("recover_display_busy") + runtime.run_until(4000) + self.assertIsNotNone(runtime.sleep) + self.assertEqual(runtime.state.display_busy_fault_count, 0) + self.assertNotIn("display_recovery_budget", runtime.tasks) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_low_battery.py b/tests/test_low_battery.py index 5b6e9a4..874fa6c 100644 --- a/tests/test_low_battery.py +++ b/tests/test_low_battery.py @@ -43,6 +43,8 @@ def resolve(source, overrides=None): 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 }; @@ -129,8 +131,8 @@ def resolve(source, overrides=None): class LowBatteryTests(unittest.TestCase): def source(self, overrides=None): - evaluation = SCRIPTS["render_and_sleep"]["then"][0]["lambda"] - selection = SCRIPTS["render_and_sleep"]["then"][1]["display.page.show"] + 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"])): @@ -166,11 +168,11 @@ def test_latch_is_persistent_and_core_has_no_transport_policy(self): self.assertTrue(latch["restore_value"]) self.assertEqual(latch["initial_value"], "false") self.assertNotIn("mqtt", PACKAGE) - self.assertNotIn("storage_active", CORE.read_text()) + 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": "render_and_sleep"}, SCRIPTS["acquire_normal"]["then"]) + self.assertIn({"script.execute": "decide_sleep"}, SCRIPTS["acquire_normal"]["then"]) def test_normal_and_emergency_sleep_keep_confirmation_and_battery_duration(self): paths = [] @@ -199,7 +201,9 @@ def find(value): 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}"}}]) - self.assertEqual(decision["else"], [{"deep_sleep.enter": "deep_sleep_control"}]) + 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__": diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py new file mode 100644 index 0000000..16156e8 --- /dev/null +++ b/tests/test_maintenance_storage.py @@ -0,0 +1,431 @@ +"""Run the real retained-control YAML and native C++ policy lambdas offline.""" +from pathlib import Path +import math +import unittest + +import yaml + +from display_automation import DisplayProgram, DisplayRuntime, PackageLoader + + +ROOT = Path(__file__).resolve().parents[1] + + +class MaintenanceStorageTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.program = DisplayProgram(ROOT, "mqtt", control=True) + + @classmethod + def tearDownClass(cls): + cls.program.close() + + def runtime(self, healthy_display=True): + runtime = DisplayRuntime(self.program) + def busy(ms): + updates = [t for t, key, value in runtime.events + if key == "component.update" and value == "my_display"] + return int(bool(updates) and healthy_display and 50 <= ms - updates[-1] < 350) + runtime.busy = busy + runtime.state.boot_sequence_complete = True + return runtime + + def publications(self, runtime, entity): + return [(t, value["state"]) for t, key, value in runtime.events + if key == "text_sensor.template.publish" and value["id"] == entity] + + def updates(self, runtime): + return [value for _, key, value in runtime.events if key == "component.update"] + + def test_admission_finite_threshold_and_missing_battery(self): + for value, available, expected in [ + (49.999, True, "REJECTED_LOW_BATTERY"), (50, True, "ON"), + (80, True, "ON"), (80, False, "BATTERY_UNAVAILABLE"), + (math.nan, True, "BATTERY_UNAVAILABLE"), (math.inf, True, "BATTERY_UNAVAILABLE"), + (-math.inf, True, "BATTERY_UNAVAILABLE"), + ]: + with self.subTest(value=value, available=available): + runtime = self.runtime() + runtime.state.batpercent.state = value + runtime.state.batpercent.available = available + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(30000) + states = [state for _, state in self.publications(runtime, "maintenance_status")] + self.assertIn(expected, states) + self.assertEqual(runtime.state.maintenance_active, expected == "ON") + if expected != "ON": + self.assertNotIn("ON", states) + self.assertFalse(runtime.state.maintenance_requested) + self.assertIsNotNone(runtime.sleep) + + def test_both_requests_choose_maintenance_before_storage(self): + runtime = self.runtime() + runtime.state.storage_requested = True + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + self.assertTrue(runtime.state.maintenance_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_maintenance) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertFalse(runtime.state.storage_active) + self.assertIsNone(runtime.sleep) + + def test_daily_storage_boot_only_samples_battery_and_preserves_long_sleep(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.execute("boot") + runtime.run_until(5000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertEqual(runtime.sleep[1], 86400000) + self.assertFalse(runtime.state.storage_display_refresh_pending) + + def test_storage_persists_when_broker_is_offline(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.mqtt_connected = False + runtime.execute("boot") + runtime.run_until(36000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_rejected_maintenance_falls_back_to_storage_and_keeps_rejection(self): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + runtime.state.storage_requested = True + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(5000) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertIn((260, "REJECTED_LOW_BATTERY"), self.publications(runtime, "maintenance_status")) + self.assertNotIn("OFF", [state for _, state in self.publications(runtime, "maintenance_status")]) + + def test_busylow_without_observed_assertion_never_acknowledges_ota(self): + runtime = self.runtime(healthy_display=False) + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(6000) + states = [state for _, state in self.publications(runtime, "maintenance_status")] + self.assertIn("DISPLAY_REFRESH_FAILED", states) + self.assertNotIn("ON", states) + self.assertIsNotNone(runtime.sleep) + + def test_ack_follows_real_busy_cycle(self): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + published = self.publications(runtime, "maintenance_status") + self.assertEqual([state for _, state in published], ["ON"]) + refresh_at = next(t for t, key, value in runtime.events + if key == "component.update" and value == "my_display") + self.assertGreaterEqual(published[0][0], refresh_at + 350 + 500) + + def test_watchdog_and_manual_off_clear_retained_command_and_restore_readings(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + for manual in (False, True): + with self.subTest(manual=manual): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + if manual: + runtime.execute("maintenance_control_off") + runtime.run_until(24000) + self.assertFalse(runtime.state.maintenance_active) + self.assertFalse(runtime.state.maintenance_requested) + self.assertIsNotNone(runtime.sleep) + self.assertNotIn("maintenance_watchdog", runtime.tasks) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + commands = [value for _, key, value in runtime.events if key == "mqtt.publish"] + self.assertTrue(any(v.get("payload") == "OFF" and "/cmd/maintenance" in v["topic"] + and v["qos"] == 1 and v["retain"] for v in commands)) + finally: + self.program.substitutions["maintenance_timeout"] = old + + def test_repeated_on_cancels_old_deadline_before_new_refresh(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2900) + # First watchdog expires after the first settled refresh plus 2s. + # Renew while that deadline is close and before the new settle ends. + runtime.execute("maintenance_control_on") + runtime.run_until(4600) + self.assertTrue(runtime.state.maintenance_active) + self.assertTrue(runtime.state.maintenance_requested) + self.assertNotIn("exit_maintenance", [v for _, k, v in runtime.events if k == "script.execute"]) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], ["ON", "ON"]) + finally: + self.program.substitutions["maintenance_timeout"] = old + + def test_successful_ota_clears_one_shot_without_new_display_refresh(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.maintenance_active = True + runtime.state.maintenance_requested = True + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "1s" + runtime.execute("maintenance_watchdog") + self.program.substitutions["maintenance_timeout"] = old + runtime.execute("ota_end") + runtime.run_until(2100) + self.assertFalse(runtime.state.maintenance_requested) + self.assertFalse(runtime.state.maintenance_active) + self.assertTrue(runtime.state.storage_display_refresh_pending) + self.assertNotIn("my_display", self.updates(runtime)) + self.assertIn("OFF", [s for _, s in self.publications(runtime, "maintenance_status")]) + self.assertNotIn("exit_maintenance", [v for _, k, v in runtime.events if k == "script.execute"]) + + def test_storage_off_reacquires_plant_sensors_before_normal_sleep(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.storage_requested = True + runtime.execute("storage_control_off") + runtime.run_until(24000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertIn("veml7700_sensor", self.updates(runtime)) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_storage_request_during_maintenance_waits_for_manual_exit(self): + runtime = self.runtime() + runtime.state.maintenance_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(2000) + before = len(self.updates(runtime)) + runtime.execute("storage_control_on") + runtime.run_until(2100) + self.assertEqual(len(self.updates(runtime)), before) + self.assertTrue(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(5000) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_storage) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_critical_protection_overrides_storage_page_and_duration(self): + runtime = self.runtime() + runtime.state.batpercent.state = 15 + runtime.state.storage_requested = True + runtime.execute("prepare_cycle") + runtime.run_until(5000) + self.assertTrue(runtime.state.low_batt_active) + self.assertEqual(runtime.state.selected_page, runtime.state.page_low_batt) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_off_cancels_pending_sample_time_wait_and_maintenance_refresh(self): + for at_ms, valid_time in [(100, True), (500, False), (500, True)]: + with self.subTest(at_ms=at_ms, valid_time=valid_time): + runtime = self.runtime() + runtime.state.esptime.valid = valid_time + runtime.execute("maintenance_control_on") + runtime.run_until(at_ms) + runtime.execute("maintenance_control_off") + runtime.state.esptime.valid = True + runtime.run_until(26000) + self.assertFalse(runtime.state.maintenance_requested) + self.assertFalse(runtime.state.maintenance_active) + self.assertNotIn("ON", [s for _, s in self.publications(runtime, "maintenance_status")]) + self.assertIn("aht20_sensor", self.updates(runtime)) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_latest_storage_command_cancels_pending_refresh_and_acquisition(self): + for final_on in (False, True): + with self.subTest(final_on=final_on): + runtime = self.runtime() + runtime.execute("storage_control_on") + runtime.run_until(450) + runtime.execute("storage_control_off") + runtime.run_until(700) + if final_on: + runtime.execute("storage_control_on") + runtime.run_until(26000) + self.assertEqual(runtime.state.storage_active, final_on) + self.assertEqual(runtime.state.selected_page, + runtime.state.page_storage if final_on else runtime.state.page1) + self.assertEqual(runtime.sleep[1], 86400000 if final_on else 3600000) + if final_on: + self.assertNotIn("acquire_normal", runtime.tasks) + + def test_rejection_off_echo_keeps_result_and_does_not_start_another_cycle(self): + for storage in (False, True): + with self.subTest(storage=storage): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + runtime.state.storage_requested = storage + runtime.execute("maintenance_control_on") + runtime.run_until(500) + # A broker forwards our retained cmd/maintenance OFF back to + # the subscribed switch. TemplateSwitch runs OFF even if OFF. + runtime.execute("maintenance_control_off") + runtime.run_until(24000) + self.assertEqual(self.updates(runtime).count("my_display"), 1) + self.assertEqual(self.updates(runtime).count("max17043_id"), 1 if storage else 2) + self.assertEqual(self.updates(runtime).count("soil"), 0 if storage else 5) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["REJECTED_LOW_BATTERY"]) + self.assertEqual(runtime.state.storage_active, storage) + self.assertIsNotNone(runtime.sleep) + + def test_exit_off_echo_preserves_one_full_acquisition(self): + old = self.program.substitutions["maintenance_timeout"] + self.program.substitutions["maintenance_timeout"] = "2s" + try: + for manual in (False, True): + with self.subTest(manual=manual): + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2000) + if manual: + runtime.execute("maintenance_control_off") + runtime.run_until(2100) + else: + runtime.run_until(3300) + self.assertFalse(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(26000) + started = [v for _, k, v in runtime.events if k == "script.execute"] + self.assertEqual(started.count("acquire_normal"), 1) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["ON", "OFF", "OFF"]) + self.assertEqual(runtime.sleep[1], 3600000) + finally: + self.program.substitutions["maintenance_timeout"] = old + + def test_storage_duplicate_on_keeps_pending_acceptance_cycle(self): + runtime = self.runtime() + runtime.execute("storage_control_on") + runtime.run_until(100) + runtime.execute("storage_control_on") + runtime.run_until(500) + runtime.execute("storage_control_on") + runtime.run_until(5000) + self.assertEqual(self.updates(runtime), ["max17043_id", "my_display"]) + self.assertTrue(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 86400000) + + def test_storage_off_echo_keeps_one_acquisition_and_exit_refresh(self): + runtime = self.runtime() + runtime.state.storage_active = True + runtime.state.storage_requested = True + runtime.execute("storage_control_off") + runtime.run_until(100) + runtime.execute("storage_control_off") + runtime.run_until(15100) + # exit_storage has now published retained cmd/storage_mode OFF. + runtime.execute("storage_control_off") + runtime.run_until(34000) + started = [v for _, k, v in runtime.events if k == "script.execute"] + self.assertEqual(started.count("acquire_normal"), 1) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("my_display"), 1) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_storage_changes_and_duplicates_during_maintenance_keep_latest_request(self): + runtime = self.runtime() + runtime.execute("maintenance_control_on") + runtime.run_until(2000) + before = list(self.updates(runtime)) + for command in ("on", "on", "off", "off"): + runtime.execute(f"storage_control_{command}") + self.assertEqual(self.updates(runtime), before) + self.assertFalse(runtime.state.storage_requested) + self.assertTrue(runtime.state.maintenance_active) + runtime.execute("maintenance_control_off") + runtime.run_until(26000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(runtime.sleep[1], 3600000) + + def test_rejected_maintenance_acquires_initially_missing_plant_states(self): + runtime = self.runtime() + runtime.state.batpercent.state = 40 + for name in ("temp", "hum", "light", "soil"): + sensor = getattr(runtime.state, name) + sensor.available = False + sensor.state = math.nan + readings = {"aht20_sensor": {"temp": 21, "hum": 45}, + "veml7700_sensor": {"light": 123}, "soil": {"soil": 42}} + def update(component, state): + for name, value in readings.get(component, {}).items(): + sensor = getattr(state, name) + sensor.state = value + sensor.available = True + runtime.sensor_update = update + runtime.execute("maintenance_control_on") + runtime.run_until(500) + runtime.execute("maintenance_control_off") # retained rejection echo + runtime.run_until(26000) + for name, value in {"temp": 21, "hum": 45, "light": 123, "soil": 42}.items(): + self.assertTrue(getattr(runtime.state, name).available) + self.assertEqual(getattr(runtime.state, name).state, value) + self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(runtime.state.selected_page, runtime.state.page1) + self.assertEqual(runtime.sleep[1], 3600000) + self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], + ["REJECTED_LOW_BATTERY"]) + + def test_topic_contract_persistence_and_transport_separation(self): + packages = ROOT / "examples/multi-device/packages" + mqtt = yaml.load((packages / "smart_plant_profile_mqtt.yaml").read_text(), PackageLoader) + core = yaml.load((packages / "smart_plant_core.yaml").read_text(), PackageLoader) + for switch in mqtt["switch"]: + self.assertEqual(switch["subscribe_qos"], 1) + self.assertEqual(switch["qos"], 1) + self.assertTrue(switch["retain"] and switch["command_retain"]) + self.assertIn("/cmd/", switch["command_topic"]) + for sensor in mqtt["text_sensor"]: + self.assertEqual(sensor["qos"], 1) + self.assertTrue(sensor["retain"]) + self.assertIn("/status/", sensor["state_topic"]) + for package, name in ((core, "storage_active"), (mqtt, "storage_display_refresh_pending")): + self.assertTrue(next(g for g in package["globals"] if g["id"] == name)["restore_value"]) + self.assertNotIn("mqtt", core) + self.assertNotIn("mqtt.publish", (packages / "smart_plant_profile_api.yaml").read_text()) + names = [s["name"] for s in mqtt["switch"] + mqtt["text_sensor"]] + self.assertEqual(len(names), len(set(names))) + + +class APIStorageIsolationTests(unittest.TestCase): + def test_restored_mqtt_storage_cannot_change_api_page_or_sleep(self): + program = DisplayProgram(ROOT, "api", control=True) + try: + for battery, latched, expected_low in [(80, False, False), (80, True, False), + (20, True, True), (math.nan, True, True)]: + with self.subTest(battery=battery, latched=latched): + runtime = DisplayRuntime(program) + runtime.state.storage_active = True + runtime.state.low_batt_active = latched + runtime.state.batpercent.state = battery + def busy(ms): + updates = [t for t, k, v in runtime.events + if k == "component.update" and v == "my_display"] + return int(bool(updates) and 50 <= ms - updates[-1] < 350) + runtime.busy = busy + runtime.execute("boot") + runtime.run_until(30000) + self.assertFalse(runtime.state.storage_active) + self.assertEqual(runtime.state.low_batt_active, expected_low) + self.assertEqual(runtime.state.selected_page, + runtime.state.page_low_batt if expected_low else runtime.state.page1) + self.assertEqual(runtime.sleep[1], 86400000 if expected_low else 3600000) + finally: + program.close() + + +if __name__ == "__main__": + unittest.main() From c5ea999cbf647d3dadc1f8ddab38a959493ceb65 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 10:48:13 +0200 Subject: [PATCH 18/23] test: adapt mode checks to merged soil diagnostics and portable libraries --- tests/test_maintenance_storage.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_maintenance_storage.py b/tests/test_maintenance_storage.py index 16156e8..d940ecf 100644 --- a/tests/test_maintenance_storage.py +++ b/tests/test_maintenance_storage.py @@ -195,7 +195,7 @@ def test_storage_off_reacquires_plant_sensors_before_normal_sleep(self): self.assertEqual(runtime.state.selected_page, runtime.state.page1) self.assertIn("aht20_sensor", self.updates(runtime)) self.assertIn("veml7700_sensor", self.updates(runtime)) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.sleep[1], 3600000) def test_storage_request_during_maintenance_waits_for_manual_exit(self): @@ -238,7 +238,7 @@ def test_off_cancels_pending_sample_time_wait_and_maintenance_refresh(self): self.assertFalse(runtime.state.maintenance_active) self.assertNotIn("ON", [s for _, s in self.publications(runtime, "maintenance_status")]) self.assertIn("aht20_sensor", self.updates(runtime)) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.sleep[1], 3600000) def test_latest_storage_command_cancels_pending_refresh_and_acquisition(self): @@ -273,7 +273,7 @@ def test_rejection_off_echo_keeps_result_and_does_not_start_another_cycle(self): runtime.run_until(24000) self.assertEqual(self.updates(runtime).count("my_display"), 1) self.assertEqual(self.updates(runtime).count("max17043_id"), 1 if storage else 2) - self.assertEqual(self.updates(runtime).count("soil"), 0 if storage else 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 0 if storage else 5) self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], ["REJECTED_LOW_BATTERY"]) self.assertEqual(runtime.state.storage_active, storage) @@ -298,7 +298,7 @@ def test_exit_off_echo_preserves_one_full_acquisition(self): runtime.run_until(26000) started = [v for _, k, v in runtime.events if k == "script.execute"] self.assertEqual(started.count("acquire_normal"), 1) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], ["ON", "OFF", "OFF"]) self.assertEqual(runtime.sleep[1], 3600000) @@ -330,7 +330,7 @@ def test_storage_off_echo_keeps_one_acquisition_and_exit_refresh(self): runtime.run_until(34000) started = [v for _, k, v in runtime.events if k == "script.execute"] self.assertEqual(started.count("acquire_normal"), 1) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(self.updates(runtime).count("my_display"), 1) self.assertFalse(runtime.state.storage_active) self.assertEqual(runtime.sleep[1], 3600000) @@ -348,7 +348,7 @@ def test_storage_changes_and_duplicates_during_maintenance_keep_latest_request(s runtime.execute("maintenance_control_off") runtime.run_until(26000) self.assertFalse(runtime.state.storage_active) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.sleep[1], 3600000) def test_rejected_maintenance_acquires_initially_missing_plant_states(self): @@ -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": {"soil": 42}} + "veml7700_sensor": {"light": 123}, "soil_voltage": {"soil": 42}} def update(component, state): for name, value in readings.get(component, {}).items(): sensor = getattr(state, name) @@ -373,7 +373,7 @@ def update(component, state): for name, value in {"temp": 21, "hum": 45, "light": 123, "soil": 42}.items(): self.assertTrue(getattr(runtime.state, name).available) self.assertEqual(getattr(runtime.state, name).state, value) - self.assertEqual(self.updates(runtime).count("soil"), 5) + self.assertEqual(self.updates(runtime).count("soil_voltage"), 5) self.assertEqual(runtime.state.selected_page, runtime.state.page1) self.assertEqual(runtime.sleep[1], 3600000) self.assertEqual([s for _, s in self.publications(runtime, "maintenance_status")], From 086faa36501c68b0d36172cc3084745b27546cc6 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 17:36:05 +0200 Subject: [PATCH 19/23] fix: reconcile maintenance/storage modes with the shared display recovery Drop the globals duplicated by the #31 merge. Let the parity test accept the multi-device extensions (fault hook, counter, logs, MQTT status) and the per-profile sleep_after_display. Bound the flow tests by each profile's on_display_fault delay. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fn1KdJJTY7LedVqZ7SxcA --- .../packages/smart_plant_core.yaml | 8 ---- tests/test_standalone_display.py | 48 +++++++++++++++---- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index 1ff6260..e18286c 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -82,14 +82,6 @@ globals: type: int restore_value: false initial_value: '-1' - - id: display_settle_ok - type: bool - restore_value: false - initial_value: 'false' - - id: display_reset_pending - type: bool - restore_value: false - initial_value: 'false' - id: display_busy_fault_count type: int restore_value: true diff --git a/tests/test_standalone_display.py b/tests/test_standalone_display.py index 3dbe1f7..b4191a8 100644 --- a/tests/test_standalone_display.py +++ b/tests/test_standalone_display.py @@ -123,6 +123,8 @@ def test_out_of_range_text_stays_raw_and_needles_match_endpoints(self): 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, profile=cls.profile, standalone=cls.standalone) @@ -166,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"]) @@ -196,10 +198,10 @@ def test_new_refresh_starts_a_fresh_recovery_budget(self): runtime.execute("recover_display_busy") runtime.run_until(33000) self.assertIsNone(runtime.sleep) - runtime.run_until(35000) + 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.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), @@ -242,16 +244,42 @@ def test_normal_and_fault_sleep_preserve_configurable_protection_duration(self): 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) - core_path = ROOT / "examples/multi-device/packages/smart_plant_core.yaml" - core = yaml.load(core_path.read_text(encoding="utf-8"), PackageLoader) public_scripts = {script["id"]: script for script in public["script"]} - core_scripts = {script["id"]: script for script in core["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", "sleep_after_display"): + "display_recovery_budget", "handle_display_busy_fault"): with self.subTest(script=name): - self.assertEqual(core_scripts[name], public_scripts[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): @@ -261,11 +289,13 @@ class StandaloneFlowTests(DisplayFlowScenarios, unittest.TestCase): 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__": From 453f95d617c416bb535122d44b7eaf95920972f5 Mon Sep 17 00:00:00 2001 From: flowcool Date: Tue, 20 Oct 2026 22:23:41 +0200 Subject: [PATCH 20/23] feat: generate generic label bands from explicit scales and inputs --- examples/multi-device/README.md | 6 + .../label_maker/LICENSE.Label-maker.txt | 21 +++ examples/multi-device/label_maker/README.md | 58 +++++++ .../multi-device/label_maker/example.yaml | 19 +++ .../label_maker/gauge-contract.yaml | 39 +++++ .../multi-device/label_maker/requirements.txt | 3 + scripts/smartplant_gauge_contract.py | 56 +++++++ scripts/smartplant_label_maker.py | 141 ++++++++++++++++++ tests/test_generic_labels.py | 97 ++++++++++++ tests/test_label_packages.py | 48 ++++++ 10 files changed, 488 insertions(+) create mode 100644 examples/multi-device/label_maker/LICENSE.Label-maker.txt create mode 100644 examples/multi-device/label_maker/README.md create mode 100644 examples/multi-device/label_maker/example.yaml create mode 100644 examples/multi-device/label_maker/gauge-contract.yaml create mode 100644 examples/multi-device/label_maker/requirements.txt create mode 100644 scripts/smartplant_gauge_contract.py create mode 100644 scripts/smartplant_label_maker.py create mode 100644 tests/test_generic_labels.py create mode 100644 tests/test_label_packages.py diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index bf5b900..93f9dcf 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -136,3 +136,9 @@ 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. 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 new file mode 100644 index 0000000..7a4eecd --- /dev/null +++ b/examples/multi-device/label_maker/README.md @@ -0,0 +1,58 @@ +# Generic e-paper label generator + +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`. + +`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. + +Run from the repository root in a Python virtual environment: + +```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 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. + +With ESPHome 2026.9.0 installed, also run: + +```sh +python3 -m unittest discover -s tests -p test_label_packages.py -v +``` + +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/gauge-contract.yaml b/examples/multi-device/label_maker/gauge-contract.yaml new file mode 100644 index 0000000..c9b1b33 --- /dev/null +++ b/examples/multi-device/label_maker/gauge-contract.yaml @@ -0,0 +1,39 @@ +size: +- 296 +- 128 +angle_degrees: 270 +radius: 22 +arc_thickness: 5 +metrics: + moisture: + position: + - 80 + - 50 + label: SOIL + scale: + - 0 + - 100 + illuminance: + position: + - 134 + - 70 + label: LUX + scale: + - 0 + - 3775 + temperature: + position: + - 188 + - 50 + label: TEMP + scale: + - -10 + - 50 + humidity: + position: + - 242 + - 70 + label: RH + scale: + - 20 + - 80 diff --git a/examples/multi-device/label_maker/requirements.txt b/examples/multi-device/label_maker/requirements.txt new file mode 100644 index 0000000..2be7876 --- /dev/null +++ b/examples/multi-device/label_maker/requirements.txt @@ -0,0 +1,3 @@ +Pillow==12.3.0 +pycairo==1.29.1 +PyYAML==6.0.3 diff --git a/scripts/smartplant_gauge_contract.py b/scripts/smartplant_gauge_contract.py new file mode 100644 index 0000000..1cfaf1d --- /dev/null +++ b/scripts/smartplant_gauge_contract.py @@ -0,0 +1,56 @@ +"""Explicit label geometry and display scales, without fleet-owned data.""" +import math +from pathlib import Path + +import yaml + +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, 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 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 new file mode 100644 index 0000000..0fae5c5 --- /dev/null +++ b/scripts/smartplant_label_maker.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""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. +""" + +# 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, resolve_metrics, substitutions +else: + 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) + +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) + 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) + 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, config): + metrics = resolve_metrics(config) + surface = cairo.ImageSurface(cairo.FORMAT_RGB24, *SIZE) + ctx = cairo.Context(surface) + ctx.set_source_rgb(1, 1, 1) + 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)) + 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(): + 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__": + main() 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() From 998270c6df59a5d546dca2c234bb43e3031d2aa9 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 10:48:14 +0200 Subject: [PATCH 21/23] ci: install pinned label renderer dependencies for unit tests --- .github/workflows/firmware-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/firmware-tests.yml b/.github/workflows/firmware-tests.yml index cf4c368..8a859f9 100644 --- a/.github/workflows/firmware-tests.yml +++ b/.github/workflows/firmware-tests.yml @@ -24,6 +24,12 @@ jobs: - 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 generator dependencies + run: pip install -r examples/multi-device/label_maker/requirements.txt + - name: Unit tests run: python -B -m unittest discover -s tests -v From 8278f14488b6da88691bd8b292e52e5825ebeb91 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 10:32:33 +0200 Subject: [PATCH 22/23] docs: refresh project entry points and first-install guides --- README.md | 116 +++++++++++----- docs/source/faq.rst | 176 ++++++++++++++++--------- docs/source/getting_started.rst | 22 +++- docs/source/index.rst | 76 ++++++----- docs/source/labels.rst | 117 ++++++++++++++++ docs/source/programming.rst | 227 ++++++++++++++++++-------------- 6 files changed, 499 insertions(+), 235 deletions(-) create mode 100644 docs/source/labels.rst diff --git a/README.md b/README.md index 38fa19f..3fb437e 100644 --- a/README.md +++ b/README.md @@ -1,45 +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 keeps a small configuration +for its identity, credentials, calibration and label image. -
  • 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 using the - native ESPHome API. -- The [multi-device example](./examples/multi-device/my-lemon-tree.yaml) and - [shared core](./examples/multi-device/packages/smart_plant_core.yaml) - demonstrate a reusable configuration with selectable - [MQTT](./examples/multi-device/packages/smart_plant_profile_mqtt.yaml) or - [native-API](./examples/multi-device/packages/smart_plant_profile_api.yaml) - transport. +- [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/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..2222555 --- /dev/null +++ b/docs/source/labels.rst @@ -0,0 +1,117 @@ +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 your first installation, keep the supplied lemon-tree image. To use another +ready-made label, browse the +`plant examples `_ +and download the generated ``*_label_page_1.png`` file. + +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 + python -c "from Plants_epaper import main; main('examples/Lemon_tree/Lemon_tree.json', simulation=False, page=1)" + +This writes ``examples/Lemon_tree/Lemon_tree_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 it, copy the lemon-tree JSON to a new filename in the same +example directory. Edit ``title.value`` and ``subtitle.value``. Set +``image.path`` to your illustration if needed. Then run the command again with +your JSON filename. 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 supplied firmware and Label-maker examples do not use identical scales for +all gauges. Before using a generated background, 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 23007b3..b1154f2 100644 --- a/docs/source/programming.rst +++ b/docs/source/programming.rst @@ -1,140 +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 - -.. _flashing: - -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). +.. _programming-esphome: -.. Caution:: - When flashing the board, make sure its only powered by the USB/Serial port. +1. Prepare ESPHome +------------------ -.. _programming-esphome: +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. -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. +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. -.. Hint:: - For using ESPHome, and all its funcionalities, you need to have a `Home Assistant `_ instance running - in the same network as your |Product|. +2. Configure your plant +----------------------- - -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 `_: +Edit these values in your copy: -.. 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. +* ``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. +Add your network credentials to the existing ``wifi:`` block. Keep its other +settings. Do not add a second ``wifi:`` block. -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. +.. code-block:: yaml -.. figure:: images/getting_started/esphome_1.png - :align: center - :figwidth: 400px + wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + # Keep the example's fast_connect, power_save_mode and ap settings here. -4. Open the recently created file and replace the content with the example configuration ``smart-plant.yaml`` +Put the matching values in ``secrets.yaml`` next to your device file: -.. Note:: You might need to keep the encription keys *OTA* and *API* +.. code-block:: yaml -.. literalinclude:: files/configuration.yaml - :language: yaml - :linenos: + wifi_ssid: "Your network name" + wifi_password: "Your network password" +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 +`_. -.. Important:: - This example performs one bounded measurement/display cycle and then enters - deep sleep. Battery charge does not keep it awake for OTA. To install an - update, power the board through USB-C or start the upload during its short - awake window. Deployments that need retained telemetry while devices sleep - can use the MQTT package under ``examples/multi-device``. +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. -.. 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. +.. _flashing: - 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 - when cell activity is low and wakes automatically. Do not call the native - component's ``sleep_mode`` action for a MAX17048 unless the selected stable - ESPHome release explicitly supports that model: older releases do not set - the required ``MODE.EnSleep`` bit, and cutting the switched sensor rail also - removes the I²C pull-ups needed to communicate with the gauge. +3. Flash through USB +-------------------- +Read :doc:`safety` and :doc:`assembly` first. -.. 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 `_ +.. caution:: + + 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. From ead2840762550538824aef773a0e71515f6c9216 Mon Sep 17 00:00:00 2001 From: flowcool Date: Wed, 21 Oct 2026 10:36:47 +0200 Subject: [PATCH 23/23] docs: provide firmware-matched label input and clarify defaults --- README.md | 4 +- docs/source/files/lemon-tree-label.json | 157 ++++++++++++++++++++++++ docs/source/labels.rst | 38 ++++-- 3 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 docs/source/files/lemon-tree-label.json diff --git a/README.md b/README.md index 3fb437e..e4f4146 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ The firmware draws the live readings over that image. | 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. | The [multi-device guide](examples/multi-device/README.md) explains the shared -hardware core and transport profiles. Each device keeps a small configuration -for its identity, credentials, calibration and label image. +hardware core and transport profiles. Each device sets its identity and +credentials. Calibration and label images use shared defaults unless overridden. ## Explore the hardware 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/labels.rst b/docs/source/labels.rst index 2222555..73d48d7 100644 --- a/docs/source/labels.rst +++ b/docs/source/labels.rst @@ -13,10 +13,13 @@ measurements and gauge needles over that image. Start with an existing background --------------------------------- -For your first installation, keep the supplied lemon-tree image. To use another -ready-made label, browse the +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 download the generated ``*_label_page_1.png`` file. +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 @@ -43,17 +46,29 @@ Activate the environment with the command for your operating system, then run: python -m pip install numpy pycairo cd 1_Plants - python -c "from Plants_epaper import main; main('examples/Lemon_tree/Lemon_tree.json', simulation=False, page=1)" -This writes ``examples/Lemon_tree/Lemon_tree_label_page_1.png``. +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 it, copy the lemon-tree JSON to a new filename in the same -example directory. Edit ``title.value`` and ``subtitle.value``. Set -``image.path`` to your illustration if needed. Then run the command again with -your JSON filename. The output takes the same filename stem. +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 @@ -62,8 +77,9 @@ are separate from the Google Fonts fetched by ESPHome during compilation. Keep the gauges consistent -------------------------- -The supplied firmware and Label-maker examples do not use identical scales for -all gauges. Before using a generated background, compare each JSON +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: