From 0b0f64cbef2338d545408c2d9728c3e96bbe68cb Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 15:31:16 +0100 Subject: [PATCH 01/27] =?UTF-8?q?docs:=20add=20design=20for=20Whisperer=20?= =?UTF-8?q?=E2=86=94=20zone=20pairing=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for the per-zone Whisperer pairing feature: new linkedWhispererDeviceId pluginProp + dropdown in zone ConfigUI, new moistureForecast state that always holds Netro's /moistures.json prediction, and a 12h staleness threshold controlling whether zone moisture reflects the paired Whisperer or falls back to the forecast. Single writer: the zone update loop pulls Whisperer state out of Indigo's device DB and resolves moisture source on each cycle. The Whisperer update loop is unchanged. Transition-aware logging avoids log spam. No Netro API changes — pairing lives entirely in plugin-side state. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-04-23-whisperer-zone-pairing-design.md | 331 ++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 docs/plans/2026-04-23-whisperer-zone-pairing-design.md diff --git a/docs/plans/2026-04-23-whisperer-zone-pairing-design.md b/docs/plans/2026-04-23-whisperer-zone-pairing-design.md new file mode 100644 index 0000000..442b25a --- /dev/null +++ b/docs/plans/2026-04-23-whisperer-zone-pairing-design.md @@ -0,0 +1,331 @@ +# Whisperer ↔ Zone pairing — design + +**Issue:** [#54](https://github.com/simons-plugins/netro-indigo/issues/54) +**Date:** 2026-04-23 +**Status:** Accepted + +## Problem + +Netro's `/moistures.json` returns a **smart-model daily prediction** for each +zone — not the paired Whisperer's sensor reading. The Netro mobile app +overlays the Whisperer reading on the zone tile when one is paired; the +public API does not. Result: the Indigo plugin's zone `moisture` state can +diverge dramatically from a paired Whisperer's actual reading (observed: +zone 89% vs Whisperer 24% on the same zone, same moment). + +The plugin already reads both values correctly, but they land on separate +Indigo devices with no linkage, so control pages, triggers, and schedules +keyed on zone `moisture` use the forecast, not the sensor. + +## Goals + +1. Let the user explicitly pair a Whisperer device to a zone device in the + plugin (no auto-matching). +2. When paired and the Whisperer reading is fresh, zone `moisture` reflects + the real sensor. +3. Keep the Netro forecast observable alongside (for comparison, schedule + tuning, and transparent fallback when the sensor goes stale). +4. No Netro API changes — pairing lives entirely in plugin-side state. + +## Non-goals + +- Auto-pairing by zone/Whisperer name similarity. +- Pairing multiple Whisperers to one zone. +- Writing sensor data back to Netro (we already expose a `set_moisture` + action; that's unchanged). + +## Architecture & data flow + +### State model + +On the zone device: + +- `moisture` (existing, semantics change) — "best available" reading: + Whisperer if paired and fresh, else the Netro forecast. +- `moistureForecast` (new) — always Netro's `/moistures.json` daily + prediction. Exposes the prediction even when a Whisperer overrides + `moisture`, so the user can compare model vs reality and tune schedules. + +A new pluginProp on the zone device: + +- `linkedWhispererDeviceId` — string holding the Indigo device ID of the + paired Whisperer, or empty string (= unpaired). Set via a new dropdown + in the zone ConfigUI. + +### Update flow (per zone, on every zone-update cycle) + +``` +1. Build states from info + schedule data (unchanged). +2. If moistures_response is available: + forecast_val = ZoneHandler.process_zone_moisture(...) + write moistureForecast = forecast_val + Else: + forecast_val = None (don't overwrite moistureForecast this cycle) +3. moisture_val, source = _resolve_zone_moisture(zone_dev, forecast_val): + - No pairing / device missing / disabled → forecast_val, "forecast" + - Paired, fresh (age ≤ 12h) → Whisperer.soilMoisture, "whisperer" + - Paired, stale (age > 12h) → forecast_val, "forecast-stale" +4. If moisture_val is not None: + write moisture = moisture_val, uiValue = "N%" +5. replaceOnServer (unchanged batch write). +``` + +The Whisperer update loop is unchanged — it still writes only its own +device states. No cross-device writes. The zone loop pulls Whisperer +state out of Indigo's device DB when it runs. + +### Design decisions + +| Decision | Choice | Reason | +|---|---|---| +| Fallback when paired-but-stale | Fall back to `moistureForecast` | Matches the Netro app's behavior: if the sensor is gone, schedules use the prediction. User can also unpair on the Netro side to force prediction-only mode. | +| Staleness threshold | 12 hours, hardcoded | Whisperers report every 1–6h depending on battery. 12h = 2–12 missed readings, survives brief outages but catches a dead battery within a day. | +| Who writes `moisture` | Zone update loop pulls from Whisperer device | Single writer, staleness logic in one place. Up to ~10min lag — irrelevant for soil moisture. | +| ConfigUI layout | Separator + section label + dropdown + help text | Matches existing style (Whisperer's `sep_api` pattern). | +| Auto-pair by name | No | Explicit config only — prevents brittle matching and silent surprises. | + +### Netro-side pairing (open empirical question) + +The issue's investigation showed `/moistures.json` returning a +saturation-based prediction (89%) while a working paired Whisperer read +24% at the same moment — suggesting Netro's prediction is independent of +Whisperer pairing on Netro's side. We rely on this: when a Whisperer is +paired in Netro but stops reporting (dead battery, unplugged), we assume +`/moistures.json` keeps producing useful predictions. This should be +dogfooded for a week with a physically disconnected Whisperer before +shipping broadly. Documented in `API_NOTES.md`. + +User workflow if a Whisperer dies: either leave it in place (our 12h +fallback takes over after the last reading ages out), or unpair in the +Netro app to force Netro-side prediction-only mode. Our plugin-side +pairing is separate from Netro-side pairing — both can coexist. + +## Code changes + +### `Devices.xml` — zone device (around line 297) + +Add a new visible section to ``: + +```xml + + + + + + + + + + + +``` + +Add a new state: + +```xml + + Integer + Moisture Forecast (%) + Moisture Forecast (%) + +``` + +`moisture` unchanged — zone tiles keep +showing the "best available" value, which is the right default. + +### `plugin.py` — new dynamic list callback + +```python +def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0): + """Populate linkedWhispererDeviceId dropdown on zone ConfigUI.""" + options = [("", "(Unpaired — use Netro forecast)")] + whisperers = sorted( + (d for d in indigo.devices.iter(filter="self") + if d.deviceTypeId == "Whisperer"), + key=lambda d: d.name.lower(), + ) + options.extend((str(d.id), d.name) for d in whisperers) + return options +``` + +### `plugin.py` — new helper `_resolve_zone_moisture` + +```python +def _resolve_zone_moisture(self, zone_dev, forecast_val): + """Resolve the "moisture" state value for a zone device. + + Returns (value_or_none, source_tag) where source_tag is one of: + "forecast", "whisperer", "forecast-stale", + "forecast-missing-device", "forecast-disabled-device". + """ + linked_id = zone_dev.pluginProps.get("linkedWhispererDeviceId", "") + if not linked_id: + return forecast_val, "forecast" + + try: + whisperer = indigo.devices[int(linked_id)] + except (KeyError, ValueError): + return forecast_val, "forecast-missing-device" + + if not whisperer.enabled: + return forecast_val, "forecast-disabled-device" + + soil = whisperer.states.get("soilMoisture") + reading_time = whisperer.states.get("readingLocalTime", "") + age_hours = _parse_reading_age_hours(reading_time) # util helper + if soil is None or age_hours is None or age_hours > WHISPERER_STALENESS_HOURS: + return forecast_val, "forecast-stale" + + return int(soil), "whisperer" +``` + +### `plugin.py` — `_update_zone_devices` call site + +After the existing moisture-response handling, rename the emitted state +key and layer the resolver on top: + +```python +forecast_val = None +if moisture_response: + forecast_states = self.zone_handler.process_zone_moisture( + moisture_response, zone_num) + for s in forecast_states: + if s["key"] == "moisture": + s["key"] = "moistureForecast" + forecast_val = s["value"] + states.extend(forecast_states) + +moisture_val, source = self._resolve_zone_moisture(zone_dev, forecast_val) +if moisture_val is not None: + states.append({"key": "moisture", "value": moisture_val, + "uiValue": f"{moisture_val}%"}) + self._log_moisture_source_transition(zone_dev, source) +``` + +### `plugin.py` — transition-aware logging + +To avoid log spam, track the last-logged source on the zone device's +pluginProps (`lastMoistureSource`). Only log on transitions between +source categories: + +- `whisperer` → `forecast-stale`: **warning** ("Zone X: Whisperer reading + stale, falling back to Netro forecast"). +- `forecast-stale` → `whisperer`: **info** ("Zone X: Whisperer reading + recovered"). +- `whisperer`/`forecast-*` → `forecast-missing-device` / + `forecast-disabled-device`: **warning** once. +- All others: no log. + +### `constants.py` + +```python +WHISPERER_STALENESS_HOURS = 12 +``` + +### `device_handlers.py` + +No changes. `ZoneHandler.process_zone_moisture` still emits keys named +`"moisture"`; the rename to `"moistureForecast"` happens at the call site +in `_update_zone_devices`, so the handler's unit tests stay stable. + +### `utils.py` (or new helper) + +Add `_parse_reading_age_hours(reading_local_time: str) -> float | None`. +Parses the Whisperer `readingLocalTime` format (check existing Whisperer +code for the format) and returns hours-since-now in the appropriate +local timezone. Returns `None` on parse failure → resolver treats as +stale. + +### Edge cases handled + +- Paired Whisperer device deleted → `KeyError` → forecast + warning. +- Paired Whisperer device disabled → forecast + warning. +- `forecast_val is None` (Netro API error) and Whisperer stale → don't + write `moisture` this cycle; previous value stays. +- `readingLocalTime` unparseable → treated as stale. +- Zone with a Whisperer paired, then the user manually sets `moisture` + via the existing "Override moisture" action — that action still calls + Netro's `set_moisture` API, unrelated to this code path. Next poll + will re-resolve via the new logic. + +## Tests + +New module `tests/test_zone_moisture_resolution.py`: + +1. Unpaired, forecast available → returns forecast, source="forecast". +2. Unpaired, forecast None → returns (None, "forecast"). +3. Paired, Whisperer fresh (age < 12h) → returns Whisperer `soilMoisture`. +4. Paired, Whisperer stale (age > 12h) → returns forecast, warning logged. +5. Paired, Whisperer stale, second call same state → warning NOT re-logged. +6. Paired, transitions fresh → stale → warning logged on transition. +7. Paired, transitions stale → fresh → info log ("sensor recovered"). +8. Paired, Whisperer device deleted → forecast, warning logged once. +9. Paired, Whisperer device disabled → forecast, warning logged once. +10. Paired, `readingLocalTime` unparseable → treated as stale. +11. Paired, forecast also None (Netro down) → returns (None, …), no crash. + +Extend `tests/test_plugin_zone_updates.py` (or equivalent): + +12. E2E: paired fresh Whisperer → zone states include `moisture` (= Whisperer) + AND `moistureForecast` (= Netro). +13. E2E: unpaired zone → `moisture` == `moistureForecast` == Netro val. +14. E2E: missing `moisture_response` → `moistureForecast` not written, + `moisture` set from Whisperer if fresh-paired. + +New test for `getWhispererDevices`: + +15. First entry is `("", "(Unpaired — use Netro forecast)")`, followed + by sorted Whisperer devices. + +Use existing Indigo mock fixtures. Use `freezegun` (or monkeypatched +`datetime.now`) for the 12h age arithmetic. Target 100% branch coverage +on the new helper. + +## Documentation + +1. **`docs/API_NOTES.md` §6** — replace "can be 12–24 hours old" with a + clear statement that `/moistures.json` is Netro's **smart-model + prediction** (post-irrigation saturation + daily decay), not a + sampled sensor value. Can diverge significantly from a paired + Whisperer's reading. Reference the new `moistureForecast` state as + the way to observe the prediction alongside the resolved value. + Note the open empirical question about Netro-side dead-Whisperer + behavior. + +2. **`README.md`** — small note under the Whisperer section explaining + the new per-zone pairing dropdown and the `moisture` vs + `moistureForecast` distinction. + +## Migration / backwards compat + +- Existing zones with no `linkedWhispererDeviceId` prop → + `dict.get(..., "")` returns `""` → unpaired path → identical to + today's behavior. +- First poll after upgrade: `moistureForecast` starts populating. + `moisture` continues to show forecast until user pairs a Whisperer. + No data backfill required. +- `moisture` stays — zone tiles + keep showing the same "primary" field (now resolved). +- No pluginProps schema migration — Indigo handles missing keys + gracefully via `.get()`. + +## Versioning + +User-visible feature (new ConfigUI + new state), so **minor** bump of +`PluginVersion` in `Info.plist`: `YYYY.R.P` → `YYYY.(R+1).0`. + +## Acceptance criteria (from issue #54) + +- [x] Zone device config has a Whisperer dropdown → Devices.xml + + `getWhispererDevices` callback. +- [x] When paired (and fresh), zone `moisture` state mirrors Whisperer's + current reading → `_resolve_zone_moisture`. +- [x] `moistureForecast` state exposes the `/moistures.json` daily value + → new state + call-site key rename. +- [x] Tests covering paired / unpaired / Whisperer unavailable paths → + `tests/test_zone_moisture_resolution.py` + E2E extensions. +- [x] `API_NOTES.md` updated → §6 rewrite. From 189b58fd63faf7a1dd31656e2446f8707c1aa4ce Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 15:36:08 +0100 Subject: [PATCH 02/27] =?UTF-8?q?docs:=20add=20implementation=20plan=20for?= =?UTF-8?q?=20Whisperer=20=E2=86=94=20zone=20pairing=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-by-task TDD plan with 11 bite-sized steps: constant, age-parsing util, Devices.xml changes (new state + ConfigUI dropdown), getWhispererDevices callback, _resolve_zone_moisture pure helper, transition-aware logging, wire-up in _update_zone_devices, doc updates, and final version bump. Uses the existing readingTime state (v1 epoch-millis + v2 ISO-8601) for staleness math rather than the swapped readingLocalTime/ readingLocalDate fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-whisperer-zone-pairing-plan.md | 1357 +++++++++++++++++ 1 file changed, 1357 insertions(+) create mode 100644 docs/plans/2026-04-23-whisperer-zone-pairing-plan.md diff --git a/docs/plans/2026-04-23-whisperer-zone-pairing-plan.md b/docs/plans/2026-04-23-whisperer-zone-pairing-plan.md new file mode 100644 index 0000000..f969c79 --- /dev/null +++ b/docs/plans/2026-04-23-whisperer-zone-pairing-plan.md @@ -0,0 +1,1357 @@ +# Whisperer ↔ Zone Pairing Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add per-zone Whisperer pairing so zone `moisture` reflects the actual sensor reading when a paired Whisperer is fresh (≤12h old), with graceful fallback to Netro's `/moistures.json` forecast. + +**Architecture:** Single writer — the zone update loop pulls the paired Whisperer's current state from Indigo's device DB, checks age, and resolves whether `moisture` comes from the sensor or the forecast. The forecast always lands in a new `moistureForecast` state. The Whisperer update loop is unchanged. + +**Tech Stack:** Python 3.10+, Indigo Plugin SDK (v3/v4), pytest + pytest-cov, pylint. Tests run against mocked `indigo.*` objects. + +**Design doc:** `docs/plans/2026-04-23-whisperer-zone-pairing-design.md` (issue #54) + +**Branch:** `feat/whisperer-zone-pairing` (already created off `origin/main`). + +**Reference files (cross-cutting):** +- Plugin module: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Device definitions: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml` +- Constants: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` +- Utils: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py` +- Handlers: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` +- Info.plist: `Netro Sprinklers.indigoPlugin/Contents/Info.plist` +- Tests conftest: `tests/conftest.py` +- Existing handler tests: `tests/test_device_handlers.py` + +**Conventions:** +- 120-char lines (enforced by pylint in `pyproject.toml`). +- Write failing test → minimal implementation → pass → commit, per task. +- **No squash merges.** Commit each task separately. +- Version bump happens at the end (single commit, minor bump: user-visible feature). +- Use `date -u` for any timestamps you reference in commit messages — don't guess. + +**Test command (shared):** `pytest tests/ -v` from the repo root. Use `-k` to narrow to a specific test during iteration. Full suite must stay green at every commit point. + +**Python interpreter:** use the project's interpreter (honored by pytest). If you need a direct invocation, `python3 -m pytest ...` is fine — the plugin runs on Python 3.10+. + +--- + +## Task 1: Add staleness constant + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` + +**Step 1: Write the failing test** + +Create `tests/test_constants_whisperer.py`: + +```python +"""Tests for Whisperer-specific constants.""" +import constants + + +def test_whisperer_staleness_hours_defined(): + """WHISPERER_STALENESS_HOURS should be defined as a positive integer.""" + assert hasattr(constants, "WHISPERER_STALENESS_HOURS") + assert isinstance(constants.WHISPERER_STALENESS_HOURS, int) + assert constants.WHISPERER_STALENESS_HOURS > 0 + + +def test_whisperer_staleness_hours_value(): + """WHISPERER_STALENESS_HOURS should be 12 hours (2-12 missed readings at 1-6h cadence).""" + assert constants.WHISPERER_STALENESS_HOURS == 12 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_constants_whisperer.py -v` +Expected: FAIL — `AttributeError: module 'constants' has no attribute 'WHISPERER_STALENESS_HOURS'`. + +**Step 3: Write minimal implementation** + +Append to `constants.py` (at end of "Default Values" section, after `TOKEN_WARNING_THRESHOLD`): + +```python +WHISPERER_STALENESS_HOURS: Final[int] = 12 +"""Maximum age (hours) for a Whisperer reading to be considered fresh. + +Whisperers report every 1-6 hours depending on battery level. 12h = 2-12 +missed readings — tolerates brief API outages but catches a dead battery +within a day. When a paired Whisperer reading is older than this, the +zone falls back to Netro's /moistures.json forecast. +""" +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_constants_whisperer.py -v` +Expected: PASS (2 tests). + +Also run the full suite to confirm no regression: `pytest tests/ -v` +Expected: all existing tests still pass. + +**Step 5: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py" tests/test_constants_whisperer.py +git commit -m "feat(netro): add WHISPERER_STALENESS_HOURS constant (#54)" +``` + +--- + +## Task 2: Add reading-age parse utility + +**Context:** Whisperer's `readingTime` state holds the raw `time` field from the API. V1 = epoch millis (e.g. `1234567890000`). V2 = ISO-8601 string (e.g. `"2026-04-07T10:00:00"`). Helper must accept either and return age-in-hours as float, `None` on unparseable input. + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py` +- Create: `tests/test_reading_age.py` + +**Step 1: Write the failing test** + +Create `tests/test_reading_age.py`: + +```python +"""Tests for parse_reading_age_hours utility.""" +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +import utils + + +class TestParseReadingAgeHours: + """Test utils.parse_reading_age_hours across supported input formats.""" + + @pytest.fixture + def fixed_now(self): + """Anchor "now" to a known UTC datetime for deterministic age math.""" + return datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + def test_v2_iso_string_fresh(self, fixed_now): + """ISO-8601 string 3h old → ~3.0 hours.""" + three_hours_ago = (fixed_now - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(three_hours_ago) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v2_iso_string_with_timezone(self, fixed_now): + """ISO-8601 string with Z suffix should be treated as UTC.""" + three_hours_ago = (fixed_now - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(three_hours_ago) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v1_epoch_millis_fresh(self, fixed_now): + """V1 epoch millis 3h old → ~3.0 hours.""" + epoch_ms = int((fixed_now - timedelta(hours=3)).timestamp() * 1000) + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(epoch_ms) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v1_epoch_millis_as_string(self, fixed_now): + """Epoch millis passed as a string should still parse.""" + epoch_ms_str = str(int((fixed_now - timedelta(hours=3)).timestamp() * 1000)) + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(epoch_ms_str) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_stale_reading(self, fixed_now): + """24h-old reading → 24.0 hours (above threshold).""" + one_day_ago = (fixed_now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(one_day_ago) + assert age is not None + assert 23.9 <= age <= 24.1 + + def test_unparseable_string_returns_none(self): + """Garbage input returns None, does not raise.""" + assert utils.parse_reading_age_hours("not-a-timestamp") is None + assert utils.parse_reading_age_hours("unknown") is None + + def test_empty_string_returns_none(self): + """Empty string returns None.""" + assert utils.parse_reading_age_hours("") is None + + def test_none_input_returns_none(self): + """None input returns None.""" + assert utils.parse_reading_age_hours(None) is None + + def test_negative_age_clamped_to_zero(self, fixed_now): + """Future timestamp (clock skew) returns 0.0, never negative.""" + future = (fixed_now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(future) + assert age == 0.0 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_reading_age.py -v` +Expected: FAIL — `AttributeError: module 'utils' has no attribute 'parse_reading_age_hours'`. + +**Step 3: Write minimal implementation** + +Add to `utils.py` (new section at end, above `get_key_from_dict`): + +```python +from datetime import datetime, timezone +from typing import Optional, Union + + +def _now_utc() -> datetime: + """Return current time as a timezone-aware UTC datetime. + + Indirected through a module-level function so tests can patch it + deterministically without depending on freezegun or similar. + """ + return datetime.now(tz=timezone.utc) + + +def parse_reading_age_hours( + reading_time: Union[str, int, float, None] +) -> Optional[float]: + """Compute age (hours) of a Whisperer reading timestamp. + + Accepts both API v1 and v2 timestamp formats emitted by + ``WhispererHandler.process_sensor_data``: + + - **V1 (epoch millis)**: e.g. ``1234567890000`` (int or numeric string) + - **V2 (ISO 8601)**: e.g. ``"2026-04-07T10:00:00"`` or ``"...Z"`` + + Args: + reading_time: Value from the Whisperer ``readingTime`` state. + + Returns: + Age in hours (non-negative float) if parseable, ``None`` otherwise. + Returns ``0.0`` when the reading is in the future (clock skew). + + Note: + V2 ISO strings without an explicit timezone are assumed to be UTC. + Netro's ``time`` field is the sensor's UTC timestamp; ``local_time`` + is the pre-formatted local variant. We intentionally use the UTC + form for age math to avoid DST/tz drift. + """ + if reading_time is None or reading_time == "": + return None + + now = _now_utc() + + # Try ISO 8601 first (covers v2 and any pre-formatted strings). + if isinstance(reading_time, str): + candidate = reading_time.rstrip("Z").strip() + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + parsed = None + else: + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + delta = (now - parsed).total_seconds() / 3600.0 + return max(0.0, delta) + + # Fall through: maybe it's a stringified epoch millis. + try: + reading_time = int(candidate) + except (TypeError, ValueError): + return None + + # Epoch millis (int or float from numeric-string fallthrough above). + if isinstance(reading_time, (int, float)): + try: + seconds = float(reading_time) / 1000.0 + parsed = datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OSError, OverflowError, ValueError): + return None + delta = (now - parsed).total_seconds() / 3600.0 + return max(0.0, delta) + + return None +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_reading_age.py -v` +Expected: PASS (9 tests). + +Full suite: `pytest tests/ -v` +Expected: all existing tests still pass. + +**Step 5: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py" tests/test_reading_age.py +git commit -m "feat(netro): add parse_reading_age_hours for v1 epoch + v2 ISO (#54)" +``` + +--- + +## Task 3: Add `moistureForecast` state to Devices.xml + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml` + +**Note:** `Devices.xml` is not unit-testable in isolation (it's consumed by the Indigo server at runtime). Rely on the XML validity check and downstream plugin tests for coverage. + +**Step 1: Verify current XML is valid, then edit** + +Run: `python3 -c "import xml.etree.ElementTree as ET; ET.parse('Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml'); print('OK')"` +Expected: `OK`. + +**Step 2: Add the new state** + +In `Devices.xml`, inside `` → ``, immediately after the existing `` block (around line 312), insert: + +```xml + + Integer + Moisture Forecast (%) + Moisture Forecast (%) + +``` + +(Keep `moisture` unchanged at the bottom of the zone device block.) + +**Step 3: Verify XML still parses** + +Run: `python3 -c "import xml.etree.ElementTree as ET; t = ET.parse('Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml'); zone = [d for d in t.findall('.//Device') if d.get('id') == 'zone'][0]; states = [s.get('id') for s in zone.findall('.//State')]; print('moisture' in states and 'moistureForecast' in states)"` +Expected: `True`. + +**Step 4: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml" +git commit -m "feat(netro): add moistureForecast state to zone device (#54)" +``` + +--- + +## Task 4: Add zone-device ConfigUI for Whisperer pairing (XML only) + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml` + +**Step 1: Add ConfigUI fields** + +In `Devices.xml`, inside `` → ``, append **after** the existing hidden `zoneNumber` field (around line 305): + +```xml + + + + + + + + + + + +``` + +**Step 2: Verify XML still parses and the callback reference is present** + +Run: `python3 -c "import xml.etree.ElementTree as ET; t = ET.parse('Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml'); zone = [d for d in t.findall('.//Device') if d.get('id') == 'zone'][0]; fields = [f.get('id') for f in zone.findall('.//ConfigUI/Field')]; print('linkedWhispererDeviceId' in fields)"` +Expected: `True`. + +**Step 3: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml" +git commit -m "feat(netro): add Whisperer pairing dropdown to zone ConfigUI (#54)" +``` + +--- + +## Task 5: Implement `getWhispererDevices` callback + +**Context:** Indigo calls this when the zone ConfigUI dropdown is opened. Returns a list of `(value, label)` tuples. First entry is the "unpaired" sentinel (empty string value). + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Create: `tests/test_whisperer_pairing_callback.py` + +**Step 1: Write the failing test** + +Create `tests/test_whisperer_pairing_callback.py`: + +```python +"""Tests for Plugin.getWhispererDevices ConfigUI callback.""" +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_indigo(monkeypatch): + """Install a minimal `indigo` module into sys.modules for plugin import.""" + indigo = MagicMock() + indigo.devices.iter = MagicMock(return_value=iter([])) + monkeypatch.setitem(sys.modules, "indigo", indigo) + return indigo + + +def _fake_device(dev_id, name, type_id="Whisperer"): + return SimpleNamespace(id=dev_id, name=name, deviceTypeId=type_id) + + +def test_returns_unpaired_sentinel_when_no_whisperers(mock_indigo): + """With zero Whisperers installed, returns only the unpaired option.""" + mock_indigo.devices.iter.return_value = iter([]) + # Import after mock is installed. + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) # skip __init__ + result = plugin.getWhispererDevices() + assert result[0] == ("", "(Unpaired — use Netro forecast)") + assert len(result) == 1 + + +def test_returns_whisperers_sorted_by_name(mock_indigo): + """Whisperers are appended, sorted case-insensitively by name.""" + devs = [ + _fake_device(101, "Zebra"), + _fake_device(102, "apple"), + _fake_device(103, "Mango"), + _fake_device(104, "Sprite 8-zone", type_id="Sprite"), # not Whisperer + ] + mock_indigo.devices.iter.return_value = iter(devs) + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + result = plugin.getWhispererDevices() + assert result[0] == ("", "(Unpaired — use Netro forecast)") + assert result[1:] == [("102", "apple"), ("103", "Mango"), ("101", "Zebra")] + + +def test_ignores_non_whisperer_devices(mock_indigo): + """Sprite/Pixie/Spark controllers and zones are excluded.""" + devs = [ + _fake_device(1, "Sprite 8", type_id="Sprite"), + _fake_device(2, "Pixie 12", type_id="Pixie"), + _fake_device(3, "Zone A", type_id="zone"), + _fake_device(4, "Garden Whisperer", type_id="Whisperer"), + ] + mock_indigo.devices.iter.return_value = iter(devs) + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + result = plugin.getWhispererDevices() + whisperer_ids = [r[0] for r in result[1:]] + assert whisperer_ids == ["4"] +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_whisperer_pairing_callback.py -v` +Expected: FAIL — `AttributeError: 'Plugin' object has no attribute 'getWhispererDevices'`. + +**Step 3: Write minimal implementation** + +In `plugin.py`, add the method to the `Plugin` class. Place it near other ConfigUI callbacks — search for an existing `def ...(self, filter="", valuesDict=None, typeId="", targetId=0)` signature to find the right neighbourhood; if none exists, place it immediately before `def _update_zone_devices` (around the helper-methods section): + +```python +def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0): + """Populate the `linkedWhispererDeviceId` dropdown on zone ConfigUI. + + Returns a list of (value, label) tuples: + - First entry: ("", "(Unpaired — use Netro forecast)") sentinel. + - Remaining entries: this plugin's Whisperer devices, sorted + case-insensitively by name. Value is the Indigo device ID as a + string; label is the device name. + + Called by Indigo when the ConfigUI is opened / reloaded. + """ + options = [("", "(Unpaired — use Netro forecast)")] + whisperers = sorted( + (d for d in indigo.devices.iter(filter="self") + if d.deviceTypeId == "Whisperer"), + key=lambda d: d.name.lower(), + ) + options.extend((str(d.id), d.name) for d in whisperers) + return options +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_whisperer_pairing_callback.py -v` +Expected: PASS (3 tests). + +Full suite: `pytest tests/ -v` +Expected: green. + +**Step 5: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py" tests/test_whisperer_pairing_callback.py +git commit -m "feat(netro): add getWhispererDevices ConfigUI callback (#54)" +``` + +--- + +## Task 6: Implement `_resolve_zone_moisture` helper + +**Context:** Pure-ish function — takes a zone device and a forecast value, returns `(resolved_value, source_tag)`. No logging here (that's task 7). No state writes here (call site does that in task 8). + +Source tags: +- `"forecast"` — unpaired. +- `"whisperer"` — paired, fresh reading. +- `"forecast-stale"` — paired, reading too old (> 12h) or no reading. +- `"forecast-missing-device"` — paired id no longer resolves. +- `"forecast-disabled-device"` — paired device exists but is disabled. + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Create: `tests/test_zone_moisture_resolution.py` + +**Step 1: Write the failing test** + +Create `tests/test_zone_moisture_resolution.py`: + +```python +"""Tests for Plugin._resolve_zone_moisture.""" +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_indigo(monkeypatch): + indigo = MagicMock() + # `indigo.devices[id]` lookup; tests install a side_effect dict. + indigo._devices_by_id = {} + + def _getitem(dev_id): + if dev_id not in indigo._devices_by_id: + raise KeyError(dev_id) + return indigo._devices_by_id[dev_id] + + indigo.devices.__getitem__.side_effect = _getitem + monkeypatch.setitem(sys.modules, "indigo", indigo) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + return Plugin.__new__(Plugin) + + +def _fake_whisperer(enabled=True, soil=30, reading_time="2026-04-23T10:00:00"): + return SimpleNamespace( + enabled=enabled, + states={"soilMoisture": soil, "readingTime": reading_time}, + ) + + +def _fake_zone(linked_id=""): + return SimpleNamespace(pluginProps={"linkedWhispererDeviceId": linked_id}) + + +FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + +# --- Unpaired paths --- + +def test_unpaired_returns_forecast(plugin_instance): + zone = _fake_zone(linked_id="") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=55) + assert (val, src) == (55, "forecast") + + +def test_unpaired_forecast_none(plugin_instance): + zone = _fake_zone(linked_id="") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=None) + assert (val, src) == (None, "forecast") + + +# --- Paired, fresh --- + +def test_paired_fresh_returns_whisperer(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (24, "whisperer") + + +# --- Paired, stale --- + +def test_paired_stale_returns_forecast(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=20)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") + + +def test_paired_stale_forecast_also_none(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=20)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=None) + assert (val, src) == (None, "forecast-stale") + + +# --- Paired, Whisperer missing --- + +def test_paired_device_deleted(plugin_instance, mock_indigo): + zone = _fake_zone(linked_id="999") # 999 not in _devices_by_id + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-missing-device") + + +def test_paired_invalid_id(plugin_instance): + zone = _fake_zone(linked_id="not-an-int") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-missing-device") + + +# --- Paired, Whisperer disabled --- + +def test_paired_device_disabled(plugin_instance, mock_indigo): + whisperer = _fake_whisperer(enabled=False, soil=24) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-disabled-device") + + +# --- Paired, unparseable time --- + +def test_paired_unparseable_reading_time(plugin_instance, mock_indigo): + whisperer = _fake_whisperer(reading_time="unknown") + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") + + +# --- Paired, no soilMoisture state --- + +def test_paired_no_soil_state(plugin_instance, mock_indigo): + whisperer = SimpleNamespace( + enabled=True, + states={"readingTime": "2026-04-23T10:00:00"}, # soilMoisture missing + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_zone_moisture_resolution.py -v` +Expected: FAIL — `AttributeError: 'Plugin' object has no attribute '_resolve_zone_moisture'`. + +**Step 3: Write minimal implementation** + +In `plugin.py`, add the method to the `Plugin` class, immediately after `getWhispererDevices`: + +```python +def _resolve_zone_moisture(self, zone_dev, forecast_val): + """Resolve the "moisture" state value for a zone device. + + Pure function (no state writes, no logging). Returns a + ``(value, source_tag)`` pair where source_tag is one of: + + - ``"forecast"``: zone has no paired Whisperer; returns forecast_val. + - ``"whisperer"``: paired Whisperer exists, is enabled, has a fresh + (≤ WHISPERER_STALENESS_HOURS old) ``soilMoisture`` reading. + - ``"forecast-stale"``: paired but reading is missing, too old, or + ``readingTime`` is unparseable. + - ``"forecast-missing-device"``: paired device id does not resolve + to an Indigo device (deleted or invalid id). + - ``"forecast-disabled-device"``: paired device exists but is + disabled in Indigo. + + ``value`` may be ``None`` if forecast_val is None and no Whisperer + value is available; the caller should skip writing ``moisture`` in + that case. + """ + from constants import WHISPERER_STALENESS_HOURS # noqa: WPS433 + from utils import parse_reading_age_hours # noqa: WPS433 + + linked_id = zone_dev.pluginProps.get("linkedWhispererDeviceId", "") + if not linked_id: + return forecast_val, "forecast" + + try: + whisperer = indigo.devices[int(linked_id)] + except (KeyError, ValueError): + return forecast_val, "forecast-missing-device" + + if not whisperer.enabled: + return forecast_val, "forecast-disabled-device" + + soil = whisperer.states.get("soilMoisture") + reading_time = whisperer.states.get("readingTime", "") + age_hours = parse_reading_age_hours(reading_time) + if soil is None or age_hours is None or age_hours > WHISPERER_STALENESS_HOURS: + return forecast_val, "forecast-stale" + + return int(soil), "whisperer" +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_zone_moisture_resolution.py -v` +Expected: PASS (10 tests). + +Full suite: `pytest tests/ -v` +Expected: green. + +**Step 5: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py" tests/test_zone_moisture_resolution.py +git commit -m "feat(netro): add _resolve_zone_moisture helper (#54)" +``` + +--- + +## Task 7: Implement `_log_moisture_source_transition` + +**Context:** Transition-aware logger — writes the zone's `lastMoistureSource` into `pluginProps` so we only log when the source category changes. Prevents log spam on steady-state operation. + +Logging rules (triggered on transition only): +- `"forecast"` → `"whisperer"`: info ("paired Whisperer reading active"). +- `"whisperer"` → `"forecast-stale"`: warning ("Whisperer reading stale, falling back to forecast"). +- `"forecast-stale"` → `"whisperer"`: info ("Whisperer reading recovered"). +- Any → `"forecast-missing-device"`: warning ("paired Whisperer device no longer exists"). +- Any → `"forecast-disabled-device"`: warning ("paired Whisperer device is disabled"). +- All other transitions: silent (e.g. `forecast` → `forecast-stale` shouldn't happen, but if it does, silent is safe). + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Create: `tests/test_moisture_source_logging.py` + +**Step 1: Write the failing test** + +Create `tests/test_moisture_source_logging.py`: + +```python +"""Tests for Plugin._log_moisture_source_transition.""" +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_indigo(monkeypatch): + indigo = MagicMock() + monkeypatch.setitem(sys.modules, "indigo", indigo) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + plugin.logger = MagicMock() + return plugin + + +def _zone(last_source=None, name="Test Zone"): + """A fake zone with a mutable pluginProps dict and a replacePluginPropsOnServer stub.""" + props = {} + if last_source is not None: + props["lastMoistureSource"] = last_source + replaced = [] + + def _replace(new_props): + replaced.append(dict(new_props)) + + return SimpleNamespace( + name=name, + pluginProps=props, + replacePluginPropsOnServer=_replace, + _replaced=replaced, + ) + + +def test_no_log_when_source_unchanged(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.warning.assert_not_called() + plugin_instance.logger.info.assert_not_called() + # pluginProps still reflect the (unchanged) value. + assert zone.pluginProps.get("lastMoistureSource") == "whisperer" + + +def test_log_info_on_forecast_to_whisperer(plugin_instance): + zone = _zone(last_source="forecast") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.info.assert_called_once() + assert "Whisperer reading" in plugin_instance.logger.info.call_args[0][0] + assert zone.pluginProps["lastMoistureSource"] == "whisperer" + + +def test_log_warning_on_whisperer_to_stale(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + plugin_instance.logger.warning.assert_called_once() + msg = plugin_instance.logger.warning.call_args[0][0] + assert "stale" in msg.lower() + assert "forecast" in msg.lower() + + +def test_log_info_on_stale_to_whisperer(plugin_instance): + zone = _zone(last_source="forecast-stale") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.info.assert_called_once() + assert "recovered" in plugin_instance.logger.info.call_args[0][0].lower() + + +def test_log_warning_on_missing_device(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-missing-device") + plugin_instance.logger.warning.assert_called_once() + assert "no longer" in plugin_instance.logger.warning.call_args[0][0].lower() + + +def test_log_warning_on_disabled_device(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-disabled-device") + plugin_instance.logger.warning.assert_called_once() + assert "disabled" in plugin_instance.logger.warning.call_args[0][0].lower() + + +def test_no_log_when_cold_start_forecast(plugin_instance): + """Fresh install, first-ever poll on an unpaired zone → silent (no transition).""" + zone = _zone(last_source=None) + plugin_instance._log_moisture_source_transition(zone, "forecast") + plugin_instance.logger.warning.assert_not_called() + plugin_instance.logger.info.assert_not_called() + assert zone.pluginProps["lastMoistureSource"] == "forecast" + + +def test_repeated_warning_suppressed(plugin_instance): + """Same stale source across two polls logs only once.""" + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + assert plugin_instance.logger.warning.call_count == 1 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_moisture_source_logging.py -v` +Expected: FAIL on all tests — method not defined. + +**Step 3: Write minimal implementation** + +In `plugin.py`, add immediately after `_resolve_zone_moisture`: + +```python +def _log_moisture_source_transition(self, zone_dev, new_source): + """Log a transition between moisture-source categories for a zone. + + Persists the current source in ``zone_dev.pluginProps['lastMoistureSource']`` + and only emits a log line when the category changes, to avoid spam. + The first-ever call on a fresh install is silent (no prior state). + + Args: + zone_dev: Indigo zone device. + new_source: One of the source tags returned by + ``_resolve_zone_moisture``. + """ + prev = zone_dev.pluginProps.get("lastMoistureSource") + if prev == new_source: + return + + transition = (prev, new_source) + if prev is not None: + if transition == ("forecast", "whisperer") or transition == ("forecast-stale", "whisperer"): + if prev == "forecast-stale": + self.logger.info( + f"Zone '{zone_dev.name}': Whisperer reading recovered — " + f"moisture now tracking paired sensor." + ) + else: + self.logger.info( + f"Zone '{zone_dev.name}': paired Whisperer reading active — " + f"moisture now tracking sensor." + ) + elif new_source == "forecast-stale": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer reading stale " + f"(>12h old) — falling back to Netro forecast." + ) + elif new_source == "forecast-missing-device": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer device no longer " + f"exists — falling back to Netro forecast." + ) + elif new_source == "forecast-disabled-device": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer device is disabled " + f"— falling back to Netro forecast." + ) + + # Persist the new source so the next poll can detect the next transition. + new_props = dict(zone_dev.pluginProps) + new_props["lastMoistureSource"] = new_source + zone_dev.pluginProps = new_props # keep test-side dict in sync + zone_dev.replacePluginPropsOnServer(new_props) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_moisture_source_logging.py -v` +Expected: PASS (8 tests). + +Full suite: `pytest tests/ -v` +Expected: green. + +**Step 5: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py" tests/test_moisture_source_logging.py +git commit -m "feat(netro): add transition-aware moisture source logging (#54)" +``` + +--- + +## Task 8: Wire resolver + logger into `_update_zone_devices` + +**Context:** Now patch the existing zone-update loop to: +1. Rename the key emitted by `process_zone_moisture` from `"moisture"` to `"moistureForecast"`. +2. Call `_resolve_zone_moisture` to decide the `"moisture"` value. +3. Call `_log_moisture_source_transition`. + +**Files:** +- Modify: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (in `_update_zone_devices` around line 690, specifically the `if moisture_response:` block near line 726). +- Create: `tests/test_update_zone_devices_integration.py` + +**Step 1: Read the current implementation** + +First, open `plugin.py` and locate `_update_zone_devices`. Find the block that processes `moisture_response` (grep: `process_zone_moisture`). It currently emits states keyed `"moisture"` directly from `ZoneHandler.process_zone_moisture`. Note the exact surrounding code so your edit matches context. + +**Step 2: Write the failing integration test** + +Create `tests/test_update_zone_devices_integration.py`: + +```python +"""Integration tests for Plugin._update_zone_devices moisture resolution. + +These tests verify the call-site rewiring: + - moistureForecast gets the /moistures.json value. + - moisture gets the resolved value (Whisperer if fresh + paired, else forecast). + - Source transitions are logged. +""" +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def mock_indigo(monkeypatch): + indigo = MagicMock() + indigo._devices_by_id = {} + indigo.devices.__getitem__.side_effect = lambda k: indigo._devices_by_id[k] + monkeypatch.setitem(sys.modules, "indigo", indigo) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + plugin.logger = MagicMock() + # ZoneHandler is instantiated in __init__; bypass it by attaching a stub. + from device_handlers import ZoneHandler + plugin.zone_handler = ZoneHandler(logger=MagicMock()) + return plugin + + +def _zone_dev(zone_num=1, linked_id="", name="Front Lawn"): + replaced_states = [] + replaced_props = [] + + def _update_states(states): + replaced_states.extend(states) + + def _replace_props(props): + replaced_props.append(dict(props)) + + dev = SimpleNamespace( + name=name, + pluginProps={"zoneNumber": str(zone_num), "linkedWhispererDeviceId": linked_id}, + enabled=True, + states={}, + updateStatesOnServer=_update_states, + replacePluginPropsOnServer=_replace_props, + _replaced_states=replaced_states, + _replaced_props=replaced_props, + ) + return dev + + +def _whisperer(soil=24, hours_old=2): + return SimpleNamespace( + enabled=True, + states={ + "soilMoisture": soil, + "readingTime": (FROZEN_NOW - timedelta(hours=hours_old)).strftime("%Y-%m-%dT%H:%M:%S"), + }, + ) + + +def _moistures_response(zone_num, forecast_val): + return { + "status": "OK", + "data": { + "moistures": [ + {"id": 1, "zone": zone_num, "date": "2026-04-23", "moisture": forecast_val}, + ], + }, + } + + +def _device_data(): + return {"zones": []} # minimum shape; _update_zone_devices iterates zone_devs, not zones + + +def test_paired_fresh_writes_sensor_to_moisture_and_forecast_to_moistureForecast( + plugin_instance, mock_indigo +): + zone = _zone_dev(zone_num=1, linked_id="999") + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=2) + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=89), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 24 + assert keys["moistureForecast"] == 89 + + +def test_unpaired_zone_mirrors_forecast_to_both(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=55), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 55 + assert keys["moistureForecast"] == 55 + + +def test_missing_moisture_response_skips_both_writes(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=None, + api_version="1", + ) + + keys = {s["key"]: s.get("value") for s in zone._replaced_states} + # When paired=no and forecast missing, we skip writing moisture. + assert "moisture" not in keys + assert "moistureForecast" not in keys + + +def test_missing_forecast_but_paired_fresh_writes_sensor(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="999") + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=2) + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=None, + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 24 + # No moistureForecast write when moisture_response is None. + assert "moistureForecast" not in keys +``` + +**Step 3: Run test to verify it fails** + +Run: `pytest tests/test_update_zone_devices_integration.py -v` +Expected: FAIL on `test_paired_fresh_...` (moisture still 89 or moistureForecast missing) — precise failure depends on current state of the code but tests will not pass. + +**Step 4: Modify `_update_zone_devices`** + +Locate the block in `_update_zone_devices` that currently looks roughly like: + +```python +if moisture_response: + try: + states.extend( + self.zone_handler.process_zone_moisture(moisture_response, zone_num) + ) + except Exception: + ... +``` + +Replace with: + +```python +forecast_val = None +if moisture_response: + try: + forecast_states = self.zone_handler.process_zone_moisture( + moisture_response, zone_num + ) + for entry in forecast_states: + if entry.get("key") == "moisture": + entry["key"] = "moistureForecast" + forecast_val = entry.get("value") + states.extend(forecast_states) + except Exception: + self.logger.exception( + f"Error processing moisture for zone {zone_num} on '{zone_dev.name}'" + ) + +moisture_val, source = self._resolve_zone_moisture(zone_dev, forecast_val) +if moisture_val is not None: + states.append({"key": "moisture", "value": moisture_val, + "uiValue": f"{moisture_val}%"}) +self._log_moisture_source_transition(zone_dev, source) +``` + +**Important:** if the existing block has a different surrounding context (e.g. a wider try/except), preserve its error-handling shape — only adjust the key rename and inject the resolver call. Read the surrounding ~15 lines before editing. + +**Step 5: Run test to verify it passes** + +Run: `pytest tests/test_update_zone_devices_integration.py -v` +Expected: PASS (4 tests). + +Full suite: `pytest tests/ -v` +Expected: green. If any existing `_update_zone_devices` or `ZoneHandler.process_zone_moisture` test starts failing, it likely asserted on the old `"moisture"` key. Update those assertions to expect `"moistureForecast"` on the zone-update side. `ZoneHandler.process_zone_moisture` itself still returns `"moisture"` — don't modify the handler or its direct tests. + +**Step 6: Commit** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py" tests/test_update_zone_devices_integration.py +git commit -m "feat(netro): resolve zone moisture source in update loop (#54)" +``` + +--- + +## Task 9: Update API_NOTES.md §6 + +**Files:** +- Modify: `docs/API_NOTES.md` + +**Step 1: Find §6** + +Run: `grep -n "^#.*6\|^##.*6\|moisture" docs/API_NOTES.md | head -20` + +Locate the section that discusses `/moistures.json` staleness (should mention "12–24 hours" based on the issue text). + +**Step 2: Rewrite the section** + +Replace the old "can be 12-24 hours old" framing with: + +```markdown +## §6 · `/moistures.json` — predictions, not sensor readings + +`/moistures.json` returns Netro's **smart-model daily prediction** for each +zone, *not* a sampled sensor value. The model assumes full saturation +immediately after irrigation, then decays based on local weather inputs. +For a zone with **no** paired Whisperer, this is the best signal +available. For a zone **with** a paired Whisperer (pairing done in the +Netro mobile app), Netro's app overlays the Whisperer's reading on the +zone tile, but **the `/moistures.json` response itself continues to +return the prediction** — the public API does not expose the app-side +overlay. + +This means the plugin's zone `moisture` state will diverge from a +paired Whisperer's actual reading whenever the model and reality +disagree. Observed example: same zone, same moment — `/moistures.json` += 89%, paired Whisperer = 24%. + +**Plugin behavior:** + +- The zone `moistureForecast` state always holds the raw + `/moistures.json` value. +- The zone `moisture` state resolves to: the paired Whisperer's current + `soilMoisture` if the pairing is configured on the zone device and + the reading is less than 12 hours old, else `moistureForecast`. +- Pairing is plugin-side (zone ConfigUI → "Paired Whisperer" dropdown), + independent of any Netro-side pairing. Both can coexist; they don't + interact. + +**Open empirical question:** whether `/moistures.json` continues to +produce sane predictions when a Whisperer is paired on Netro's side but +has stopped reporting (dead battery, unplugged). The issue-#54 +investigation showed `/moistures.json` returning a saturation-based +prediction even with a working paired Whisperer, suggesting the +prediction is independent of Whisperer reporting state. Recommend +dogfooding with a disconnected Whisperer for a week before relying on +the fallback in production. +``` + +(Adjust heading level to match the surrounding file.) + +**Step 3: Commit** + +```bash +git add docs/API_NOTES.md +git commit -m "docs(netro): clarify /moistures.json is prediction + pairing notes (#54)" +``` + +--- + +## Task 10: Update README + +**Files:** +- Modify: `README.md` (root of repo) + +**Step 1: Find the Whisperer section** + +Run: `grep -n -i "whisperer" README.md | head -5` + +**Step 2: Add a short note** + +Under or near the existing Whisperer section, add: + +```markdown +### Pairing a Whisperer to a Zone + +Each zone device has a **Paired Whisperer** dropdown in its config UI. +When paired and the Whisperer has reported within the last 12 hours, +the zone's `moisture` state mirrors the Whisperer's `soilMoisture` +reading (the actual measured value). Otherwise the zone falls back to +Netro's daily forecast. + +The `/moistures.json` forecast is always visible separately on the +`moistureForecast` state — useful for comparing model predictions to +the sensor's ground truth, and for tuning schedules. See +[`docs/API_NOTES.md`](docs/API_NOTES.md) §6 for details. +``` + +**Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(netro): document Whisperer-zone pairing in README (#54)" +``` + +--- + +## Task 11: Final verification + version bump + +**Step 1: Run the full suite one more time** + +Run: `pytest tests/ -v --tb=short` +Expected: all tests pass. Confirm the new tests are all included: + +- `tests/test_constants_whisperer.py` +- `tests/test_reading_age.py` +- `tests/test_whisperer_pairing_callback.py` +- `tests/test_zone_moisture_resolution.py` +- `tests/test_moisture_source_logging.py` +- `tests/test_update_zone_devices_integration.py` + +**Step 2: Run pylint on changed modules** + +Run: +``` +pylint "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py" \ + "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py" \ + "Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py" +``` +Expected: no new issues introduced. (Match the existing baseline score — don't regress.) + +**Step 3: Bump `PluginVersion` in Info.plist** + +Run: `grep -A1 "PluginVersion" "Netro Sprinklers.indigoPlugin/Contents/Info.plist"` to read current version. + +Compute new version: the workspace convention is `YYYY.R.P` where R increments for user-visible features. This change adds new ConfigUI + new state → **minor bump** (R+1, P=0). + +Edit `Info.plist` to change the existing `X.Y.Z` immediately after `PluginVersion` to the new value. Verify the file still parses: + +Run: `plutil -lint "Netro Sprinklers.indigoPlugin/Contents/Info.plist"` +Expected: `OK`. + +**Step 4: Commit the bump** + +```bash +git add "Netro Sprinklers.indigoPlugin/Contents/Info.plist" +git commit -m "chore(netro): bump PluginVersion for Whisperer-zone pairing (#54)" +``` + +**Step 5: Push the branch** + +```bash +git push -u origin feat/whisperer-zone-pairing +``` + +Expected: remote accepts the branch. Do **not** open the PR from this plan — the user will create the PR (per workspace feedback rule: wait for explicit go-ahead before merging or opening PRs is user-driven via the `/ship` or `gh pr create` workflow). + +--- + +## Post-implementation checklist (do NOT run as tasks — user confirms) + +- [ ] All 11 tasks complete on `feat/whisperer-zone-pairing`. +- [ ] `pytest tests/ -v` green. +- [ ] `pylint` on changed modules: no new issues. +- [ ] `Info.plist` version bumped (minor: R+1, P=0). +- [ ] Branch pushed to `origin/feat/whisperer-zone-pairing`. +- [ ] User opens PR referencing #54; CI (`version-check` + tests) green. +- [ ] User dogfoods the fallback behavior with a physically disconnected + Whisperer for ~1 week before merge (validates the open empirical + question from the design doc §"Netro-side pairing"). + +## Not in scope for this plan + +- Bulk-pairing action or UI. +- Auto-pairing heuristic. +- Changes to Netro `set_moisture` action, `ZoneHandler.process_zone_moisture` + internals, or Whisperer update loop. +- Integration tests against the live Netro API (local testing documented + separately in `docs/LOCAL_TESTING.md`). From 2042433ad0f9ec2859e6a6387928962921c86725 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:13:12 +0100 Subject: [PATCH 03/27] feat(netro): add WHISPERER_STALENESS_HOURS constant (#54) --- .../Contents/Server Plugin/constants.py | 9 +++++++++ tests/test_constants_whisperer.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/test_constants_whisperer.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py index 72bb1e7..6bacc52 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py @@ -185,6 +185,15 @@ TOKEN_WARNING_THRESHOLD: Final[int] = 200 """Token count below which warnings are logged.""" +WHISPERER_STALENESS_HOURS: Final[int] = 12 +"""Maximum age (hours) for a Whisperer reading to be considered fresh. + +Whisperers report every 1-6 hours depending on battery level. 12h = 2-12 +missed readings — tolerates brief API outages but catches a dead battery +within a day. When a paired Whisperer reading is older than this, the +zone falls back to Netro's /moistures.json forecast. +""" + # ============================================================================= # V2 Online Status Values diff --git a/tests/test_constants_whisperer.py b/tests/test_constants_whisperer.py new file mode 100644 index 0000000..ab3bcb2 --- /dev/null +++ b/tests/test_constants_whisperer.py @@ -0,0 +1,14 @@ +"""Tests for Whisperer-specific constants.""" +import constants + + +def test_whisperer_staleness_hours_defined(): + """WHISPERER_STALENESS_HOURS should be defined as a positive integer.""" + assert hasattr(constants, "WHISPERER_STALENESS_HOURS") + assert isinstance(constants.WHISPERER_STALENESS_HOURS, int) + assert constants.WHISPERER_STALENESS_HOURS > 0 + + +def test_whisperer_staleness_hours_value(): + """WHISPERER_STALENESS_HOURS should be 12 hours (2-12 missed readings at 1-6h cadence).""" + assert constants.WHISPERER_STALENESS_HOURS == 12 From f5c60729b43ada9cc8cf351a6096d6bab21ba759 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:17:12 +0100 Subject: [PATCH 04/27] feat(netro): add parse_reading_age_hours for v1 epoch + v2 ISO (#54) --- .../Contents/Server Plugin/utils.py | 75 +++++++++++++++++- tests/test_reading_age.py | 76 +++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/test_reading_age.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py index 71abc59..90caa1a 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py @@ -6,6 +6,8 @@ rainfall, wind speed, and pressure - Weather data conversion: convert_weather_us_to_metric / convert_weather_metric_to_us for transforming weather dicts between API v1 (US) and v2 (metric) formats +- parse_reading_age_hours: Parse Whisperer reading timestamps (v1 epoch millis + or v2 ISO 8601) and return age in hours - get_key_from_dict: Safely retrieve values from dictionaries These functions are extracted from plugin.py to enable reuse and testing. @@ -15,7 +17,8 @@ It has no dependencies on other plugin modules to prevent circular imports. """ -from typing import Any, Dict +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Union def fahrenheit_to_celsius(f: float) -> float: @@ -126,6 +129,76 @@ def convert_weather_metric_to_us(weather_data: Dict[str, Any]) -> Dict[str, Any] return converted +def _now_utc() -> datetime: + """Return current time as a timezone-aware UTC datetime. + + Indirected through a module-level function so tests can patch it + deterministically without depending on freezegun or similar. + """ + return datetime.now(tz=timezone.utc) + + +def parse_reading_age_hours( + reading_time: Union[str, int, float, None] +) -> Optional[float]: + """Compute age (hours) of a Whisperer reading timestamp. + + Accepts both API v1 and v2 timestamp formats emitted by + ``WhispererHandler.process_sensor_data``: + + - **V1 (epoch millis)**: e.g. ``1234567890000`` (int or numeric string) + - **V2 (ISO 8601)**: e.g. ``"2026-04-07T10:00:00"`` or ``"...Z"`` + + Args: + reading_time: Value from the Whisperer ``readingTime`` state. + + Returns: + Age in hours (non-negative float) if parseable, ``None`` otherwise. + Returns ``0.0`` when the reading is in the future (clock skew). + + Note: + V2 ISO strings without an explicit timezone are assumed to be UTC. + Netro's ``time`` field is the sensor's UTC timestamp; ``local_time`` + is the pre-formatted local variant. We intentionally use the UTC + form for age math to avoid DST/tz drift. + """ + if reading_time is None or reading_time == "": + return None + + now = _now_utc() + + # Try ISO 8601 first (covers v2 and any pre-formatted strings). + if isinstance(reading_time, str): + candidate = reading_time.rstrip("Z").strip() + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + parsed = None + else: + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + delta = (now - parsed).total_seconds() / 3600.0 + return max(0.0, delta) + + # Fall through: maybe it's a stringified epoch millis. + try: + reading_time = int(candidate) + except (TypeError, ValueError): + return None + + # Epoch millis (int or float from numeric-string fallthrough above). + if isinstance(reading_time, (int, float)): + try: + seconds = float(reading_time) / 1000.0 + parsed = datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OSError, OverflowError, ValueError): + return None + delta = (now - parsed).total_seconds() / 3600.0 + return max(0.0, delta) + + return None + + def get_key_from_dict(key: str, data: dict, default: Any = None) -> Any: """Safely get value from dictionary with graceful error handling. diff --git a/tests/test_reading_age.py b/tests/test_reading_age.py new file mode 100644 index 0000000..92bba73 --- /dev/null +++ b/tests/test_reading_age.py @@ -0,0 +1,76 @@ +"""Tests for parse_reading_age_hours utility.""" +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest + +import utils + + +class TestParseReadingAgeHours: + """Test utils.parse_reading_age_hours across supported input formats.""" + + @pytest.fixture + def fixed_now(self): + """Anchor "now" to a known UTC datetime for deterministic age math.""" + return datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + def test_v2_iso_string_fresh(self, fixed_now): + """ISO-8601 string 3h old → ~3.0 hours.""" + three_hours_ago = (fixed_now - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(three_hours_ago) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v2_iso_string_with_timezone(self, fixed_now): + """ISO-8601 string with Z suffix should be treated as UTC.""" + three_hours_ago = (fixed_now - timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(three_hours_ago) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v1_epoch_millis_fresh(self, fixed_now): + """V1 epoch millis 3h old → ~3.0 hours.""" + epoch_ms = int((fixed_now - timedelta(hours=3)).timestamp() * 1000) + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(epoch_ms) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_v1_epoch_millis_as_string(self, fixed_now): + """Epoch millis passed as a string should still parse.""" + epoch_ms_str = str(int((fixed_now - timedelta(hours=3)).timestamp() * 1000)) + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(epoch_ms_str) + assert age is not None + assert 2.9 <= age <= 3.1 + + def test_stale_reading(self, fixed_now): + """24h-old reading → 24.0 hours (above threshold).""" + one_day_ago = (fixed_now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(one_day_ago) + assert age is not None + assert 23.9 <= age <= 24.1 + + def test_unparseable_string_returns_none(self): + """Garbage input returns None, does not raise.""" + assert utils.parse_reading_age_hours("not-a-timestamp") is None + assert utils.parse_reading_age_hours("unknown") is None + + def test_empty_string_returns_none(self): + """Empty string returns None.""" + assert utils.parse_reading_age_hours("") is None + + def test_none_input_returns_none(self): + """None input returns None.""" + assert utils.parse_reading_age_hours(None) is None + + def test_negative_age_clamped_to_zero(self, fixed_now): + """Future timestamp (clock skew) returns 0.0, never negative.""" + future = (fixed_now + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + with patch("utils._now_utc", return_value=fixed_now): + age = utils.parse_reading_age_hours(future) + assert age == 0.0 From 3ea75be18067c08f1d3958dc847daf02cd4f03b5 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:21:19 +0100 Subject: [PATCH 05/27] refactor(netro): tighten parse_reading_age_hours edge cases (#54) - Use str.removesuffix("Z") instead of rstrip("Z") to reject multi-Z malformed input rather than silently accepting it. - Reject bool inputs in the epoch branch (bool is subclass of int). Two new tests pin the behavior. --- .../Contents/Server Plugin/utils.py | 5 +++-- tests/test_reading_age.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py index 90caa1a..64bc1d2 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py @@ -169,7 +169,7 @@ def parse_reading_age_hours( # Try ISO 8601 first (covers v2 and any pre-formatted strings). if isinstance(reading_time, str): - candidate = reading_time.rstrip("Z").strip() + candidate = reading_time.removesuffix("Z").strip() try: parsed = datetime.fromisoformat(candidate) except ValueError: @@ -187,7 +187,8 @@ def parse_reading_age_hours( return None # Epoch millis (int or float from numeric-string fallthrough above). - if isinstance(reading_time, (int, float)): + # Reject bool explicitly — bool is a subclass of int. + if isinstance(reading_time, (int, float)) and not isinstance(reading_time, bool): try: seconds = float(reading_time) / 1000.0 parsed = datetime.fromtimestamp(seconds, tz=timezone.utc) diff --git a/tests/test_reading_age.py b/tests/test_reading_age.py index 92bba73..a04bf47 100644 --- a/tests/test_reading_age.py +++ b/tests/test_reading_age.py @@ -74,3 +74,15 @@ def test_negative_age_clamped_to_zero(self, fixed_now): with patch("utils._now_utc", return_value=fixed_now): age = utils.parse_reading_age_hours(future) assert age == 0.0 + + def test_bool_input_returns_none(self): + """Booleans (subclass of int) must NOT be treated as epoch millis.""" + assert utils.parse_reading_age_hours(True) is None + assert utils.parse_reading_age_hours(False) is None + + def test_iso_with_triple_z_suffix_rejected(self): + """Malformed ISO with multiple 'Z's is not silently stripped to valid.""" + # Only a single trailing 'Z' is accepted; "...ZZZ" is malformed input + # and should return None rather than silently parsing as if it were "...Z". + result = utils.parse_reading_age_hours("2026-04-23T10:00:00ZZZ") + assert result is None From 55be3284f3fcaa73db3be6866c2e982cdb366c61 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:22:29 +0100 Subject: [PATCH 06/27] feat(netro): add moistureForecast state to zone device (#54) --- .../Contents/Server Plugin/Devices.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml index ecc3a3f..e21e5c2 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -310,6 +310,11 @@ Moisture Level (%) Moisture Level (%) + + Integer + Moisture Forecast (%) + Moisture Forecast (%) + Boolean Zone Enabled From c9b729bee00c92168cb98307d2cf1eca6da93e8f Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:24:57 +0100 Subject: [PATCH 07/27] feat(netro): add Whisperer pairing dropdown to zone ConfigUI (#54) --- .../Contents/Server Plugin/Devices.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml index e21e5c2..a78e4e3 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -303,6 +303,17 @@ + + + + + + + + + + + From eb9b4d88e7d96e4c789f3e2e07a57a3541104ff4 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:29:09 +0100 Subject: [PATCH 08/27] feat(netro): add getWhispererDevices ConfigUI callback (#54) --- .../Contents/Server Plugin/plugin.py | 22 ++++++ tests/test_whisperer_pairing_callback.py | 75 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/test_whisperer_pairing_callback.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index a6aef44..1780157 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1157,6 +1157,28 @@ def sprinklerList(self, dev_filter="", valuesDict=None, typeId="", targetId=0): self.logger.threaddebug("sprinklerList") return [(s.id, s.name) for s in indigo.devices.iter(filter="self")] + ######################################## + # pylint: disable=unused-argument + def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0): + """Populate the `linkedWhispererDeviceId` dropdown on zone ConfigUI. + + Returns a list of (value, label) tuples: + - First entry: ("", "(Unpaired — use Netro forecast)") sentinel. + - Remaining entries: this plugin's Whisperer devices, sorted + case-insensitively by name. Value is the Indigo device ID as a + string; label is the device name. + + Called by Indigo when the ConfigUI is opened / reloaded. + """ + options = [("", "(Unpaired — use Netro forecast)")] + whisperers = sorted( + (d for d in indigo.devices.iter(filter="self") + if d.deviceTypeId == "Whisperer"), + key=lambda d: d.name.lower(), + ) + options.extend((str(d.id), d.name) for d in whisperers) + return options + ######################################## # Validation callbacks ######################################## diff --git a/tests/test_whisperer_pairing_callback.py b/tests/test_whisperer_pairing_callback.py new file mode 100644 index 0000000..a7a9acd --- /dev/null +++ b/tests/test_whisperer_pairing_callback.py @@ -0,0 +1,75 @@ +"""Tests for Plugin.getWhispererDevices ConfigUI callback.""" +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_indigo(monkeypatch): + """Install a minimal `indigo` module into sys.modules for plugin import. + + `PluginBase` must be a real class so `class Plugin(indigo.PluginBase):` + at import time produces a real class — not a MagicMock attribute. + """ + indigo = MagicMock() + + class _PluginBase: + pass + + indigo.PluginBase = _PluginBase + indigo.Dict = dict + indigo.devices.iter = MagicMock(return_value=iter([])) + monkeypatch.setitem(sys.modules, "indigo", indigo) + # Force a fresh import of `plugin` so the Plugin class is rebuilt against + # this fixture's mock (previous tests may have cached a stale module). + monkeypatch.delitem(sys.modules, "plugin", raising=False) + return indigo + + +def _fake_device(dev_id, name, type_id="Whisperer"): + return SimpleNamespace(id=dev_id, name=name, deviceTypeId=type_id) + + +def test_returns_unpaired_sentinel_when_no_whisperers(mock_indigo): + """With zero Whisperers installed, returns only the unpaired option.""" + mock_indigo.devices.iter.return_value = iter([]) + # Import after mock is installed. + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) # skip __init__ + result = plugin.getWhispererDevices() + assert result[0] == ("", "(Unpaired — use Netro forecast)") + assert len(result) == 1 + + +def test_returns_whisperers_sorted_by_name(mock_indigo): + """Whisperers are appended, sorted case-insensitively by name.""" + devs = [ + _fake_device(101, "Zebra"), + _fake_device(102, "apple"), + _fake_device(103, "Mango"), + _fake_device(104, "Sprite 8-zone", type_id="Sprite"), # not Whisperer + ] + mock_indigo.devices.iter.return_value = iter(devs) + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + result = plugin.getWhispererDevices() + assert result[0] == ("", "(Unpaired — use Netro forecast)") + assert result[1:] == [("102", "apple"), ("103", "Mango"), ("101", "Zebra")] + + +def test_ignores_non_whisperer_devices(mock_indigo): + """Sprite/Pixie/Spark controllers and zones are excluded.""" + devs = [ + _fake_device(1, "Sprite 8", type_id="Sprite"), + _fake_device(2, "Pixie 12", type_id="Pixie"), + _fake_device(3, "Zone A", type_id="zone"), + _fake_device(4, "Garden Whisperer", type_id="Whisperer"), + ] + mock_indigo.devices.iter.return_value = iter(devs) + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + result = plugin.getWhispererDevices() + whisperer_ids = [r[0] for r in result[1:]] + assert whisperer_ids == ["4"] From dbb6a9766b6ffab49ce56000d453c12d1010b5a8 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:33:51 +0100 Subject: [PATCH 09/27] feat(netro): add _resolve_zone_moisture helper (#54) --- .../Contents/Server Plugin/plugin.py | 47 +++++- tests/test_zone_moisture_resolution.py | 158 ++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tests/test_zone_moisture_resolution.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 1780157..fea42a8 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -57,6 +57,7 @@ OPERATIONAL_ERROR_EVENTS, COMM_ERROR_EVENTS, DEVICE_EVENT_TYPES, + WHISPERER_STALENESS_HOURS, ) from exceptions import ThrottleDelayError from validators import ( @@ -67,7 +68,11 @@ ) from api_client import NetroAPIClient from device_handlers import SprinklerHandler, WhispererHandler, ZoneHandler -from utils import convert_weather_us_to_metric, convert_weather_metric_to_us +from utils import ( + convert_weather_us_to_metric, + convert_weather_metric_to_us, + parse_reading_age_hours, +) from tomorrow_client import TomorrowClient @@ -1179,6 +1184,46 @@ def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0) options.extend((str(d.id), d.name) for d in whisperers) return options + def _resolve_zone_moisture(self, zone_dev, forecast_val): + """Resolve the "moisture" state value for a zone device. + + Pure function (no state writes, no logging). Returns a + ``(value, source_tag)`` pair where source_tag is one of: + + - ``"forecast"``: zone has no paired Whisperer; returns forecast_val. + - ``"whisperer"``: paired Whisperer exists, is enabled, has a fresh + (<= WHISPERER_STALENESS_HOURS old) ``soilMoisture`` reading. + - ``"forecast-stale"``: paired but reading is missing, too old, or + ``readingTime`` is unparseable. + - ``"forecast-missing-device"``: paired device id does not resolve + to an Indigo device (deleted or invalid id). + - ``"forecast-disabled-device"``: paired device exists but is + disabled in Indigo. + + ``value`` may be ``None`` if forecast_val is None and no Whisperer + value is available; the caller should skip writing ``moisture`` in + that case. + """ + linked_id = zone_dev.pluginProps.get("linkedWhispererDeviceId", "") + if not linked_id: + return forecast_val, "forecast" + + try: + whisperer = indigo.devices[int(linked_id)] + except (KeyError, ValueError): + return forecast_val, "forecast-missing-device" + + if not whisperer.enabled: + return forecast_val, "forecast-disabled-device" + + soil = whisperer.states.get("soilMoisture") + reading_time = whisperer.states.get("readingTime", "") + age_hours = parse_reading_age_hours(reading_time) + if soil is None or age_hours is None or age_hours > WHISPERER_STALENESS_HOURS: + return forecast_val, "forecast-stale" + + return int(soil), "whisperer" + ######################################## # Validation callbacks ######################################## diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py new file mode 100644 index 0000000..c934f7c --- /dev/null +++ b/tests/test_zone_moisture_resolution.py @@ -0,0 +1,158 @@ +"""Tests for Plugin._resolve_zone_moisture.""" +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +class _PluginBase: + """Stand-in for indigo.PluginBase used at Plugin class definition time.""" + + +@pytest.fixture +def mock_indigo(monkeypatch): + """Install a minimal `indigo` module into sys.modules for plugin import. + + `PluginBase` must be a real class so `class Plugin(indigo.PluginBase):` + at import time produces a real class — not a MagicMock attribute. + """ + indigo = MagicMock() + indigo.PluginBase = _PluginBase + indigo.Dict = dict + indigo._devices_by_id = {} + + def _getitem(dev_id): + if dev_id not in indigo._devices_by_id: + raise KeyError(dev_id) + return indigo._devices_by_id[dev_id] + + indigo.devices.__getitem__.side_effect = _getitem + monkeypatch.setitem(sys.modules, "indigo", indigo) + # Force a fresh import of `plugin` so the Plugin class is rebuilt against + # this fixture's mock (previous tests may have cached a stale module). + monkeypatch.delitem(sys.modules, "plugin", raising=False) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + return Plugin.__new__(Plugin) + + +def _fake_whisperer(enabled=True, soil=30, reading_time="2026-04-23T10:00:00"): + return SimpleNamespace( + enabled=enabled, + states={"soilMoisture": soil, "readingTime": reading_time}, + ) + + +def _fake_zone(linked_id=""): + return SimpleNamespace(pluginProps={"linkedWhispererDeviceId": linked_id}) + + +FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + +# --- Unpaired paths --- + +def test_unpaired_returns_forecast(plugin_instance): + zone = _fake_zone(linked_id="") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=55) + assert (val, src) == (55, "forecast") + + +def test_unpaired_forecast_none(plugin_instance): + zone = _fake_zone(linked_id="") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=None) + assert (val, src) == (None, "forecast") + + +# --- Paired, fresh --- + +def test_paired_fresh_returns_whisperer(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (24, "whisperer") + + +# --- Paired, stale --- + +def test_paired_stale_returns_forecast(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=20)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") + + +def test_paired_stale_forecast_also_none(plugin_instance, mock_indigo): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=20)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=None) + assert (val, src) == (None, "forecast-stale") + + +# --- Paired, Whisperer missing --- + +def test_paired_device_deleted(plugin_instance, mock_indigo): + zone = _fake_zone(linked_id="999") # 999 not in _devices_by_id + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-missing-device") + + +def test_paired_invalid_id(plugin_instance): + zone = _fake_zone(linked_id="not-an-int") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-missing-device") + + +# --- Paired, Whisperer disabled --- + +def test_paired_device_disabled(plugin_instance, mock_indigo): + whisperer = _fake_whisperer(enabled=False, soil=24) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-disabled-device") + + +# --- Paired, unparseable time --- + +def test_paired_unparseable_reading_time(plugin_instance, mock_indigo): + whisperer = _fake_whisperer(reading_time="unknown") + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") + + +# --- Paired, no soilMoisture state --- + +def test_paired_no_soil_state(plugin_instance, mock_indigo): + whisperer = SimpleNamespace( + enabled=True, + states={"readingTime": "2026-04-23T10:00:00"}, # soilMoisture missing + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") From b9db047db6cd6b8ef72d9a56c5ca19e34f007149 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:37:05 +0100 Subject: [PATCH 10/27] refactor(netro): harden _resolve_zone_moisture against non-numeric soil (#54) - Treat non-numeric soilMoisture as stale rather than raising ValueError (latent crash surface from Whisperer handler contract drift). - Add resolver-level test for v1 epoch-millis readingTime (previously only covered by the util's own tests). --- .../Contents/Server Plugin/plugin.py | 5 +++- tests/test_zone_moisture_resolution.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index fea42a8..9b9995e 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1222,7 +1222,10 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): if soil is None or age_hours is None or age_hours > WHISPERER_STALENESS_HOURS: return forecast_val, "forecast-stale" - return int(soil), "whisperer" + try: + return int(soil), "whisperer" + except (TypeError, ValueError): + return forecast_val, "forecast-stale" ######################################## # Validation callbacks diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py index c934f7c..7a5f9eb 100644 --- a/tests/test_zone_moisture_resolution.py +++ b/tests/test_zone_moisture_resolution.py @@ -156,3 +156,31 @@ def test_paired_no_soil_state(plugin_instance, mock_indigo): with patch("utils._now_utc", return_value=FROZEN_NOW): val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) assert (val, src) == (89, "forecast-stale") + + +# --- Paired, v1 epoch-millis readingTime --- + +def test_paired_fresh_v1_epoch_millis(plugin_instance, mock_indigo): + """v1 API stores readingTime as an epoch-millis int — resolver should honour it.""" + two_hours_ago_ms = int((FROZEN_NOW - timedelta(hours=2)).timestamp() * 1000) + whisperer = _fake_whisperer(soil=28, reading_time=two_hours_ago_ms) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (28, "whisperer") + + +# --- Paired, non-numeric soilMoisture (defensive) --- + +def test_paired_non_numeric_soil_treated_as_stale(plugin_instance, mock_indigo): + """Non-numeric soilMoisture (should never happen in practice) falls back safely.""" + whisperer = SimpleNamespace( + enabled=True, + states={"soilMoisture": "unknown", "readingTime": "2026-04-23T10:00:00"}, + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-stale") From edf4c73aa674963602d99a0c128a13b1f697c9e4 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:39:04 +0100 Subject: [PATCH 11/27] feat(netro): add transition-aware moisture source logging (#54) --- .../Contents/Server Plugin/plugin.py | 50 ++++++++ tests/test_moisture_source_logging.py | 110 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/test_moisture_source_logging.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 9b9995e..9dfca0c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1227,6 +1227,56 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): except (TypeError, ValueError): return forecast_val, "forecast-stale" + def _log_moisture_source_transition(self, zone_dev, new_source): + """Log a transition between moisture-source categories for a zone. + + Persists the current source in ``zone_dev.pluginProps['lastMoistureSource']`` + and only emits a log line when the category changes, to avoid spam. + The first-ever call on a fresh install is silent (no prior state). + + Args: + zone_dev: Indigo zone device. + new_source: One of the source tags returned by + ``_resolve_zone_moisture``. + """ + prev = zone_dev.pluginProps.get("lastMoistureSource") + if prev == new_source: + return + + if prev is not None: + if new_source == "whisperer": + if prev == "forecast-stale": + self.logger.info( + f"Zone '{zone_dev.name}': Whisperer reading recovered — " + f"moisture now tracking paired sensor." + ) + else: + self.logger.info( + f"Zone '{zone_dev.name}': paired Whisperer reading active — " + f"moisture now tracking sensor." + ) + elif new_source == "forecast-stale": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer reading stale " + f"(>12h old) — falling back to Netro forecast." + ) + elif new_source == "forecast-missing-device": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer device no longer " + f"exists — falling back to Netro forecast." + ) + elif new_source == "forecast-disabled-device": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer device is disabled " + f"— falling back to Netro forecast." + ) + + # Persist the new source so the next poll can detect the next transition. + new_props = dict(zone_dev.pluginProps) + new_props["lastMoistureSource"] = new_source + zone_dev.pluginProps = new_props # keep test-side dict in sync + zone_dev.replacePluginPropsOnServer(new_props) + ######################################## # Validation callbacks ######################################## diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py new file mode 100644 index 0000000..fd762db --- /dev/null +++ b/tests/test_moisture_source_logging.py @@ -0,0 +1,110 @@ +"""Tests for Plugin._log_moisture_source_transition.""" +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +class _PluginBase: + """Stand-in for indigo.PluginBase.""" + + +@pytest.fixture +def mock_indigo(monkeypatch): + indigo = MagicMock() + indigo.PluginBase = _PluginBase + indigo.Dict = dict + monkeypatch.setitem(sys.modules, "indigo", indigo) + monkeypatch.delitem(sys.modules, "plugin", raising=False) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + plugin.logger = MagicMock() + return plugin + + +def _zone(last_source=None, name="Test Zone"): + """A fake zone with a mutable pluginProps dict and a replacePluginPropsOnServer stub.""" + props = {} + if last_source is not None: + props["lastMoistureSource"] = last_source + replaced = [] + + def _replace(new_props): + replaced.append(dict(new_props)) + + return SimpleNamespace( + name=name, + pluginProps=props, + replacePluginPropsOnServer=_replace, + _replaced=replaced, + ) + + +def test_no_log_when_source_unchanged(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.warning.assert_not_called() + plugin_instance.logger.info.assert_not_called() + # pluginProps still reflect the (unchanged) value. + assert zone.pluginProps.get("lastMoistureSource") == "whisperer" + + +def test_log_info_on_forecast_to_whisperer(plugin_instance): + zone = _zone(last_source="forecast") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.info.assert_called_once() + assert "Whisperer reading" in plugin_instance.logger.info.call_args[0][0] + assert zone.pluginProps["lastMoistureSource"] == "whisperer" + + +def test_log_warning_on_whisperer_to_stale(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + plugin_instance.logger.warning.assert_called_once() + msg = plugin_instance.logger.warning.call_args[0][0] + assert "stale" in msg.lower() + assert "forecast" in msg.lower() + + +def test_log_info_on_stale_to_whisperer(plugin_instance): + zone = _zone(last_source="forecast-stale") + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.info.assert_called_once() + assert "recovered" in plugin_instance.logger.info.call_args[0][0].lower() + + +def test_log_warning_on_missing_device(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-missing-device") + plugin_instance.logger.warning.assert_called_once() + assert "no longer" in plugin_instance.logger.warning.call_args[0][0].lower() + + +def test_log_warning_on_disabled_device(plugin_instance): + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-disabled-device") + plugin_instance.logger.warning.assert_called_once() + assert "disabled" in plugin_instance.logger.warning.call_args[0][0].lower() + + +def test_no_log_when_cold_start_forecast(plugin_instance): + """Fresh install, first-ever poll on an unpaired zone → silent (no transition).""" + zone = _zone(last_source=None) + plugin_instance._log_moisture_source_transition(zone, "forecast") + plugin_instance.logger.warning.assert_not_called() + plugin_instance.logger.info.assert_not_called() + assert zone.pluginProps["lastMoistureSource"] == "forecast" + + +def test_repeated_warning_suppressed(plugin_instance): + """Same stale source across two polls logs only once.""" + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + assert plugin_instance.logger.warning.call_count == 1 From f01d260b2c1975e0e982f731b80f5793aa991938 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:43:51 +0100 Subject: [PATCH 12/27] feat(netro): resolve zone moisture source in update loop (#54) --- .../Contents/Server Plugin/plugin.py | 29 ++- tests/test_update_zone_devices_integration.py | 209 ++++++++++++++++++ 2 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 tests/test_update_zone_devices_integration.py diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 9dfca0c..ac8c4b6 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -728,10 +728,33 @@ def _update_zone_devices(self, parent_dev, device_data, schedule_response, moist ) ) + forecast_val = None if moisture_response: - states.extend( - self.zone_handler.process_zone_moisture(moisture_response, zone_num) - ) + try: + forecast_states = self.zone_handler.process_zone_moisture( + moisture_response, zone_num + ) + for entry in forecast_states: + if entry.get("key") == "moisture": + entry["key"] = "moistureForecast" + forecast_val = entry.get("value") + states.extend(forecast_states) + except Exception: + self.logger.exception( + f"Error processing moisture for zone {zone_num} " + f"on '{zone_dev.name}'" + ) + + moisture_val, source = self._resolve_zone_moisture( + zone_dev, forecast_val + ) + if moisture_val is not None: + states.append({ + "key": "moisture", + "value": moisture_val, + "uiValue": f"{moisture_val}%", + }) + self._log_moisture_source_transition(zone_dev, source) if states: zone_dev.updateStatesOnServer(states) diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py new file mode 100644 index 0000000..1983ed6 --- /dev/null +++ b/tests/test_update_zone_devices_integration.py @@ -0,0 +1,209 @@ +"""Integration tests for Plugin._update_zone_devices moisture resolution. + +These tests verify the call-site rewiring: + - moistureForecast gets the /moistures.json value. + - moisture gets the resolved value (Whisperer if fresh + paired, else forecast). + - Source transitions are logged. +""" +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) + + +class _PluginBase: + pass + + +@pytest.fixture +def mock_indigo(monkeypatch): + indigo = MagicMock() + indigo.PluginBase = _PluginBase + indigo.Dict = dict + indigo._devices_by_id = {} + + def _getitem(dev_id): + if dev_id not in indigo._devices_by_id: + raise KeyError(dev_id) + return indigo._devices_by_id[dev_id] + + indigo.devices.__getitem__.side_effect = _getitem + monkeypatch.setitem(sys.modules, "indigo", indigo) + monkeypatch.delitem(sys.modules, "plugin", raising=False) + return indigo + + +@pytest.fixture +def plugin_instance(mock_indigo): + from plugin import Plugin # noqa: WPS433 + plugin = Plugin.__new__(Plugin) + plugin.logger = MagicMock() + from device_handlers import ZoneHandler + plugin.zone_handler = ZoneHandler(logger=MagicMock()) + return plugin + + +def _zone_dev(zone_num=1, linked_id="", name="Front Lawn"): + """Build a zone device double that captures state + prop writes. + + The zone must look enabled in the extracted-zone-states so the moisture + block runs; that requires ``zones`` in ``device_data`` to mark the zone + as enabled, not the device itself. ``enabled`` here controls the Indigo + device-enabled flag, not the zone's ``enabled`` state. + """ + replaced_states = [] + replaced_props = [] + + def _update_states(states): + replaced_states.extend(states) + + def _replace_props(props): + replaced_props.append(dict(props)) + + dev = SimpleNamespace( + id=1000 + zone_num, + name=name, + pluginProps={ + "zoneNumber": str(zone_num), + "linkedWhispererDeviceId": linked_id, + }, + enabled=True, + states={}, + deviceTypeId="zone", + updateStatesOnServer=_update_states, + replacePluginPropsOnServer=_replace_props, + setErrorStateOnServer=lambda *a, **kw: None, + updateStateImageOnServer=lambda *a, **kw: None, + _replaced_states=replaced_states, + _replaced_props=replaced_props, + ) + return dev + + +def _whisperer(soil=24, hours_old=2): + return SimpleNamespace( + enabled=True, + states={ + "soilMoisture": soil, + "readingTime": (FROZEN_NOW - timedelta(hours=hours_old)).strftime( + "%Y-%m-%dT%H:%M:%S" + ), + }, + ) + + +def _moistures_response(zone_num, forecast_val): + return { + "status": "OK", + "data": { + "moistures": [ + { + "id": 1, + "zone": zone_num, + "date": "2026-04-23", + "moisture": forecast_val, + }, + ], + }, + } + + +def _device_data(zone_num=1): + """Return a minimal device_data with a single enabled zone. + + ``_update_zone_devices`` pulls ``zones`` out of ``device_data`` and + passes them to ``ZoneHandler.extract_zone_states`` which uses the + ``enabled`` flag on the matching zone to decide whether to run the + moisture block. + """ + return { + "zones": [ + {"ith": zone_num, "name": f"Zone {zone_num}", "enabled": True, + "smart": "SMART"}, + ], + } + + +def test_paired_fresh_writes_sensor_to_moisture_and_forecast_to_moistureForecast( + plugin_instance, mock_indigo +): + zone = _zone_dev(zone_num=1, linked_id="999") + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=2) + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, + _device_data(1), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=89), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 24 + assert keys["moistureForecast"] == 89 + + +def test_unpaired_zone_mirrors_forecast_to_both(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + plugin_instance._update_zone_devices( + parent, + _device_data(1), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=55), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 55 + assert keys["moistureForecast"] == 55 + + +def test_missing_moisture_response_skips_both_writes(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + plugin_instance._update_zone_devices( + parent, + _device_data(1), + schedule_response=None, + moisture_response=None, + api_version="1", + ) + + keys = {s["key"]: s.get("value") for s in zone._replaced_states} + # When paired=no and forecast missing, we skip writing moisture. + assert "moisture" not in keys + assert "moistureForecast" not in keys + + +def test_missing_forecast_but_paired_fresh_writes_sensor(plugin_instance, mock_indigo): + zone = _zone_dev(zone_num=1, linked_id="999") + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=2) + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, + _device_data(1), + schedule_response=None, + moisture_response=None, + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + assert keys["moisture"] == 24 + # No moistureForecast write when moisture_response is None. + assert "moistureForecast" not in keys From ad79f844d03eb5ca2feca8d8ab5272bb4f51f37f Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:47:50 +0100 Subject: [PATCH 13/27] refactor(netro): don't mutate handler output + add stale-fallback integration test (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build new dicts rather than mutating entries from process_zone_moisture to avoid a coupling to that handler's caller-writable implementation detail. - Add an end-to-end test for paired-but-stale → forecast fallback to close the paired/unpaired × fresh/stale matrix at the integration level. --- .../Contents/Server Plugin/plugin.py | 9 +++++--- tests/test_update_zone_devices_integration.py | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index ac8c4b6..b5007f4 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -734,11 +734,14 @@ def _update_zone_devices(self, parent_dev, device_data, schedule_response, moist forecast_states = self.zone_handler.process_zone_moisture( moisture_response, zone_num ) + found = False for entry in forecast_states: - if entry.get("key") == "moisture": - entry["key"] = "moistureForecast" + if not found and entry.get("key") == "moisture": forecast_val = entry.get("value") - states.extend(forecast_states) + states.append({**entry, "key": "moistureForecast"}) + found = True + else: + states.append(entry) except Exception: self.logger.exception( f"Error processing moisture for zone {zone_num} " diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index 1983ed6..26c8405 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -207,3 +207,24 @@ def test_missing_forecast_but_paired_fresh_writes_sensor(plugin_instance, mock_i assert keys["moisture"] == 24 # No moistureForecast write when moisture_response is None. assert "moistureForecast" not in keys + + +def test_paired_stale_falls_back_to_forecast(plugin_instance, mock_indigo): + """End-to-end: paired Whisperer with stale reading → moisture shows forecast.""" + zone = _zone_dev(zone_num=1, linked_id="999") + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=20) + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=89), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + # Stale Whisperer → fall back to forecast for both states. + assert keys["moisture"] == 89 + assert keys["moistureForecast"] == 89 From bdaa2281faf91e00b2676aa22b82fd55951eeef4 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:50:23 +0100 Subject: [PATCH 14/27] docs(netro): clarify /moistures.json is prediction + pairing notes (#54) --- docs/API_NOTES.md | 443 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 docs/API_NOTES.md diff --git a/docs/API_NOTES.md b/docs/API_NOTES.md new file mode 100644 index 0000000..d338bdf --- /dev/null +++ b/docs/API_NOTES.md @@ -0,0 +1,443 @@ +# Netro API Notes & Quirks + +Documentation of Netro Public API (NPA) quirks, limitations, and undocumented behavior discovered during development and testing. + +## API Overview + +- **Base URL**: `http://api.netrohome.com/npa/v1/` +- **Authentication**: Device serial number as URL parameter (`?key={serial}`) +- **Rate Limit**: 2000 calls per day per serial number +- **Response Format**: JSON +- **HTTP Methods**: GET, POST, PUT + +## Known Quirks + +### 1. Timestamp Format Inconsistency + +**Issue**: API docs say timestamps are numbers, but API returns strings + +**Discovered**: Live testing (commit 9e93000) + +**Example**: +```json +{ + "start_time": "1740664800000" // String, not number! +} +``` + +**Workaround**: +```python +# Handle both string and number +start_time_raw = data.get("start_time", 0) +try: + start_time = float(start_time_raw) if isinstance(start_time_raw, str) else start_time_raw +except (ValueError, TypeError): + start_time = 0 +``` + +**Affected Fields**: +- `start_time` in schedules +- `time` in various responses +- Any Unix timestamp field + +**Fixed in**: plugin.py lines 309-314, 337-344 + +### 2. Device vs Devices Response + +**Issue**: Inconsistent array/object response structure + +**API documentation says**: Returns `devices` array + +**Reality**: Returns single `device` object + +**Example**: +```json +{ + "status": "OK", + "data": { + "device": { // Singular! + "serial": "...", + "name": "...", + "zones": [...] + } + } +} +``` + +**Not**: +```json +{ + "data": { + "devices": [...] // This doesn't exist + } +} +``` + +**Workaround**: +```python +device = reply_dict["data"]["device"] // Not devices[0] +``` + +**Fixed in**: plugin.py line 233, test_local_api.py line 98 + +### 3. Offline Status Quirks + +**Issue**: Controller status changes are slow and inconsistent + +**Observations from live testing**: + +**When controller turned OFF**: +- Status changes from "ONLINE" to "STANDBY" (not "OFFLINE"!) +- Schedules reduce from 50 to 2 +- Last active timestamp updates +- Can take 5-10 minutes to reflect in API + +**When controller turned ON**: +- Status changes from "STANDBY" to "ONLINE" +- Schedules repopulate +- Can take even longer (10+ minutes) + +**Implications**: +- Don't rely on immediate status changes +- "STANDBY" can mean either user-set standby OR offline +- Use `last_active` timestamp for better offline detection + +### 4. Smart Schedule Types + +**Issue**: Underdocumented schedule source types + +**Observed values**: +- `"AUTOMATIC"` - Auto-generated smart schedule +- `"MANUAL"` - User-created manual schedule +- `"SMART"` - Smart schedule variant +- `"FIX"` - Fixed schedule +- Others unknown + +**Also seen**: +- `smart` field can be boolean `true`/`false` +- OR string `"SMART"`/`"MANUAL"` + +**Workaround**: +```python +smart = zone.get('smart', 'MANUAL') +# Handle both boolean and string +if isinstance(smart, bool): + smart_str = "SMART" if smart else "MANUAL" +else: + smart_str = smart +``` + +### 5. API Rate Limiting Behavior + +**Issue**: Undocumented throttle response format + +**When rate limit exceeded**: +- HTTP 429 response +- No specific error message in JSON +- Plugin must track throttle state locally + +**Token tracking**: +```json +{ + "meta": { + "token_remaining": 1847, + "token_reset": 1609545600 // Unix timestamp + } +} +``` + +**Observations**: +- Token count decreases by 1 per call +- Resets at midnight UTC +- Going over limit triggers 61-minute lockout +- Subsequent calls during lockout also return 429 + +**Best practices**: +- Check `token_remaining` in every response +- Warn user when <100 remaining +- Implement exponential backoff if needed + +### 6. `/moistures.json` — predictions, not sensor readings + +**Issue**: `/moistures.json` returns Netro's **smart-model daily prediction** +for each zone, *not* a sampled sensor value. The model assumes full +saturation immediately after irrigation, then decays based on local +weather inputs. + +For a zone with **no** paired Whisperer, this is the best signal +available. For a zone **with** a paired Whisperer (pairing done in the +Netro mobile app), Netro's app overlays the Whisperer's reading on the +zone tile, but **the `/moistures.json` response itself continues to +return the prediction** — the public API does not expose the app-side +overlay. + +This means the plugin's zone `moisture` state will diverge from a +paired Whisperer's actual reading whenever the model and reality +disagree. Observed example: same zone, same moment — `/moistures.json` += 89%, paired Whisperer = 24%. + +**Response format**: +```json +{ + "moistures": [ + { + "id": 12345, + "zone": 1, + "moisture": 45, + "date": "2025-01-27" + } + ] +} +``` + +**Plugin behavior**: + +- The zone `moistureForecast` state always holds the raw + `/moistures.json` value. +- The zone `moisture` state resolves to: the paired Whisperer's current + `soilMoisture` if the pairing is configured on the zone device and + the reading is less than 12 hours old, else `moistureForecast`. +- Pairing is plugin-side (zone ConfigUI → "Paired Whisperer" dropdown), + independent of any Netro-side pairing. Both can coexist; they don't + interact. + +**Workarounds** (when consuming the raw feed): +- Sort by ID to get most recent: `sort(key=lambda x: x['id'], reverse=True)` +- Filter by most recent date +- Show date with moisture value so users understand it is a daily + prediction, not a live reading + +**Open empirical question**: whether `/moistures.json` continues to +produce sane predictions when a Whisperer is paired on Netro's side but +has stopped reporting (dead battery, unplugged). The issue-#54 +investigation showed `/moistures.json` returning a saturation-based +prediction even with a working paired Whisperer, suggesting the +prediction is independent of Whisperer reporting state. Recommend +dogfooding with a disconnected Whisperer for a week before relying on +the fallback in production. + +### 7. Whisperer Sensor Reporting + +**Issue**: Sensor update frequency is variable + +**Observations**: +- Sensors report every 4-6 hours normally +- Can be longer if battery low +- No way to force immediate update +- Battery level crucial - <20% = unreliable + +**Response structure**: +```json +{ + "sensor_data": [ + { + "id": 123, + "moisture": 45, + "celsius": 22, + "fahrenheit": 72, + "sunlight": 1200, + "battery_level": 85, + "time": "...", + "local_date": "2025-01-27", + "local_time": "14:30:00" + } + ] +} +``` + +**Field notes**: +- `local_date` and `local_time` are STRINGS (not swapped anymore - fixed in commit 617a630) +- `battery_level` is percentage (0-100) +- `sunlight` is in lux +- Most recent reading is first after sorting by ID + +### 8. Weather Reporting Quirks + +**Issue**: Weather parameters poorly documented + +**Required fields**: +- `key`: Serial number +- `t`: Current temperature (Fahrenheit) +- `date`: YYYY-MM-DD format + +**Optional fields**: +- `t_max`, `t_min`: High/low temperature +- `humidity`: 0-100 percentage +- `condition`: Weather code (0=clear, others undocumented) +- `rain`: Rainfall amount (units unclear) +- `rain_prob`: Rain probability 0-100 +- `wind_speed`: Wind speed (units unclear - likely mph) +- `pressure`: Atmospheric pressure (units unclear - likely inHg) + +**Condition codes** (observed/guessed): +``` +0 = Clear +1 = Cloudy? +2 = Rain? +3+ = Unknown +``` + +**Note**: Weather reporting doesn't trigger immediate schedule changes + +### 9. Zone Control Limitations + +**What works**: +- ✅ Start individual zone with duration +- ✅ Stop all zones +- ✅ Set rain delay +- ✅ Set standby mode + +**What doesn't work** (API limitations): +- ❌ Pause/resume individual zones +- ❌ Skip to next zone in schedule +- ❌ Go back to previous zone +- ❌ Modify zone duration while running +- ❌ Create/modify schedules via API +- ❌ Change zone settings (soil type, etc.) + +**Advanced features** (available but less tested): +- `delay`: Minutes before starting (0-60) +- `start_time`: Schedule for future (Unix timestamp) + +### 10. Error Response Format + +**Issue**: Inconsistent error responses + +**Success**: +```json +{ + "status": "OK", + "data": {...}, + "meta": {...} +} +``` + +**Error examples**: +```json +// HTTP 401 +{ + "status": "ERROR", + "error": "Invalid key" +} + +// HTTP 429 +{ + "status": "ERROR", + "error": "Rate limit exceeded" +} + +// HTTP 500 +{ + "status": "ERROR", + "error": "Internal server error" +} +``` + +**Sometimes**: Just HTTP status code, no JSON body + +**Workaround**: Check HTTP status first, then parse JSON if available + +## Testing Observations + +### Live Testing Results (Jan 2025) + +**Test controller**: "Clark Castle Spark" +- Serial: 0cb8152f9f78 +- 16 zones total +- 8 enabled zones +- API tokens: 1665/2000 remaining + +**When ONLINE**: +- 50 schedules returned +- Status: "ONLINE" +- All zone data populated + +**When OFFLINE** (controller powered off): +- 2 schedules returned (down from 50) +- Status: "STANDBY" (not "OFFLINE"!) +- Historical moisture data still available +- `last_active` timestamp updates when status changes + +**Polling behavior**: +- 3-minute interval: ~480 calls/day (safe) +- 5-minute interval: ~288 calls/day (very safe) +- 1-minute interval: ~1440 calls/day (risky!) + +## Recommendations + +### For Plugin Developers + +1. **Always handle both string and number timestamps** +2. **Don't assume response structure matches docs** +3. **Test with controller both online and offline** +4. **Implement generous error handling** +5. **Log API responses in debug mode** +6. **Check token_remaining in every response** +7. **Use conservative polling intervals (5+ minutes)** + +### For Users + +1. **Increase polling interval if approaching rate limit** +2. **Don't rely on immediate status updates** +3. **Use Netro app for zone configuration** +4. **Replace sensor batteries when <20%** +5. **Allow 10-15 minutes for status changes** + +## Useful Testing Commands + +### Test API directly: +```bash +# Device info +curl "http://api.netrohome.com/npa/v1/info.json?key=YOUR_SERIAL" + +# Schedules +curl "http://api.netrohome.com/npa/v1/schedules.json?key=YOUR_SERIAL" + +# Moisture +curl "http://api.netrohome.com/npa/v1/moistures.json?key=YOUR_SERIAL" +``` + +### Use test script: +```bash +python3 test_local_api.py --serial YOUR_SERIAL +``` + +See [LOCAL_TESTING.md](LOCAL_TESTING.md) for details. + +## API Documentation + +Official (limited) docs: +- https://www.netrohome.com/en/shop/articles/10 + +Better info sources: +- This file (API_NOTES.md) +- [NETRO_API.md](NETRO_API.md) - Full endpoint documentation +- test_local_api.py source code +- plugin.py _make_api_call() implementation + +## Version History + +**Discoveries by version**: +- v1.0 (initial): Basic API integration +- v2.0 (overhaul): + - Timestamp string/number handling (commit 9e93000) + - Device vs devices fix (commit 617a630) + - Offline status observations (Jan 2025 testing) + - Token warning thresholds added + - All quirks documented in this file + +## Future Watch List + +**Behaviors to monitor**: +- Schedule type values (may discover new types) +- Weather condition codes (need complete list) +- Error response formats (may vary) +- New API endpoints (if Netro adds features) +- Rate limit policy changes + +**Report issues**: +- Unexpected API responses +- New schedule types +- Different error formats +- Undocumented fields + From df41e3929c8b1ca96c0ee416dd202b0dc7f1b68c Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:51:32 +0100 Subject: [PATCH 15/27] docs(netro): document Whisperer-zone pairing in README (#54) --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index cf2f2b7..756adbb 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,19 @@ Track your daily API call usage directly from Indigo device states. The Netro AP Integrate Whisperer soil sensors to monitor temperature, humidity, and moisture levels independently of your sprinkler zones. +### Pairing a Whisperer to a Zone + +Each zone device has a **Paired Whisperer** dropdown in its config UI. +When paired and the Whisperer has reported within the last 12 hours, +the zone's `moisture` state mirrors the Whisperer's `soilMoisture` +reading (the actual measured value). Otherwise the zone falls back to +Netro's daily forecast. + +The `/moistures.json` forecast is always visible separately on the +`moistureForecast` state — useful for comparing model predictions to +the sensor's ground truth, and for tuning schedules. See +[`docs/API_NOTES.md`](docs/API_NOTES.md) §6 for details. + ### Indigo Triggers Create automations based on sprinkler events: From 16142f49230c45876b03f0042417abc25d37809f Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Thu, 23 Apr 2026 17:52:43 +0100 Subject: [PATCH 16/27] chore(netro): bump PluginVersion to 2026.5.0 for Whisperer-zone pairing (#54) --- Netro Sprinklers.indigoPlugin/Contents/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index 22e459d..8570a4d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.4.4 + 2026.5.0 ServerApiVersion 3.6 IwsApiVersion From 580a4272512e4767ed7d78529e4f2d605716fb86 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 24 Apr 2026 09:45:22 +0100 Subject: [PATCH 17/27] fix(netro): don't assign read-only pluginProps on zone device (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit indigo.devices.Device.pluginProps is read-only — the test doubles were plain dicts so the assignment worked in tests but crashed production with "the attribute 'pluginProps' is read-only on this instance". Remove the assignment; rely on replacePluginPropsOnServer to persist, which Indigo then reflects back into pluginProps after the round-trip. Update test fixtures so their replacePluginPropsOnServer stubs mutate the pluginProps dict in place — simulating Indigo's real behavior rather than the previous leak-through. --- .../Contents/Server Plugin/plugin.py | 1 - tests/test_moisture_source_logging.py | 3 +++ tests/test_update_zone_devices_integration.py | 16 ++++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index b5007f4..421d708 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1300,7 +1300,6 @@ def _log_moisture_source_transition(self, zone_dev, new_source): # Persist the new source so the next poll can detect the next transition. new_props = dict(zone_dev.pluginProps) new_props["lastMoistureSource"] = new_source - zone_dev.pluginProps = new_props # keep test-side dict in sync zone_dev.replacePluginPropsOnServer(new_props) ######################################## diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index fd762db..4af5fb9 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -37,6 +37,9 @@ def _zone(last_source=None, name="Test Zone"): def _replace(new_props): replaced.append(dict(new_props)) + # Simulate Indigo's real behavior: pluginProps reflects the server write. + props.clear() + props.update(new_props) return SimpleNamespace( name=name, diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index 26c8405..b7b847c 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -58,20 +58,24 @@ def _zone_dev(zone_num=1, linked_id="", name="Front Lawn"): """ replaced_states = [] replaced_props = [] + props = { + "zoneNumber": str(zone_num), + "linkedWhispererDeviceId": linked_id, + } def _update_states(states): replaced_states.extend(states) - def _replace_props(props): - replaced_props.append(dict(props)) + def _replace_props(new_props): + replaced_props.append(dict(new_props)) + # Simulate Indigo's real behavior: pluginProps reflects the server write. + props.clear() + props.update(new_props) dev = SimpleNamespace( id=1000 + zone_num, name=name, - pluginProps={ - "zoneNumber": str(zone_num), - "linkedWhispererDeviceId": linked_id, - }, + pluginProps=props, enabled=True, states={}, deviceTypeId="zone", From 23fabb81eb6e1c2032255dad48a0b4e611c430c5 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 24 Apr 2026 12:02:21 +0100 Subject: [PATCH 18/27] fix(netro): protect zone state writes from transition-log failures (#54) _update_zone_devices called _log_moisture_source_transition before updateStatesOnServer, and the logger's replacePluginPropsOnServer had no try/except. An IOM hiccup during the prop write would raise, bubble to the outer zone try/except, and silently drop the entire state batch (moisture, moistureForecast, schedules, isIrrigating). Fix: persist state first, then log transitions. Additionally wrap the prop write in try/except so the auxiliary log can never gate the primary state persistence. --- .../Contents/Server Plugin/plugin.py | 15 +++++++++++++-- tests/test_moisture_source_logging.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 421d708..a8c1cf5 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -757,11 +757,15 @@ def _update_zone_devices(self, parent_dev, device_data, schedule_response, moist "value": moisture_val, "uiValue": f"{moisture_val}%", }) - self._log_moisture_source_transition(zone_dev, source) + # State write FIRST — it's the authoritative side effect. if states: zone_dev.updateStatesOnServer(states) + # Transition log is auxiliary; must not gate the state write. + if is_enabled: + self._log_moisture_source_transition(zone_dev, source) + # Set error state and icon after state update if not is_enabled: zone_dev.setErrorStateOnServer('disabled') @@ -1300,7 +1304,14 @@ def _log_moisture_source_transition(self, zone_dev, new_source): # Persist the new source so the next poll can detect the next transition. new_props = dict(zone_dev.pluginProps) new_props["lastMoistureSource"] = new_source - zone_dev.replacePluginPropsOnServer(new_props) + try: + zone_dev.replacePluginPropsOnServer(new_props) + except Exception as exc: + self.logger.warning( + f"Zone '{zone_dev.name}': could not persist moisture source " + f"'{new_source}' ({type(exc).__name__}: {exc}) — transition " + f"log may repeat next cycle." + ) ######################################## # Validation callbacks diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index 4af5fb9..50d0f6a 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -111,3 +111,20 @@ def test_repeated_warning_suppressed(plugin_instance): plugin_instance._log_moisture_source_transition(zone, "forecast-stale") plugin_instance._log_moisture_source_transition(zone, "forecast-stale") assert plugin_instance.logger.warning.call_count == 1 + + +def test_replace_props_failure_logs_warning_not_raises(plugin_instance): + """If replacePluginPropsOnServer raises, logger.warning is called and no exception escapes.""" + zone = _zone(last_source="whisperer") + + def _failing_replace(new_props): + raise RuntimeError("IOM hiccup") + + zone.replacePluginPropsOnServer = _failing_replace + # Should not raise. + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + # The stale transition warning AND the persistence-failure warning should both have fired. + assert plugin_instance.logger.warning.call_count >= 1 + # Verify at least one warning mentions persistence failure. + messages = [call.args[0] for call in plugin_instance.logger.warning.call_args_list] + assert any("could not persist moisture source" in m for m in messages) From 82dd75bdf6026d778c528f0818734345dcf53856 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 24 Apr 2026 12:04:13 +0100 Subject: [PATCH 19/27] fix(netro): split moisture-source tags + add readingID liveness (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver previously collapsed 5 distinct failure modes into "forecast-stale", so the user-facing log message claimed "stale (>12h old)" even when the sensor was just uninitialized or its readingTime was garbage. Split into three tags: forecast-missing-reading: readingID==0 or soilMoisture absent or non-numeric (sensor hasn't reported, or state is uninitialised) forecast-unparseable-time: readingTime can't be parsed forecast-stale: genuinely >12h old Also add a readingID!=0 liveness check because real Indigo returns integer default 0 for unset Integer states — so an unreported Whisperer could silently return (0, "whisperer") and look like a real 0% reading. Tests updated to reflect production defaults. Adds a debug breadcrumb in the resolver logging the raw readingTime when parsing fails, so support debugging has the data. --- .../Contents/Server Plugin/plugin.py | 42 +++++++++++-- tests/test_moisture_source_logging.py | 16 +++++ tests/test_update_zone_devices_integration.py | 3 +- tests/test_zone_moisture_resolution.py | 61 +++++++++++++++---- 4 files changed, 104 insertions(+), 18 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index a8c1cf5..53af607 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1217,14 +1217,18 @@ def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0) def _resolve_zone_moisture(self, zone_dev, forecast_val): """Resolve the "moisture" state value for a zone device. - Pure function (no state writes, no logging). Returns a - ``(value, source_tag)`` pair where source_tag is one of: + Returns a ``(value, source_tag)`` pair where source_tag is one of: - ``"forecast"``: zone has no paired Whisperer; returns forecast_val. - ``"whisperer"``: paired Whisperer exists, is enabled, has a fresh (<= WHISPERER_STALENESS_HOURS old) ``soilMoisture`` reading. - - ``"forecast-stale"``: paired but reading is missing, too old, or - ``readingTime`` is unparseable. + - ``"forecast-missing-reading"``: paired but ``readingID`` is 0 (the + Indigo Integer-state default — sensor hasn't reported yet) or + ``soilMoisture`` is missing/non-numeric. + - ``"forecast-unparseable-time"``: paired and reading present but + ``readingTime`` cannot be parsed. + - ``"forecast-stale"``: paired, readingID > 0, soil numeric, age + parsed, but > WHISPERER_STALENESS_HOURS old. - ``"forecast-missing-device"``: paired device id does not resolve to an Indigo device (deleted or invalid id). - ``"forecast-disabled-device"``: paired device exists but is @@ -1233,6 +1237,9 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): ``value`` may be ``None`` if forecast_val is None and no Whisperer value is available; the caller should skip writing ``moisture`` in that case. + + Note: emits a debug breadcrumb (no other logging) when readingTime + is unparseable so support debugging has the raw value. """ linked_id = zone_dev.pluginProps.get("linkedWhispererDeviceId", "") if not linked_id: @@ -1246,16 +1253,29 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): if not whisperer.enabled: return forecast_val, "forecast-disabled-device" + reading_id = whisperer.states.get("readingID") or 0 soil = whisperer.states.get("soilMoisture") reading_time = whisperer.states.get("readingTime", "") + + if reading_id == 0 or soil is None: + return forecast_val, "forecast-missing-reading" + age_hours = parse_reading_age_hours(reading_time) - if soil is None or age_hours is None or age_hours > WHISPERER_STALENESS_HOURS: + if age_hours is None: + # Leave a debug breadcrumb with the raw value so support debugging has the data. + self.logger.debug( + f"Zone '{zone_dev.name}': paired Whisperer readingTime " + f"{reading_time!r} is unparseable — treating as stale." + ) + return forecast_val, "forecast-unparseable-time" + + if age_hours > WHISPERER_STALENESS_HOURS: return forecast_val, "forecast-stale" try: return int(soil), "whisperer" except (TypeError, ValueError): - return forecast_val, "forecast-stale" + return forecast_val, "forecast-missing-reading" def _log_moisture_source_transition(self, zone_dev, new_source): """Log a transition between moisture-source categories for a zone. @@ -1290,6 +1310,16 @@ def _log_moisture_source_transition(self, zone_dev, new_source): f"Zone '{zone_dev.name}': paired Whisperer reading stale " f"(>12h old) — falling back to Netro forecast." ) + elif new_source == "forecast-missing-reading": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer has no reading yet " + f"— showing Netro forecast until sensor reports." + ) + elif new_source == "forecast-unparseable-time": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer readingTime is " + f"unparseable — showing Netro forecast. Check Whisperer poll." + ) elif new_source == "forecast-missing-device": self.logger.warning( f"Zone '{zone_dev.name}': paired Whisperer device no longer " diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index 50d0f6a..a6a7188 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -113,6 +113,22 @@ def test_repeated_warning_suppressed(plugin_instance): assert plugin_instance.logger.warning.call_count == 1 +def test_log_warning_on_missing_reading(plugin_instance): + """Paired, but Whisperer has no reading yet (readingID==0) → warning.""" + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-missing-reading") + plugin_instance.logger.warning.assert_called_once() + assert "no reading yet" in plugin_instance.logger.warning.call_args[0][0].lower() + + +def test_log_warning_on_unparseable_time(plugin_instance): + """Paired, but readingTime can't be parsed → warning.""" + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-unparseable-time") + plugin_instance.logger.warning.assert_called_once() + assert "unparseable" in plugin_instance.logger.warning.call_args[0][0].lower() + + def test_replace_props_failure_logs_warning_not_raises(plugin_instance): """If replacePluginPropsOnServer raises, logger.warning is called and no exception escapes.""" zone = _zone(last_source="whisperer") diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index b7b847c..bb07bf2 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -89,7 +89,7 @@ def _replace_props(new_props): return dev -def _whisperer(soil=24, hours_old=2): +def _whisperer(soil=24, hours_old=2, reading_id=1001): return SimpleNamespace( enabled=True, states={ @@ -97,6 +97,7 @@ def _whisperer(soil=24, hours_old=2): "readingTime": (FROZEN_NOW - timedelta(hours=hours_old)).strftime( "%Y-%m-%dT%H:%M:%S" ), + "readingID": reading_id, }, ) diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py index 7a5f9eb..1280def 100644 --- a/tests/test_zone_moisture_resolution.py +++ b/tests/test_zone_moisture_resolution.py @@ -39,18 +39,27 @@ def _getitem(dev_id): @pytest.fixture def plugin_instance(mock_indigo): from plugin import Plugin # noqa: WPS433 - return Plugin.__new__(Plugin) + plugin = Plugin.__new__(Plugin) + plugin.logger = MagicMock() + return plugin -def _fake_whisperer(enabled=True, soil=30, reading_time="2026-04-23T10:00:00"): +def _fake_whisperer(enabled=True, soil=30, reading_time="2026-04-23T10:00:00", reading_id=1001): return SimpleNamespace( enabled=enabled, - states={"soilMoisture": soil, "readingTime": reading_time}, + states={ + "soilMoisture": soil, + "readingTime": reading_time, + "readingID": reading_id, + }, ) -def _fake_zone(linked_id=""): - return SimpleNamespace(pluginProps={"linkedWhispererDeviceId": linked_id}) +def _fake_zone(linked_id="", name="Test Zone"): + return SimpleNamespace( + name=name, + pluginProps={"linkedWhispererDeviceId": linked_id}, + ) FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) @@ -141,7 +150,7 @@ def test_paired_unparseable_reading_time(plugin_instance, mock_indigo): mock_indigo._devices_by_id[999] = whisperer zone = _fake_zone(linked_id="999") val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) - assert (val, src) == (89, "forecast-stale") + assert (val, src) == (89, "forecast-unparseable-time") # --- Paired, no soilMoisture state --- @@ -149,13 +158,13 @@ def test_paired_unparseable_reading_time(plugin_instance, mock_indigo): def test_paired_no_soil_state(plugin_instance, mock_indigo): whisperer = SimpleNamespace( enabled=True, - states={"readingTime": "2026-04-23T10:00:00"}, # soilMoisture missing + states={"readingTime": "2026-04-23T10:00:00", "readingID": 1001}, # soilMoisture missing ) mock_indigo._devices_by_id[999] = whisperer zone = _fake_zone(linked_id="999") with patch("utils._now_utc", return_value=FROZEN_NOW): val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) - assert (val, src) == (89, "forecast-stale") + assert (val, src) == (89, "forecast-missing-reading") # --- Paired, v1 epoch-millis readingTime --- @@ -173,14 +182,44 @@ def test_paired_fresh_v1_epoch_millis(plugin_instance, mock_indigo): # --- Paired, non-numeric soilMoisture (defensive) --- -def test_paired_non_numeric_soil_treated_as_stale(plugin_instance, mock_indigo): +def test_paired_non_numeric_soil_treated_as_missing_reading(plugin_instance, mock_indigo): """Non-numeric soilMoisture (should never happen in practice) falls back safely.""" whisperer = SimpleNamespace( enabled=True, - states={"soilMoisture": "unknown", "readingTime": "2026-04-23T10:00:00"}, + states={ + "soilMoisture": "unknown", + "readingTime": "2026-04-23T10:00:00", + "readingID": 1001, + }, ) mock_indigo._devices_by_id[999] = whisperer zone = _fake_zone(linked_id="999") with patch("utils._now_utc", return_value=FROZEN_NOW): val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) - assert (val, src) == (89, "forecast-stale") + assert (val, src) == (89, "forecast-missing-reading") + + +# --- Paired, readingID == 0 (sensor uninitialised) --- + +def test_paired_reading_id_zero_is_missing_reading(plugin_instance, mock_indigo): + """readingID == 0 (Indigo Integer-state default) → sensor hasn't reported yet.""" + fresh = (FROZEN_NOW - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + whisperer = _fake_whisperer(soil=24, reading_time=fresh, reading_id=0) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (89, "forecast-missing-reading") + + +# --- Paired, real soil=0 reading --- + +def test_paired_soil_integer_zero_with_valid_reading(plugin_instance, mock_indigo): + """soil=0 with readingID>0 and fresh time IS a valid sensor report (bone-dry).""" + fresh = (FROZEN_NOW - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") + whisperer = _fake_whisperer(soil=0, reading_time=fresh, reading_id=1001) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert (val, src) == (0, "whisperer") From ac0a46861a472da8afec614b893dea4a78ab5648 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 24 Apr 2026 12:06:41 +0100 Subject: [PATCH 20/27] test(netro): add wiring/boundary/cold-start coverage + hoist fixtures (#54) - Integration test confirms _update_zone_devices actually calls _log_moisture_source_transition (closes wiring gap). - process_zone_moisture returns [] on empty/missing/error paths instead of a fake 0% row, so moistureForecast no longer gets written on API outages. - Boundary test at 11.9/12.0/12.1h locks in the > (not >=) semantics of WHISPERER_STALENESS_HOURS. - Cold-start -> whisperer silence + unchanged-source no-prop-write tests close gaps flagged in review. - Hoist _PluginBase stub + mock_indigo_base fixture into conftest.py to stop the three test modules drifting. - Narrow _update_zone_devices inner except to the exception shapes that can actually leak from process_zone_moisture. --- .../Contents/Server Plugin/device_handlers.py | 6 +- .../Contents/Server Plugin/plugin.py | 7 ++- tests/conftest.py | 26 ++++++++- tests/test_moisture_source_logging.py | 28 ++++------ tests/test_update_zone_devices_integration.py | 55 +++++++++++++++---- tests/test_zone_handler.py | 8 +-- tests/test_zone_moisture_resolution.py | 40 ++++++++------ 7 files changed, 114 insertions(+), 56 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py index 491a24b..b09288f 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py @@ -698,7 +698,7 @@ def process_zone_moisture(self, api_response, zone_number): try: moistures = api_response["data"]["moistures"] if not moistures: - return [{"key": "moisture", "value": 0, "uiValue": "0%"}] + return [] moistures_sorted = sorted(moistures, key=lambda x: x.get("id", 0), reverse=True) max_date = moistures_sorted[0].get("date") @@ -708,11 +708,11 @@ def process_zone_moisture(self, api_response, zone_number): val = m.get("moisture", 0) return [{"key": "moisture", "value": val, "uiValue": f"{val}%"}] - return [{"key": "moisture", "value": 0, "uiValue": "0%"}] + return [] except (KeyError, TypeError, IndexError) as exc: self.logger.error(f"Error parsing zone moisture: {exc}") - return [{"key": "moisture", "value": 0, "uiValue": "0%"}] + return [] def extract_zone_states(self, zones, zone_number): """Extract enabled and smartMode for a single zone from info data. diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 53af607..7b5797d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -742,10 +742,11 @@ def _update_zone_devices(self, parent_dev, device_data, schedule_response, moist found = True else: states.append(entry) - except Exception: + except (AttributeError, TypeError, KeyError, IndexError): self.logger.exception( - f"Error processing moisture for zone {zone_num} " - f"on '{zone_dev.name}'" + f"Error processing moisture for zone {zone_num} on " + f"'{zone_dev.name}' — moisture + moistureForecast " + f"states will not update this cycle." ) moisture_val, source = self._resolve_zone_moisture( diff --git a/tests/conftest.py b/tests/conftest.py index d0efe11..d3fedfb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ """ import sys from pathlib import Path -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest # Add Server Plugin directory to path for imports @@ -192,6 +192,30 @@ def sample_v2_schedules(): } +class _IndigoPluginBaseStub: + """Stand-in for indigo.PluginBase used at Plugin class definition time. + + Real Indigo's PluginBase performs server-bound initialisation the tests + don't need; subclassing this empty stub lets `class Plugin(indigo.PluginBase):` + succeed under MagicMock-based test doubles. + """ + + +@pytest.fixture +def mock_indigo_base(monkeypatch): + """Install a minimal indigo module into sys.modules. + + Tests that need `indigo.devices[id]` lookups can extend the returned + MagicMock with their own `_devices_by_id` dict and side_effect. + """ + indigo = MagicMock() + indigo.PluginBase = _IndigoPluginBaseStub + indigo.Dict = dict + monkeypatch.setitem(sys.modules, "indigo", indigo) + monkeypatch.delitem(sys.modules, "plugin", raising=False) + return indigo + + @pytest.fixture def sample_v2_sensor_data(): """Sample v2 sensor data response with ISO 8601 timestamps.""" diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index a6a7188..5967b2f 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -1,27 +1,12 @@ """Tests for Plugin._log_moisture_source_transition.""" -import sys from types import SimpleNamespace from unittest.mock import MagicMock import pytest -class _PluginBase: - """Stand-in for indigo.PluginBase.""" - - -@pytest.fixture -def mock_indigo(monkeypatch): - indigo = MagicMock() - indigo.PluginBase = _PluginBase - indigo.Dict = dict - monkeypatch.setitem(sys.modules, "indigo", indigo) - monkeypatch.delitem(sys.modules, "plugin", raising=False) - return indigo - - @pytest.fixture -def plugin_instance(mock_indigo): +def plugin_instance(mock_indigo_base): from plugin import Plugin # noqa: WPS433 plugin = Plugin.__new__(Plugin) plugin.logger = MagicMock() @@ -56,6 +41,17 @@ def test_no_log_when_source_unchanged(plugin_instance): plugin_instance.logger.info.assert_not_called() # pluginProps still reflect the (unchanged) value. assert zone.pluginProps.get("lastMoistureSource") == "whisperer" + # Repeated calls with same source must not hit replacePluginPropsOnServer. + assert zone._replaced == [] + + +def test_no_log_when_cold_start_whisperer(plugin_instance): + """Paired zone, first poll with no prior lastMoistureSource → silent.""" + zone = _zone(last_source=None) + plugin_instance._log_moisture_source_transition(zone, "whisperer") + plugin_instance.logger.warning.assert_not_called() + plugin_instance.logger.info.assert_not_called() + assert zone.pluginProps["lastMoistureSource"] == "whisperer" def test_log_info_on_forecast_to_whisperer(plugin_instance): diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index bb07bf2..14a0c87 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -5,7 +5,6 @@ - moisture gets the resolved value (Whisperer if fresh + paired, else forecast). - Source transitions are logged. """ -import sys from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -16,15 +15,10 @@ FROZEN_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=timezone.utc) -class _PluginBase: - pass - - @pytest.fixture -def mock_indigo(monkeypatch): - indigo = MagicMock() - indigo.PluginBase = _PluginBase - indigo.Dict = dict +def mock_indigo(mock_indigo_base): + """Extend the shared mock_indigo_base with a `_devices_by_id` lookup.""" + indigo = mock_indigo_base indigo._devices_by_id = {} def _getitem(dev_id): @@ -33,8 +27,6 @@ def _getitem(dev_id): return indigo._devices_by_id[dev_id] indigo.devices.__getitem__.side_effect = _getitem - monkeypatch.setitem(sys.modules, "indigo", indigo) - monkeypatch.delitem(sys.modules, "plugin", raising=False) return indigo @@ -214,6 +206,47 @@ def test_missing_forecast_but_paired_fresh_writes_sensor(plugin_instance, mock_i assert "moistureForecast" not in keys +def test_source_transition_logged_during_update(plugin_instance, mock_indigo): + """Verify _update_zone_devices actually wires up the transition logger.""" + zone = _zone_dev(zone_num=1, linked_id="999") + zone.pluginProps["lastMoistureSource"] = "whisperer" # prior state + mock_indigo._devices_by_id[999] = _whisperer(soil=24, hours_old=20) # now stale + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + with patch("utils._now_utc", return_value=FROZEN_NOW): + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=89), + api_version="1", + ) + + assert plugin_instance.logger.warning.call_count >= 1 + # The transition should have been recorded. + assert zone.pluginProps.get("lastMoistureSource") == "forecast-stale" + + +def test_empty_moistures_response_skips_forecast_write(plugin_instance, mock_indigo): + """Empty data.moistures → moistureForecast not written (no fake 0%).""" + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda parent_id: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + empty_response = {"status": "OK", "data": {"moistures": []}} + + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=empty_response, + api_version="1", + ) + + keys = {s["key"]: s.get("value") for s in zone._replaced_states} + assert "moistureForecast" not in keys + assert "moisture" not in keys + + def test_paired_stale_falls_back_to_forecast(plugin_instance, mock_indigo): """End-to-end: paired Whisperer with stale reading → moisture shows forecast.""" zone = _zone_dev(zone_num=1, linked_id="999") diff --git a/tests/test_zone_handler.py b/tests/test_zone_handler.py index c72e5a2..481d673 100644 --- a/tests/test_zone_handler.py +++ b/tests/test_zone_handler.py @@ -221,11 +221,11 @@ def test_zone_not_found(self, zone_handler, sample_moistures_response): states = zone_handler.process_zone_moisture( sample_moistures_response, zone_number=99 ) - state_dict = {s["key"]: s["value"] for s in states} - assert state_dict["moisture"] == 0 + # Empty list signals "no data for this zone" — caller skips writing. + assert states == [] def test_empty_moistures(self, zone_handler): response = {"data": {"moistures": []}} states = zone_handler.process_zone_moisture(response, zone_number=1) - state_dict = {s["key"]: s["value"] for s in states} - assert state_dict["moisture"] == 0 + # Empty list signals "no data" — caller skips moistureForecast write. + assert states == [] diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py index 1280def..7f32d2a 100644 --- a/tests/test_zone_moisture_resolution.py +++ b/tests/test_zone_moisture_resolution.py @@ -1,5 +1,4 @@ """Tests for Plugin._resolve_zone_moisture.""" -import sys from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -7,20 +6,10 @@ import pytest -class _PluginBase: - """Stand-in for indigo.PluginBase used at Plugin class definition time.""" - - @pytest.fixture -def mock_indigo(monkeypatch): - """Install a minimal `indigo` module into sys.modules for plugin import. - - `PluginBase` must be a real class so `class Plugin(indigo.PluginBase):` - at import time produces a real class — not a MagicMock attribute. - """ - indigo = MagicMock() - indigo.PluginBase = _PluginBase - indigo.Dict = dict +def mock_indigo(mock_indigo_base): + """Extend the shared mock_indigo_base with a `_devices_by_id` lookup.""" + indigo = mock_indigo_base indigo._devices_by_id = {} def _getitem(dev_id): @@ -29,10 +18,6 @@ def _getitem(dev_id): return indigo._devices_by_id[dev_id] indigo.devices.__getitem__.side_effect = _getitem - monkeypatch.setitem(sys.modules, "indigo", indigo) - # Force a fresh import of `plugin` so the Plugin class is rebuilt against - # this fixture's mock (previous tests may have cached a stale module). - monkeypatch.delitem(sys.modules, "plugin", raising=False) return indigo @@ -223,3 +208,22 @@ def test_paired_soil_integer_zero_with_valid_reading(plugin_instance, mock_indig with patch("utils._now_utc", return_value=FROZEN_NOW): val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) assert (val, src) == (0, "whisperer") + + +# --- Boundary: WHISPERER_STALENESS_HOURS is strict > (not >=) --- + +@pytest.mark.parametrize("hours_old,expected_source", [ + (11.9, "whisperer"), + (12.0, "whisperer"), # boundary is > not >= + (12.1, "forecast-stale"), +]) +def test_staleness_threshold_boundary(plugin_instance, mock_indigo, hours_old, expected_source): + whisperer = _fake_whisperer( + soil=24, + reading_time=(FROZEN_NOW - timedelta(hours=hours_old)).strftime("%Y-%m-%dT%H:%M:%S"), + ) + mock_indigo._devices_by_id[999] = whisperer + zone = _fake_zone(linked_id="999") + with patch("utils._now_utc", return_value=FROZEN_NOW): + _, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) + assert src == expected_source From fded4b75bc30ba02e8abb19061f94188dc7a72e1 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 24 Apr 2026 12:06:53 +0100 Subject: [PATCH 21/27] chore(netro): bump PluginVersion to 2026.5.1 for review fixes (#54) --- Netro Sprinklers.indigoPlugin/Contents/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index 8570a4d..39f4ad2 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.5.0 + 2026.5.1 ServerApiVersion 3.6 IwsApiVersion From ad265657f35e711ceaae46922a2af01890f2e23f Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:28:53 +0100 Subject: [PATCH 22/27] fix(netro): defensive guards for log-spam + resolver exceptions (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent-failure surfaces flagged in round-2 review: F2: when replacePluginPropsOnServer fails, the next poll re-read unchanged pluginProps and re-logged the same transition forever. Add an in-memory fallback dict (lazy-init) so a known-logged transition isn't re-emitted even if persistence is broken. Plugin restart re-syncs from pluginProps as before. F6: _resolve_zone_moisture reads whisperer.states.get(...) and .enabled — if Indigo returns something unexpected (corruption, race), AttributeError bubbles to the outer catch and all state writes for the zone are silently lost. Wrap the resolver call with a narrow catch + warning + forecast fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Contents/Server Plugin/plugin.py | 41 +++++++++++++++---- tests/test_moisture_source_logging.py | 32 +++++++++++++++ tests/test_update_zone_devices_integration.py | 28 +++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 7b5797d..10bc317 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -749,9 +749,16 @@ def _update_zone_devices(self, parent_dev, device_data, schedule_response, moist f"states will not update this cycle." ) - moisture_val, source = self._resolve_zone_moisture( - zone_dev, forecast_val - ) + try: + moisture_val, source = self._resolve_zone_moisture( + zone_dev, forecast_val + ) + except (AttributeError, KeyError, TypeError) as exc: + self.logger.warning( + f"Zone '{zone_dev.name}': could not resolve moisture source " + f"({type(exc).__name__}: {exc}) — falling back to forecast." + ) + moisture_val, source = forecast_val, "forecast" if moisture_val is not None: states.append({ "key": "moisture", @@ -1290,7 +1297,18 @@ def _log_moisture_source_transition(self, zone_dev, new_source): new_source: One of the source tags returned by ``_resolve_zone_moisture``. """ - prev = zone_dev.pluginProps.get("lastMoistureSource") + # Lazy-init the in-memory fallback (Plugin.__new__ in tests skips __init__). + log_cache = getattr(self, "_last_logged_moisture_source", None) + if log_cache is None: + log_cache = {} + self._last_logged_moisture_source = log_cache + + # Prefer in-memory value when available — it survives IOM persistence + # failures, so we don't re-emit the same warning every poll. + prev_persisted = zone_dev.pluginProps.get("lastMoistureSource") + prev_logged = log_cache.get(zone_dev.id) + prev = prev_logged if prev_logged is not None else prev_persisted + if prev == new_source: return @@ -1332,16 +1350,23 @@ def _log_moisture_source_transition(self, zone_dev, new_source): f"— falling back to Netro forecast." ) - # Persist the new source so the next poll can detect the next transition. + # Update the in-memory marker BEFORE attempting persistence so a + # persistence failure doesn't cause repeat-logs next cycle. + log_cache[zone_dev.id] = new_source + + # Best-effort persist via Indigo's IOM. Failures are tolerated. new_props = dict(zone_dev.pluginProps) new_props["lastMoistureSource"] = new_source try: zone_dev.replacePluginPropsOnServer(new_props) - except Exception as exc: + # Tolerate any IOM error — the in-memory fallback above ensures we + # don't log-spam if persistence is unavailable. Narrowing would + # reintroduce fragility against unexpected Indigo exception types. + except Exception as exc: # pylint: disable=broad-exception-caught self.logger.warning( f"Zone '{zone_dev.name}': could not persist moisture source " - f"'{new_source}' ({type(exc).__name__}: {exc}) — transition " - f"log may repeat next cycle." + f"'{new_source}' ({type(exc).__name__}: {exc}) — using " + f"in-memory fallback so the transition log will not repeat." ) ######################################## diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index 5967b2f..a51a5b9 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -13,6 +13,9 @@ def plugin_instance(mock_indigo_base): return plugin +_NEXT_ZONE_ID = [9000] + + def _zone(last_source=None, name="Test Zone"): """A fake zone with a mutable pluginProps dict and a replacePluginPropsOnServer stub.""" props = {} @@ -26,7 +29,10 @@ def _replace(new_props): props.clear() props.update(new_props) + # Each fake zone gets a unique id so the in-memory transition cache can key on it. + _NEXT_ZONE_ID[0] += 1 return SimpleNamespace( + id=_NEXT_ZONE_ID[0], name=name, pluginProps=props, replacePluginPropsOnServer=_replace, @@ -140,3 +146,29 @@ def _failing_replace(new_props): # Verify at least one warning mentions persistence failure. messages = [call.args[0] for call in plugin_instance.logger.warning.call_args_list] assert any("could not persist moisture source" in m for m in messages) + + +def test_repeated_transition_with_persist_failure_logs_once(plugin_instance): + """When replacePluginPropsOnServer keeps failing, the transition log fires only once. + + Pre-fix: every poll re-logged the same transition because pluginProps never updated. + Post-fix: in-memory cache prevents the spam while IOM is broken. + """ + zone = _zone(last_source="whisperer") + # Give the zone an id so the in-memory cache can key on it. + zone.id = 1234 + zone.replacePluginPropsOnServer = lambda new_props: (_ for _ in ()).throw(RuntimeError("IOM")) + + # First call: transition logs the stale warning + the persist-failure warning. + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + + # Second and third calls with the SAME source must not re-log the transition. + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + plugin_instance._log_moisture_source_transition(zone, "forecast-stale") + + # Persistence still fails on every call, but the *transition* warning fires only once. + transition_msgs = [ + c.args[0] for c in plugin_instance.logger.warning.call_args_list + if "stale" in c.args[0].lower() and "Netro forecast" in c.args[0] + ] + assert len(transition_msgs) == 1, transition_msgs diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index 14a0c87..2cad7eb 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -266,3 +266,31 @@ def test_paired_stale_falls_back_to_forecast(plugin_instance, mock_indigo): # Stale Whisperer → fall back to forecast for both states. assert keys["moisture"] == 89 assert keys["moistureForecast"] == 89 + + +def test_resolver_exception_falls_back_to_forecast(plugin_instance, mock_indigo, monkeypatch): + """If _resolve_zone_moisture raises, the zone falls back to forecast and other states still write.""" + zone = _zone_dev(zone_num=1, linked_id="999") + plugin_instance._get_zone_devices = lambda pid: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + def _bad_resolver(*args, **kwargs): + raise AttributeError("simulated IOM corruption") + + monkeypatch.setattr(plugin_instance, "_resolve_zone_moisture", _bad_resolver) + + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=_moistures_response(1, forecast_val=55), + api_version="1", + ) + + keys = {s["key"]: s["value"] for s in zone._replaced_states} + # Forecast still wrote (the resolver failed AFTER moistureForecast was added): + assert keys.get("moistureForecast") == 55 + # Moisture wrote with forecast fallback (resolver returned (forecast_val, "forecast")): + assert keys.get("moisture") == 55 + # Warning was emitted with the right context: + msgs = [c.args[0] for c in plugin_instance.logger.warning.call_args_list] + assert any("could not resolve moisture source" in m for m in msgs) From 86df7d0e41cabe101401b03c14f400509d8cc627 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:30:05 +0100 Subject: [PATCH 23/27] fix(netro): split forecast-invalid-reading from forecast-missing-reading (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review flagged that "non-numeric soilMoisture" was bucketed into forecast-missing-reading alongside "sensor hasn't reported yet" — the operator-actionable signals are different (check sensor firmware vs wait for next poll), but the user-facing log message claimed "no reading yet" for both. Add forecast-invalid-reading tag with a distinct warning message that points operators at the right diagnostic path. Resolver gains a debug breadcrumb logging the raw soilMoisture value when coercion fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Contents/Server Plugin/plugin.py | 41 ++++++++++++------- tests/test_moisture_source_logging.py | 8 ++++ tests/test_zone_moisture_resolution.py | 4 +- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 10bc317..158e1fa 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1228,26 +1228,29 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): Returns a ``(value, source_tag)`` pair where source_tag is one of: - ``"forecast"``: zone has no paired Whisperer; returns forecast_val. - - ``"whisperer"``: paired Whisperer exists, is enabled, has a fresh - (<= WHISPERER_STALENESS_HOURS old) ``soilMoisture`` reading. - - ``"forecast-missing-reading"``: paired but ``readingID`` is 0 (the - Indigo Integer-state default — sensor hasn't reported yet) or - ``soilMoisture`` is missing/non-numeric. - - ``"forecast-unparseable-time"``: paired and reading present but - ``readingTime`` cannot be parsed. - - ``"forecast-stale"``: paired, readingID > 0, soil numeric, age - parsed, but > WHISPERER_STALENESS_HOURS old. + - ``"whisperer"``: paired Whisperer is enabled, has a fresh + (<= WHISPERER_STALENESS_HOURS old) numeric ``soilMoisture`` reading. - ``"forecast-missing-device"``: paired device id does not resolve - to an Indigo device (deleted or invalid id). - - ``"forecast-disabled-device"``: paired device exists but is - disabled in Indigo. + (deleted or invalid id). + - ``"forecast-disabled-device"``: paired device exists but is disabled. + - ``"forecast-missing-reading"``: paired but the sensor has not + reported yet — ``readingID`` is 0 (Indigo Integer-state default) + or ``soilMoisture`` is None. + - ``"forecast-invalid-reading"``: paired and reporting, but + ``soilMoisture`` is present and not coercible to int (corrupt or + unexpected payload — investigate sensor / API). + - ``"forecast-unparseable-time"``: paired and reporting numeric soil, + but ``readingTime`` can't be parsed. + - ``"forecast-stale"``: paired, parseable, but reading is older than + WHISPERER_STALENESS_HOURS. ``value`` may be ``None`` if forecast_val is None and no Whisperer value is available; the caller should skip writing ``moisture`` in that case. Note: emits a debug breadcrumb (no other logging) when readingTime - is unparseable so support debugging has the raw value. + is unparseable or soilMoisture is non-numeric, so support debugging + has the raw values. """ linked_id = zone_dev.pluginProps.get("linkedWhispererDeviceId", "") if not linked_id: @@ -1283,7 +1286,11 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): try: return int(soil), "whisperer" except (TypeError, ValueError): - return forecast_val, "forecast-missing-reading" + self.logger.debug( + f"Zone '{zone_dev.name}': paired Whisperer soilMoisture " + f"{soil!r} is non-numeric — treating as invalid reading." + ) + return forecast_val, "forecast-invalid-reading" def _log_moisture_source_transition(self, zone_dev, new_source): """Log a transition between moisture-source categories for a zone. @@ -1334,6 +1341,12 @@ def _log_moisture_source_transition(self, zone_dev, new_source): f"Zone '{zone_dev.name}': paired Whisperer has no reading yet " f"— showing Netro forecast until sensor reports." ) + elif new_source == "forecast-invalid-reading": + self.logger.warning( + f"Zone '{zone_dev.name}': paired Whisperer reported a " + f"non-numeric soil value — showing Netro forecast. Check " + f"sensor firmware or Netro API response." + ) elif new_source == "forecast-unparseable-time": self.logger.warning( f"Zone '{zone_dev.name}': paired Whisperer readingTime is " diff --git a/tests/test_moisture_source_logging.py b/tests/test_moisture_source_logging.py index a51a5b9..d0762c2 100644 --- a/tests/test_moisture_source_logging.py +++ b/tests/test_moisture_source_logging.py @@ -131,6 +131,14 @@ def test_log_warning_on_unparseable_time(plugin_instance): assert "unparseable" in plugin_instance.logger.warning.call_args[0][0].lower() +def test_log_warning_on_invalid_reading(plugin_instance): + """Paired, but soilMoisture is non-numeric → distinct warning from missing-reading.""" + zone = _zone(last_source="whisperer") + plugin_instance._log_moisture_source_transition(zone, "forecast-invalid-reading") + plugin_instance.logger.warning.assert_called_once() + assert "non-numeric soil value" in plugin_instance.logger.warning.call_args[0][0].lower() + + def test_replace_props_failure_logs_warning_not_raises(plugin_instance): """If replacePluginPropsOnServer raises, logger.warning is called and no exception escapes.""" zone = _zone(last_source="whisperer") diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py index 7f32d2a..b919cb1 100644 --- a/tests/test_zone_moisture_resolution.py +++ b/tests/test_zone_moisture_resolution.py @@ -167,7 +167,7 @@ def test_paired_fresh_v1_epoch_millis(plugin_instance, mock_indigo): # --- Paired, non-numeric soilMoisture (defensive) --- -def test_paired_non_numeric_soil_treated_as_missing_reading(plugin_instance, mock_indigo): +def test_paired_non_numeric_soil_treated_as_invalid_reading(plugin_instance, mock_indigo): """Non-numeric soilMoisture (should never happen in practice) falls back safely.""" whisperer = SimpleNamespace( enabled=True, @@ -181,7 +181,7 @@ def test_paired_non_numeric_soil_treated_as_missing_reading(plugin_instance, moc zone = _fake_zone(linked_id="999") with patch("utils._now_utc", return_value=FROZEN_NOW): val, src = plugin_instance._resolve_zone_moisture(zone, forecast_val=89) - assert (val, src) == (89, "forecast-missing-reading") + assert (val, src) == (89, "forecast-invalid-reading") # --- Paired, readingID == 0 (sensor uninitialised) --- From a2e072362e98f03a62c61b79328e4b3e6e3850f8 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:30:56 +0100 Subject: [PATCH 24/27] test(netro): tighten review wiring + hoist callback fixture + edge cases (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin the source-transition wiring test to user-facing message contents ("stale", "Netro forecast") so future log-wording changes are visible. - Hoist test_whisperer_pairing_callback.py to use the shared mock_indigo_base fixture from conftest.py — closes the remaining drift risk on the _PluginBase stub. - Add coverage for the inner except (AttributeError, TypeError, KeyError, IndexError) in _update_zone_devices so a malformed /moistures.json response can never abort other zone writes. - Parametrize the readingID liveness test to also cover the missing-key case (states without "readingID" at all). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_update_zone_devices_integration.py | 28 +++++++++++++++++++ tests/test_whisperer_pairing_callback.py | 26 ++++------------- tests/test_zone_moisture_resolution.py | 20 +++++++++---- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/tests/test_update_zone_devices_integration.py b/tests/test_update_zone_devices_integration.py index 2cad7eb..5a2a436 100644 --- a/tests/test_update_zone_devices_integration.py +++ b/tests/test_update_zone_devices_integration.py @@ -225,6 +225,10 @@ def test_source_transition_logged_during_update(plugin_instance, mock_indigo): assert plugin_instance.logger.warning.call_count >= 1 # The transition should have been recorded. assert zone.pluginProps.get("lastMoistureSource") == "forecast-stale" + # Pin the user-facing wording so a refactor of the warning message would fail loudly. + warn_msgs = [c.args[0] for c in plugin_instance.logger.warning.call_args_list] + assert any("stale" in m.lower() for m in warn_msgs), warn_msgs + assert any("Netro forecast" in m for m in warn_msgs), warn_msgs def test_empty_moistures_response_skips_forecast_write(plugin_instance, mock_indigo): @@ -268,6 +272,30 @@ def test_paired_stale_falls_back_to_forecast(plugin_instance, mock_indigo): assert keys["moistureForecast"] == 89 +def test_malformed_moisture_response_swallows_and_continues(plugin_instance, mock_indigo): + """A malformed moisture_response triggers the inner except; other state writes survive.""" + zone = _zone_dev(zone_num=1, linked_id="") + plugin_instance._get_zone_devices = lambda pid: {1: zone} + parent = SimpleNamespace(id=42, name="Sprite") + + # data.moistures wrong shape → handler can return [] cleanly OR (worst case) + # caller's loop hits AttributeError on entry.get(). Either way the inner + # except in _update_zone_devices must catch it. + bogus = {"status": "OK", "data": {"moistures": "not-a-list"}} + + plugin_instance._update_zone_devices( + parent, _device_data(), + schedule_response=None, + moisture_response=bogus, + api_version="1", + ) + + # Test reaches this line means no exception escaped to the outer per-zone try/except. + keys = {s["key"] for s in zone._replaced_states} + # moistureForecast not written (bogus moistures path): + assert "moistureForecast" not in keys + + def test_resolver_exception_falls_back_to_forecast(plugin_instance, mock_indigo, monkeypatch): """If _resolve_zone_moisture raises, the zone falls back to forecast and other states still write.""" zone = _zone_dev(zone_num=1, linked_id="999") diff --git a/tests/test_whisperer_pairing_callback.py b/tests/test_whisperer_pairing_callback.py index a7a9acd..60dbf71 100644 --- a/tests/test_whisperer_pairing_callback.py +++ b/tests/test_whisperer_pairing_callback.py @@ -1,31 +1,15 @@ """Tests for Plugin.getWhispererDevices ConfigUI callback.""" -import sys from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @pytest.fixture -def mock_indigo(monkeypatch): - """Install a minimal `indigo` module into sys.modules for plugin import. - - `PluginBase` must be a real class so `class Plugin(indigo.PluginBase):` - at import time produces a real class — not a MagicMock attribute. - """ - indigo = MagicMock() - - class _PluginBase: - pass - - indigo.PluginBase = _PluginBase - indigo.Dict = dict - indigo.devices.iter = MagicMock(return_value=iter([])) - monkeypatch.setitem(sys.modules, "indigo", indigo) - # Force a fresh import of `plugin` so the Plugin class is rebuilt against - # this fixture's mock (previous tests may have cached a stale module). - monkeypatch.delitem(sys.modules, "plugin", raising=False) - return indigo +def mock_indigo(mock_indigo_base): + """Extend conftest mock_indigo_base with iter() for callback tests.""" + mock_indigo_base.devices.iter = MagicMock(return_value=iter([])) + return mock_indigo_base def _fake_device(dev_id, name, type_id="Whisperer"): diff --git a/tests/test_zone_moisture_resolution.py b/tests/test_zone_moisture_resolution.py index b919cb1..98dfc19 100644 --- a/tests/test_zone_moisture_resolution.py +++ b/tests/test_zone_moisture_resolution.py @@ -184,12 +184,20 @@ def test_paired_non_numeric_soil_treated_as_invalid_reading(plugin_instance, moc assert (val, src) == (89, "forecast-invalid-reading") -# --- Paired, readingID == 0 (sensor uninitialised) --- - -def test_paired_reading_id_zero_is_missing_reading(plugin_instance, mock_indigo): - """readingID == 0 (Indigo Integer-state default) → sensor hasn't reported yet.""" - fresh = (FROZEN_NOW - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S") - whisperer = _fake_whisperer(soil=24, reading_time=fresh, reading_id=0) +# --- Paired, readingID == 0 or absent (sensor uninitialised / state missing) --- + +@pytest.mark.parametrize("reading_id_value", [0, None]) +def test_paired_reading_id_zero_or_missing_is_missing_reading( + plugin_instance, mock_indigo, reading_id_value +): + """readingID == 0 OR readingID None (key missing) both → forecast-missing-reading.""" + states = { + "soilMoisture": 24, + "readingTime": (FROZEN_NOW - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S"), + } + if reading_id_value is not None: + states["readingID"] = reading_id_value + whisperer = SimpleNamespace(enabled=True, states=states) mock_indigo._devices_by_id[999] = whisperer zone = _fake_zone(linked_id="999") with patch("utils._now_utc", return_value=FROZEN_NOW): From 1d7870cc8c1972288a4f4faa51ba23601134f681 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:32:19 +0100 Subject: [PATCH 25/27] docs(netro): refresh docstrings for round-2 changes (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _log_moisture_source_transition: document best-effort persistence and in-memory fallback semantics. - _resolve_zone_moisture: enumerate all 8 source tags accurately (was 5 in pre-round-2 doc, now matches the post-split reality). - process_zone_moisture: document the [] empty-list return on the three no-data paths and explain the contract with its caller. - API_NOTES.md §6: tighten "less than 12 hours" to "≤ 12 hours" to match the strict > boundary in code. - conftest.py: thicker _IndigoPluginBaseStub docstring with pointer to mock_indigo_base. - Devices.xml: cross-ref comment so the help text and the WHISPERER_STALENESS_HOURS constant stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Contents/Server Plugin/Devices.xml | 2 ++ .../Contents/Server Plugin/device_handlers.py | 14 +++++++++++++- .../Contents/Server Plugin/plugin.py | 19 +++++++++++++------ docs/API_NOTES.md | 2 +- tests/conftest.py | 14 +++++++++----- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml index a78e4e3..c998667 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml @@ -311,6 +311,8 @@ + diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py index b09288f..21baf69 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py @@ -693,7 +693,19 @@ def process_zone_moisture(self, api_response, zone_number): zone_number: Zone ith number (1-based) Returns: - List with single moisture state update dict + A list containing a single + ``{"key": "moisture", "value": int, "uiValue": "%"}`` dict when + a reading for ``zone_number`` exists on the most recent date. + Returns ``[]`` (empty list) when: + - ``moistures`` is empty or missing, + - no entry matches ``zone_number`` on the most-recent date, or + - the response shape triggers ``KeyError``/``TypeError``/ + ``IndexError`` (the error is logged via ``self.logger.error``, + not raised). + + The empty-list shape signals "no forecast data this cycle" so the + caller can skip writing ``moistureForecast`` rather than persisting + a fake 0%. """ try: moistures = api_response["data"]["moistures"] diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 158e1fa..b784282 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -1229,7 +1229,7 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): - ``"forecast"``: zone has no paired Whisperer; returns forecast_val. - ``"whisperer"``: paired Whisperer is enabled, has a fresh - (<= WHISPERER_STALENESS_HOURS old) numeric ``soilMoisture`` reading. + (≤ WHISPERER_STALENESS_HOURS old) numeric ``soilMoisture`` reading. - ``"forecast-missing-device"``: paired device id does not resolve (deleted or invalid id). - ``"forecast-disabled-device"``: paired device exists but is disabled. @@ -1241,8 +1241,8 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): unexpected payload — investigate sensor / API). - ``"forecast-unparseable-time"``: paired and reporting numeric soil, but ``readingTime`` can't be parsed. - - ``"forecast-stale"``: paired, parseable, but reading is older than - WHISPERER_STALENESS_HOURS. + - ``"forecast-stale"``: paired, parseable, but reading is + > WHISPERER_STALENESS_HOURS old. ``value`` may be ``None`` if forecast_val is None and no Whisperer value is available; the caller should skip writing ``moisture`` in @@ -1295,9 +1295,16 @@ def _resolve_zone_moisture(self, zone_dev, forecast_val): def _log_moisture_source_transition(self, zone_dev, new_source): """Log a transition between moisture-source categories for a zone. - Persists the current source in ``zone_dev.pluginProps['lastMoistureSource']`` - and only emits a log line when the category changes, to avoid spam. - The first-ever call on a fresh install is silent (no prior state). + Maintains an in-memory fallback (``_last_logged_moisture_source``) keyed + by zone device id so a known-logged transition isn't re-emitted across + polls. The persistence side effect (``replacePluginPropsOnServer`` of + ``lastMoistureSource``) is best-effort — failures are caught and logged + at WARNING, but never propagated, and never block the in-memory + suppression. After plugin restart the in-memory cache resets and the + first transition per zone is re-emitted from the persisted value. + + Logs are emitted only on category change. The first call on a fresh + install (no prior persisted source AND no in-memory entry) is silent. Args: zone_dev: Indigo zone device. diff --git a/docs/API_NOTES.md b/docs/API_NOTES.md index d338bdf..aa2d49b 100644 --- a/docs/API_NOTES.md +++ b/docs/API_NOTES.md @@ -196,7 +196,7 @@ disagree. Observed example: same zone, same moment — `/moistures.json` `/moistures.json` value. - The zone `moisture` state resolves to: the paired Whisperer's current `soilMoisture` if the pairing is configured on the zone device and - the reading is less than 12 hours old, else `moistureForecast`. + the reading is ≤ 12 hours old (see `WHISPERER_STALENESS_HOURS`), else `moistureForecast`. - Pairing is plugin-side (zone ConfigUI → "Paired Whisperer" dropdown), independent of any Netro-side pairing. Both can coexist; they don't interact. diff --git a/tests/conftest.py b/tests/conftest.py index d3fedfb..e279692 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -193,11 +193,15 @@ def sample_v2_schedules(): class _IndigoPluginBaseStub: - """Stand-in for indigo.PluginBase used at Plugin class definition time. - - Real Indigo's PluginBase performs server-bound initialisation the tests - don't need; subclassing this empty stub lets `class Plugin(indigo.PluginBase):` - succeed under MagicMock-based test doubles. + """Stand-in for ``indigo.PluginBase`` used at Plugin class definition time. + + Real Indigo's ``PluginBase`` performs server-bound initialisation the + tests don't need; subclassing this empty stub lets + ``class Plugin(indigo.PluginBase):`` import-succeed under + MagicMock-based test doubles. The class is deliberately method-less — + any base-class behaviour exercised by tests (e.g. ``self.logger``) is + supplied per-test, not by this stub. See also ``mock_indigo_base`` + which installs this into ``sys.modules['indigo']``. """ From b3f93f7ddf8e238ab80feacc5d05ff88791ba449 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:32:36 +0100 Subject: [PATCH 26/27] chore(netro): bump PluginVersion to 2026.5.2 for round-2 review fixes (#54) Co-Authored-By: Claude Opus 4.7 (1M context) --- Netro Sprinklers.indigoPlugin/Contents/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index 39f4ad2..77ede8d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.5.1 + 2026.5.2 ServerApiVersion 3.6 IwsApiVersion From 7d946029396ebeea25cfd5f24d7ee4005603132a Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sun, 26 Apr 2026 12:33:47 +0100 Subject: [PATCH 27/27] chore(netro): init _last_logged_moisture_source in __init__ to silence pylint W0201 (#54) The lazy-init via getattr in _log_moisture_source_transition is required (tests use Plugin.__new__(Plugin) which skips __init__), but pylint flagged the attribute as defined-outside-init. Add an explicit empty-dict initialiser in __init__ so the attribute is discoverable to static analysis while keeping the lazy-init fallback intact for tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Contents/Server Plugin/plugin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index b784282..025ff96 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -106,6 +106,11 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): super().__init__(pluginId, pluginDisplayName, pluginVersion, pluginPrefs) # Used to control when to show connection errors (vs just repeated retries) self._displayed_connection_error = False + # In-memory fallback for moisture-source transition logging — keyed by zone + # device id. Survives IOM persistence failures so the same transition is + # not re-logged every poll. Lazy-init in `_log_moisture_source_transition` + # for tests that bypass __init__ via Plugin.__new__(Plugin). + self._last_logged_moisture_source = {} self.pluginId = pluginId self.debug = pluginPrefs.get("showDebugInfo", False) self.timeout = int(pluginPrefs.get("apiTimeout", DEFAULT_API_TIMEOUT_SECONDS))