Skip to content

feat: per-zone Whisperer pairing with sensor-over-forecast fallback#55

Merged
simons-plugins merged 27 commits into
mainfrom
feat/whisperer-zone-pairing
Apr 26, 2026
Merged

feat: per-zone Whisperer pairing with sensor-over-forecast fallback#55
simons-plugins merged 27 commits into
mainfrom
feat/whisperer-zone-pairing

Conversation

@simons-plugins
Copy link
Copy Markdown
Owner

@simons-plugins simons-plugins commented Apr 24, 2026

Closes #54.

Summary

Adds per-zone Whisperer pairing in the plugin so the zone's moisture
state reflects the actual soil sensor reading (when fresh, ≤ 12h old)
rather than Netro's smart-model daily prediction. Netro's prediction
remains observable on a new moistureForecast state.

  • New pluginProp linkedWhispererDeviceId on zone devices.
  • New ConfigUI dropdown "Paired Whisperer" on the zone device
    config, populated by a dynamic getWhispererDevices callback.
  • New state moistureForecast — always holds the raw
    /moistures.json prediction for the zone.
  • Zone moisture semantics — resolves to the paired Whisperer's
    soilMoisture when fresh, else falls back to moistureForecast.
    Falls back cleanly when the pairing is missing, disabled, stale,
    unparseable, or has no reading yet — each case logged with a
    distinct message only on category transitions (no log spam).
  • No Netro API changes — pairing is entirely plugin-side.

Design: docs/plans/2026-04-23-whisperer-zone-pairing-design.md
Implementation plan: docs/plans/2026-04-23-whisperer-zone-pairing-plan.md

Build history

Implemented via TDD with 11 bite-sized commits, then hardened through
a multi-agent code review that caught (a) a production pluginProps
read-only bug found during live dogfooding on jarvis, (b) a latent
state-write-drop on auxiliary-log failure, (c) five distinct failure
modes collapsing into one misleading forecast-stale tag, and (d)
Indigo's integer-default 0 sneaking through as a "fresh" sensor
reading. All fixed on this branch; no follow-up PR needed.

Test plan

  • pytest tests/488 passed (up from 438 baseline, +50).
  • pylint on plugin.py / utils.py / constants.py /
    device_handlers.py9.48/10 (no regression).
  • Full plugin deployed to live Indigo server (jarvis) as
    2026.5.0 → hotfix → 2026.5.1. Currently running cleanly with
    zero errors since last restart.
  • New test modules cover every resolver source tag with
    (value, source) tuple assertions:
    • tests/test_constants_whisperer.py
    • tests/test_reading_age.py (v1 epoch millis + v2 ISO 8601,
      including Z suffix, garbage input, bool rejection, clock skew)
    • tests/test_whisperer_pairing_callback.py
    • tests/test_zone_moisture_resolution.py (unpaired, paired+fresh,
      stale, missing-device, disabled-device, missing-reading,
      unparseable-time, readingID liveness, 12h boundary)
    • tests/test_moisture_source_logging.py (all 5 transition
      messages, cold-start silence, replacePluginPropsOnServer failure
      graceful handling)
    • tests/test_update_zone_devices_integration.py (end-to-end
      paired/unpaired × fresh/stale matrix, empty-moistures fallback,
      logger-wiring verification)
  • Open empirical question from design doc: verify
    /moistures.json keeps producing sane predictions when a
    Whisperer is paired in Netro but stops reporting. Recommended
    to dogfood with a disconnected Whisperer for ~1 week before
    fully relying on the fallback.

Breaking changes

None. Backward compatible:

  • Existing zones have no linkedWhispererDeviceId prop → ""
    unpaired path → identical to pre-PR behaviour (moisture shows
    the Netro forecast, same as today).
  • moistureForecast starts populating on next poll; no data
    backfill needed.
  • UiDisplayStateId stays moisture — zone tiles in Indigo keep
    showing the "primary" value (now sensor-or-forecast) which is the
    right default.

Files changed

 Netro Sprinklers.indigoPlugin/Contents/Info.plist      |    2 +-
 .../Contents/Server Plugin/Devices.xml                 |   28 +
 .../Contents/Server Plugin/constants.py                |    9 +
 .../Contents/Server Plugin/device_handlers.py          |    6 +-
 .../Contents/Server Plugin/plugin.py                   |  199 ++-
 .../Contents/Server Plugin/utils.py                    |   76 +-
 README.md                                              |   13 +
 docs/API_NOTES.md                                      |  443 +++++++
 docs/plans/...                                         | 1688 +++++++++++++++
 tests/ (6 new + 2 modified)                            |  820 +++++++++

Version: 2026.4.42026.5.1 (minor for the user-visible feature

  • patch for review-hardening fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Zone-to-Whisperer sensor pairing for per-zone soil moisture with a UI option to select a Whisperer device.
    • New separate moistureForecast state exposing Netro forecast while moisture reflects the best available value.
  • Bug Fixes

    • Avoids writing spurious 0% moisture when no valid data is available; resolves stale/missing data handling.
  • Documentation

    • README pairing instructions and expanded API notes.
  • Tests

    • Added unit and integration tests covering parsing, resolution, logging, and the pairing callback.
  • Chores

    • Plugin version bumped.

simons-plugins and others added 21 commits April 23, 2026 15:31
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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.
…il (#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).
…egration test (#54)

- 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.
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.
_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.
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.
…#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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Adds per-zone Whisperer pairing and freshness logic: zones can be paired to a Whisperer device; when paired and the Whisperer reading is ≤12 hours old, zone moisture mirrors the Whisperer soilMoisture; otherwise moisture falls back to Netro /moistures.json and moistureForecast always exposes the forecast.

Changes

Cohort / File(s) Summary
Plugin metadata
Netro Sprinklers.indigoPlugin/Contents/Info.plist
PluginVersion bumped from 2026.4.42026.5.2.
Device UI & states
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml
Added zone ConfigUI fields (sep_sensor, sensorLabel, linkedWhispererDeviceId, sensorHelp) and new moistureForecast Integer state; UI help explains Whisperer pairing/freshness behavior.
Constants
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py
Added typed constant WHISPERER_STALENESS_HOURS: Final[int] = 12.
Time parsing utilities
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py
Added _now_utc() and parse_reading_age_hours(reading_time) which accepts ISO-8601 or epoch-millis (int/str), clamps future timestamps to 0.0, rejects boolean-as-number, and returns None for unparseable inputs.
Zone moisture parsing
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py
ZoneHandler.process_zone_moisture now returns [] when no valid moisture data exists instead of emitting a default moisture: 0.
Moisture resolution & plugin logic
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py
Major changes: emit moistureForecast separately; added _resolve_zone_moisture() to prefer fresh Whisperer soilMoisture (≤WHISPERER_STALENESS_HOURS) or fall back to forecast with explicit source tags; added _log_moisture_source_transition() with in-memory suppression and best-effort persistence; added ConfigUI callback getWhispererDevices(); improved error handling and logging.
Docs & design
README.md, docs/API_NOTES.md, docs/plans/...-whisperer-zone-pairing-*.md
README updated with pairing instructions; new API_NOTES.md documents Netro API quirks and /moistures.json semantics; design and implementation plan docs added.
Tests & test infra
tests/conftest.py, tests/*
Added mock_indigo_base fixture; new tests for constants, reading-age parsing, source-logging, pairing callback, resolver, integration update flow; updated zone handler tests to expect [] when no data.

Sequence Diagram

sequenceDiagram
    participant IND as Indigo Plugin
    participant ZH as ZoneHandler
    participant UTIL as Utils
    participant WH as Whisperer Device
    participant DB as Indigo State DB
    participant LOG as Transition Logger

    IND->>ZH: process_zone_moisture(zone_data)
    ZH-->>IND: return forecast entries → plugin treats as "moistureForecast"

    IND->>IND: read zone.pluginProps.linkedWhispererDeviceId
    alt Whisperer paired & enabled
        IND->>WH: read soilMoisture, readingTime
        IND->>UTIL: parse_reading_age_hours(readingTime)
        UTIL-->>IND: age_hours
        alt age_hours ≤ WHISPERER_STALENESS_HOURS
            IND->>IND: _resolve_zone_moisture(whisperer.soilMoisture) → ("whisperer")
        else age_hours > WHISPERER_STALENESS_HOURS
            IND->>IND: _resolve_zone_moisture(forecast) → ("forecast-stale")
        end
    else No Whisperer / disabled
        IND->>IND: _resolve_zone_moisture(forecast) → ("forecast")
    end

    IND->>DB: write "moistureForecast" state
    IND->>DB: write "moisture" state (only if resolved value non-null)
    IND->>LOG: _log_moisture_source_transition(zone, old, new)
    LOG->>DB: attempt to persist lastMoistureSource (best-effort)
    LOG-->>IND: suppressed or emitted log based on in-memory cache
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 In the garden where the soft roots sleep,

I nudge the soil and secrets keep.
A Whisperer hums, twelve hours true,
If fresh, the zone will honor you.
Forecast waits — but sensor wins the cue.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main feature: implementing per-zone Whisperer pairing with sensor-over-forecast fallback behavior.
Linked Issues check ✅ Passed All objectives from issue #54 are met: zone ConfigUI dropdown added, Whisperer pairing logic implemented with fresh-reading preference (≤12h), moistureForecast state introduced, separate pairing config, documentation updated, and comprehensive tests added.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the linked issue objectives. The PR includes necessary supporting files (design/implementation plans, API notes, tests, README updates) and implementation changes across plugin code, configuration, and utilities.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/whisperer-zone-pairing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
docs/plans/2026-04-23-whisperer-zone-pairing-plan.md (1)

1-1358: Plan document preserved as-is.

Historical TDD plan. Some reference implementations (e.g., _resolve_zone_moisture at lines 672-714, _log_moisture_source_transition at lines 869-919) predate the hardening commits (readingID checks, split failure tags, aux-log protection), so they no longer match the production code verbatim. That's expected for an implementation plan and not a concern. Consider adding a one-line front-matter note like "Final implementation differs in places — see plugin.py for the source of truth" if future readers might be confused.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-23-whisperer-zone-pairing-plan.md` around lines 1 - 1358,
The plan document claims to be preserved but can mislead readers because
concrete implementations (e.g., Plugin._resolve_zone_moisture and
Plugin._log_moisture_source_transition) evolved; add a single-line front-matter
note at the top of docs/plans/2026-04-23-whisperer-zone-pairing-plan.md stating
that the plan is historical and that the canonical implementation lives in
plugin.py (mentioning the symbols _resolve_zone_moisture and
_log_moisture_source_transition as examples), so future readers know to consult
plugin.py for the current source of truth.
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py (1)

165-200: LGTM — defensive parsing with correct bool guard.

The explicit not isinstance(reading_time, bool) check correctly handles the bool-as-int subclass issue, and max(0.0, delta) guards against clock skew.

Note: Python 3.11+ datetime.fromisoformat natively accepts trailing 'Z', while Python 3.10 rejects it. An edge case like "...ZZ" will parse successfully on 3.11+ but fail on 3.10 after the single removesuffix("Z") strip. This doesn't affect real Netro timestamps, but if strict cross-version behavior is required, consider validating with regex to reject any remaining 'Z' after the strip.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py around lines
165 - 200, The current ISO8601 parsing uses candidate =
reading_time.removesuffix("Z").strip(), which can yield different behavior
across Python versions for inputs like "...ZZ"; fix this by validating the
candidate after stripping: if candidate.endswith("Z") (i.e., there were multiple
trailing 'Z' chars) then treat it as an invalid timestamp and return None before
calling datetime.fromisoformat; update the parsing logic around candidate,
datetime.fromisoformat, and the surrounding reading_time handling to perform
this extra validation so cross-version behavior is consistent.
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py (3)

1198-1198: Rename filter parameter to dev_filter for consistency and to avoid shadowing the builtin.

Three of the four sibling dropdown callbacks in this file (availableControllers, sprinklerList, pickController) already use dev_filter. Renaming here lines up with the house style and clears the Ruff A002 warning. The Indigo callback dispatch is positional, so the rename is safe.

♻️ Proposed rename
-    def getWhispererDevices(self, filter="", valuesDict=None, typeId="", targetId=0):
+    def getWhispererDevices(self, dev_filter="", valuesDict=None, typeId="", targetId=0):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py at line 1198,
Rename the parameter named filter in the getWhispererDevices function to
dev_filter to match the naming used by sibling callback functions
(availableControllers, sprinklerList, pickController) and avoid shadowing the
built-in name (fixing Ruff A002); update the function signature def
getWhispererDevices(self, dev_filter="", valuesDict=None, typeId="", targetId=0)
and replace all internal references to filter with dev_filter (no changes to
call sites needed because Indigo dispatch is positional).

731-768: Consider initializing source before the is_enabled branch for robustness.

source is bound only inside the first if is_enabled: block (line 752) and then used inside a second if is_enabled: block (line 768). It is functionally correct today because both guards evaluate the same value, but a future refactor that splits the second guard or inlines any of the intermediate states would produce an UnboundLocalError. A trivial pre-binding makes the data-flow obvious and eliminates the implicit coupling between the two guards.

♻️ Proposed defensive initialization
                 if is_enabled:
                     if schedule_response:
                         states.extend(
                             self.zone_handler.process_zone_schedules(
                                 schedule_response, zone_num, api_version=api_version
                             )
                         )
 
+                    source = None
                     forecast_val = None
                     if moisture_response:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py around lines
731 - 768, The variable source can be unbound if control paths change;
initialize it defensively before the is_enabled block that calls
_resolve_zone_moisture and later _log_moisture_source_transition. Locate the
block around process_zone_moisture()/ _resolve_zone_moisture(zone_dev,
forecast_val) and add a default assignment for source (e.g., None or a sentinel)
prior to calling _resolve_zone_moisture so that subsequent use in
_log_moisture_source_transition(zone_dev, source) is always safe.

1336-1345: Persisting runtime bookkeeping in pluginProps has a subtle clobber race — prefer a hidden device state.

lastMoistureSource is internal runtime bookkeeping, not user configuration, yet it's written back via replacePluginPropsOnServer(dict(zone_dev.pluginProps)). Because the dict is a snapshot of pluginProps captured inside the polling loop, a concurrent user edit to the zone's ConfigUI (e.g., changing linkedWhispererDeviceId through the Paired Whisperer dropdown) landing between the snapshot and the write will be silently overwritten. The window is narrow but real, and users pairing/unpairing mid-poll is exactly the workflow being introduced.

A clean fix is to expose lastMoistureSource as a hidden zone state in Devices.xml and persist via updateStateOnServer, which avoids touching pluginProps entirely and also removes the pluginProps write from the hot polling path.

♻️ Sketch of the state-based approach
<!-- Devices.xml, inside the zone device's <States> block -->
<State id="lastMoistureSource" hidden="true">
    <ValueType>String</ValueType>
    <TriggerLabel>Last Moisture Source</TriggerLabel>
    <ControlPageLabel>Last Moisture Source</ControlPageLabel>
</State>
-        prev = zone_dev.pluginProps.get("lastMoistureSource")
+        prev = zone_dev.states.get("lastMoistureSource") or None
         if prev == new_source:
             return
         ...
-        new_props = dict(zone_dev.pluginProps)
-        new_props["lastMoistureSource"] = new_source
         try:
-            zone_dev.replacePluginPropsOnServer(new_props)
+            zone_dev.updateStateOnServer("lastMoistureSource", new_source)
         except Exception as exc:
             ...

Side note: the broad except Exception at line 1340 is the right call here (this auxiliary path must not gate state writes); Ruff BLE001 is a false positive and can be locally silenced if it's noisy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py around lines
1336 - 1345, The current logic persists lastMoistureSource by copying
zone_dev.pluginProps and calling replacePluginPropsOnServer, which can clobber
concurrent user edits; instead declare a hidden zone state (State
id="lastMoistureSource" hidden="true") in Devices.xml and replace the
pluginProps write in the polling code: remove the dict
snapshot/replacePluginPropsOnServer usage and call
zone_dev.updateStateOnServer("lastMoistureSource", new_source) (keeping the
surrounding try/except behavior), so runtime bookkeeping stays out of
pluginProps and avoids the race.
docs/plans/2026-04-23-whisperer-zone-pairing-design.md (1)

178-179: Optional: note design-vs-implementation identifier drift.

The implementation uses the Whisperer state key readingTime (see plugin.py line 1259 and the tests in tests/test_zone_moisture_resolution.py), while this design still says readingLocalTime here and on line 249. Similarly, the util is public parse_reading_age_hours (imported at plugin.py line 74 and again at line 237 of this doc with a leading underscore).

Since the doc is marked Status: Accepted, it's fine to leave as a historical record — but a one-line addendum at the top ("Implementation note: finalized as readingTime and parse_reading_age_hours") would help future readers reconcile doc vs code without bisecting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-23-whisperer-zone-pairing-design.md` around lines 178 -
179, Add a one-line implementation note at the top of the document stating that
the finalized runtime keys and helpers are `readingTime` (not
`readingLocalTime`) and `parse_reading_age_hours` (not
`_parse_reading_age_hours`); update any mentions in the text (e.g., the line
using readingLocalTime and the util reference) to reflect this note so readers
can reconcile the design doc with the actual implementation that uses
`readingTime` and `parse_reading_age_hours`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/API_NOTES.md`:
- Around line 77-79: The Python example contains a JS-style comment after the
line device = reply_dict["data"]["device"] using // which causes a SyntaxError;
update that snippet by replacing the // comment with a Python comment marker (#)
or removing the inline comment so the line reads as valid Python (locate the
example containing device = reply_dict["data"]["device"] in docs/API_NOTES.md
and edit the comment accordingly).

In `@Netro` Sprinklers.indigoPlugin/Contents/Info.plist:
- Line 6: The PluginVersion in Info.plist is colliding with an existing git tag;
update the <string> value currently set to "2026.5.1" (the PluginVersion) to a
new unused version (for example "2026.5.2") so the release tag can be created;
ensure the updated PluginVersion string in Info.plist matches the intended new
release version.

---

Nitpick comments:
In `@docs/plans/2026-04-23-whisperer-zone-pairing-design.md`:
- Around line 178-179: Add a one-line implementation note at the top of the
document stating that the finalized runtime keys and helpers are `readingTime`
(not `readingLocalTime`) and `parse_reading_age_hours` (not
`_parse_reading_age_hours`); update any mentions in the text (e.g., the line
using readingLocalTime and the util reference) to reflect this note so readers
can reconcile the design doc with the actual implementation that uses
`readingTime` and `parse_reading_age_hours`.

In `@docs/plans/2026-04-23-whisperer-zone-pairing-plan.md`:
- Around line 1-1358: The plan document claims to be preserved but can mislead
readers because concrete implementations (e.g., Plugin._resolve_zone_moisture
and Plugin._log_moisture_source_transition) evolved; add a single-line
front-matter note at the top of
docs/plans/2026-04-23-whisperer-zone-pairing-plan.md stating that the plan is
historical and that the canonical implementation lives in plugin.py (mentioning
the symbols _resolve_zone_moisture and _log_moisture_source_transition as
examples), so future readers know to consult plugin.py for the current source of
truth.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py:
- Line 1198: Rename the parameter named filter in the getWhispererDevices
function to dev_filter to match the naming used by sibling callback functions
(availableControllers, sprinklerList, pickController) and avoid shadowing the
built-in name (fixing Ruff A002); update the function signature def
getWhispererDevices(self, dev_filter="", valuesDict=None, typeId="", targetId=0)
and replace all internal references to filter with dev_filter (no changes to
call sites needed because Indigo dispatch is positional).
- Around line 731-768: The variable source can be unbound if control paths
change; initialize it defensively before the is_enabled block that calls
_resolve_zone_moisture and later _log_moisture_source_transition. Locate the
block around process_zone_moisture()/ _resolve_zone_moisture(zone_dev,
forecast_val) and add a default assignment for source (e.g., None or a sentinel)
prior to calling _resolve_zone_moisture so that subsequent use in
_log_moisture_source_transition(zone_dev, source) is always safe.
- Around line 1336-1345: The current logic persists lastMoistureSource by
copying zone_dev.pluginProps and calling replacePluginPropsOnServer, which can
clobber concurrent user edits; instead declare a hidden zone state (State
id="lastMoistureSource" hidden="true") in Devices.xml and replace the
pluginProps write in the polling code: remove the dict
snapshot/replacePluginPropsOnServer usage and call
zone_dev.updateStateOnServer("lastMoistureSource", new_source) (keeping the
surrounding try/except behavior), so runtime bookkeeping stays out of
pluginProps and avoids the race.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py:
- Around line 165-200: The current ISO8601 parsing uses candidate =
reading_time.removesuffix("Z").strip(), which can yield different behavior
across Python versions for inputs like "...ZZ"; fix this by validating the
candidate after stripping: if candidate.endswith("Z") (i.e., there were multiple
trailing 'Z' chars) then treat it as an invalid timestamp and return None before
calling datetime.fromisoformat; update the parsing logic around candidate,
datetime.fromisoformat, and the surrounding reading_time handling to perform
this extra validation so cross-version behavior is consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d9db4a9-cdf6-4613-9f84-e086c64fd454

📥 Commits

Reviewing files that changed from the base of the PR and between 5c4f050 and fded4b7.

📒 Files selected for processing (18)
  • Netro Sprinklers.indigoPlugin/Contents/Info.plist
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py
  • README.md
  • docs/API_NOTES.md
  • docs/plans/2026-04-23-whisperer-zone-pairing-design.md
  • docs/plans/2026-04-23-whisperer-zone-pairing-plan.md
  • tests/conftest.py
  • tests/test_constants_whisperer.py
  • tests/test_moisture_source_logging.py
  • tests/test_reading_age.py
  • tests/test_update_zone_devices_integration.py
  • tests/test_whisperer_pairing_callback.py
  • tests/test_zone_handler.py
  • tests/test_zone_moisture_resolution.py

Comment thread docs/API_NOTES.md
Comment on lines +77 to +79
```python
device = reply_dict["data"]["device"] // Not devices[0]
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JS-style comment in Python example.

// is not a Python comment marker; readers copy-pasting the snippet will get a SyntaxError.

📝 Proposed fix
-device = reply_dict["data"]["device"]  // Not devices[0]
+device = reply_dict["data"]["device"]  # Not devices[0]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```python
device = reply_dict["data"]["device"] // Not devices[0]
```
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/API_NOTES.md` around lines 77 - 79, The Python example contains a
JS-style comment after the line device = reply_dict["data"]["device"] using //
which causes a SyntaxError; update that snippet by replacing the // comment with
a Python comment marker (#) or removing the inline comment so the line reads as
valid Python (locate the example containing device =
reply_dict["data"]["device"] in docs/API_NOTES.md and edit the comment
accordingly).

Comment thread Netro Sprinklers.indigoPlugin/Contents/Info.plist Outdated
simons-plugins and others added 5 commits April 26, 2026 12:28
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) <noreply@anthropic.com>
…ing (#54)

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) <noreply@anthropic.com>
…ses (#54)

- 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) <noreply@anthropic.com>
- _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) <noreply@anthropic.com>
…#54)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py (1)

736-755: Recommended: simplify the forecast-extraction loop — the found flag + else-branch are unreachable.

ZoneHandler.process_zone_moisture returns either [] or a single-entry list with key="moisture", so the found = False guard and the else: states.append(entry) branch never fire under the current handler contract. The over-defensive shape obscures intent.

♻️ Proposed simplification
                 forecast_val = None
                 if moisture_response:
                     try:
                         forecast_states = self.zone_handler.process_zone_moisture(
                             moisture_response, zone_num
                         )
-                        found = False
-                        for entry in forecast_states:
-                            if not found and entry.get("key") == "moisture":
-                                forecast_val = entry.get("value")
-                                states.append({**entry, "key": "moistureForecast"})
-                                found = True
-                            else:
-                                states.append(entry)
+                        if forecast_states:
+                            entry = forecast_states[0]
+                            forecast_val = entry.get("value")
+                            states.append({**entry, "key": "moistureForecast"})
                     except (AttributeError, TypeError, KeyError, IndexError):
                         self.logger.exception(
                             f"Error processing moisture for zone {zone_num} on "
                             f"'{zone_dev.name}' — moisture + moistureForecast "
                             f"states will not update this cycle."
                         )

If you want to keep the loop as a hedge against a future handler change that returns extra keys, drop just the found flag and inline the else-branch — but at that point the implicit contract is wider than the docstring of process_zone_moisture advertises.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py around lines
736 - 755, The loop handling forecast_states is over-defensive: remove the
unused found flag and the "not found and" guard in the for-loop condition and
simply check each entry's key; if entry.get("key") == "moisture" set
forecast_val and append the modified entry ({**entry,
"key":"moistureForecast"}), otherwise append the entry as-is. This keeps
behavior if future process_zone_moisture returns multiple keys but eliminates
the unreachable found logic; update the block around
zone_handler.process_zone_moisture, forecast_states, and the for entry loop
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Netro` Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py:
- Around line 736-755: The loop handling forecast_states is over-defensive:
remove the unused found flag and the "not found and" guard in the for-loop
condition and simply check each entry's key; if entry.get("key") == "moisture"
set forecast_val and append the modified entry ({**entry,
"key":"moistureForecast"}), otherwise append the entry as-is. This keeps
behavior if future process_zone_moisture returns multiple keys but eliminates
the unreachable found logic; update the block around
zone_handler.process_zone_moisture, forecast_states, and the for entry loop
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7d2d51ed-0aa2-485a-9084-900573b871e1

📥 Commits

Reviewing files that changed from the base of the PR and between fded4b7 and 7d94602.

📒 Files selected for processing (10)
  • Netro Sprinklers.indigoPlugin/Contents/Info.plist
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py
  • docs/API_NOTES.md
  • tests/conftest.py
  • tests/test_moisture_source_logging.py
  • tests/test_update_zone_devices_integration.py
  • tests/test_whisperer_pairing_callback.py
  • tests/test_zone_moisture_resolution.py
✅ Files skipped from review due to trivial changes (1)
  • Netro Sprinklers.indigoPlugin/Contents/Info.plist
🚧 Files skipped from review as they are similar to previous changes (1)
  • Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml

@simons-plugins simons-plugins merged commit c4eb64a into main Apr 26, 2026
3 checks passed
@simons-plugins simons-plugins deleted the feat/whisperer-zone-pairing branch April 26, 2026 12:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: UI for pairing Whisperer sensors to zones; prefer sensor over daily forecast

1 participant