From 9f8872320cac5f417491f295c6714201ac6717aa Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 19:10:01 +0100 Subject: [PATCH 1/9] fix: track API tokens per device instead of globally Each Netro device has its own independent 2000/day token limit. The plugin was using a single global counter that got overwritten by every API response, causing incorrect polling pauses. - Add DeviceTokenState dataclass for per-device token tracking - Replace scalar _token_remaining with _device_tokens dict - Add device_key param to make_request(), threaded through all 11 convenience methods - Add should_pause_polling_for() and token_remaining_for() methods - Move pause check from runConcurrentThread into per-device update methods (_update_sprinkler_device, _update_whisperer_device) - Versioned state persistence (v2 format) with v1 migration - Updated all existing token tests + new per-device isolation tests Closes #43 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/api_client.py | 194 ++++++++++++------ .../Contents/Server Plugin/plugin.py | 28 ++- tests/test_api_client.py | 189 +++++++++++------ 4 files changed, 281 insertions(+), 132 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index b2a17dd..3356a85 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.3.0 + 2026.3.1 ServerApiVersion 3.6 IwsApiVersion diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 4a1e006..66e32be 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py @@ -19,6 +19,7 @@ import json import logging +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, Final, List, Optional, Set, Union @@ -54,8 +55,18 @@ from exceptions import NetroAPIError, ThrottleDelayError +@dataclass +class DeviceTokenState: + """Per-device API token budget tracking. + + Each Netro device has its own independent 2000/day token limit. + """ + token_remaining: int = 2000 + token_reset: Optional[datetime] = None + + # Re-export threshold constants for convenient access -__all__ = ["NetroAPIClient", "TOKEN_PAUSE_THRESHOLD", "TOKEN_WARNING_THRESHOLD"] +__all__ = ["NetroAPIClient", "DeviceTokenState", "TOKEN_PAUSE_THRESHOLD", "TOKEN_WARNING_THRESHOLD"] # ============================================================================= # Expected Response Keys (for schema validation) @@ -118,10 +129,11 @@ def __init__( self._prefs_getter = prefs_getter self._prefs_setter = prefs_setter - # Throttle state + # Throttle state (global — HTTP 429 is account-level) self._throttle_until: Optional[datetime] = None - self._token_remaining: int = 2000 - self._token_reset: Optional[datetime] = None + + # Per-device token budgets (keyed by API key or serial number) + self._device_tokens: Dict[str, DeviceTokenState] = {} # HTTP configuration self.headers: Dict[str, str] = { @@ -168,36 +180,63 @@ def throttle_expires(self) -> Optional[datetime]: @property def token_remaining(self) -> int: - """Get current API token count. + """Get minimum API token count across all tracked devices. Returns: - Number of API tokens remaining (max 2000 per day) + Minimum tokens remaining across devices, or 2000 if none tracked """ - return self._token_remaining + if not self._device_tokens: + return 2000 + return min(s.token_remaining for s in self._device_tokens.values()) + + def token_remaining_for(self, device_key: str) -> int: + """Get API token count for a specific device. + + Args: + device_key: Device API key or serial number + + Returns: + Tokens remaining for this device, or 2000 if not yet tracked + """ + state = self._device_tokens.get(device_key) + return state.token_remaining if state else 2000 @property def should_pause_polling(self) -> bool: - """Check if polling should pause due to low token budget. + """Check if any device should pause due to low token budget. - This is PROACTIVE prevention - the plugin should check this - before making API calls to avoid exhausting the daily limit. + Returns: + True if any tracked device is below the pause threshold + """ + return any( + self.should_pause_polling_for(k) for k in self._device_tokens + ) + + def should_pause_polling_for(self, device_key: str) -> bool: + """Check if polling should pause for a specific device. Auto-resets token count if past reset time to prevent self-locking. + Args: + device_key: Device API key or serial number + Returns: - True if remaining tokens are below pause threshold + True if this device's tokens are below the pause threshold """ - # Check if we're past the token reset time and should auto-reset - if self._token_reset: - now = datetime.now(timezone.utc) if self._token_reset.tzinfo else datetime.now() - if now >= self._token_reset: - # Past reset time - automatically reset to daily limit - self._token_remaining = 2000 - self._token_reset = None + state = self._device_tokens.get(device_key) + if state is None: + return False # Unknown device — assume full budget + + # Auto-reset if past token reset time + if state.token_reset: + now = datetime.now(timezone.utc) if state.token_reset.tzinfo else datetime.now() + if now >= state.token_reset: + state.token_remaining = 2000 + state.token_reset = None self._save_throttle_state() - self.logger.info("API token budget has reset to daily limit (2000)") + self.logger.info(f"API token budget reset for device {device_key[:8]}...") - return self._token_remaining < TOKEN_PAUSE_THRESHOLD + return state.token_remaining < TOKEN_PAUSE_THRESHOLD # ========================================================================= # Core Request Method @@ -207,7 +246,8 @@ def make_request( self, url: str, method: str = "get", - data: Optional[Dict[str, Any]] = None + data: Optional[Dict[str, Any]] = None, + device_key: Optional[str] = None ) -> Union[Dict[str, Any], bool]: """Make API request with error handling and throttle enforcement. @@ -215,13 +255,14 @@ def make_request( - Throttle state checking before requests - HTTP method selection (GET/POST/PUT) - Response parsing and error handling - - Token budget tracking from response metadata + - Per-device token budget tracking from response metadata - Connection error suppression (log once) Args: url: Full URL for the API endpoint method: HTTP method ('get', 'post', or 'put') data: Optional request body data (for POST/PUT) + device_key: Optional device API key or serial for per-device token tracking Returns: Parsed JSON response dict, or True for 204 responses @@ -279,7 +320,7 @@ def make_request( if "meta" in result: # Validate meta structure self._validate_response_schema(result["meta"], EXPECTED_META_KEYS, f"{url}/meta") - self._update_token_budget(result["meta"]) + self._update_token_budget(result["meta"], device_key=device_key) return result @@ -396,32 +437,44 @@ def _handle_http_error(self, exc: requests.exceptions.HTTPError) -> None: # Token Budget Management # ========================================================================= - def _update_token_budget(self, meta: Dict[str, Any]) -> None: - """Update token tracking from API response metadata. + def _update_token_budget( + self, meta: Dict[str, Any], device_key: Optional[str] = None + ) -> None: + """Update per-device token tracking from API response metadata. Parses token_remaining and token_reset from the response meta section, logs warnings at thresholds, and saves state. - Handles both v1 (strptime) and v2 (fromisoformat) timestamp formats. - Args: meta: Response meta dict containing token info + device_key: Device API key or serial number (None to skip tracking) """ + if device_key is None: + return + try: - self._token_remaining = int(meta.get("token_remaining", 2000)) + remaining = int(meta.get("token_remaining", 2000)) + reset_time = None reset_str = meta.get("token_reset", "") if reset_str: - self._token_reset = datetime.fromisoformat(reset_str).replace(tzinfo=timezone.utc) + reset_time = datetime.fromisoformat(reset_str).replace(tzinfo=timezone.utc) except (ValueError, TypeError) as exc: self.logger.warning(f"Could not parse token info from response: {exc}") - # Set safe default to trigger proactive pause and prevent exhausting token budget - self._token_remaining = TOKEN_PAUSE_THRESHOLD - 1 + remaining = TOKEN_PAUSE_THRESHOLD - 1 + reset_time = None + + # Update or create per-device state + self._device_tokens[device_key] = DeviceTokenState( + token_remaining=remaining, + token_reset=reset_time, + ) # Log warnings at thresholds - if self._token_remaining < TOKEN_WARNING_THRESHOLD: - reset_info = f", resets at {self._token_reset}" if self._token_reset else "" + if remaining < TOKEN_WARNING_THRESHOLD: + key_display = device_key[:8] + "..." if len(device_key) > 8 else device_key + reset_info = f", resets at {reset_time}" if reset_time else "" self.logger.warning( - f"API tokens low: {self._token_remaining} remaining{reset_info}" + f"API tokens low for {key_display}: {remaining} remaining{reset_info}" ) # Persist state @@ -432,32 +485,40 @@ def _update_token_budget(self, meta: Dict[str, Any]) -> None: # ========================================================================= def _save_throttle_state(self) -> None: - """Persist throttle state to pluginPrefs. + """Persist throttle and per-device token state to pluginPrefs. - Serializes current throttle and token state to JSON and saves - via the prefs_setter callback. Does nothing if no setter provided. + Serializes current throttle and per-device token state to JSON + and saves via the prefs_setter callback. Uses version 2 format + with per-device token tracking. """ if not self._prefs_setter: return - state = { + device_tokens = {} + for key, state in self._device_tokens.items(): + device_tokens[key] = { + "token_remaining": state.token_remaining, + "token_reset": state.token_reset.isoformat() if state.token_reset else None, + } + + save_state = { + "version": 2, "throttle_until": self._throttle_until.isoformat() if self._throttle_until else None, - "token_remaining": self._token_remaining, - "token_reset": self._token_reset.isoformat() if self._token_reset else None, + "device_tokens": device_tokens, "last_saved": datetime.now(timezone.utc).isoformat() } try: - self._prefs_setter("throttle_state", json.dumps(state)) + self._prefs_setter("throttle_state", json.dumps(save_state)) except (TypeError, ValueError) as exc: self.logger.warning(f"Could not save throttle state: {exc}") def _restore_throttle_state(self) -> None: - """Restore throttle state from pluginPrefs on startup. + """Restore throttle and per-device token state from pluginPrefs. - Loads and parses throttle state JSON from prefs. Only applies - throttle_until if it's still in the future. Does nothing if - no prefs_getter provided or state is missing/invalid. + Supports both v1 (global token) and v2 (per-device token) formats. + V1 format: restores only throttle_until, tokens populate on first poll. + V2 format: restores per-device token budgets. """ if not self._prefs_getter: return @@ -477,7 +538,6 @@ def _restore_throttle_state(self) -> None: # Restore throttle expiry only if still in future if state.get("throttle_until"): throttle_until = datetime.fromisoformat(state["throttle_until"]) - # Ensure timezone-aware comparison now = datetime.now(timezone.utc) if throttle_until.tzinfo else datetime.now() if throttle_until > now: self._throttle_until = throttle_until @@ -485,10 +545,17 @@ def _restore_throttle_state(self) -> None: f"Restored throttle state: paused until {throttle_until:%H:%M:%S}" ) - # Restore token info - self._token_remaining = state.get("token_remaining", 2000) - if state.get("token_reset"): - self._token_reset = datetime.fromisoformat(state["token_reset"]) + # V2 format: restore per-device token budgets + if state.get("version", 1) >= 2 and "device_tokens" in state: + for key, token_state in state["device_tokens"].items(): + reset_time = None + if token_state.get("token_reset"): + reset_time = datetime.fromisoformat(token_state["token_reset"]) + self._device_tokens[key] = DeviceTokenState( + token_remaining=token_state.get("token_remaining", 2000), + token_reset=reset_time, + ) + # V1 format: ignore global token_remaining, let first poll populate per-device except (json.JSONDecodeError, ValueError, KeyError) as exc: self.logger.warning("Could not restore throttle state: %s", exc) @@ -577,7 +644,7 @@ def get_device_info(self, key: str, api_version: str = "1") -> Dict[str, Any]: Returns: API response containing device info """ - return self.make_request(f"{self._endpoint('info', api_version)}?key={key}") + return self.make_request(f"{self._endpoint('info', api_version)}?key={key}", device_key=key) def get_schedules(self, key: str, api_version: str = "1") -> Dict[str, Any]: """Get device schedules from Netro API. @@ -589,7 +656,7 @@ def get_schedules(self, key: str, api_version: str = "1") -> Dict[str, Any]: Returns: API response containing schedules """ - return self.make_request(f"{self._endpoint('schedules', api_version)}?key={key}") + return self.make_request(f"{self._endpoint('schedules', api_version)}?key={key}", device_key=key) def get_moistures(self, key: str, api_version: str = "1") -> Dict[str, Any]: """Get moisture levels from Netro API. @@ -601,7 +668,7 @@ def get_moistures(self, key: str, api_version: str = "1") -> Dict[str, Any]: Returns: API response containing moisture data """ - return self.make_request(f"{self._endpoint('moistures', api_version)}?key={key}") + return self.make_request(f"{self._endpoint('moistures', api_version)}?key={key}", device_key=key) def get_sensor_data(self, key: str, api_version: str = "1") -> Dict[str, Any]: """Get Whisperer sensor data from Netro API. @@ -613,7 +680,7 @@ def get_sensor_data(self, key: str, api_version: str = "1") -> Dict[str, Any]: Returns: API response containing sensor readings """ - return self.make_request(f"{self._endpoint('sensor_data', api_version)}?key={key}") + return self.make_request(f"{self._endpoint('sensor_data', api_version)}?key={key}", device_key=key) def get_events( self, @@ -640,7 +707,7 @@ def get_events( url += f"&start_date={start_date}" if end_date: url += f"&end_date={end_date}" - return self.make_request(url) + return self.make_request(url, device_key=key) def start_watering( self, @@ -668,7 +735,7 @@ def start_watering( if start_time: data["start_time"] = start_time return self.make_request( - self._endpoint("water", api_version), method="post", data=data + self._endpoint("water", api_version), method="post", data=data, device_key=key ) def stop_watering(self, key: str, api_version: str = "1") -> Dict[str, Any]: @@ -684,7 +751,8 @@ def stop_watering(self, key: str, api_version: str = "1") -> Dict[str, Any]: return self.make_request( self._endpoint("stop_water", api_version), method="post", - data={"key": key} + data={"key": key}, + device_key=key ) def set_device_status(self, key: str, status: int, api_version: str = "1") -> Dict[str, Any]: @@ -701,7 +769,8 @@ def set_device_status(self, key: str, status: int, api_version: str = "1") -> Di return self.make_request( self._endpoint("set_status", api_version), method="post", - data={"key": key, "status": status} + data={"key": key, "status": status}, + device_key=key ) def set_no_water(self, key: str, days: int, api_version: str = "1") -> Dict[str, Any]: @@ -718,7 +787,8 @@ def set_no_water(self, key: str, days: int, api_version: str = "1") -> Dict[str, return self.make_request( self._endpoint("no_water", api_version), method="post", - data={"key": key, "days": days} + data={"key": key, "days": days}, + device_key=key ) def report_weather( @@ -738,7 +808,8 @@ def report_weather( return self.make_request( self._endpoint("report_weather", api_version), method="post", - data=data + data=data, + device_key=key ) def set_moisture( @@ -764,5 +835,6 @@ def set_moisture( return self.make_request( self._endpoint("set_moisture", api_version), method="post", - data=data + data=data, + device_key=key ) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 25fa412..798169c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -725,6 +725,15 @@ def _update_sprinkler_device(self, dev): try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) + + # Per-device token pause check + if self.api_client.should_pause_polling_for(key): + self.logger.warning( + f"Polling paused for '{dev.name}': only " + f"{self.api_client.token_remaining_for(key)} tokens remaining" + ) + return + schedule_dict = None moisture_dict = None @@ -852,6 +861,15 @@ def _update_whisperer_device(self, dev): try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) + + # Per-device token pause check + if self.api_client.should_pause_polling_for(key): + self.logger.warning( + f"Polling paused for '{dev.name}': only " + f"{self.api_client.token_remaining_for(key)} tokens remaining" + ) + return + self.logger.debug(f"Device ID: {dev.address} (API v{api_version})") if dev.sensorValue is not None: @@ -997,14 +1015,8 @@ def runConcurrentThread(self): self.logger.debug("Starting concurrent thread") while True: try: - # Check proactive pause before polling - if self.api_client.should_pause_polling: - self.logger.warning( - f"Polling paused: only {self.api_client.token_remaining} tokens " - f"remaining (threshold: 100), will resume when tokens reset" - ) - else: - self._update_from_netro() + # Per-device token pause is checked inside each device update method + self._update_from_netro() # Tomorrow.io uses its own API; run regardless of Netro token pause self._update_weather_from_tomorrow() diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 13199ab..20e42b7 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -98,7 +98,7 @@ def test_throttle_expires_none_when_not_throttled(self, client): assert client.throttle_expires is None def test_save_throttle_state_calls_prefs_setter(self, mock_logger, mock_prefs): - """Save should call prefs_setter with JSON.""" + """Save should call prefs_setter with v2 JSON format.""" prefs_getter, prefs_setter, prefs_data = mock_prefs client = NetroAPIClient( logger=mock_logger, @@ -110,12 +110,13 @@ def test_save_throttle_state_calls_prefs_setter(self, mock_logger, mock_prefs): assert "throttle_state" in prefs_data saved_state = json.loads(prefs_data["throttle_state"]) + assert saved_state["version"] == 2 assert "throttle_until" in saved_state - assert "token_remaining" in saved_state + assert "device_tokens" in saved_state assert "last_saved" in saved_state - def test_restore_throttle_state_from_valid_prefs(self, mock_logger): - """Restore should parse JSON and set state.""" + def test_restore_v1_format_only_restores_throttle(self, mock_logger): + """V1 format (no version key) restores throttle_until, ignores global tokens.""" future_time = datetime.now() + timedelta(minutes=30) state = { "throttle_until": future_time.isoformat(), @@ -130,10 +131,33 @@ def test_restore_throttle_state_from_valid_prefs(self, mock_logger): prefs_setter=lambda k, v: None ) - assert client._token_remaining == 500 assert client._throttle_until is not None - # Check throttle is approximately correct (within 1 second) assert abs((client._throttle_until - future_time).total_seconds()) < 1 + # V1 format: per-device tokens not restored, will populate on first poll + assert len(client._device_tokens) == 0 + + def test_restore_v2_format_restores_per_device_tokens(self, mock_logger): + """V2 format restores per-device token budgets.""" + from api_client import DeviceTokenState + state = { + "version": 2, + "throttle_until": None, + "device_tokens": { + "key_A": {"token_remaining": 1500, "token_reset": "2026-04-11T00:00:00+00:00"}, + "key_B": {"token_remaining": 1800, "token_reset": None}, + }, + } + prefs_data = {"throttle_state": json.dumps(state)} + + client = NetroAPIClient( + logger=mock_logger, + prefs_getter=lambda: prefs_data, + prefs_setter=lambda k, v: None + ) + + assert len(client._device_tokens) == 2 + assert client._device_tokens["key_A"].token_remaining == 1500 + assert client._device_tokens["key_B"].token_remaining == 1800 def test_restore_throttle_state_ignores_expired(self, mock_logger): """Restore ignores throttle_until if in past.""" @@ -152,7 +176,6 @@ def test_restore_throttle_state_ignores_expired(self, mock_logger): ) assert client._throttle_until is None - assert client._token_remaining == 500 def test_restore_throttle_state_handles_invalid_json(self, mock_logger): """Invalid JSON doesn't crash, logs warning.""" @@ -187,50 +210,96 @@ def test_restore_throttle_state_handles_missing_prefs(self, mock_logger): class TestProactivePause: """Tests for proactive pause logic at threshold boundaries.""" - def test_should_pause_when_below_threshold(self, client): - """token_remaining < 100 returns True.""" - client._token_remaining = TOKEN_PAUSE_THRESHOLD - 1 + def test_should_pause_for_below_threshold(self, client): + """should_pause_polling_for returns True when device below threshold.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=TOKEN_PAUSE_THRESHOLD - 1) + assert client.should_pause_polling_for("KEY_A") is True + + def test_should_not_pause_for_above_threshold(self, client): + """should_pause_polling_for returns False when device above threshold.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=TOKEN_PAUSE_THRESHOLD + 100) + assert client.should_pause_polling_for("KEY_A") is False + + def test_should_not_pause_for_unknown_device(self, client): + """should_pause_polling_for returns False for unknown device key.""" + assert client.should_pause_polling_for("UNKNOWN_KEY") is False + + def test_should_pause_property_any_device(self, client): + """should_pause_polling returns True if any device is below threshold.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=50) + client._device_tokens["KEY_B"] = DeviceTokenState(token_remaining=1500) assert client.should_pause_polling is True - def test_should_not_pause_when_above_threshold(self, client): - """token_remaining > 100 returns False.""" - client._token_remaining = TOKEN_PAUSE_THRESHOLD + 100 + def test_should_not_pause_property_all_above(self, client): + """should_pause_polling returns False if all devices above threshold.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=500) + client._device_tokens["KEY_B"] = DeviceTokenState(token_remaining=1500) assert client.should_pause_polling is False - def test_should_not_pause_at_exactly_threshold(self, client): - """token_remaining == 100 returns False (boundary test).""" - client._token_remaining = TOKEN_PAUSE_THRESHOLD + def test_should_not_pause_property_no_devices(self, client): + """should_pause_polling returns False when no devices tracked.""" assert client.should_pause_polling is False - def test_token_remaining_property(self, client): - """Verify property returns current count.""" - client._token_remaining = 1500 - assert client.token_remaining == 1500 - - def test_update_token_budget_from_meta(self, client): - """_update_token_budget parses meta correctly.""" + def test_token_remaining_returns_minimum(self, client): + """token_remaining returns minimum across all tracked devices.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=500) + client._device_tokens["KEY_B"] = DeviceTokenState(token_remaining=1500) + assert client.token_remaining == 500 + + def test_token_remaining_default_when_no_devices(self, client): + """token_remaining returns 2000 when no devices tracked.""" + assert client.token_remaining == 2000 + + def test_token_remaining_for_device(self, client): + """token_remaining_for returns per-device count.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=750) + assert client.token_remaining_for("KEY_A") == 750 + + def test_token_remaining_for_unknown_device(self, client): + """token_remaining_for returns 2000 for unknown device.""" + assert client.token_remaining_for("UNKNOWN") == 2000 + + def test_update_token_budget_per_device(self, client): + """_update_token_budget stores tokens per device key.""" meta = { "token_remaining": 750, "token_reset": "2026-02-02T00:00:00" } - client._update_token_budget(meta) - assert client._token_remaining == 750 - assert client._token_reset == datetime(2026, 2, 2, 0, 0, 0, tzinfo=timezone.utc) + client._update_token_budget(meta, device_key="KEY_A") + assert client._device_tokens["KEY_A"].token_remaining == 750 + assert client._device_tokens["KEY_A"].token_reset == datetime(2026, 2, 2, 0, 0, 0, tzinfo=timezone.utc) + + def test_update_token_budget_independent_devices(self, client): + """Two devices maintain independent token counts.""" + client._update_token_budget({"token_remaining": 500}, device_key="KEY_A") + client._update_token_budget({"token_remaining": 1800}, device_key="KEY_B") + assert client._device_tokens["KEY_A"].token_remaining == 500 + assert client._device_tokens["KEY_B"].token_remaining == 1800 + + def test_update_token_budget_skips_without_device_key(self, client): + """_update_token_budget with no device_key does not track tokens.""" + client._update_token_budget({"token_remaining": 500}) + assert len(client._device_tokens) == 0 def test_update_token_budget_logs_warning_below_200(self, client, mock_logger): - """Logs warning when tokens < 200.""" + """Logs warning when device tokens < 200.""" meta = {"token_remaining": TOKEN_WARNING_THRESHOLD - 1} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") mock_logger.warning.assert_called() - # Verify warning contains token info warning_call = mock_logger.warning.call_args[0][0] - assert "tokens" in warning_call.lower() or str(TOKEN_WARNING_THRESHOLD - 1) in warning_call + assert str(TOKEN_WARNING_THRESHOLD - 1) in warning_call def test_update_token_budget_no_warning_above_200(self, client, mock_logger): - """No warning when tokens >= 200.""" + """No warning when device tokens >= 200.""" mock_logger.warning.reset_mock() meta = {"token_remaining": TOKEN_WARNING_THRESHOLD + 100} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") mock_logger.warning.assert_not_called() def test_update_token_budget_saves_state(self, mock_logger, mock_prefs): @@ -244,37 +313,32 @@ def test_update_token_budget_saves_state(self, mock_logger, mock_prefs): prefs_data.clear() meta = {"token_remaining": 1500} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") assert "throttle_state" in prefs_data def test_update_token_budget_sets_safe_default_on_parse_failure(self, client, mock_logger): """Sets safe token count on parsing failure to trigger proactive pause.""" - # Start with high token count - client._token_remaining = 1500 - - # Try to parse invalid token data meta = {"token_remaining": "invalid"} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") - # Should set to safe default (below pause threshold) - assert client._token_remaining == TOKEN_PAUSE_THRESHOLD - 1 - assert client.should_pause_polling is True + assert client._device_tokens["KEY_A"].token_remaining == TOKEN_PAUSE_THRESHOLD - 1 + assert client.should_pause_polling_for("KEY_A") is True mock_logger.warning.assert_called() - def test_should_pause_polling_auto_resets_past_reset_time(self, client, mock_logger): - """Auto-resets token count when past token_reset time to prevent self-locking.""" - # Set low tokens and a reset time in the past - client._token_remaining = 50 # Below threshold - client._token_reset = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) # Past date + def test_auto_resets_past_reset_time(self, client, mock_logger): + """Auto-resets token count when past token_reset time.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState( + token_remaining=50, + token_reset=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + ) - # Check should_pause_polling - will auto-reset tokens since reset time passed - should_pause = client.should_pause_polling + should_pause = client.should_pause_polling_for("KEY_A") - # Tokens should be auto-reset to 2000 (not paused) assert should_pause is False - assert client._token_remaining == 2000 - assert client._token_reset is None + assert client._device_tokens["KEY_A"].token_remaining == 2000 + assert client._device_tokens["KEY_A"].token_reset is None mock_logger.info.assert_called() assert "reset" in mock_logger.info.call_args[0][0].lower() @@ -454,13 +518,12 @@ def test_make_request_timeout_resets_after_success(self, client, mock_logger): def test_make_request_timeout_preserves_throttle_state(self, client, mock_logger): """Timeout doesn't affect throttle state.""" - import requests as req - from datetime import timedelta + from api_client import DeviceTokenState # Set throttle state future_time = datetime.now() + timedelta(minutes=30) client._throttle_until = future_time - client._token_remaining = 500 + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=500) # Cause timeout (should raise ThrottleDelayError before hitting network) with pytest.raises(ThrottleDelayError): @@ -468,7 +531,7 @@ def test_make_request_timeout_preserves_throttle_state(self, client, mock_logger # Throttle state should be preserved assert client._throttle_until == future_time - assert client._token_remaining == 500 + assert client._device_tokens["KEY_A"].token_remaining == 500 def test_make_request_timeout_with_custom_timeout_value(self, client): """Client timeout attribute passed to requests library.""" @@ -744,8 +807,8 @@ def test_api_client_multiple_device_requests_state_isolation(self, client, mock_ assert response1["data"]["device"]["serial"] == "SERIAL1" assert response2["data"]["device"]["serial"] == "SERIAL2" - def test_api_client_token_budget_tracks_across_requests(self, client): - """Token budget is tracked across multiple requests.""" + def test_api_client_token_budget_tracks_per_device(self, client): + """Token budget is tracked independently per device key.""" mock_response1 = Mock() mock_response1.status_code = 200 mock_response1.json.return_value = { @@ -759,15 +822,17 @@ def test_api_client_token_budget_tracks_across_requests(self, client): mock_response2.json.return_value = { "status": "OK", "data": {}, - "meta": {"token_remaining": 1300} + "meta": {"token_remaining": 1800} } with patch("api_client.requests.get", side_effect=[mock_response1, mock_response2]): - client.make_request("https://api.test.com/endpoint1") - assert client._token_remaining == 1500 + client.make_request("https://api.test.com/endpoint1", device_key="KEY_A") + assert client.token_remaining_for("KEY_A") == 1500 - client.make_request("https://api.test.com/endpoint2") - assert client._token_remaining == 1300 + client.make_request("https://api.test.com/endpoint2", device_key="KEY_B") + assert client.token_remaining_for("KEY_B") == 1800 + # KEY_A's count should be unchanged + assert client.token_remaining_for("KEY_A") == 1500 # ============================================================================= From 37728e072f981d95608d40363ffeaaf62758a3f1 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 19:14:30 +0100 Subject: [PATCH 2/9] fix: don't restore v1 throttle state on migration to per-device tracking V1 throttle was often triggered by incorrect global token counting. Restoring it after migration blocks all API calls until midnight. Only restore throttle_until from v2 format (where it's legitimate). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Server Plugin/api_client.py | 9 ++++++--- tests/test_api_client.py | 9 ++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 66e32be..519d1d0 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py @@ -534,9 +534,12 @@ def _restore_throttle_state(self) -> None: try: state = json.loads(state_json) + is_v2 = state.get("version", 1) >= 2 - # Restore throttle expiry only if still in future - if state.get("throttle_until"): + # Restore throttle expiry only from v2 format and if still in future. + # V1 throttles were often triggered by incorrect global token tracking + # and should not carry over after the per-device migration. + if is_v2 and state.get("throttle_until"): throttle_until = datetime.fromisoformat(state["throttle_until"]) now = datetime.now(timezone.utc) if throttle_until.tzinfo else datetime.now() if throttle_until > now: @@ -546,7 +549,7 @@ def _restore_throttle_state(self) -> None: ) # V2 format: restore per-device token budgets - if state.get("version", 1) >= 2 and "device_tokens" in state: + if is_v2 and "device_tokens" in state: for key, token_state in state["device_tokens"].items(): reset_time = None if token_state.get("token_reset"): diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 20e42b7..d66730f 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -115,8 +115,8 @@ def test_save_throttle_state_calls_prefs_setter(self, mock_logger, mock_prefs): assert "device_tokens" in saved_state assert "last_saved" in saved_state - def test_restore_v1_format_only_restores_throttle(self, mock_logger): - """V1 format (no version key) restores throttle_until, ignores global tokens.""" + def test_restore_v1_format_ignores_stale_throttle(self, mock_logger): + """V1 format (no version key) does not restore throttle — may be from incorrect global tracking.""" future_time = datetime.now() + timedelta(minutes=30) state = { "throttle_until": future_time.isoformat(), @@ -131,9 +131,8 @@ def test_restore_v1_format_only_restores_throttle(self, mock_logger): prefs_setter=lambda k, v: None ) - assert client._throttle_until is not None - assert abs((client._throttle_until - future_time).total_seconds()) < 1 - # V1 format: per-device tokens not restored, will populate on first poll + # V1 throttle not restored — was likely caused by incorrect global token tracking + assert client._throttle_until is None assert len(client._device_tokens) == 0 def test_restore_v2_format_restores_per_device_tokens(self, mock_logger): From 415503d2a9832cf31420e39c146f0e166c8772fd Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Fri, 10 Apr 2026 20:24:39 +0100 Subject: [PATCH 3/9] fix: address all review findings for per-device token tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set near-future reset time on parse failure to prevent permanent device self-lock (was None → no auto-reset possible) - Cast token_remaining to int() on restore + add TypeError to except to handle corrupted prefs - Per-device try/except in restore loop so one bad timestamp doesn't abort all subsequent devices - Log info when discarding v1 throttle state during migration - Remove unused `field` import from dataclasses - Add boundary test at exactly TOKEN_PAUSE_THRESHOLD - Add v2 throttle restore tests (future and expired) - Add test for malformed device timestamp skipping Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Server Plugin/api_client.py | 31 ++++---- tests/test_api_client.py | 72 ++++++++++++++++++- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 519d1d0..8da3a14 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py @@ -19,7 +19,7 @@ import json import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, Final, List, Optional, Set, Union @@ -461,7 +461,8 @@ def _update_token_budget( except (ValueError, TypeError) as exc: self.logger.warning(f"Could not parse token info from response: {exc}") remaining = TOKEN_PAUSE_THRESHOLD - 1 - reset_time = None + # Set near-future reset so auto-reset can unlock this device + reset_time = datetime.now(timezone.utc) + timedelta(hours=1) # Update or create per-device state self._device_tokens[device_key] = DeviceTokenState( @@ -551,16 +552,22 @@ def _restore_throttle_state(self) -> None: # V2 format: restore per-device token budgets if is_v2 and "device_tokens" in state: for key, token_state in state["device_tokens"].items(): - reset_time = None - if token_state.get("token_reset"): - reset_time = datetime.fromisoformat(token_state["token_reset"]) - self._device_tokens[key] = DeviceTokenState( - token_remaining=token_state.get("token_remaining", 2000), - token_reset=reset_time, - ) - # V1 format: ignore global token_remaining, let first poll populate per-device - - except (json.JSONDecodeError, ValueError, KeyError) as exc: + try: + reset_time = None + if token_state.get("token_reset"): + reset_time = datetime.fromisoformat(token_state["token_reset"]) + self._device_tokens[key] = DeviceTokenState( + token_remaining=int(token_state.get("token_remaining", 2000)), + token_reset=reset_time, + ) + except (ValueError, TypeError) as exc: + key_display = key[:8] + "..." if len(key) > 8 else key + self.logger.warning(f"Could not restore token state for device {key_display}: {exc}") + elif not is_v2 and state.get("throttle_until"): + # V1 format: log that legacy throttle is being discarded + self.logger.info("Migrating to per-device token tracking — discarding legacy throttle state") + + except (json.JSONDecodeError, ValueError, KeyError, TypeError) as exc: self.logger.warning("Could not restore throttle state: %s", exc) # ========================================================================= diff --git a/tests/test_api_client.py b/tests/test_api_client.py index d66730f..a7302cc 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -158,8 +158,72 @@ def test_restore_v2_format_restores_per_device_tokens(self, mock_logger): assert client._device_tokens["key_A"].token_remaining == 1500 assert client._device_tokens["key_B"].token_remaining == 1800 + def test_restore_v2_throttle_future(self, mock_logger): + """V2 format restores throttle_until if still in future.""" + future_time = datetime.now() + timedelta(minutes=30) + state = { + "version": 2, + "throttle_until": future_time.isoformat(), + "device_tokens": {}, + } + prefs_data = {"throttle_state": json.dumps(state)} + + client = NetroAPIClient( + logger=mock_logger, + prefs_getter=lambda: prefs_data, + prefs_setter=lambda k, v: None + ) + + assert client._throttle_until is not None + assert abs((client._throttle_until - future_time).total_seconds()) < 1 + + def test_restore_v2_throttle_expired(self, mock_logger): + """V2 format ignores throttle_until if in past.""" + past_time = datetime.now() - timedelta(minutes=30) + state = { + "version": 2, + "throttle_until": past_time.isoformat(), + "device_tokens": {}, + } + prefs_data = {"throttle_state": json.dumps(state)} + + client = NetroAPIClient( + logger=mock_logger, + prefs_getter=lambda: prefs_data, + prefs_setter=lambda k, v: None + ) + + assert client._throttle_until is None + + def test_restore_v2_bad_device_timestamp_skips_device(self, mock_logger): + """Malformed token_reset for one device doesn't abort others.""" + state = { + "version": 2, + "throttle_until": None, + "device_tokens": { + "key_A": {"token_remaining": 1500, "token_reset": None}, + "key_B": {"token_remaining": 1200, "token_reset": "not-a-date"}, + "key_C": {"token_remaining": 900, "token_reset": None}, + }, + } + prefs_data = {"throttle_state": json.dumps(state)} + + client = NetroAPIClient( + logger=mock_logger, + prefs_getter=lambda: prefs_data, + prefs_setter=lambda k, v: None + ) + + # key_A and key_C should be restored, key_B skipped + assert "key_A" in client._device_tokens + assert client._device_tokens["key_A"].token_remaining == 1500 + assert "key_B" not in client._device_tokens + assert "key_C" in client._device_tokens + assert client._device_tokens["key_C"].token_remaining == 900 + mock_logger.warning.assert_called() + def test_restore_throttle_state_ignores_expired(self, mock_logger): - """Restore ignores throttle_until if in past.""" + """V1 format with past throttle — ignored entirely.""" past_time = datetime.now() - timedelta(minutes=30) state = { "throttle_until": past_time.isoformat(), @@ -215,6 +279,12 @@ def test_should_pause_for_below_threshold(self, client): client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=TOKEN_PAUSE_THRESHOLD - 1) assert client.should_pause_polling_for("KEY_A") is True + def test_should_not_pause_for_at_exactly_threshold(self, client): + """should_pause_polling_for returns False at exactly threshold (< not <=).""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState(token_remaining=TOKEN_PAUSE_THRESHOLD) + assert client.should_pause_polling_for("KEY_A") is False + def test_should_not_pause_for_above_threshold(self, client): """should_pause_polling_for returns False when device above threshold.""" from api_client import DeviceTokenState From aa4525452287bcef47ed595131ab6ca468bb146b Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sat, 11 Apr 2026 07:45:06 +0100 Subject: [PATCH 4/9] fix: update whisperer token/time states even when sensor reading unchanged The reading deduplication was skipping ALL state updates when the readingID hadn't changed, including token_remaining and time which change every poll. Now updates metadata states (token_remaining, token_reset, time, last_active) on every poll while still skipping sensor data updates to preserve Indigo's lastChanged timestamp. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Server Plugin/plugin.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 798169c..c14cfaf 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -885,9 +885,10 @@ def _update_whisperer_device(self, dev): else: dev.setErrorStateOnServer('') - # Skip state update if the reading hasn't changed — this keeps + # Skip full state update if the reading hasn't changed — this keeps # Indigo's lastChanged reflecting when new sensor data arrived, - # not when we last polled the API + # not when we last polled the API. Still update token/time states + # which change every poll regardless of sensor readings. new_reading_id = next( (s["value"] for s in states if s["key"] == "readingID"), None ) @@ -897,8 +898,13 @@ def _update_whisperer_device(self, dev): and current_reading_id is not None and str(new_reading_id) == str(current_reading_id) ): + # Update only metadata states that change every poll + meta_keys = {"token_remaining", "token_reset", "time", "last_active", "api_last_active"} + meta_states = [s for s in states if s["key"] in meta_keys] + if meta_states: + dev.updateStatesOnServer(meta_states) self.logger.debug( - f"Sensor '{dev.name}' reading unchanged (ID {new_reading_id}), skipping update" + f"Sensor '{dev.name}' reading unchanged (ID {new_reading_id}), updated metadata only" ) return From 82bde0ed3d6dd82ed528118364efb5316f9394e7 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sat, 11 Apr 2026 08:12:18 +0100 Subject: [PATCH 5/9] feat: per-endpoint polling intervals to reduce API token usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Netro's 2000 calls/day budget is shared across all devices on an account. With everything polling at 5-min intervals, the plugin exhausted tokens by mid-afternoon. Now each endpoint has its own configurable interval: - Events: 5 min (fast — online/offline status) - Device info: 10 min (status, firmware, tokens) - Moistures: 10 min (zone moisture levels) - Schedules: 30 min (watering times, rarely change) - Sensors: 30 min (Whisperer readings) - Weather realtime: 30 min (existing, unchanged) - Forecast: 4 hrs (was hardcoded 1 hr, now configurable) Expected daily usage: ~1,752 calls (down from ~4,000+) Also fixes whisperer token_remaining state not updating when sensor reading unchanged (reading dedup was skipping all states). Closes #48 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/PluginConfig.xml | 61 ++++- .../Contents/Server Plugin/constants.py | 53 +++- .../Contents/Server Plugin/plugin.py | 259 +++++++++++------- .../Contents/Server Plugin/validators.py | 44 ++- tests/test_base_modules.py | 4 +- tests/test_validators.py | 37 ++- 7 files changed, 326 insertions(+), 134 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index 3356a85..b2b4388 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.3.1 + 2026.4.0 ServerApiVersion 3.6 IwsApiVersion diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml index 8730bfc..5ea15cb 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml @@ -6,12 +6,52 @@ - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -64,10 +104,17 @@ - + - + + + + + + + + diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py index 42f3d3d..72bb1e7 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py @@ -126,19 +126,58 @@ """Default timeout for API requests in seconds.""" MINIMUM_POLLING_INTERVAL_MINUTES: Final[int] = 3 -"""Minimum polling interval in minutes to avoid API rate limits.""" +"""Minimum polling interval in minutes (used as main loop sleep).""" + +# Per-endpoint polling defaults (minutes) +DEFAULT_EVENTS_INTERVAL_MINUTES: Final[int] = 5 +"""Default interval for device event polling (online/offline, v2 only).""" + +DEFAULT_DEVICE_INFO_INTERVAL_MINUTES: Final[int] = 10 +"""Default interval for device info polling (status, firmware, tokens).""" + +DEFAULT_MOISTURES_INTERVAL_MINUTES: Final[int] = 10 +"""Default interval for zone moisture polling.""" + +DEFAULT_SCHEDULES_INTERVAL_MINUTES: Final[int] = 30 +"""Default interval for schedule polling.""" + +DEFAULT_SENSOR_INTERVAL_MINUTES: Final[int] = 30 +"""Default interval for Whisperer sensor data polling.""" DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES: Final[int] = 30 -"""Default interval for weather updates in minutes (matches PluginConfig.xml).""" +"""Default interval for Tomorrow.io realtime weather updates.""" -THROTTLE_LIMIT_MINUTES: Final[int] = 61 -"""Duration to wait after rate limit error before retrying (minutes).""" +DEFAULT_FORECAST_INTERVAL_MINUTES: Final[int] = 240 +"""Default interval for Tomorrow.io forecast updates (4 hours).""" + +# Per-endpoint minimum intervals (minutes) +MINIMUM_EVENTS_INTERVAL_MINUTES: Final[int] = 3 +"""Minimum interval for event polling.""" -FORECAST_UPDATE_INTERVAL_MINUTES: Final[int] = 60 -"""Interval between forecast updates in minutes.""" +MINIMUM_DEVICE_INFO_INTERVAL_MINUTES: Final[int] = 5 +"""Minimum interval for device info polling.""" + +MINIMUM_SCHEDULES_INTERVAL_MINUTES: Final[int] = 10 +"""Minimum interval for schedule polling.""" + +MINIMUM_MOISTURES_INTERVAL_MINUTES: Final[int] = 5 +"""Minimum interval for moisture polling.""" + +MINIMUM_SENSOR_INTERVAL_MINUTES: Final[int] = 10 +"""Minimum interval for sensor data polling.""" MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES: Final[int] = 10 -"""Minimum interval for Tomorrow.io weather updates in minutes.""" +"""Minimum interval for Tomorrow.io weather updates.""" + +MINIMUM_FORECAST_INTERVAL_MINUTES: Final[int] = 60 +"""Minimum interval for Tomorrow.io forecast updates.""" + +THROTTLE_LIMIT_MINUTES: Final[int] = 61 +"""Duration to wait after rate limit error before retrying (minutes).""" + +# Legacy constant — kept for backward compatibility during migration +FORECAST_UPDATE_INTERVAL_MINUTES: Final[int] = 240 +"""Default forecast interval (use DEFAULT_FORECAST_INTERVAL_MINUTES instead).""" TOKEN_PAUSE_THRESHOLD: Final[int] = 100 """Token count below which polling should pause proactively.""" diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index c14cfaf..e2c2c8c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -47,8 +47,13 @@ from constants import ( MAX_ZONE_DURATION_SECONDS, DEFAULT_API_TIMEOUT_SECONDS, + DEFAULT_EVENTS_INTERVAL_MINUTES, + DEFAULT_DEVICE_INFO_INTERVAL_MINUTES, + DEFAULT_MOISTURES_INTERVAL_MINUTES, + DEFAULT_SCHEDULES_INTERVAL_MINUTES, + DEFAULT_SENSOR_INTERVAL_MINUTES, DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES, - FORECAST_UPDATE_INTERVAL_MINUTES, + DEFAULT_FORECAST_INTERVAL_MINUTES, MINIMUM_POLLING_INTERVAL_MINUTES, ZONE_START_ENDPOINT, OPERATIONAL_ERROR_EVENTS, @@ -96,9 +101,22 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): self._displayed_connection_error = False self.pluginId = pluginId self.debug = pluginPrefs.get("showDebugInfo", False) - self.pollingInterval = int(pluginPrefs.get("pollingInterval", MINIMUM_POLLING_INTERVAL_MINUTES)) self.timeout = int(pluginPrefs.get("apiTimeout", DEFAULT_API_TIMEOUT_SECONDS)) + # Per-endpoint polling intervals (minutes) + self._events_interval = int(pluginPrefs.get("eventsInterval", DEFAULT_EVENTS_INTERVAL_MINUTES)) + self._device_info_interval = int(pluginPrefs.get("deviceInfoInterval", DEFAULT_DEVICE_INFO_INTERVAL_MINUTES)) + self._moistures_interval = int(pluginPrefs.get("moisturesInterval", DEFAULT_MOISTURES_INTERVAL_MINUTES)) + self._schedules_interval = int(pluginPrefs.get("schedulesInterval", DEFAULT_SCHEDULES_INTERVAL_MINUTES)) + self._sensor_interval = int(pluginPrefs.get("sensorInterval", DEFAULT_SENSOR_INTERVAL_MINUTES)) + + # Main loop sleep uses the shortest interval so fast endpoints fire on time + self._loop_interval = min( + self._events_interval, self._device_info_interval, + self._moistures_interval, self._schedules_interval, + self._sensor_interval + ) + self.unused_devices = {} # Netro API uses serial number for authentication (not bearer tokens) # Serial numbers are configured per-device, not at plugin level @@ -112,12 +130,23 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): self.triggerDict = {} - # Initialize Tomorrow.io weather integration - self._next_weather_update = datetime.now() - self._next_forecast_update = datetime.now() + # Per-endpoint next-update timers (all fire immediately on first cycle) + now = datetime.now() + self._next_device_info_update = now + self._next_schedules_update = now + self._next_moistures_update = now + self._next_events_update = now + self._next_sensor_update = now + + # Tomorrow.io weather integration timers + self._next_weather_update = now + self._next_forecast_update = now self._weather_update_interval = int( pluginPrefs.get("weatherUpdateInterval", DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES) ) + self._forecast_interval = int( + pluginPrefs.get("forecastInterval", DEFAULT_FORECAST_INTERVAL_MINUTES) + ) self._tomorrow_client = self._create_tomorrow_client(pluginPrefs) # Initialize API client with prefs callbacks for throttle state persistence @@ -491,7 +520,7 @@ def _update_forecast_from_tomorrow(self): # Schedule next update regardless of success/failure self._next_forecast_update = datetime.now() + timedelta( - minutes=FORECAST_UPDATE_INTERVAL_MINUTES + minutes=self._forecast_interval ) forecast_data = self._tomorrow_client.fetch_forecast() @@ -719,6 +748,10 @@ def _update_from_netro(self): def _update_sprinkler_device(self, dev): """Update a single sprinkler device from Netro API. + Each endpoint fires independently based on its own timer. + Device info is the foundation — schedules, moistures, and zone + updates only run when device info also fires. + Args: dev: Indigo sprinkler device to update """ @@ -734,80 +767,80 @@ def _update_sprinkler_device(self, dev): ) return - schedule_dict = None - moisture_dict = None + now = datetime.now() - # Get device info - reply_dict = self.api_client.get_device_info(key, api_version=api_version) + # --- Device info (foundation for zone updates) --- + if now >= self._next_device_info_update: + reply_dict = self.api_client.get_device_info(key, api_version=api_version) + update_list, is_online, device_data = self.sprinkler_handler.process_device_info( + reply_dict, dev.address, api_version=api_version + ) - # Delegate state transformation to handler - update_list, is_online, device_data = self.sprinkler_handler.process_device_info( - reply_dict, dev.address, api_version=api_version - ) + # Update person/netro_devices for legacy compatibility + netro_serial = device_data.get("serial", dev.address) + device_data["id"] = netro_serial + self.person = {"id": netro_serial, "devices": [device_data]} + self.netro_devices = self.person["devices"] - # Update person/netro_devices for legacy compatibility - netro_serial = device_data.get("serial", dev.address) - device_data["id"] = netro_serial - self.person = {"id": netro_serial, "devices": [device_data]} - self.netro_devices = self.person["devices"] - self.logger.debug(self.netro_devices) + # Set error state based on online status + if not is_online: + dev.setErrorStateOnServer('unavailable') + else: + dev.setErrorStateOnServer('') - # Set error state based on online status - if not is_online: - dev.setErrorStateOnServer('unavailable') - else: - dev.setErrorStateOnServer('') + # --- Schedules (only when info also fires) --- + schedule_dict = None + active_schedule_name = None + if now >= self._next_schedules_update: + try: + schedule_dict = self.api_client.get_schedules(key, api_version=api_version) + schedule_states, active_schedule_name = self.sprinkler_handler.process_schedules( + schedule_dict, api_version=api_version + ) + update_list.extend(schedule_states) + except Exception: + update_list.append( + {"key": "activeSchedule", "value": "Error getting current schedule"}) + self.logger.debug(f"API error: \n{traceback.format_exc(10)}") + self._fireTrigger("getScheduleCall") - # Get schedule info - active_schedule_name = None - try: - schedule_dict = self.api_client.get_schedules(key, api_version=api_version) - schedule_states, active_schedule_name = self.sprinkler_handler.process_schedules( - schedule_dict, api_version=api_version - ) - update_list.extend(schedule_states) - except Exception: - update_list.append( - {"key": "activeSchedule", "value": "Error getting current schedule"}) - self.logger.debug(f"API error: \n{traceback.format_exc(10)}") - self._fireTrigger("getScheduleCall") + # Send state updates + if update_list: + dev.updateStatesOnServer(update_list) - # Send the state updates to the server - if update_list: - dev.updateStatesOnServer(update_list) + # Update zone information (properties, not states) + zone_names, max_durations, zones_data = self.sprinkler_handler.extract_zone_info( + device_data, self.maxZoneRunTime + ) + props = copy.deepcopy(dev.pluginProps) + props["NumZones"] = len(device_data.get("zones", [])) + props["ZoneNames"] = zone_names + props["MaxZoneDurations"] = ", ".join(max_durations) + props["zones"] = json.dumps(zones_data) + if active_schedule_name: + props["ScheduledZoneDurations"] = active_schedule_name + dev.replacePluginPropsOnServer(props) - # Update zone information (properties, not states) - zone_names, max_durations, zones_data = self.sprinkler_handler.extract_zone_info( - device_data, self.maxZoneRunTime - ) - props = copy.deepcopy(dev.pluginProps) - props["NumZones"] = len(device_data.get("zones", [])) - props["ZoneNames"] = zone_names - props["MaxZoneDurations"] = ", ".join(max_durations) - props["zones"] = json.dumps(zones_data) - if active_schedule_name: - props["ScheduledZoneDurations"] = active_schedule_name - dev.replacePluginPropsOnServer(props) - - # Fetch moisture levels (used by zone devices below) - try: - moisture_dict = self.api_client.get_moistures(key, api_version=api_version) - except Exception: - self.logger.warning(f"Moisture API unavailable for '{dev.name}' - zone moisture states may be stale") - self.logger.debug(f"Moisture API error: \n{traceback.format_exc(10)}") + # --- Moistures (only when info also fires) --- + moisture_dict = None + if now >= self._next_moistures_update: + try: + moisture_dict = self.api_client.get_moistures(key, api_version=api_version) + except Exception: + self.logger.warning(f"Moisture API unavailable for '{dev.name}' - zone moisture states may be stale") + self.logger.debug(f"Moisture API error: \n{traceback.format_exc(10)}") - # Auto-create and update zone devices - self._ensure_zone_devices(dev, zones_data) - self._update_zone_devices( - dev, device_data, schedule_dict, moisture_dict, api_version - ) + # Update zone devices (uses info + schedule + moisture data) + self._ensure_zone_devices(dev, zones_data) + self._update_zone_devices( + dev, device_data, schedule_dict, moisture_dict, api_version + ) - # Ensure Indigo variables exist for each zone (for variable substitution) - # Must be after replacePluginPropsOnServer to avoid props overwrite race - self._ensure_zone_variables(dev, zones_data) + # Ensure Indigo variables exist for each zone + self._ensure_zone_variables(dev, zones_data) - # Poll device events (v2 only) - if api_version == "2": + # --- Events (independent timer, v2 only) --- + if api_version == "2" and now >= self._next_events_update: try: today = date.today().strftime("%Y-%m-%d") events_dict = self.api_client.get_events(key, start_date=today) @@ -818,8 +851,6 @@ def _update_sprinkler_device(self, dev): ) self._last_event_ids[dev.id] = highest_id - # Skip firing triggers on first poll after startup to avoid - # replaying today's events as duplicate triggers if first_run: self.logger.debug( f"Events catch-up for '{dev.name}': " @@ -842,7 +873,6 @@ def _update_sprinkler_device(self, dev): self.logger.warning(f"Events API error for '{dev.name}': \n{traceback.format_exc(10)}") except ThrottleDelayError: - # Already logged detailed error in api_client, just skip this device pass except requests.exceptions.HTTPError as exc: self._handle_http_error(exc) @@ -855,9 +885,14 @@ def _update_sprinkler_device(self, dev): def _update_whisperer_device(self, dev): """Update a single Whisperer sensor device from Netro API. + Only polls when the sensor interval timer has elapsed. + Args: dev: Indigo Whisperer device to update """ + if datetime.now() < self._next_sensor_update: + return + try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) @@ -1005,36 +1040,40 @@ def shutdown(self): def runConcurrentThread(self): """Background thread that polls Netro API periodically. - This thread runs continuously while the plugin is enabled, calling - _update_from_netro() every pollingInterval minutes. Uses self.sleep() - to allow clean shutdown when plugin is disabled. - - The polling interval is configurable but must be at least 3 minutes - to avoid hitting Netro's API rate limit (2000 calls/day). + Each API endpoint has its own polling interval. The main loop + sleeps on the shortest interval so fast endpoints (events) fire + promptly. Per-endpoint timers inside update methods control when + each endpoint actually makes API calls. - Includes proactive pause when API tokens drop below threshold to - prevent exhausting the daily limit. - - Exceptions during updates are silently caught to prevent the thread - from exiting - errors are logged within _update_from_netro(). + Per-device token pause is checked inside each device update method. """ self.logger.debug("Starting concurrent thread") while True: try: - # Per-device token pause is checked inside each device update method self._update_from_netro() + # Reset per-endpoint timers that have elapsed + now = datetime.now() + if now >= self._next_device_info_update: + self._next_device_info_update = now + timedelta(minutes=self._device_info_interval) + if now >= self._next_schedules_update: + self._next_schedules_update = now + timedelta(minutes=self._schedules_interval) + if now >= self._next_moistures_update: + self._next_moistures_update = now + timedelta(minutes=self._moistures_interval) + if now >= self._next_events_update: + self._next_events_update = now + timedelta(minutes=self._events_interval) + if now >= self._next_sensor_update: + self._next_sensor_update = now + timedelta(minutes=self._sensor_interval) + # Tomorrow.io uses its own API; run regardless of Netro token pause self._update_weather_from_tomorrow() self._update_forecast_from_tomorrow() except self.StopThread: - # Clean shutdown requested by Indigo - must re-raise self.logger.debug("Concurrent thread stopping") raise except Exception: - # Log error with full traceback but continue polling - thread must not die self.logger.exception("Error in polling loop, will retry next interval") - self.sleep(self.pollingInterval * 60) + self.sleep(self._loop_interval * 60) ######################################## # Dialog list callbacks @@ -1156,14 +1195,29 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): if self.debug: self.logger.debug("Debug logging enabled") - # Update polling interval - try: - new_polling_interval = int(valuesDict.get("pollingInterval", MINIMUM_POLLING_INTERVAL_MINUTES)) - if new_polling_interval != self.pollingInterval: - self.pollingInterval = new_polling_interval - self.logger.info(f"Polling interval updated to {self.pollingInterval} minutes") - except (ValueError, TypeError): - self.logger.warning("Invalid polling interval value, keeping existing setting") + # Update per-endpoint polling intervals + interval_fields = { + "eventsInterval": ("_events_interval", DEFAULT_EVENTS_INTERVAL_MINUTES), + "deviceInfoInterval": ("_device_info_interval", DEFAULT_DEVICE_INFO_INTERVAL_MINUTES), + "moisturesInterval": ("_moistures_interval", DEFAULT_MOISTURES_INTERVAL_MINUTES), + "schedulesInterval": ("_schedules_interval", DEFAULT_SCHEDULES_INTERVAL_MINUTES), + "sensorInterval": ("_sensor_interval", DEFAULT_SENSOR_INTERVAL_MINUTES), + } + for field_id, (attr_name, default) in interval_fields.items(): + try: + new_val = int(valuesDict.get(field_id, default)) + if new_val != getattr(self, attr_name): + setattr(self, attr_name, new_val) + self.logger.info(f"{field_id} updated to {new_val} minutes") + except (ValueError, TypeError): + self.logger.warning(f"Invalid {field_id} value, keeping existing setting") + + # Recalculate main loop sleep interval + self._loop_interval = min( + self._events_interval, self._device_info_interval, + self._moistures_interval, self._schedules_interval, + self._sensor_interval + ) # Update max zone runtime try: @@ -1189,6 +1243,19 @@ def closedPrefsConfigUi(self, valuesDict, userCancelled): except (ValueError, TypeError): self.logger.warning("Invalid weather update interval value, keeping existing setting") + try: + new_forecast = int(valuesDict.get( + "forecastInterval", DEFAULT_FORECAST_INTERVAL_MINUTES + )) + if new_forecast != self._forecast_interval: + self._forecast_interval = new_forecast + weather_settings_changed = True + self.logger.info( + f"Forecast interval updated to {new_forecast} minutes" + ) + except (ValueError, TypeError): + self.logger.warning("Invalid forecast interval value, keeping existing setting") + old_client = self._tomorrow_client new_client = self._create_tomorrow_client(valuesDict) was_enabled = old_client is not None diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py index d3d6f34..99f3347 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py @@ -26,7 +26,16 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from constants import MINIMUM_POLLING_INTERVAL_MINUTES, MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES +from constants import ( + MINIMUM_POLLING_INTERVAL_MINUTES, + MINIMUM_EVENTS_INTERVAL_MINUTES, + MINIMUM_DEVICE_INFO_INTERVAL_MINUTES, + MINIMUM_SCHEDULES_INTERVAL_MINUTES, + MINIMUM_MOISTURES_INTERVAL_MINUTES, + MINIMUM_SENSOR_INTERVAL_MINUTES, + MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES, + MINIMUM_FORECAST_INTERVAL_MINUTES, +) # Type alias for validation function return values @@ -580,11 +589,29 @@ class PrefsFieldSpec: # Preferences field validation specifications _PREFS_FIELDS: List[PrefsFieldSpec] = [ PrefsFieldSpec( - "pollingInterval", MINIMUM_POLLING_INTERVAL_MINUTES, 1440, - MINIMUM_POLLING_INTERVAL_MINUTES, - f"Polling interval must be at least {MINIMUM_POLLING_INTERVAL_MINUTES} " - "minutes to avoid API rate limits", - "Polling interval cannot exceed 1440 minutes (24 hours)", + "eventsInterval", MINIMUM_EVENTS_INTERVAL_MINUTES, 1440, 5, + f"Events interval must be at least {MINIMUM_EVENTS_INTERVAL_MINUTES} minutes", + "Events interval cannot exceed 1440 minutes (24 hours)", + ), + PrefsFieldSpec( + "deviceInfoInterval", MINIMUM_DEVICE_INFO_INTERVAL_MINUTES, 1440, 10, + f"Device info interval must be at least {MINIMUM_DEVICE_INFO_INTERVAL_MINUTES} minutes", + "Device info interval cannot exceed 1440 minutes (24 hours)", + ), + PrefsFieldSpec( + "moisturesInterval", MINIMUM_MOISTURES_INTERVAL_MINUTES, 1440, 10, + f"Moistures interval must be at least {MINIMUM_MOISTURES_INTERVAL_MINUTES} minutes", + "Moistures interval cannot exceed 1440 minutes (24 hours)", + ), + PrefsFieldSpec( + "schedulesInterval", MINIMUM_SCHEDULES_INTERVAL_MINUTES, 1440, 30, + f"Schedules interval must be at least {MINIMUM_SCHEDULES_INTERVAL_MINUTES} minutes", + "Schedules interval cannot exceed 1440 minutes (24 hours)", + ), + PrefsFieldSpec( + "sensorInterval", MINIMUM_SENSOR_INTERVAL_MINUTES, 1440, 30, + f"Sensor interval must be at least {MINIMUM_SENSOR_INTERVAL_MINUTES} minutes", + "Sensor interval cannot exceed 1440 minutes (24 hours)", ), PrefsFieldSpec( "apiTimeout", 1, 60, 5, @@ -601,6 +628,11 @@ class PrefsFieldSpec: f"Weather update interval must be at least {MINIMUM_WEATHER_UPDATE_INTERVAL_MINUTES} minutes", "Weather update interval cannot exceed 1440 minutes (24 hours)", ), + PrefsFieldSpec( + "forecastInterval", MINIMUM_FORECAST_INTERVAL_MINUTES, 1440, 240, + f"Forecast interval must be at least {MINIMUM_FORECAST_INTERVAL_MINUTES} minutes", + "Forecast interval cannot exceed 1440 minutes (24 hours)", + ), ] diff --git a/tests/test_base_modules.py b/tests/test_base_modules.py index 76d4327..545448c 100644 --- a/tests/test_base_modules.py +++ b/tests/test_base_modules.py @@ -143,8 +143,8 @@ def test_throttle_limit(self): assert THROTTLE_LIMIT_MINUTES == 61 def test_forecast_update_interval(self): - """FORECAST_UPDATE_INTERVAL_MINUTES should be 1 hour.""" - assert FORECAST_UPDATE_INTERVAL_MINUTES == 60 + """FORECAST_UPDATE_INTERVAL_MINUTES should be 4 hours (legacy constant).""" + assert FORECAST_UPDATE_INTERVAL_MINUTES == 240 def test_operational_error_events_immutable(self): """OPERATIONAL_ERROR_EVENTS should be a frozenset.""" diff --git a/tests/test_validators.py b/tests/test_validators.py index ff21f2a..063bc2e 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -443,13 +443,18 @@ class TestPrefsConfigValidation: def test_valid_prefs(self): """All valid values pass.""" values = { - "pollingInterval": "10", + "eventsInterval": "5", + "deviceInfoInterval": "10", + "moisturesInterval": "10", + "schedulesInterval": "30", + "sensorInterval": "30", "apiTimeout": "30", "maxZoneRunTime": "3600", } is_valid, sanitized, errors = validate_prefs_config(values) assert is_valid is True - assert sanitized["pollingInterval"] == 10 + assert sanitized["eventsInterval"] == 5 + assert sanitized["deviceInfoInterval"] == 10 assert sanitized["apiTimeout"] == 30 assert sanitized["maxZoneRunTime"] == 3600 assert errors == {} @@ -459,33 +464,35 @@ def test_valid_prefs_defaults(self): values = {} is_valid, sanitized, errors = validate_prefs_config(values) assert is_valid is True - assert sanitized["pollingInterval"] == 3 + assert sanitized["eventsInterval"] == 5 + assert sanitized["deviceInfoInterval"] == 10 + assert sanitized["schedulesInterval"] == 30 assert sanitized["apiTimeout"] == 5 assert sanitized["maxZoneRunTime"] == 3600 - def test_polling_interval_too_low(self): + def test_events_interval_too_low(self): """< 3 minutes errors.""" - values = {"pollingInterval": "1"} + values = {"eventsInterval": "1"} is_valid, sanitized, errors = validate_prefs_config(values) assert is_valid is False - assert "pollingInterval" in errors - assert "at least" in errors["pollingInterval"].lower() + assert "eventsInterval" in errors + assert "at least" in errors["eventsInterval"].lower() - def test_polling_interval_too_high(self): + def test_events_interval_too_high(self): """> 1440 minutes errors.""" - values = {"pollingInterval": "2000"} + values = {"eventsInterval": "2000"} is_valid, sanitized, errors = validate_prefs_config(values) assert is_valid is False - assert "pollingInterval" in errors - assert "exceed" in errors["pollingInterval"].lower() + assert "eventsInterval" in errors + assert "exceed" in errors["eventsInterval"].lower() - def test_polling_interval_non_numeric(self): + def test_events_interval_non_numeric(self): """Non-numeric errors.""" - values = {"pollingInterval": "fast"} + values = {"eventsInterval": "fast"} is_valid, sanitized, errors = validate_prefs_config(values) assert is_valid is False - assert "pollingInterval" in errors - assert "valid number" in errors["pollingInterval"].lower() + assert "eventsInterval" in errors + assert "valid number" in errors["eventsInterval"].lower() def test_api_timeout_too_low(self): """< 1 second errors.""" From b90b94da22d57876f1871aa9f0fe9cd92e047030 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Sat, 11 Apr 2026 17:24:01 +0100 Subject: [PATCH 6/9] docs: map existing codebase --- .planning/codebase/ARCHITECTURE.md | 336 +++++++------------ .planning/codebase/CONCERNS.md | 419 +++++++++++------------- .planning/codebase/CONVENTIONS.md | 375 ++++++++------------- .planning/codebase/INTEGRATIONS.md | 269 ++++++--------- .planning/codebase/STACK.md | 129 +++----- .planning/codebase/STRUCTURE.md | 407 +++++++++-------------- .planning/codebase/TESTING.md | 509 ++++++++++++----------------- 7 files changed, 938 insertions(+), 1506 deletions(-) diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 310e3ec..80ee96a 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,256 +1,154 @@ # Architecture -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## Pattern Overview -**Overall:** Plugin-based Indigo integration with polling-based state synchronization +**Overall:** Indigo Plugin with layered separation of concerns **Key Characteristics:** -- Single-file monolithic plugin architecture (1635 lines in `plugin.py`) -- Polling-based data refresh cycle via concurrent background thread -- Per-device serial number authentication (no plugin-level API keys) -- Graceful error handling with error suppression after first display -- Trigger-based event notification system +- Plugin coordinator (`plugin.py`) owns the Indigo lifecycle and orchestrates all data flow +- No-dependency base modules (constants, exceptions, utils) are importable anywhere +- API clients (`api_client.py`, `tomorrow_client.py`) are pure Python — no `indigo` import +- Device handlers (`device_handlers.py`) transform API responses to Indigo state dicts — no `indigo` import +- Validators (`validators.py`) are pure functions with no side effects — fully testable in isolation ## Layers -**Presentation/Configuration Layer:** -- Purpose: Handle Indigo UI configuration and validation -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` methods: `validatePrefsConfigUi()` (line 1031), `validateDeviceConfigUi()` (line 881), `validateActionConfigUi()` (line 923), `validateEventConfigUi()` (line 1010), and XML config files -- Contains: Plugin settings UI, device configuration, action parameter validation, event filter validation -- Depends on: None (independent layer) -- Used by: Indigo UI framework - -**Control/Action Layer:** -- Purpose: Execute user-requested actions on Netro devices -- Location: `plugin.py` methods: `actionControlSprinkler()` (line 1238), `setNoWater()` (line 1350), `setStandbyMode()` (line 1383), `startZoneWithDelay()` (line 1408), `reportWeather()` (line 1479) -- Contains: Standard sprinkler actions (zone on/off), custom actions (rain delay, standby, weather reporting, zone with delay) -- Depends on: API Integration Layer (via `_make_api_call()`), State Management (throttle checking) -- Used by: Indigo action dispatcher - -**State Management Layer:** -- Purpose: Maintain plugin and device state, manage throttling and tracking -- Location: `plugin.py` instance variables (lines 157-187): `throttle_next_call`, `_displayed_connection_error`, `triggerDict`, `person`, `netro_devices` -- Contains: Throttle expiry tracking, connection error tracking, active triggers, cached API response data, device metadata -- Depends on: API Integration Layer (to know when throttled), Polling Loop (to update state) -- Used by: All layers for state validation and status checks - -**Data Synchronization Layer:** -- Purpose: Periodically poll Netro API and update Indigo device states -- Location: `plugin.py` methods: `_update_from_netro()` (line 373), `runConcurrentThread()` (line 810), `callMoisturesAPI()` (line 696), `callSensorAPI()` (line 735) -- Contains: Main polling loop, device state refresh for sprinklers and sensors, schedule fetching, moisture data retrieval, sensor reading updates -- Depends on: API Integration Layer (`_make_api_call()`), State Management (throttle checking) -- Used by: Background concurrent thread, manual refresh actions - -**API Integration Layer:** -- Purpose: Handle HTTP communication with Netro Public API -- Location: `plugin.py` method: `_make_api_call()` (line 195) -- Contains: HTTP request/response handling, rate limit detection and throttling (HTTP 400 with error code 3), timeout handling, JSON parsing, error classification, connection error suppression -- Depends on: `requests` library -- Used by: Data Synchronization Layer, Control/Action Layer - -**Trigger System Layer:** -- Purpose: Fire Indigo triggers based on operational and communication events -- Location: `plugin.py` methods: `_fireTrigger()` (line 1164), `triggerStartProcessing()` (line 1203), `triggerStopProcessing()` (line 1218) -- Contains: Trigger dispatch logic, event filtering, device association -- Depends on: Indigo trigger framework -- Used by: API Integration Layer (on errors), Control/Action Layer (on success/failure) - -**Device Lifecycle Layer:** -- Purpose: Initialize and manage device communication -- Location: `plugin.py` methods: `deviceStartComm()` (line 1137), `deviceStopComm()` (line 1153), `didDeviceCommPropertyChange()` (line 1119) -- Contains: Device startup/shutdown, configuration change detection -- Depends on: State Management -- Used by: Indigo device framework +**Base Layer (no dependencies):** +- Purpose: Shared constants, exception types, utility functions +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py`, `exceptions.py`, `utils.py` +- Contains: API URL constants, timing defaults, exception hierarchy, unit conversion functions +- Depends on: Python stdlib only +- Used by: All other modules + +**API Client Layer:** +- Purpose: HTTP communication with external APIs, rate-limit management +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py`, `tomorrow_client.py` +- Contains: `NetroAPIClient` (Netro Public API), `TomorrowClient` (Tomorrow.io weather API) +- Depends on: `constants`, `exceptions`, `requests` +- Used by: `plugin.py` + +**Validation Layer:** +- Purpose: Pure validation of user-supplied config before it reaches the plugin +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` +- Contains: `validate_device_config`, `validate_action_config`, `validate_event_config`, `validate_prefs_config` +- Depends on: `constants` only +- Used by: `plugin.py` in `validateDeviceConfigUi` / `validateActionConfigUi` / `validatePrefsConfigUi` callbacks + +**Handler Layer:** +- Purpose: Transform raw API response dicts into Indigo state-update lists +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` +- Contains: `SprinklerHandler`, `WhispererHandler`, `ZoneHandler` +- Depends on: `constants`, `utils` +- Used by: `plugin.py` + +**Plugin Coordinator:** +- Purpose: Indigo lifecycle, polling loop, Indigo device/variable management +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Contains: `Plugin(indigo.PluginBase)` class +- Depends on: All other layers, `indigo` (Indigo SDK) +- Used by: Indigo server (loaded as plugin bundle) ## Data Flow -**Polling Cycle (main loop, 3-300 minute intervals):** - -1. `runConcurrentThread()` calls `_update_from_netro()` every N minutes -2. `_update_from_netro()` iterates over enabled sprinkler and sensor devices -3. For sprinkler controllers: - - Call `_make_api_call(DEVICE_INFO_URL)` → Get device info, status, zones, token counts - - Parse response, merge metadata, cache in `self.person` and `self.netro_devices` - - Call `_make_api_call(DEVICE_SCHEDULES_URL)` → Get active and next schedule - - Call `_make_api_call(DEVICE_MOISTURES_URL)` → Get moisture per zone - - Build state update list with all values - - Call `dev.updateStatesOnServer(update_list)` → Write to Indigo device -4. For sensor devices: - - Call `_make_api_call(DEVICE_SENSOR_DATA_URL)` → Get temperature, moisture, sunlight, battery - - Update sensor device states -5. Return to sleep, wake in N minutes and repeat - -**Action Execution (triggered by user or automation):** - -1. Action callback method receives action object and device -2. Check `self.throttle_next_call` - if throttled, log error and fire failure trigger, return -3. Build API request data dict with zone/action parameters -4. Call `_make_api_call(API_ENDPOINT, method, data)` → Make HTTP request -5. On success: log success message, update local device state, fire success trigger (implicit) -6. On API error: log error, fire failure trigger (explicit), do NOT update state -7. On connection error: log error once, re-raise exception - -**Throttle Management Flow:** - -1. API Integration Layer detects HTTP 400 with error code 3 -2. Extract reset time from response meta: `meta.token_reset` -3. Store reset datetime in `self.throttle_next_call` -4. Fire "rateLimitExceeded" trigger -5. All subsequent API calls check `throttle_next_call` first -6. If throttled: raise `ThrottleDelayError`, Action Layer catches and fires failure trigger -7. When throttle expires: reset `throttle_next_call = None`, log resume message, resume normal operation - -**Error Handling Flow:** - -1. Exception raised from `_make_api_call()` or action execution -2. Check if this is the first display of this error type: - - Connection errors: Check `self._displayed_connection_error` - - API errors: Log once per API call -3. Log error with context (device name, zone, action, API response) -4. Raise exception or return False -5. Caller catches exception, fires appropriate trigger -6. Indigo continues operation (polling resumes, actions fail but don't crash plugin) - -**State vs Properties:** - -- **States** (frequently changing): `status`, `activeZone`, `activeSchedule`, `nextScheduleTime`, `nextScheduleZone`, `nextScheduleSource`, `nextScheduleDuration`, `token_remaining`, `moisture_1` through `moisture_12`, sensor readings -- **Properties** (static config): `address` (MAC), `model`, `name`, `serialNumber`, zone names and counts -- **Computed** (derived): `api_version`, `time` (last refresh), `last_active` (last API response time) +**Polling Cycle (every `_loop_interval` minutes):** -## Key Abstractions +1. `runConcurrentThread()` wakes and calls `_update_from_netro()` +2. `_update_from_netro()` iterates `indigo.devices.iter(filter="self")` for enabled devices +3. For each sprinkler: `_update_sprinkler_device(dev)` checks per-endpoint timers +4. Per-timer endpoint calls fire: `api_client.get_device_info()` → `api_client.get_schedules()` → `api_client.get_moistures()` → `api_client.get_events()` +5. API responses are passed to handler methods: `sprinkler_handler.process_device_info()` → returns `(state_list, is_online, device_data)` +6. `plugin.py` calls `dev.updateStatesOnServer(state_list)` and `dev.replacePluginPropsOnServer(props)` +7. Zone devices are created/updated via `_ensure_zone_devices()` and `_update_zone_devices()` +8. Indigo variables for moisture are maintained via `_ensure_zone_variables()` -**ThrottleDelayError:** -- Purpose: Indicate rate limit has been hit -- Location: `plugin.py` lines 91-98 -- Pattern: Custom exception raised when `self.throttle_next_call` active, caught at action layer +**Weather Flow (optional, Tomorrow.io):** -**Device Type Abstraction:** -- Purpose: Support multiple Netro device types -- Examples: `deviceTypeId` "sprinkler" (in Devices.xml line 9), `deviceTypeId` "Whisperer" (line 199) -- Pattern: Type-specific update logic in `_update_from_netro()` based on `dev.deviceTypeId` +1. `runConcurrentThread()` calls `_update_weather_from_tomorrow()` and `_update_forecast_from_tomorrow()` on their own timers +2. `TomorrowClient.fetch_current_weather()` / `fetch_forecast()` returns metric weather dict +3. `plugin.py` converts units for v1 devices (`convert_weather_metric_to_us`) — v2 stays metric +4. `api_client.report_weather()` posts to Netro to improve smart scheduling +5. Weather device states updated on the sprinkler device -**State Update List Pattern:** -- Purpose: Batch device state updates for efficiency -- Location: `plugin.py` line 425, 696 -- Pattern: Build list of dicts `[{"key": "state_id", "value": value}]`, pass to `dev.updateStatesOnServer()` +**User Action Flow:** -**Zone Dictionary Abstraction:** -- Purpose: Represent zone configuration and state -- Location: Returned by `_get_zone_dict()` (line 354) -- Pattern: Dict with keys: `id`, `name`, `maxRuntime`, `enabled`, index +1. User invokes action in Indigo UI +2. `plugin.py` action callback validates parameters via `validators.validate_action_config()` +3. Plugin calls `api_client.start_watering()` / `stop_watering()` / `set_no_water()` etc. +4. API response logged; state updated next polling cycle -**Device Dictionary Abstraction:** -- Purpose: Cache Netro API response structure -- Location: `self.person`, `self.netro_devices` -- Pattern: Mirrors Netro API response structure with keys: `device`, `zones`, `schedules`, `moistures` +**State Management:** +- API throttle state persisted to `pluginPrefs["throttle_state"]` as JSON (survives restarts) +- Per-endpoint timers are in-memory `datetime` attributes on the `Plugin` instance +- Zone-to-variable mapping stored in device `pluginProps["zoneVariableMap"]` as JSON +- Last-seen event ID tracked in `self._last_event_ids` dict (in-memory, keyed by Indigo device ID) -## Entry Points +## Key Abstractions + +**NetroAPIClient (`api_client.py`):** +- Purpose: All HTTP communication with Netro Public API; per-device token budget tracking +- Pattern: Dependency-injection for logger and prefs callbacks — no `indigo` import +- Constructor receives `prefs_getter` / `prefs_setter` callbacks to persist throttle state + +**DeviceTokenState (`api_client.py`):** +- Purpose: Dataclass tracking token budget per device (keyed by API key or serial) +- Pattern: Per-device tracking (2000 tokens/day limit is per-device, not account-wide) + +**SprinklerHandler / WhispererHandler / ZoneHandler (`device_handlers.py`):** +- Purpose: Stateless transformers — receive API response dict, return list of state update dicts +- Pattern: No `indigo` import, no side effects; fully unit-testable +- Return type: `List[Dict[str, Any]]` matching `updateStatesOnServer()` format -**Plugin Initialization:** -- Location: `__init__()` (line 157) -- Triggers: Indigo loads plugin -- Responsibilities: Initialize instance variables, parse preferences, set up data structures +**ValidationResult (`validators.py`):** +- Purpose: Consistent return type from all validators +- Pattern: `Tuple[bool, Dict[str, Any], Dict[str, str]]` — `(is_valid, sanitized_values, errors_dict)` -**Plugin Startup:** -- Location: `startup()` (line 793) -- Triggers: Indigo enables plugin -- Responsibilities: Log startup (minimal), defer heavy initialization to concurrent thread +**Dual API version support (`api_client.py`, `device_handlers.py`, `plugin.py`):** +- Purpose: Support both v1 (serial number auth) and v2 (API key auth) simultaneously +- Pattern: `_get_device_auth(dev)` returns `(key, api_version)` — all downstream calls parameterised by version +- Endpoint selection: `_ENDPOINT_MAP` dict keyed by `(name, version)` in `NetroAPIClient` -**Concurrent Polling Thread:** -- Location: `runConcurrentThread()` (line 810) -- Triggers: Indigo launches background thread -- Responsibilities: Loop forever, call `_update_from_netro()` every N minutes, catch and suppress exceptions +## Entry Points + +**Plugin Bundle Load:** +- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- Triggers: Indigo server loads plugin bundle on startup or enable +- Responsibilities: `Plugin.__init__()` initialises all state, handlers, clients -**Device Communication:** -- Location: `deviceStartComm()` (line 1137), `deviceStopComm()` (line 1153) -- Triggers: Indigo device enabled/disabled or plugin reloaded -- Responsibilities: Log device lifecycle, allow concurrent thread to process devices +**`startup()` (`plugin.py` line ~1002):** +- Triggers: After `__init__`, before first concurrent thread tick +- Responsibilities: Logs API version per device, subscribes to Indigo variable changes -**User Actions:** -- Location: `actionControlSprinkler()` (line 1238), custom action handlers -- Triggers: User executes action from Indigo UI or automation -- Responsibilities: Validate throttle state, execute API call, update state, fire triggers +**`runConcurrentThread()` (`plugin.py` line ~1040):** +- Triggers: Called by Indigo after `startup()`; runs until `StopThread` +- Responsibilities: Per-endpoint timer-based polling loop; sleeps `_loop_interval * 60` seconds -**Triggers:** -- Location: `triggerStartProcessing()` (line 1203), `triggerStopProcessing()` (line 1218) -- Triggers: User creates/deletes Indigo trigger -- Responsibilities: Register/deregister trigger in `self.triggerDict` +**Action Callbacks:** +- Pattern: All action handlers in `plugin.py` validate via `validators.validate_action_config()` then delegate to `api_client` ## Error Handling -**Strategy:** Fail gracefully without crashing plugin; log details; fire triggers for user automation +**Strategy:** Layered — API layer raises typed exceptions; plugin coordinator catches and logs; polling loop continues **Patterns:** - -**Connection Errors** (ConnectionError, Timeout, ReadTimeout): -```python -except requests.exceptions.ConnectionError as exc: - if not self._displayed_connection_error: - self.logger.error("Connection to Netro API server failed. Will continue to retry silently.") - self._displayed_connection_error = True - raise exc -``` -- Location: `plugin.py` lines 248-252 -- Behavior: Log once, suppress subsequent logs, re-raise to caller -- Caller catches and continues polling cycle - -**Rate Limit Errors** (HTTP 400 with error code 3): -```python -if error.get("code") == 3: - reset_dt = datetime.strptime(token_reset, "%Y-%m-%dT%H:%M:%S") - self.throttle_next_call = reset_dt - self.logger.error(f"netro api rate limit exceeded ({token_msg}), calls will resume after {reset_dt}") - self._fireTrigger("rateLimitExceeded") -``` -- Location: `plugin.py` lines 276-294 -- Behavior: Parse reset time, store in `throttle_next_call`, fire trigger, log error -- All API calls check throttle state, action layer fires failure trigger - -**Validation Errors** (invalid input): -- Location: `validatePrefsConfigUi()` (line 1031), `validateDeviceConfigUi()` (line 881), `validateActionConfigUi()` (line 923) -- Behavior: Check constraints (serial format, polling interval, parameter ranges), return `(False, error_message)` to Indigo -- Indigo prevents invalid configuration from being saved - -**Action Execution Errors**: -```python -try: - self._make_api_call(ZONE_START_URL, request_method="put", data=data) - self.logger.info(f'sent "{dev.name} - {zoneName}" on') -except (Exception,): - self.logger.error(f'send "{dev.name} - {zoneName}" on failed') - self._fireTrigger("startZoneFailed", dev.id) -``` -- Location: `plugin.py` lines 1281-1290 -- Behavior: Try to execute, catch any exception, log error, fire failure trigger -- Device state NOT updated on failure (Netro is source of truth) +- `NetroAPIClient.make_request()` raises `ThrottleDelayError` on rate-limit; `NetroAPIError` on API error; `requests` exceptions propagate +- `ThrottleDelayError` caught silently in `_update_sprinkler_device()` — polling just skips the device +- Connection/timeout errors logged once per error type, then silently retried (suppression via `_last_error_type`) +- Device handlers return error state list on `KeyError`/`TypeError` — never raise +- Validators return `(False, values, errors)` — never raise +- Outer try/except in `runConcurrentThread()` ensures loop never exits on unexpected exception ## Cross-Cutting Concerns -**Logging:** -- Framework: Python `logging` via `self.logger` -- Pattern: Debug level for API calls, info for actions, error for failures -- Example: `self.logger.debug(f"API call: {request_method.upper()} {url}")` (line 220) - -**Validation:** -- Configuration validation: Enforce serial format (12 hex chars), polling interval (≥3 min), timeout (1-60s), max zone runtime (60-10800s) -- Action parameter validation: Zone duration (1-180 min for delays), delay (0-60 min), rain days (1-100), weather ranges -- Location: `validatePrefsConfigUi()` (line 1031), `validateDeviceConfigUi()` (line 881), `validateActionConfigUi()` (line 923) - -**Authentication:** -- Method: Serial number as URL parameter `?key={serial}` -- Location: `_update_from_netro()` line 397: `DEVICE_INFO_URL?key={dev.address}` -- Per-device: Each device has own serial number stored in `dev.address` -- No bearer tokens or plugin-level keys - -**Rate Limiting:** -- Netro limit: 2000 calls/day shared across all plugin instances -- Detection: HTTP 400 with error code 3 and `token_reset` time -- Response: 61-minute backoff, store reset time in `throttle_next_call` -- Monitoring: Display `token_remaining` state, warn when <200 tokens left -- Prevention: Default 5-minute polling = ~288 calls/day (safe) +**Logging:** Uses `self.logger` (Indigo's logger) everywhere; API key values masked in debug logs (`key=***`); error suppression avoids log spam on repeated connection failures + +**Validation:** All user-facing config goes through `validators.py` before reaching plugin logic; integer-range helpers enforce minimums + +**Authentication:** Per-device — `_get_device_auth(dev)` reads `pluginProps["apiKey"]`; if present, uses v2 (API key); otherwise v1 (serial from `dev.address`) + +**Rate Limiting:** Proactive — tracks per-device token budget from every response meta; pauses polling when `token_remaining < TOKEN_PAUSE_THRESHOLD` (100); persisted to survive restarts --- -*Architecture analysis: 2026-02-01* +*Architecture analysis: 2026-04-11* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 8dd07e3..8ca6f42 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,243 +1,198 @@ # Codebase Concerns -**Analysis Date:** 2026-02-01 - -## Code Quality & Maintainability - -**Bare Exception Handlers:** -- Issue: Multiple bare `except (Exception,):` patterns that catch all exceptions -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 131, 827, 1230, 1285, 1306) -- Impact: Masks unexpected errors, hides bugs, makes debugging difficult -- Fix approach: Replace with specific exception types (e.g., `except requests.RequestException`, `except KeyError`, `except ValueError`). Only use bare except for intentional error suppression with clear documentation. - -**Bare Except with Silent Pass:** -- Issue: `except (Exception,): pass` at line 827 silently ignores all errors in `runConcurrentThread()` -- Files: `plugin.py:827` in polling loop -- Impact: Thread dies silently on unexpected errors; no logging makes debugging production issues impossible -- Fix approach: Log exception before passing: `except Exception as exc: self.logger.error(f"Polling error: {exc}")` with traceback. Never silently ignore thread errors. - -**Large Single File (1635 lines):** -- Issue: Monolithic plugin.py contains all logic - no separation of concerns -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` -- Impact: Hard to test individual components; high cyclomatic complexity; difficult code navigation -- Fix approach: Extract API client to `api_client.py`, validation to `validators.py`, actions to `actions.py`. Reduces main file to ~800 lines. - -**Overly Broad Exception Handling in State Updates:** -- Issue: Try-except blocks wrap entire update methods (lines 584-661), obscuring which operation failed -- Files: `plugin.py:584-661` in `_update_from_netro()` -- Impact: When schedule updates fail, hard to distinguish device info vs schedules vs moisture failures -- Fix approach: Wrap each API call independently with specific error handling and logging for that operation. - -## Code Style & Conventions - -**Inconsistent Logging Levels:** -- Issue: `self.logger.info()` used for errors, `self.logger.error()` for warnings -- Files: `plugin.py` lines 1308, 1374, 1376 -- Impact: Event log filtering unreliable; user can't distinguish serious vs informational messages -- Fix approach: Use correct levels: `.error()` for failures, `.warning()` for conditions to avoid, `.info()` for normal operations. - -**String Formatting Mix:** -- Issue: Mix of f-strings, `.format()`, and string concatenation throughout -- Files: `plugin.py` scattered (e.g., lines 1304 has `f'sent "{dev.name}" {"all zones off"}'` which is awkward) -- Impact: Inconsistent style makes code harder to read -- Fix approach: Use f-strings exclusively (Google Python Style Guide compliant). Replace old `.format()` calls. - -**Bare Tuple in Exception Handling:** -- Issue: `except (Exception,):` uses trailing comma - unusual Python pattern -- Files: Multiple locations (lines 131, 827, 1230, 1285, 1306) -- Impact: Non-idiomatic Python; confuses linters and reviewers -- Fix approach: Change to `except Exception:` (no parentheses/comma needed for single exception type). - -**Unused Variable Declarations:** -- Issue: Variables assigned but not used (e.g., `exc` in multiple except blocks) -- Files: `plugin.py` lines 1402, 1473, 1534 -- Impact: Code appears incomplete; may hide refactoring mistakes -- Fix approach: Remove unused variables or use underscore convention `except Exception as _:`. - -## Performance & Scalability - -**Single Device Per Plugin Instance:** -- Issue: Plugin designed for single controller only; multiple controllers require multiple plugin instances -- Files: `CLAUDE.md:377` documents as limitation -- Impact: Reduces flexibility; users manage multiple Indigo plugin instances for multi-controller setups -- Fix approach: Refactor state management to handle device dict per controller serial. Medium effort; would improve user experience significantly. - -**All Devices Polled Sequentially in Single Thread:** -- Issue: `runConcurrentThread()` polls all devices in series (lines 824-829); waits for all to complete before sleep -- Files: `plugin.py:810-829` and `_update_from_netro():373-695` -- Impact: If one device is slow (timeout), all others wait; timeout delays next poll cycle -- Fix approach: Use thread pool for parallel device polling, with per-device timeouts. Improves responsiveness. - -**Polling Interval Affects All Devices Equally:** -- Issue: Plugin-level polling interval applies to all devices; no per-device configuration -- Files: `plugin.py:163` and `runConcurrentThread():829` -- Impact: Can't optimize polling for fast vs slow controllers; users forced to choose conservative interval for all -- Fix approach: Add per-device polling configuration (overrides plugin default). Medium effort. - -**Throttle Management Persists in Memory Only:** -- Issue: `self.throttle_next_call` lost on plugin restart; no persistent state -- Files: `plugin.py:180, 210-217` -- Impact: If rate limit hit just before plugin restart, throttle timer immediately expires after restart (rate limit hit again) -- Fix approach: Persist throttle state to pluginPrefs; restore on startup. Prevents immediate re-triggering. - -## API Integration Fragility - -**Reliance on Undocumented API Behavior:** -- Issue: Plugin handles 10 known API quirks documented in `API_NOTES.md` -- Files: `API_NOTES.md` documents all; `plugin.py` implements workarounds -- Impact: Netro API changes break plugin silently (e.g., timestamp format change from string to number) -- Fix approach: Add comprehensive API response schema validation; detect format changes early. Implement API version detection. - -**Timestamp Type Handling Scattered:** -- Issue: String/number timestamp conversion happens in 4+ places -- Files: `plugin.py` lines 524-527, 554-560 (in _update_from_netro), plus test_local_api.py -- Impact: Easy to miss one instance when API changes format; inconsistent conversions -- Fix approach: Extract to single `_parse_timestamp(raw)` utility function called everywhere. Single source of truth. - -**Error Response Format Variation:** -- Issue: API sometimes returns JSON error body, sometimes just HTTP status -- Files: `plugin.py:265-324` has defensive parsing for both cases -- Impact: Complex error handling; easy to miss new error format variant -- Fix approach: Wrap all API responses in normalized error object with detected format. - -**No Rate Limit Prevention - Only Detection:** -- Issue: Plugin detects rate limit *after* hitting it; requires 61-minute backoff -- Files: `plugin.py:274-306` handles HTTP 400 error code 3 -- Impact: User gets service interruption every time they exceed daily quota -- Fix approach: Implement token budget tracking; pause polling when <100 tokens remain (warn at <200). Proactive vs reactive. +**Analysis Date:** 2026-04-11 + +## Tech Debt + +**V2 Status Set Incomplete — SLEEPING and POWEROFF Treated as Offline:** +- Issue: `V2_ONLINE_STATUSES` only includes `"ONLINE"` and `"WATERING"`. The v2 API returns 7 status values: `STANDBY`, `SETUP`, `ONLINE`, `WATERING`, `OFFLINE`, `SLEEPING`, `POWEROFF`. `SLEEPING` (battery-powered deep sleep) is treated as offline, but may be a temporary low-power state where the device is functionally available. `SETUP` is also unhandled. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` (line 193), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (line 99) +- Impact: Users with battery-powered controllers may see spurious offline error states during normal sleep cycles. +- Fix approach: Review Netro v2 documentation for intended semantics of `SLEEPING`/`SETUP`, add appropriate status values or treat `SLEEPING` as a degraded-but-online state. + +**Legacy `ZONE_START_ENDPOINT` Used for Zone On (v1 Only, No v2 Counterpart):** +- Issue: `actionControlSprinkler()` calls `self.api_client.make_request(ZONE_START_ENDPOINT, ...)` with a PUT method — this is the legacy `/zone/start` endpoint and does not route through `_get_device_auth()`. For v2 devices (API key auth), this call uses the wrong auth and the wrong endpoint. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1530), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` (line 76) +- Impact: Zone On action silently fails for v2 devices. No trigger or error clearly attributes the failure to this cause. +- Fix approach: Replace with `self.api_client.start_watering(key, zones, api_version=api_version)` after resolving v2 zone ID format (which may differ between v1 and v2). + +**`import re` Inside a Method:** +- Issue: `_slugify()` in `plugin.py` does `import re` inside the static method body. While Python caches module imports, this is non-standard and could confuse linters or static analysis tools. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 250) +- Impact: Negligible runtime cost, but poor style; `re` should be a top-level import. +- Fix approach: Move `import re` to the top-level imports section of `plugin.py`. + +**`setNoWater` Trigger Name Mismatch:** +- Issue: `_fireTrigger("setNoWater", dev.id)` fires the trigger `"setNoWater"`, but the `COMM_ERROR_EVENTS` and `OPERATIONAL_ERROR_EVENTS` sets use distinct names like `"setStandbyFailed"`. There is no event named `"setNoWater"` registered in `Events.xml` — it would silently fail to match any trigger type. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1629) +- Impact: Rain delay failures do not fire user triggers; users cannot automate responses to rain delay errors. +- Fix approach: Either add `"setNoWaterFailed"` to `OPERATIONAL_ERROR_EVENTS` and `Events.xml`, or correct the trigger name to an existing one like `"commError"`. + +**Dual Headers Dict — Plugin and APIClient Both Define HTTP Headers:** +- Issue: `Plugin.__init__()` initializes `self.headers` (lines 125-129) but never uses it. All actual HTTP calls go through `NetroAPIClient` which has its own headers. The plugin-level headers dict is dead code from the pre-refactor era. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 125-129) +- Impact: Confusing to read; could mislead future developers into calling `requests` directly from `plugin.py`. +- Fix approach: Remove the `self.headers` assignment from `Plugin.__init__()`. + +**Polling Timers Not Reset When Prefs Change:** +- Issue: When polling intervals are updated via `closedPrefsConfigUi()`, the main `_loop_interval` sleep is recalculated, but the per-endpoint `_next_*_update` timers are not reset. An endpoint set to 60-minute polling that fires at T+0 will still fire at T+60 regardless of whether the interval was changed to 5 minutes at T+10. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1196-1220) +- Impact: After changing polling intervals, users must wait for the old interval to expire before seeing the new cadence take effect. This is particularly noticeable for long-interval endpoints like schedules (default 30 min). +- Fix approach: When an interval is reduced in `closedPrefsConfigUi()`, reset the corresponding `_next_*_update` to `datetime.now()` to trigger immediate next-cycle execution. + +**`battery_level` for Whisperer Returns Float (0.0-1.0) in v2 but Int (0-100) in v1:** +- Issue: The v2 API returns `battery_level` as a float `0.0-1.0` (per `NETRO_API_V2.md` line 116), while v1 returns it as an integer 0-100. `WhispererHandler.process_sensor_data()` calls `dev_states.get("battery_level", 0)` without version-aware conversion, meaning v2 devices will show battery as `0.85` instead of `85`. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (line 564) +- Impact: Incorrect battery level display for v2 Whisperer sensors. +- Fix approach: In `WhispererHandler.process_sensor_data()`, check `api_version` and multiply by 100 when v2 float format is detected. + +## Known Bugs + +**Zone On Action Uses Wrong Endpoint for v2 Devices:** +- Symptoms: Starting a zone via Indigo sprinkler controls on a v2-authenticated device sends a PUT to the v1 `/zone/start` endpoint without the v2 API key, causing an authentication failure or incorrect behavior. No clear error is shown. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1530) +- Trigger: User performs Zone On action on a sprinkler device configured with an API key (v2 mode). +- Workaround: Use the custom "Start Zone with Delay" action (via `startZoneWithDelay()`), which correctly uses `_get_device_auth()`. + +**`person` Dict Overwrites on Each Poll — Multi-Device Support is Broken:** +- Symptoms: `_update_sprinkler_device()` rebuilds `self.person` from each device's API response in sequence. If multiple sprinkler devices exist, each overwrites the shared `self.person` and `self.netro_devices`. Any code that reads `self.person` after the loop sees only the last device's data. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 780-783) +- Trigger: User has two or more sprinkler controller devices configured. +- Workaround: Use separate plugin instances per controller (documented as a known limitation). ## Security Considerations -**Serial Number in Log Messages:** -- Issue: API calls logged with full URLs containing serial number (device authentication key) -- Files: `plugin.py:220` logs URL with `?key={serial}` -- Impact: Serial number exposed in Indigo Event Log (stored in database); potential unauthorized API access -- Fix approach: Log redacted URL: `"API call: GET info.json?key=***redacted***"`. Add security note to CLAUDE.md. - -**No Input Validation on External Actions:** -- Issue: Plugin actions accept user input without comprehensive validation until API call -- Files: `plugin.py:1408-1476` (startZoneWithDelay), `1479-1536` (reportWeather) -- Impact: Invalid inputs cause API errors instead of being rejected early in UI validation -- Fix approach: Expand `validateActionConfigUi()` to validate all parameter combinations and constraints. - -**Throttle Timer Not Validated:** -- Issue: If Netro API returns invalid `token_reset` timestamp, fallback uses hardcoded 61 minutes (line 297) -- Files: `plugin.py:281-302` -- Impact: User could be throttled longer than necessary if API returns garbage timestamp -- Fix approach: Parse with fallback to current_time + 61min, but log warning about invalid API response. +**API Keys Stored in Indigo pluginProps (Plaintext):** +- Risk: v2 API keys are stored as plaintext values in Indigo device pluginProps, which are persisted to Indigo's XML database on disk. If the Indigo database file is accessed by another process or user, the key is exposed. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 371), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` (line 353) +- Current mitigation: API key is masked in debug log output (`masked_url` in `api_client.py` line 285). Indigo itself provides no encryption for pluginProps. +- Recommendations: This is an Indigo platform limitation that cannot be fully addressed at the plugin level. Consider documenting to users that the API key provides only device-level access (not account access) to reduce perceived risk. Do not log the key or include it in trigger payloads. + +**Tomorrow.io API Key Stored in Indigo pluginPrefs (Plaintext):** +- Risk: Same pattern as above — Tomorrow.io API key is stored in pluginPrefs and persisted to disk. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 386-387) +- Current mitigation: None beyond not logging it. +- Recommendations: Same as above — document the limited blast radius of a leaked Tomorrow.io key (rate-limited, not billing-linked on free tier). + +**Serial Number Used as URL Parameter (v1):** +- Risk: The device serial number is embedded in API request URLs as `?key={serial}`. This serial appears in HTTP access logs on any intermediary proxy/router, and in debug logs if `masked_url` logic is bypassed. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` (lines 285, 657) +- Current mitigation: `masked_url` masks only `key=` params in debug logs. v1 serial is still logged correctly. +- Recommendations: v2 API key is treated the same as v1 serial for masking, which is correct. No further mitigation available without Netro API changes. + +## Performance Bottlenecks + +**Zone Variable Scan Iterates All Sprinkler Devices on Every Variable Change:** +- Problem: `variableUpdated()` loops over all `self.sprinkler` devices and parses their `zoneVariableMap` JSON on every Indigo variable change — not just zone moisture variables. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1429-1484) +- Cause: No precomputed reverse index mapping variable IDs to (device, zone) pairs. The reverse scan happens on every `variableUpdated()` call because Indigo subscribes to ALL variable changes at startup (line 1030). +- Improvement path: Build and cache a `{var_id: (dev_id, zone_num)}` lookup dict at startup and update it in `_ensure_zone_variables()`. Early-exit `variableUpdated()` on cache miss without scanning all devices. + +**Schedule Processing Re-Sorts and Re-Scans on Every Polling Cycle:** +- Problem: `process_schedules()` and `process_zone_schedules()` iterate all schedules returned by the API to find the current and next schedule. At 50 schedules per device, this is O(n) per device per poll cycle, run on every device info + schedule refresh. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (lines 169-200, 614-630) +- Cause: No caching of schedule parse results between polls. Data is reprocessed even when the API returns the same response. +- Improvement path: Compare response hash or schedule list length before reprocessing. For typical usage (3-5 devices, 50 schedules each), this is negligible but worth noting as device count grows. + +**Forecast Reporting Makes N API Calls Per Device Per Day:** +- Problem: `_update_forecast_from_tomorrow()` calls `report_weather` once per forecast day per device. With 6 forecast days and 3 sprinkler devices, each forecast cycle consumes 18 Netro API tokens. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 543-575) +- Cause: Netro's report_weather API is per-day, requiring one call per forecast day. No way to batch. +- Improvement path: Reduce default `forecastInterval` or limit forecast days sent. Consider only sending the current + next day rather than all 6. + +## Fragile Areas + +**`_get_device_dict()` Uses `self.person["devices"]` — KeyError if `person` Not Yet Populated:** +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 186-190) +- Why fragile: `self.person` is initialized to `{}` in `__init__`. `_get_device_dict()` immediately accesses `self.person["devices"]` without checking if the key exists. If called before the first successful API poll (e.g. from `setNoWater()` before any poll has run), this raises a `KeyError`. +- Safe modification: Always guard with `self.person.get("devices", [])` instead of `self.person["devices"]`. +- Test coverage: No test exercises `_get_device_dict()` on an uninitialized plugin. + +**Zone Variable Mapping Stored as JSON String in pluginProps:** +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 273-348) +- Why fragile: `zoneVariableMap` is serialized as a JSON string in `dev.pluginProps`. If the JSON is malformed (e.g., due to a partial write), `_ensure_zone_variables()` silently resets the map and recreates variables, potentially creating duplicates with name conflicts. The error path at line 329 catches `Exception` and attempts to recover by looking up the variable by name, which may pick up a wrong variable. +- Safe modification: Add a schema version field to the JSON and validate structure before use. Add a dry-run path that checks for name conflicts before writing. +- Test coverage: No tests cover the corruption/recovery path. + +**`runConcurrentThread()` Catches All Exceptions and Continues:** +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1074-1076) +- Why fragile: The outer exception handler at line 1074 catches any `Exception` not already handled by the per-device update methods. While this prevents the thread from dying, it means transient bugs (e.g., a `NameError` in new code) will be silently swallowed and retried every polling cycle, flooding the Indigo log with the same error every N minutes without any operator action. +- Safe modification: Consider distinguishing between `Exception` (catch and retry) and programming errors (`AttributeError`, `NameError`) that should be reported at higher severity. The current approach is acceptable for a home automation plugin but makes bug reproduction harder. +- Test coverage: Not directly testable without Indigo runtime. + +**`_update_sprinkler_device()` Only Updates Zone Devices When Device Info Also Fires:** +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 792-840) +- Why fragile: Schedules, moistures, and zone device updates are gated inside the `if now >= self._next_device_info_update` block. If the device info endpoint fires on a different cycle than moistures (e.g., device info every 10 min, moistures every 10 min but offset), moistures data is fetched but never applied to zone devices unless device info also ran. This is by design but the dependency is subtle and not documented in code. +- Safe modification: Cache the last `device_data` response so zone device updates can run independently of device info polling. +- Test coverage: No integration test covers the timer offset scenario. + +**`_ensure_zone_devices()` Calls `indigo.device.create()` Without Checking for Name Collisions:** +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 635-656) +- Why fragile: If a zone device already exists with the same name but different `pluginProps` (e.g., the user manually created a device, or `parentDeviceId` changed), `indigo.device.create()` raises an exception. The error is caught and logged, but the zone device is not created, and subsequent polls will keep trying and logging errors. +- Safe modification: Check `existing` dict before calling `create()`. Verify no existing non-zone device shares the name `expected_name`. +- Test coverage: Not covered. + +## Scaling Limits + +**Single Plugin Instance — One Global `self.person` Dict:** +- Current capacity: Functionally one sprinkler controller per plugin behavior (see multi-device bug above). +- Limit: Two or more sprinkler devices cause `self.person` to only reflect the last-polled device's data. Actions that call `_get_device_dict()` will fail or return incorrect data for all but the last-updated device. +- Scaling path: Replace `self.person` with a `{device_id: person_data}` dict keyed by Indigo device ID, or remove the shared cache entirely since per-device data is already embedded in each device's states/props. + +**API Token Budget — 2000/day per Netro Device:** +- Current capacity: With default intervals (device info 10 min, schedules 30 min, moistures 10 min, events 5 min, sensor 30 min) and 1 device: ~approx. 200-300 calls/day, well within limit. +- Limit: Each additional sprinkler or Whisperer device multiplies token consumption. With 5 devices at default intervals and weather forecast reporting (18 calls per cycle), approaching 1200+ calls/day. +- Scaling path: Token pause threshold (`TOKEN_PAUSE_THRESHOLD = 100`) is the safety valve. Consider per-device configurable intervals as a future enhancement. + +## Dependencies at Risk + +**`requests` Library Bundled as Single Runtime Dependency:** +- Risk: `requirements.txt` pins `requests==2.32.5`. This is a single, stable dependency that Indigo auto-installs. No known active CVEs but `requests` has had HTTP injection vulnerabilities in the past (CVE-2023-32681 fixed in 2.31.0). +- Impact: If Indigo's Python environment drifts or the user has an older version installed, HTTP behaviour may differ. +- Migration plan: No alternative; `requests` is the correct choice for synchronous HTTP in Indigo plugins. Ensure version pinned to 2.31.0+ for the CVE fix. + +## Missing Critical Features + +**No Rate Limit Trigger for Token Depletion (Only for HTTP 429):** +- Problem: The plugin fires `rateLimitExceeded` only on HTTP 429. Token-budget pause (when `token_remaining < TOKEN_PAUSE_THRESHOLD`) does not fire any trigger, so users cannot automate alerts for proactive token warnings. +- Blocks: Users with high polling frequencies or multiple devices have no automated notification before service interruption. + +**No Cleanup When a Zone Device's Parent Controller is Deleted:** +- Problem: Zone devices store their parent's `parentDeviceId` in `pluginProps`, but there is no `deviceStopComm()` or `deviceDeleted()` handler that removes orphaned zone devices when a parent controller is deleted. +- Blocks: Orphaned zone devices accumulate in Indigo after controller removal and require manual cleanup. ## Test Coverage Gaps -**High-Risk Untested Areas:** -- Files: `plugin.py:662-694` (Whisperer sensor updates) - device type not well tested -- Files: `plugin.py:696-732` (Moisture data handling) - edge cases with empty moisture list -- Risk: Sensor devices could silently fail to update without error logging -- Priority: **High** - affects user-visible features - -**Error Handling Not Tested:** -- Files: Missing tests for network timeouts, API 500 errors, malformed JSON responses -- Risk: Unknown behavior during actual failures; error messages may not display correctly -- Priority: **High** - affects production reliability - -**Validation Edge Cases:** -- Files: No tests for unicode in device names, very long serial numbers, special characters in zone names -- Risk: Could cause plugin crashes or Indigo database corruption -- Priority: **Medium** - low probability but high impact - -**Schedule Parsing Edge Cases:** -- Files: `plugin.py:515-582` - multiple schedule type formats handled but not thoroughly tested -- Risk: New schedule type from API could cause exceptions -- Priority: **Medium** - affected by API evolution - -**Missing Integration Test for Throttle Recovery:** -- Files: No test simulating 61-minute throttle expiry and recovery -- Risk: Throttle state transitions untested; could get stuck permanently -- Priority: **Medium** - important recovery path - -## Known Limitations (Accepted Constraints) - -**API Limitations (Not Plugin Bugs):** -- ❌ Cannot pause/resume schedules (Netro API limitation) -- ❌ Cannot create/modify schedules (Netro API limitation) -- ❌ Cannot change zone settings (Netro API limitation) -- ❌ Moisture updates only once per day (Netro sensor limitation) - -These are documented in `CLAUDE.md:368-375` and `TROUBLESHOOTING.md:227-240`. Not actionable but important context for users. - -## Fragile Areas (Safe Modification Guidance) - -**Moisture Data Handling:** -- Files: `plugin.py:696-732` (callMoisturesAPI method) -- Why fragile: Assumes moisture list is sorted by ID (line 716), filters by date (line 721) -- Safe modification: Add defensive checks for empty lists (done at line 711); add logging for unexpected data structure -- Test coverage: Thin - only basic happy path tested - -**Schedule Data Extraction:** -- Files: `plugin.py:515-582` in `_update_from_netro()` -- Why fragile: Handles multiple timestamp formats (string/number), multiple schedule types, finds earliest start time -- Safe modification: Use defensive `.get()` calls with defaults; test with API response variations -- Test coverage: 70%+ - fairly comprehensive - -**Whisperer Sensor Updates:** -- Files: `plugin.py:663-690` in `_update_from_netro()` -- Why fragile: Device type rarely tested; different state structure than controller devices; onState/sensorValue handling complex -- Safe modification: Add comprehensive logging for each operation; test with real Whisperer device -- Test coverage: <50% - minimal testing - -**Action Parameter Validation:** -- Files: `validateActionConfigUi()` at lines 923-1006 -- Why fragile: Validates but doesn't reject invalid combinations (e.g., duration=0) -- Safe modification: Expand validation to prevent invalid parameter combinations; reject at UI level -- Test coverage: 24 unit tests, but integration gaps - -## Technical Debt Summary - -| Item | Severity | Impact | Effort | Priority | -|------|----------|--------|--------|----------| -| Bare exception handlers | High | Debugging impossible | Low | High | -| Single-file architecture | Medium | Maintenance hard | High | Medium | -| Timestamp handling scattered | Medium | Bug-prone | Low | High | -| No proactive throttle prevention | Medium | Service interruption | Medium | Medium | -| Serial number in logs | High | Security exposure | Low | High | -| Whisperer sensor untested | Medium | Silent failures | Medium | Medium | -| Per-device polling config | Low | Feature request | High | Low | -| Multi-controller support | Low | Usability | High | Low | - -## Code Quality Metrics - -**Current Status**: -- Pylint score: ~6.5/10 (target 8.0) -- Test coverage: >70% overall, gaps in Whisperer and error paths -- Lines of code: 1635 (main plugin file only) -- Cyclomatic complexity: High (large methods, nested conditionals) -- Documentation: Excellent (CLAUDE.md, API_NOTES.md, TROUBLESHOOTING.md) - -**Blockers to Higher Quality**: -1. Bare exception handlers obscure true error handling -2. Single large file increases cognitive load -3. Insufficient error path testing -4. API quirk workarounds scattered throughout - -## Recommendations for Next Phase - -**Priority 1 (Do Now):** -- [ ] Replace bare `except (Exception,):` with specific exception types -- [ ] Add security note about serial number exposure; consider redacting in logs -- [ ] Extract timestamp parsing to utility function -- [ ] Add comprehensive Whisperer sensor tests - -**Priority 2 (Next Sprint):** -- [ ] Split plugin.py into modules (api_client, validators, actions) -- [ ] Add per-device error logging (identify which operation failed) -- [ ] Implement proactive throttle prevention (pause polling when tokens <100) -- [ ] Persist throttle state across plugin restarts - -**Priority 3 (Future):** -- [ ] Multi-controller support in single plugin instance -- [ ] Per-device polling interval configuration -- [ ] API response schema validation layer -- [ ] Comprehensive API error scenario testing +**`plugin.py` Has 0% Test Coverage:** +- What's not tested: All plugin lifecycle methods (`startup`, `shutdown`, `runConcurrentThread`), all action handlers (`setNoWater`, `setStandbyMode`, `startZoneWithDelay`, `reportWeather`, `setZoneMoisture`), all Indigo callbacks (`variableUpdated`, `triggerStartProcessing`, `actionControlSprinkler`), and the entire polling loop. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (898 statements, 0 covered per htmlcov) +- Risk: Any regression in the main plugin class will not be caught by CI. All plugin.py bugs reach production. +- Priority: High — this is the highest-impact gap. Even smoke tests with a heavily mocked `indigo` module would catch most action handler bugs. + +**`api_client.py` Has 17% Coverage (Throttle and Error Paths Not Tested):** +- What's not tested: `_handle_http_error()` rate-limit branch, `_restore_throttle_state()` v1→v2 migration path, `_validate_response_schema()`, `should_pause_polling_for()` auto-reset logic. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` (205/255 statements missing) +- Risk: Throttle state corruption after restart or during v1→v2 migration is undetected. +- Priority: High — throttle management is critical to API budget safety. + +**`tomorrow_client.py` Has 7% Coverage:** +- What's not tested: `fetch_current_weather()`, `fetch_forecast()`, `_transform_response()`, `_transform_forecast_response()`, all HTTP error paths. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py` (138/153 statements missing) +- Risk: Weather integration failures will not be caught by tests. Incorrect unit conversion or missing fields could silently corrupt Netro's smart scheduling data. +- Priority: Medium — weather integration is optional but regressions here are hard to detect without live API calls. + +**`device_handlers.py` Has 10% Coverage (Most Handler Logic Untested):** +- What's not tested: `SprinklerHandler.process_moistures()`, `SprinklerHandler.extract_zone_info()`, `SprinklerHandler.process_events()`, `WhispererHandler.process_sensor_data()`, `ZoneHandler.process_zone_schedules()`, all v2 code paths in schedule/timestamp handling. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (219/250 statements missing) +- Risk: API response parsing bugs will reach production silently. +- Priority: High — these are pure Python functions with no Indigo dependency and are straightforward to unit test. + +**`validators.py` Has 10% Coverage Despite Clean Architecture:** +- What's not tested: `validate_prefs_config()`, `validate_action_config()` for `reportWeather` and `setMoisture` action types, `validate_event_config()`, `validate_api_key()`, `is_indigo_substitution()`. +- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` (193/226 statements missing) +- Risk: Invalid user configurations that should be rejected may be accepted and cause runtime errors. +- Priority: Medium — validators are pure functions that are easy to test. The gap is surprising given the modular design. --- -*Concerns audit: 2026-02-01* +*Concerns audit: 2026-04-11* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index aa49d20..fe81874 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,314 +1,201 @@ # Coding Conventions -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## Naming Patterns **Files:** -- Single file `plugin.py` in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/` -- XML configuration files with PascalCase names: `Devices.xml`, `Actions.xml`, `Events.xml`, `PluginConfig.xml`, `MenuItems.xml` -- Documentation files: `NETRO_API.md`, `API_NOTES.md`, `TESTING.md`, `TROUBLESHOOTING.md` +- `snake_case.py` for all Python source files: `api_client.py`, `device_handlers.py`, `tomorrow_client.py`, `validators.py`, `utils.py`, `constants.py`, `exceptions.py` +- `test_.py` for test files: `test_api_client.py`, `test_device_handlers.py` +- XML config files use PascalCase: `Devices.xml`, `Actions.xml`, `PluginConfig.xml` -**Functions:** -- Module-level helper functions use `snake_case`: `convert_timestamp()`, `get_key_from_dict()` -- Class methods use `snake_case`: `_make_api_call()`, `_update_from_netro()`, `actionControlSprinkler()` -- Private methods (internal to class) prefixed with single underscore: `_make_api_call()`, `_get_device_dict()`, `_fireTrigger()` -- Public callback methods follow Indigo conventions: `validateDeviceConfigUi()`, `deviceStartComm()`, `triggerStartProcessing()` +**Functions and Methods:** +- `snake_case` for all module-level functions: `validate_device_config()`, `convert_weather_us_to_metric()`, `get_key_from_dict()` +- `camelCase` for Indigo framework callbacks (required by SDK): `actionControlSprinkler()`, `validateDeviceConfigUi()`, `runConcurrentThread()` +- Private methods prefixed with underscore: `_make_api_call()`, `_get_device_dict()`, `_save_throttle_state()` +- Static helpers prefixed with underscore if module-private: `_slugify()` **Variables:** -- Instance attributes use `camelCase`: `serialNo`, `pollingInterval`, `throttle_next_call`, `triggerDict` -- Local variables use `snake_case`: `reply_dict`, `update_list`, `current_schedule_dict`, `sensorReadings` -- Constants use `UPPER_SNAKE_CASE`: `NETRO_API_VERSION`, `MINIMUM_POLLING_INTERVAL`, `DEFAULT_API_CALL_TIMEOUT`, `THROTTLE_LIMIT_TIMER` +- `snake_case` for local variables and instance attributes: `token_remaining`, `prefs_getter`, `device_handlers` +- `camelCase` for Indigo-required attribute names: `pluginPrefs`, `pluginId`, `triggerDict`, `serialNo` +- Timer attributes prefixed with `_next_`: `_next_device_info_update`, `_next_schedules_update` +- Interval attributes prefixed with `_` and suffixed with `_interval`: `_events_interval`, `_weather_update_interval` -**Types:** -- Custom exceptions use PascalCase: `ThrottleDelayError` -- Class names use PascalCase: `Plugin` (inherits `indigo.PluginBase`) -- Device type IDs use lowercase with underscores in XML, referenced as strings: `"sprinkler"`, `"Whisperer"` +**Constants:** +- `SCREAMING_SNAKE_CASE` throughout `constants.py`: `MAX_ZONE_DURATION_SECONDS`, `DEFAULT_API_TIMEOUT_SECONDS`, `TOKEN_PAUSE_THRESHOLD` +- Annotated with `typing.Final` to indicate immutability: `NETRO_API_VERSION: Final[str] = "1"` +- Each constant has a docstring on the following line explaining purpose + +**Classes:** +- `PascalCase` for all class names: `NetroAPIClient`, `SprinklerHandler`, `WhispererHandler`, `ZoneHandler`, `DeviceTokenState` +- Exception classes named `NetroError` inheriting from `NetroError`: `ThrottleDelayError`, `NetroAPIError` + +**Type Aliases:** +- Defined at module level with docstring: `ValidationResult = Tuple[bool, Dict[str, Any], Dict[str, str]]` ## Code Style **Formatting:** -- PEP 8 compliant with some exceptions for Indigo API conventions -- Line length up to 120 characters (noted in pylint command) -- 4-space indentation -- Blank lines: Two between top-level definitions, one between method definitions +- No formatter configured (no `.prettierrc`, `black`, or `autopep8` config) +- Max line length: 120 characters (configured in `pyproject.toml` `[tool.pylint.format]`) +- 4-space indentation (Python standard) **Linting:** -- Pylint used for static analysis (target score: 8.0, current: ~6.5/10) -- Pylint disable directives placed inline for specific methods: - ```python - # pylint: disable=too-many-lines - # pylint: disable=invalid-name - # pylint: disable=too-many-branches,too-many-statements - # pylint: disable=unused-argument - ``` -- File-level disable at top: `# pylint: disable=too-many-lines` -- Method-level disables immediately before method definition +- `pylint` with target score 9.0 (`fail-under = 9.0` in `pyproject.toml`) +- Key rules disabled for Indigo plugin patterns: + - `too-many-lines` — large plugin.py by design + - `too-many-public-methods` — Indigo requires many callbacks + - `invalid-name` — Indigo requires camelCase callbacks +- `method-rgx = "[a-z_][a-zA-Z0-9_]{2,}$"` to allow both snake_case and camelCase methods ## Import Organization -**Order:** -1. Standard library imports (`json`, `copy`, `traceback`, `datetime`) -2. Third-party library imports (`requests`, `dateutil`) -3. Indigo SDK imports (`indigo`) +**Order within files:** +1. Python standard library: `import json`, `from datetime import datetime` +2. Third-party: `import requests` +3. Local plugin modules: `from constants import ...`, `from exceptions import ...` -**Pattern observed:** +**Path management in tests:** +Every test file manually inserts the Server Plugin directory into `sys.path` using `pathlib.Path`: ```python -import json -import copy -import traceback -from operator import itemgetter -from datetime import datetime, timedelta, date - -import indigo -import requests -from dateutil import tz +SERVER_PLUGIN_DIR = ( + Path(__file__).parent.parent + / "Netro Sprinklers.indigoPlugin" + / "Contents" + / "Server Plugin" +) +sys.path.insert(0, str(SERVER_PLUGIN_DIR)) ``` -**Path Aliases:** -Not used in this codebase. All imports are fully qualified. +**Module `__all__` usage:** +Modules with public APIs declare `__all__`: `validators.py`, `device_handlers.py`, `api_client.py`. This explicitly controls what is exported and documents the public surface. -## Error Handling +**Circular import prevention:** +Each module has explicit rules in its docstring: +- `constants.py` — no dependencies on other plugin modules +- `exceptions.py` — no dependencies on other plugin modules +- `utils.py` — no dependencies on other plugin modules +- `validators.py` — only imports from `constants.py` +- `device_handlers.py` — only imports from `constants.py` and `utils.py` +- `api_client.py` — only imports from `constants.py` and `exceptions.py` -**Patterns:** -- Broad `try/except` blocks with specific exception types handled differently -- Custom exception: `ThrottleDelayError` for rate limit violations -- Defensive exception handling with graceful fallbacks +## Error Handling -**API Error Pattern** (`_make_api_call()` at `plugin.py:195-334`): -```python -try: - # Attempt request - r = requests.get(url, headers=self.headers, timeout=self.timeout) -except requests.exceptions.ConnectionError as exc: - # Handle with flag to avoid duplicate logging - if not self._displayed_connection_error: - self.logger.error("Connection failed...") - self._displayed_connection_error = True - raise exc -except requests.exceptions.HTTPError as exc: - # Specific handling for Netro error codes - error_data = exc.response.json() - if error_data.get("status") == "ERROR": - # Process error codes 1 (invalid key), 3 (rate limit) - raise exc -except ThrottleDelayError: - raise # Re-raise after logging in _make_api_call -except Exception as exc: - self.logger.error(f"Connection failed: {exc.__class__.__name__}") - self.logger.debug(f"Full traceback:\n{traceback.format_exc(10)}") - raise exc +**Exception hierarchy:** +All plugin exceptions inherit from `NetroError` (in `exceptions.py`): ``` +NetroError (base) +├── ThrottleDelayError - API rate limit exceeded +├── NetroAPIError - API returned error response +├── NetroConnectionError - Network connection failed +└── NetroTimeoutError - Request timed out +``` + +**Exception constructors:** +All custom exceptions take `message: str` as first arg with a sensible default, plus context-specific optional attributes (`retry_after`, `status_code`, `error_code`, `timeout_seconds`). Always call `super().__init__(message)`. -**Defensive Parsing Pattern:** +**Fail gracefully pattern:** ```python +# From utils.py - silent fallback for API response parsing try: - value = int(some_value) -except (ValueError, TypeError): - value = default_value - self.logger.debug("Invalid value, using default") + return data[key] +except KeyError: + return "unavailable from API" if default is None else default +except (TypeError, AttributeError): + return "unknown error" if default is None else default ``` -**Silent Loop Exception Pattern** (`runConcurrentThread()` at `plugin.py:810-829`): +**Connection error suppression:** +Plugin tracks `_displayed_connection_error` to log network errors once then suppress repeats: ```python -while True: - try: - self._update_from_netro() - except (Exception,): - # Swallow all exceptions to prevent thread exit - # Detailed logging happens in _update_from_netro() - pass - self.sleep(self.pollingInterval * 60) +if not self._displayed_connection_error: + self.logger.error("Timeout - will retry silently") + self._displayed_connection_error = True ``` -## Logging +**Throttle management:** +`ThrottleDelayError` is raised and caught at the API client level. Plugin checks `client.is_throttled` before making calls. State is persisted across restarts via `pluginPrefs`. -**Framework:** Indigo's built-in `self.logger` (inherits from `indigo.PluginBase`) +## Logging -**Patterns:** -- `self.logger.debug()` - Detailed operation logs, API calls, data structures -- `self.logger.info()` - Normal operation (plugin start, status changes, successful actions) -- `self.logger.warning()` - Warnings (API tokens low, unusual conditions) -- `self.logger.error()` - Errors (API failures, invalid config, failed actions) -- Special method: `self.logger.threaddebug()` - Debug in callback context +**Framework:** Indigo's built-in logger via `self.logger` (injected as constructor argument in extracted modules) -**Usage examples:** -```python -self.logger.debug(f"API call: {request_method.upper()} {url}") -self.logger.info(f"Polling interval updated to {self.pollingInterval} minutes") -self.logger.warning(f"api tokens low: {tokens_remaining} of 2000 remaining") -self.logger.error("Connection to Netro API server failed") -self.logger.debug(f"traceback:\n{traceback.format_exc(10)}") -self.logger.threaddebug("validateDeviceConfigUi") -``` +**Log methods used:** +- `self.logger.debug(...)` — detailed trace, only shown when debug enabled +- `self.logger.info(...)` — significant state changes, startup, successful operations +- `self.logger.warning(...)` — token budget warnings (<200 remaining), non-fatal anomalies +- `self.logger.error(...)` — failures that degrade functionality, first-occurrence connection errors +- `self.logger.exception(exc)` — unexpected exceptions with full traceback -**Error suppression pattern:** +**Logger injection pattern:** +Extracted modules (api_client, device_handlers, tomorrow_client) receive logger as constructor arg with fallback to module logger: ```python -if not self._displayed_connection_error: - self.logger.error("Connection failed. Will retry silently.") - self._displayed_connection_error = True +def __init__(self, logger=None): + self.logger = logger or logging.getLogger(__name__) ``` ## Comments -**When to Comment:** -- Complex business logic (e.g., timestamp parsing, zone data transformation) -- Non-obvious API behavior (e.g., "API returns timestamps as string numbers") -- Important warnings or caveats -- Section dividers for large methods - -**Examples:** -```python -# Check if we're in a throttle period -if self.throttle_next_call and datetime.now() < self.throttle_next_call: - raise ThrottleDelayError(...) - -# Handle start_time as either string or number -start_time_raw = sch_dict.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 -``` +**Module docstrings:** +Every module has a docstring describing purpose, key features, dependency rules, and usage. Format is plain prose, not Google/NumPy style. -**Docstring Format:** Google-style docstrings with triple quotes +**Class docstrings:** +PascalCase classes have docstrings covering purpose, key attributes, and usage examples. +**Method docstrings:** +All public methods use Google-style Args/Returns/Raises sections: ```python -def convert_timestamp(timestamp): - """Convert Unix timestamp (milliseconds) to local timezone datetime. +def process_device_info(self, api_response, serial, api_version="1"): + """Process device info API response. Args: - timestamp: Unix timestamp in milliseconds + api_response: Dict with Netro API response structure + serial: Device serial number for logging + api_version: API version string ("1" or "2") Returns: - datetime: Timestamp converted to local timezone + Tuple of (states_list, is_online, device_data_dict) """ ``` -**JSDoc/Type Hints:** -- Method docstrings required for all public and private methods -- Args, Returns, Raises sections consistently used -- No Python type hints (project uses Python 3.10+ but opts for docstring-only documentation) -- Exception documentation in Raises section when applicable +**Section separators:** +Long files use `# =============================================================================` banners to divide logical sections (e.g., "API Configuration", "Default Values", "Event Sets" in `constants.py`). + +**Inline comments:** +Used for non-obvious logic: API quirks, backward compatibility notes, legacy constants. Example: `# Legacy constant — kept for backward compatibility during migration`. + +**pylint inline disables:** +Used sparingly at class/method level with explicit reason: +```python +# pylint: disable=too-many-public-methods,too-many-instance-attributes +class Plugin(indigo.PluginBase): +``` ## Function Design -**Size:** -- Most methods range 15-100 lines -- Large update method `_update_from_netro()` is 260 lines (marked with `pylint: disable=too-many-branches,too-many-statements`) -- Typically broken into logical sections with comment dividers +**Size:** Large methods are accepted for `plugin.py` given Indigo's callback-driven architecture. Extracted utility modules (api_client, device_handlers, validators, utils) keep functions small and focused. -**Parameters:** -- Device callbacks use standard Indigo signatures: `(self, dev)`, `(self, action, dev)`, `(self, valuesDict, typeId, devId)` -- Optional parameters provided with defaults: `def _make_api_call(self, url, request_method="get", data=None)` -- Dictionary parameters (valuesDict) extensively used for configuration passing +**Parameters:** Constructor dependency injection preferred over global state. Callbacks (prefs_getter, prefs_setter) passed as callables rather than direct object references to avoid circular imports. **Return Values:** -- Most methods return None or single value -- Validation methods return tuple: `(bool, dict, dict)` - `(is_valid, valuesDict, errorsDict)` -- Configuration lists return tuples: `[(id, name), ...]` -- API methods return JSON dict or True (for 204 No Content responses) +- Validators return 3-tuple: `(is_valid: bool, sanitized_values: dict, errors: dict)` +- Handlers return lists of state dicts for `updateStatesOnServer()` +- API methods return parsed JSON dict or raise typed exception ## Module Design **Exports:** -- Single class `Plugin` exported implicitly (main entry point for Indigo) -- Module-level functions: `convert_timestamp()`, `get_key_from_dict()` -- All other implementation is in `Plugin` class - -**Barrel Files:** -- Not used. Single monolithic file approach: `plugin.py` (1600+ lines) - -**Class Structure:** -- Single `Plugin` class inheriting `indigo.PluginBase` -- Organized by functional group with comment section dividers: - - Internal helper methods - - Lifecycle methods (startup, shutdown, concurrent thread) - - Dialog list callbacks - - Validation callbacks - - Device callbacks - - Event callbacks - - Action callbacks - - Menu callbacks - -**Organization sections:** -```python -######################################## -# Internal helper methods -######################################## - -######################################## -# startup, concurrent thread, and shutdown methods -######################################## - -######################################## -# Dialog list callbacks -######################################## -``` - -## API Constants - -**Convention:** All API endpoints and configuration defined at module top (`plugin.py:49-73`): -```python -NETRO_API_VERSION = "1" -NETRO_MAX_ZONE_DURATION = 10800 -DEFAULT_API_CALL_TIMEOUT = 5 -MINIMUM_POLLING_INTERVAL = 3 -THROTTLE_LIMIT_TIMER = 61 - -API_BASE_URL = "http://api.netrohome.com/npa/v{apiVersion}/" -API_URL = API_BASE_URL.format(apiVersion=NETRO_API_VERSION) - -DEVICE_INFO_URL = API_URL + "info.json" -DEVICE_SCHEDULES_URL = API_URL + "schedules.json" -# ... etc -``` - -**Error Event Sets:** -```python -ALL_OPERATIONAL_ERROR_EVENTS = { - "startZoneFailed", - "stopFailed", - "setStandbyFailed", -} - -ALL_COMM_ERROR_EVENTS = { - "personCall", - "personInfoCall", - "getScheduleCall", - "forecastCall", -} -``` +Modules declare `__all__` to define their public API surface. -## Indigo API Conventions +**Barrel files:** +Not used. Each module is imported directly by name. -**Device State Updates:** -```python -# Build list of updates, then apply atomically -update_list = [ - {"key": "status", "value": reply_dict_device["status"]}, - {"key": "activeZone", "value": current_schedule_dict["zone"]}, -] -dev.updateStatesOnServer(update_list) - -# Or single state update -dev.updateStateOnServer("activeZone", action.zoneIndex) -``` - -**Device Properties (static config):** -```python -props = copy.deepcopy(dev.pluginProps) -props["NumZones"] = len(zones) -props["ZoneNames"] = zone_names_string -dev.replacePluginPropsOnServer(props) -``` +**Dataclasses:** +Used for simple value objects: `DeviceTokenState` in `api_client.py`, `ValidationResult` type alias in `validators.py`. -**Indigo Collections:** -- `indigo.devices` - Device collection -- Filter by plugin: `indigo.devices.iter(filter="self")` -- Lookup by ID: `indigo.devices[device_id]` +**Constants module:** +All magic numbers and configuration strings live in `constants.py`. Do not define numeric literals in business logic — import the named constant. --- -*Convention analysis: 2026-02-01* +*Convention analysis: 2026-04-11* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index c4fb7be..20facdb 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,206 +1,129 @@ # External Integrations -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## APIs & External Services -**Netro Smart Irrigation API:** -- Service: Netro Public API (NPA) v1 -- What it's used for: Complete control and monitoring of Netro sprinkler controllers and Whisperer sensors - - SDK/Client: `requests` library (2.32.5) - - Auth: Device serial number as URL parameter (`key={serial}`) - - Base URL: `http://api.netrohome.com/npa/v1/` - -**Supported Devices:** -- Netro Sprite -- Netro Pixie -- Netro Spark -- Whisperer soil moisture sensors - -## API Endpoints - -Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py:60-74` - -| Endpoint | Method | Purpose | -|----------|--------|---------| -| `info.json` | GET | Get device status, zones, model, serial number | -| `schedules.json` | GET | Get current and upcoming watering schedules | -| `moistures.json` | GET | Get soil moisture levels per zone | -| `sensor_data.json` | GET | Get Whisperer sensor readings (moisture, temp, sunlight) | -| `water.json` | POST | Start watering zone(s) with optional delay/schedule | -| `stop_water.json` | POST | Stop all watering | -| `set_status.json` | POST | Set standby mode on/off | -| `no_water.json` | POST | Set rain delay (N days no watering) | -| `report_weather.json` | POST | Report local weather for AI scheduling | -| `zone/start` | PUT | Start specific zone (alternative endpoint) | - -## Rate Limiting - -**API Quota:** -- Daily limit: 2,000 API calls per day -- Reset time: Midnight UTC (stored in `meta.token_reset`) -- Remaining quota: Tracked in `meta.token_remaining` - -**Plugin Rate Limit Management:** -- Location: `plugin.py:91-98, 195-335` -- Throttle enforcement: 61-minute backoff on HTTP 429 or API error code 3 -- Throttle state: `self.throttle_next_call` datetime -- Automatic reset: Clears when expiry time passes -- Default polling (3 min): ~480 calls/day (safe) -- Aggressive polling (1 min): ~1440 calls/day (risky) - -**Error Handling:** -- HTTP 429 responses trigger `ThrottleDelayError` exception -- Netro API error code 3 (rate limit) parsed from JSON response -- Fallback to 61-minute delay if reset time cannot be parsed -- Logs warning and fires "rateLimitExceeded" trigger +**Netro Public API (NPA) — Primary:** +- Netro smart irrigation controller API — device info, schedules, moisture levels, watering control, rain delay, sensor data, weather reporting + - SDK/Client: `NetroAPIClient` class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` + - Auth: Device serial number (v1) passed as `?key=` query param; API key (v2) passed the same way + - Base URLs: + - v1: `https://api.netrohome.com/npa/v1/` (serial auth) + - v2: `https://api.netrohome.com/npa/v2/` (API key auth) + - Rate limit: 2000 tokens/device/day; HTTP 429 or error code 3 triggers throttle + - Throttle handling: state persisted to `pluginPrefs["throttle_state"]` as JSON; auto-restores on plugin restart + - Supported endpoints (both v1 and v2 unless noted): + - `info.json` — device status and zone info + - `schedules.json` — watering schedules + - `moistures.json` — per-zone moisture levels + - `sensor_data.json` — Whisperer soil sensor readings + - `water.json` — start watering (zones, duration, delay) + - `stop_water.json` — stop active watering + - `set_status.json` — online/standby toggle + - `no_water.json` — rain delay (N days) + - `report_weather.json` — push local weather data to improve smart scheduling + - `set_moisture.json` — override zone moisture + - `events.json` — device events (v2 only; online/offline/schedule start/end) + +**Tomorrow.io Weather API — Secondary:** +- Real-time weather and daily forecast fetched to report to Netro's `report_weather` endpoint, improving smart irrigation scheduling + - SDK/Client: `TomorrowClient` class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py` + - Auth: API key passed as `?apikey=` query param; key stored in `pluginPrefs` (configured via `PluginConfig.xml` UI) + - Endpoints used: + - `https://api.tomorrow.io/v4/weather/realtime` — current conditions + - `https://api.tomorrow.io/v4/weather/forecast` — daily forecast (`timesteps=1d`) + - Response format: metric units (Celsius, mm, m/s, hPa) + - Weather codes mapped to Netro conditions (0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind) via `_TOMORROW_TO_NETRO_CONDITION` dict in `tomorrow_client.py` + - Default poll interval: 30 min realtime, 4 hours forecast (configurable via `pluginPrefs`) + - Free tier provides ~6 days of daily forecast data ## Data Storage -**In-Memory Caching:** -- `self.person` - Cached device data from last API call - - Structure: `{"id": serial, "devices": [...]}` - - Updated every poll cycle in `_update_from_netro()` -- `self.netro_devices` - Flattened list of cached devices -- `self.key_val_list` - Cached Whisperer sensor readings - -**Indigo Device State Storage:** -- Device states for sprinkler controllers: - - `id` - Controller serial number - - `status` - ONLINE/OFFLINE - - `activeZone` - Currently watering zone (1-16) - - `activeSchedule` - Current schedule type (Smart, Manual, etc.) - - `nextScheduleTime` - Next scheduled watering timestamp - - `nextScheduleZone` - Next zone name - - `token_remaining` - API calls left today - - `zone_1_moisture` through `zone_16_moisture` - Per-zone moisture % - - `paused`, `scheduleModeType`, `model`, `api_version` - Metadata - -- Device states for Whisperer sensors: - - `sensorValue` - Current moisture percentage - - `humidity` - Soil moisture - - `temperature` - Celsius reading - - `soilTemperature` - Same as temperature - - `sunlight` - Lux reading - - `batteryLevel` - Battery percentage - - `readingTime`, `readingLocalDate`, `readingLocalTime` - Timestamps - - `token_remaining`, `token_reset` - API quota info - **Databases:** -- None used - pure API integration with in-memory caching +- None — no external database + +**Plugin Preferences (Indigo-managed persistence):** +- All persistent state stored in Indigo's `pluginPrefs` dict +- Key entries: + - `throttle_state` — JSON blob with per-device token budgets and throttle expiry + - `showDebugInfo`, `apiTimeout`, polling interval prefs + - Tomorrow.io API key and location +- Access pattern: `self.pluginPrefs.get(key, default)` / `self.pluginPrefs.__setitem__(key, value)` +- Callbacks passed into `NetroAPIClient`: `prefs_getter` and `prefs_setter` lambdas (in `plugin.py` at `NetroAPIClient` instantiation) **File Storage:** -- None - all configuration in Indigo database +- Local filesystem only — plugin icon at `Netro Sprinklers.indigoPlugin/Contents/Resources/icon.png` +- No file-based data storage **Caching:** -- In-memory only, no persistence across plugin restarts -- Cache refreshed every polling interval (default 3 minutes) +- In-memory only — device state cached in `self.person`, `self.netro_devices`, `self.zone_handler` during plugin runtime +- Throttle state persisted to `pluginPrefs` across restarts ## Authentication & Identity -**Auth Provider:** -- Custom: Serial number-based authentication -- Implementation: Device serial number passed as `key` parameter in all API calls - - Query parameter format: `?key={serial}` for GET requests - - JSON body format: `{"key": "{serial}", ...}` for POST/PUT requests -- Serial number location: Device configuration (set by user during setup) -- Serial number source: Found on physical device or Netro mobile app Settings +**Netro v1 Auth:** +- Device serial number — passed as URL query param `?key=` +- No bearer tokens; no user account credentials -**Security Considerations:** -- Serial numbers are not secret - Netro treats them as public -- Serial number grants full API access to controller -- No bearer tokens, API keys, or OAuth used -- Plugin requires active internet connection to api.netrohome.com +**Netro v2 Auth:** +- API key — passed as URL query param `?key=` +- Configured per Indigo device in device config UI + +**Tomorrow.io Auth:** +- API key — passed as `?apikey=` query param +- Configured at plugin level via `PluginConfig.xml`; stored in `pluginPrefs` +- Key is masked in debug logs: `url.split("key=")[0] + "key=***"` (in `api_client.py` `make_request`) ## Monitoring & Observability **Error Tracking:** -- None (no external error tracking service) -- All errors logged to Indigo Event Log via `self.logger` - -**Logging:** -- Framework: Indigo's built-in logging (`indigo.PluginBase.logger`) -- Levels used: - - `logger.debug()` - Verbose output, API call details - - `logger.info()` - Normal operation, state changes - - `logger.warning()` - Rate limit warnings, token low - - `logger.error()` - API errors, validation failures - - `logger.exception()` - Full stack traces (via traceback format) -- Destination: Indigo Event Log (viewable in Indigo UI) - -**Monitoring Points:** -- API token counts logged at polling (tokens <50: error, <200: warning, <500: info) -- Connection errors logged once, then silent retries -- Throttle state displayed in error messages with retry time -- Device online/offline status updated every poll +- None (no Sentry, Rollbar, or similar) -## Webhooks & Callbacks +**Logs:** +- Indigo event log via `self.logger` (provided by `indigo.PluginBase`) +- Log levels: `debug`, `info`, `warning`, `error`, `exception` +- Connection errors suppressed after first occurrence to avoid log spam (`_last_error_type` field in `NetroAPIClient`) +- API key values masked before logging -**Incoming:** -- None - plugin uses pull model (polling) not push (webhooks) +## CI/CD & Deployment -**Outgoing:** -- None - Netro API does not provide webhook support -- Plugin can send data via `report_weather.json` endpoint but does not receive callbacks - -**Internal Callbacks (Indigo Framework):** -- Location: `plugin.py:1162-1233` -- `deviceStartComm()` - Triggers initial API update when device enabled -- `deviceStopComm()` - Called when device disabled -- `triggerStartProcessing()` - Called when Indigo trigger enabled -- `triggerStopProcessing()` - Called when Indigo trigger disabled -- `closedPrefsConfigUi()` - Applies config changes without plugin restart - -## Events & Triggers - -**Plugin-defined Triggers** (defined in `Events.xml`): -- `sprinklerError` - Zone start, stop, or standby mode failures -- `commError` - API communication failures -- `rateLimitExceeded` - API rate limit hit -- `setNoWater` - Rain delay action failed -- `setStandbyFailed` - Standby mode action failed -- `startZoneFailed` - Zone start action failed -- `stopFailed` - Stop all zones action failed -- `getScheduleCall` - Schedule fetch failed -- `personInfoCall` - Device info fetch failed -- `forecastCall` - Forecast/weather fetch failed - -**Trigger Firing:** -- Location: `plugin.py:1134-1174` -- Method: `_fireTrigger(event, dev_id=None)` -- Fired during API errors to enable user automation -- Example: Alert user when rate limit exceeded - -**Standard Indigo Triggers Used:** -- `RequestStatus` action - Forces immediate API update -- Sprinkler Zone On/Off actions - Standard Indigo sprinkler control - -## Plugin Configuration Flow - -1. **User installs plugin** → Indigo loads `Info.plist` -2. **User configures plugin** → Sets polling interval, timeout, max zone runtime in `PluginConfig.xml` UI -3. **User creates device** → Specifies Netro controller serial number -4. **Plugin validates config** → `validateDeviceConfigUi()` and `validatePrefsConfigUi()` -5. **Device enabled** → `deviceStartComm()` triggers immediate `_update_from_netro()` call -6. **Concurrent thread starts** → Polls API every N minutes (minimum 3) -7. **API responses parsed** → Device states updated in Indigo -8. **User creates actions** → Custom actions for rain delay, weather reporting, zone delay -9. **User creates triggers** → Event triggers fire on errors +**Hosting:** +- Plugin runs on macOS Indigo server (typically `jarvis.local` per workspace CLAUDE.md) +- No cloud hosting + +**CI Pipeline:** +- None detected (no `.github/workflows/`, no CircleCI, no Travis) +- Manual deployment: copy plugin bundle to `/Volumes/Macintosh HD-1/Library/Application Support/Perceptive Automation/Indigo 2025.1/Plugins/` + +**Version Control:** +- GitHub: `https://github.com/simons-plugins/netro-indigo.git` +- Version in `Info.plist`: `PluginVersion = 2026.4.0` ## Environment Configuration -**Required env vars:** -- None - all configuration via Indigo UI +**Required configuration (set via Indigo plugin UI, stored in pluginPrefs):** +- Netro device serial number or API key — per Indigo device +- Tomorrow.io API key — plugin-level preference (optional; disables weather reporting if absent) +- Tomorrow.io location string (lat,lon or place name) — plugin-level preference + +**Dev/test environment:** +- `.env` file present but git-ignored; used for local testing (likely contains test API keys) +- `docs/test_local_api.py` — manual local API testing script **Secrets location:** -- Device serial numbers stored in Indigo device configuration -- Not in environment variables or config files -- Stored encrypted by Indigo database +- Runtime: Indigo `pluginPrefs` (encrypted by Indigo/macOS Keychain) +- Development: `.env` file (git-ignored) + +## Webhooks & Callbacks -**Connection Testing:** -- Plugin includes standalone test utility: `docs/test_local_api.py` -- Useful for debugging API connectivity before plugin integration +**Incoming:** +- None — plugin polls Netro API on a timer; no webhooks received + +**Outgoing:** +- `report_weather` POST to Netro API — plugin pushes local weather data to Netro to influence smart scheduling +- All other interactions are GET/POST polling (not event-driven from the plugin's perspective) --- -*Integration audit: 2026-02-01* +*Integration audit: 2026-04-11* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index d85617e..bc575ad 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,124 +1,87 @@ # Technology Stack -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## Languages **Primary:** -- Python 3.10+ - Main plugin implementation -- XML - Device types, actions, events, plugin configuration +- Python 3.10+ - All plugin logic; `pyproject.toml` sets `requires-python = ">=3.10"` +- Python 3.11 - Development host runtime (3.11.6 detected via `python3 --version`) **Secondary:** -- Plist/XML - Plugin metadata and Info.plist +- XML - Indigo plugin configuration files (`Devices.xml`, `Actions.xml`, `Events.xml`, `MenuItems.xml`, `PluginConfig.xml`) +- Plist - Plugin metadata (`Info.plist`) ## Runtime **Environment:** -- Indigo 2023+ (macOS home automation server) -- Python 3.10+ (managed by Indigo) +- macOS (Indigo home automation platform runs on macOS only) +- Plugin runs inside Indigo's Python 3.10+ interpreter at `/Library/Frameworks/Python.framework/Versions/Current/bin/python3` **Package Manager:** -- pip (Indigo handles automatic installation) -- Lockfile: `requirements.txt` present +- pip (no lockfile — `requirements.txt` pins exact versions) +- Lockfile: Not present (only `requirements.txt` with pinned versions) +- Indigo auto-installs packages from `requirements.txt` on plugin load ## Frameworks **Core:** -- Indigo PluginBase 3.6 - Plugin framework, inherits from `indigo.PluginBase` -- requests 2.32.5 - HTTP client for Netro API calls +- `indigo.PluginBase` - Indigo home automation SDK base class; plugin class `Plugin` in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` inherits from it +- No web framework (Indigo provides the runtime and UI via XML config files) **Testing:** -- pytest 8.0.0+ - Test framework -- pytest-cov 4.1.0+ - Coverage reporting -- pytest-mock 3.12.0+ - Mocking support +- pytest 7.4.3 - Test runner; config in `pytest.ini` and `pyproject.toml` +- pytest-cov 4.1.0 - Coverage reporting (85% minimum enforced via `[coverage:report] fail_under = 85`) **Build/Dev:** -- pylint - Code quality analysis (target score 8.0) +- pylint - Static analysis; configured in `pyproject.toml` with minimum score 9.0 +- No build system (plugin is deployed by copying the `.indigoPlugin` bundle) ## Key Dependencies **Critical:** -- requests 2.32.5 - HTTP client for all Netro API communication - - Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/requirements.txt` - - Installed automatically by Indigo on plugin load +- `requests==2.32.5` - HTTP client for all Netro API and Tomorrow.io API calls; declared in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/requirements.txt` -**Infrastructure:** -- dateutil - Timezone handling in `plugin.py:47` - - Used for timestamp conversion from UTC to local timezone - - Pre-installed with Indigo +**Standard Library (key usage):** +- `json` - API request/response serialization and throttle state persistence +- `datetime`, `timedelta`, `timezone` - Rate limit token reset tracking +- `dataclasses` - `DeviceTokenState` dataclass in `api_client.py` +- `typing` - Type hints throughout; `Final` for immutable constants +- `logging` - Logger passed as callback into `NetroAPIClient` and `TomorrowClient` +- `unittest.mock` - Mock objects in test suite (`conftest.py`, all test files) +- `pathlib` - Test path manipulation in `conftest.py` -**Built-in Libraries:** -- json - JSON parsing for API responses -- copy - Deep copying device dictionaries -- traceback - Exception logging -- datetime - Schedule timestamp handling -- operator - itemgetter for sorting zones +**Infrastructure:** +- `indigo` - Indigo SDK module; imported directly in `plugin.py`; provides `indigo.PluginBase`, device/trigger APIs, `pluginPrefs` ## Configuration **Environment:** -- Plugin preferences stored in Indigo database -- Device configuration per controller (serial number as unique ID) - -**Configuration Files:** -- `Info.plist` - Plugin metadata, version (2025.1.7), API versions -- `PluginConfig.xml` - Plugin-level settings UI (polling interval, timeout, max zone runtime) -- `Devices.xml` - Device type definitions (sprinkler, Whisperer) -- `Actions.xml` - Custom action definitions (rain delay, weather reporting, zone delay) -- `Events.xml` - Trigger event definitions -- `MenuItems.xml` - Plugin menu items (debug toggle, force update) - -**Key Configuration Settings:** -- `pollingInterval` - Minutes between API polls (minimum 3, default 3) -- `apiTimeout` - API request timeout in seconds (default 5) -- `maxZoneRunTime` - Maximum zone runtime in seconds (default 3600) -- `showDebugInfo` - Debug logging flag (default false) +- `.env` file present (excluded from git per `.gitignore`) +- Plugin user config stored in Indigo's `pluginPrefs` dict (persists across restarts) +- Key plugin prefs: `showDebugInfo`, `apiTimeout`, `eventsInterval`, `deviceInfoInterval`, `moisturesInterval`, `schedulesInterval`, `sensorInterval`, `weatherUpdateInterval`, `forecastInterval`, `maxZoneRunTime`, `throttle_state` (JSON blob) +- Tomorrow.io API key and location configured via `PluginConfig.xml` UI, stored in `pluginPrefs` +- Netro device serial numbers / API keys configured per-device, not globally + +**Build:** +- `pyproject.toml` - pylint and pytest configuration +- `pytest.ini` - Pytest options including coverage targets +- No Makefile or build script; deployment is manual bundle copy ## Platform Requirements **Development:** -- macOS (Indigo runs on macOS only) -- Indigo 2023.2+ installed and running -- Python 3.10+ (provided by Indigo) -- Git (for version control) -- pytest, pytest-cov, pytest-mock (for running tests) +- Python 3.10+ (3.11 on development host) +- pytest and pytest-cov installed in dev environment +- pylint installed in dev environment +- Indigo not required to run tests (mocked via `unittest.mock`) **Production:** -- Indigo 2023.2+ running on macOS -- Active internet connection (required for Netro API) -- Netro controller with known serial number -- Indigo server address in DNS/network configuration - -**API Requirements:** -- Netro API base: `http://api.netrohome.com/npa/v1/` -- No additional auth beyond device serial number -- Network access to api.netrohome.com on port 80 (HTTP) - -## Build & Deployment - -**Plugin Installation:** -```bash -# Copy plugin to Indigo plugins directory -cp -r "Netro Sprinklers.indigoPlugin" "/Library/Application Support/Perceptive Automation/Indigo 2023.2/Plugins/" - -# Or disabled plugins for development -cp -r "Netro Sprinklers.indigoPlugin" "/Library/Application Support/Perceptive Automation/Indigo 2023.2/Plugins (Disabled)/" -``` - -**Testing:** -```bash -# Run all tests with coverage -pytest tests/ --cov="Netro Sprinklers.indigoPlugin/Contents/Server Plugin" - -# Generate HTML coverage report -pytest tests/ --cov --cov-report=html -``` - -**Distribution:** -- Plugin packaged as `.indigoPlugin` bundle (contains Contents directory) -- Distributed via GitHub releases -- Double-click to install in Indigo +- Indigo 2023.2+ (requires Python 3.10+) +- macOS running Indigo server +- Active internet connection for Netro API and Tomorrow.io API calls +- Plugin bundle: `Netro Sprinklers.indigoPlugin` copied to Indigo Plugins folder --- -*Stack analysis: 2026-02-01* +*Stack analysis: 2026-04-11* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index 7ee23f8..a755630 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,300 +1,191 @@ # Codebase Structure -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## Directory Layout ``` -netro/ -├── Netro Sprinklers.indigoPlugin/ # Plugin bundle (macOS app package) +netro/ # Repo root +├── Netro Sprinklers.indigoPlugin/ # Plugin bundle (loaded by Indigo server) │ └── Contents/ -│ ├── Info.plist # Plugin metadata and version -│ ├── Server Plugin/ -│ │ ├── plugin.py # Main implementation (1635 lines) -│ │ ├── Devices.xml # Device type definitions -│ │ ├── Actions.xml # Custom action definitions -│ │ ├── Events.xml # Trigger event definitions -│ │ ├── MenuItems.xml # Plugin menu items -│ │ ├── PluginConfig.xml # Plugin settings UI -│ │ └── requirements.txt # Python dependencies -│ └── Resources/ # Web content and assets -├── docs/ # Documentation -│ ├── CLAUDE.md # Developer guide -│ ├── NETRO_API.md # Complete API reference -│ ├── API_NOTES.md # API quirks and discoveries -│ ├── TESTING.md # Test suite guide -│ ├── TROUBLESHOOTING.md # User troubleshooting -│ ├── LOCAL_TESTING.md # Standalone API tester guide -│ ├── DEPENDENCIES.md # Package management -│ └── test_local_api.py # Standalone API test utility -├── tests/ # Test suite (64 tests documented) -│ ├── conftest.py # pytest fixtures (documented, not in repo) -│ ├── test_api_client.py # API tests (17 tests, documented) -│ ├── test_validation.py # Validation tests (24 tests, documented) -│ ├── test_actions.py # Action tests (23 tests, documented) -│ └── fixtures/ # Mock API responses (documented) -├── .planning/ # GSD planning documents -│ └── codebase/ # Codebase analysis -├── .github/ # GitHub configuration -│ └── workflows/ # CI/CD workflows -├── pytest.ini # pytest configuration -├── README.md # User-facing plugin documentation -├── CLAUDE.md # Developer guide (root level) -└── .env # Environment configuration (git-ignored) +│ ├── Info.plist # Plugin metadata, version, bundle ID +│ ├── Resources/ +│ │ └── icon.png # Plugin icon +│ └── Server Plugin/ # All Python source (Indigo loads this) +│ ├── plugin.py # Main plugin class (entry point) +│ ├── api_client.py # Netro API HTTP client +│ ├── tomorrow_client.py # Tomorrow.io weather API client +│ ├── device_handlers.py # API response → Indigo state transformers +│ ├── validators.py # Pure config validation functions +│ ├── constants.py # API URLs, defaults, event sets +│ ├── exceptions.py # Custom exception hierarchy +│ ├── utils.py # Unit conversions, dict helpers +│ ├── Devices.xml # Indigo device type definitions + states +│ ├── Actions.xml # Indigo action definitions +│ ├── Events.xml # Indigo trigger/event definitions +│ ├── MenuItems.xml # Plugin menu item definitions +│ ├── PluginConfig.xml # Plugin-level preferences UI +│ └── requirements.txt # Python dependencies +├── tests/ # Test suite (runs outside Indigo) +│ ├── conftest.py # Pytest fixtures (indigo mock, logger) +│ ├── test_api_client.py # NetroAPIClient unit tests +│ ├── test_base_modules.py # constants, exceptions, utils tests +│ ├── test_device_handlers.py # SprinklerHandler, WhispererHandler tests +│ ├── test_validators.py # validators.py unit tests +│ ├── test_tomorrow_client.py # TomorrowClient unit tests +│ ├── test_weather_integration.py # Weather integration tests +│ └── test_zone_handler.py # ZoneHandler unit tests +├── docs/ # Developer documentation +│ ├── CLAUDE.md # Plugin-specific dev guide (primary reference) +│ ├── NETRO_API.md # Netro API v1 endpoint documentation +│ ├── NETRO_API_V2.md # Netro API v2 endpoint documentation +│ ├── API_NOTES.md # Known API quirks and limitations +│ ├── TESTING.md # Testing guide +│ ├── LOCAL_TESTING.md # Local/manual testing instructions +│ ├── TROUBLESHOOTING.md # Common issues and fixes +│ └── plans/ # Design documents +│ ├── 2026-04-07-zone-devices-design.md +│ └── 2026-04-07-zone-devices-plan.md +├── .planning/ # GSD planning system +│ ├── PROJECT.md # Project goals and scope +│ ├── STATE.md # Current project state +│ ├── MILESTONES.md # Milestone definitions +│ ├── codebase/ # Codebase analysis docs (this dir) +│ ├── milestones/ # Milestone files +│ ├── phases/ # Completed phase plans + summaries +│ └── research/ # Research documents +├── .github/workflows/ +│ ├── version-check.yml # CI: verifies PluginVersion bumped in PRs +│ └── create-release.yml # CI: creates GitHub release on version tag +├── pyproject.toml # Python project config (pytest, coverage) +├── pytest.ini # Pytest configuration +├── htmlcov/ # HTML coverage report (generated, not committed) +└── README.md # Public-facing plugin README ``` ## Directory Purposes -**Netro Sprinklers.indigoPlugin:** -- Purpose: macOS-compatible plugin bundle recognized by Indigo -- Structure: Standard macOS app bundle structure with `Contents/` directory -- Installed to: `/Library/Application Support/Perceptive Automation/Indigo 2023.2/Plugins/` - -**Contents/Server Plugin:** -- Purpose: Python plugin implementation and configuration -- Contains: Main `plugin.py` class (inherits `indigo.PluginBase`), XML configuration files, dependencies list -- Entry point: `plugin.py` - main Plugin class with all handlers - -**Contents/Info.plist:** -- Purpose: Plugin metadata for Indigo and macOS -- Contains: Version, bundle identifier, API version, GitHub info -- Current: v2025.1.7, API v3.6, identifier `com.simons-plugins.netro` - -**Contents/Resources:** -- Purpose: Static web assets (if using HTTP Responder features) -- Current state: Exists but minimal content (not heavily used in this plugin) - -**docs/:** -- Purpose: Developer and user documentation -- CLAUDE.md: Comprehensive developer reference with architecture, workflow, code patterns -- NETRO_API.md: Complete Netro API endpoint reference -- API_NOTES.md: API quirks discovered during development (timestamps, device structure, offline status) -- TESTING.md: How to run test suite and test patterns -- TROUBLESHOOTING.md: User-facing troubleshooting guide -- test_local_api.py: Standalone utility to test API calls against real Netro API - -**tests/:** -- Purpose: Automated test suite (64 tests, >70% coverage) -- conftest.py: pytest fixtures for mocking Indigo, device data, API responses -- test_api_client.py: 17 tests for `_make_api_call()`, throttling, error handling -- test_validation.py: 24 tests for config/action/device validation -- test_actions.py: 23 tests for action execution and state updates -- fixtures/: Mock Netro API response files -- Run: `pytest tests/` with coverage - -**.planning/codebase/:** -- Purpose: GSD (Generative Software Development) planning documents -- Content: Architecture analysis, structure guide, conventions, testing patterns, concerns -- Created by: `/gsd:map-codebase` command - -**.github/workflows/:** -- Purpose: CI/CD pipeline configuration -- Contains: GitHub Actions workflows for testing, linting, release automation - -**pytest.ini:** -- Purpose: pytest configuration and options -- Specifies: Test discovery, coverage settings, output format +**`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/`:** +- Purpose: All Python source that Indigo loads when the plugin is enabled +- Contains: `plugin.py` (main), plus the extracted module files +- Key files: `plugin.py` (coordinator), `api_client.py` (Netro HTTP), `device_handlers.py` (state transform) +- Note: The `Server Plugin/` name is an Indigo convention — do not rename + +**`tests/`:** +- Purpose: Pytest test suite that runs outside Indigo (no Indigo server required) +- Contains: One test file per source module; `conftest.py` provides `indigo` mock +- Key files: `conftest.py` (mock setup), `test_api_client.py` (most critical coverage) + +**`docs/`:** +- Purpose: Developer-facing documentation and API reference +- Contains: Guides for testing, API quirks, troubleshooting +- Key files: `CLAUDE.md` (primary dev guide), `NETRO_API.md` / `NETRO_API_V2.md` (API reference) + +**`.planning/`:** +- Purpose: GSD planning system — milestones, phases, research +- Generated: No — manually maintained by GSD commands +- Committed: Yes -**README.md:** -- Purpose: User-facing plugin documentation -- Contains: Features, device types, setup instructions, API usage, troubleshooting links +**`htmlcov/`:** +- Purpose: Generated HTML coverage report from `pytest --cov` +- Generated: Yes (by `pytest --cov --cov-report=html`) +- Committed: Yes (`.gitignore` inside htmlcov excludes nothing — entire dir tracked) ## Key File Locations **Entry Points:** - -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py`: Main plugin implementation - - Class: `Plugin(indigo.PluginBase)` at line 132 - - Lifecycle: `__init__` → `startup` → `runConcurrentThread` → `shutdown` - -- `Netro Sprinklers.indigoPlugin/Contents/Info.plist`: Plugin metadata - - Indigo reads this to identify plugin, load version, display name - -- `docs/test_local_api.py`: Standalone API testing tool - - Run: `python3 test_local_api.py --serial YOUR_SERIAL` +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py`: Main plugin class, Indigo lifecycle **Configuration:** - -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml`: Plugin settings UI - - Fields: Polling interval, API timeout, max zone runtime, debug logging - - Validation: `validatePrefsConfigUi()` in plugin.py line 1031 - -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml`: Device type definitions - - Types: `sprinkler` (line 9), `Whisperer` (line 199) - - States: 40+ states for sprinkler (zones, schedules, moisture, API info) - - States: 15+ states for sensor (temperature, moisture, sunlight, battery) +- `Netro Sprinklers.indigoPlugin/Contents/Info.plist`: Plugin version (`PluginVersion`), bundle ID +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml`: Device types and state definitions +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml`: Plugin preferences UI +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Actions.xml`: User-invokable actions +- `pyproject.toml`: Test dependencies and coverage settings +- `pytest.ini`: Test paths and options **Core Logic:** - -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` key methods: - - `__init__` (line 157): Initialize plugin state - - `_make_api_call` (line 195): HTTP requests with throttling and error handling - - `_update_from_netro` (line 373): Main polling cycle, state refresh - - `runConcurrentThread` (line 810): Background polling loop (3+ minute interval) - - `actionControlSprinkler` (line 1238): Zone on/off actions - - `setNoWater` (line 1350): Rain delay action - - `setStandbyMode` (line 1383): Standby mode action - - `startZoneWithDelay` (line 1408): Delayed zone start action - - `reportWeather` (line 1479): Weather reporting action +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py`: All Netro API HTTP calls +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py`: State transformation +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py`: Config validation +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py`: All magic numbers and URLs +- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py`: Weather API client **Testing:** - -- `tests/test_api_client.py`: API integration tests -- `tests/test_validation.py`: Configuration validation tests -- `tests/test_actions.py`: Action execution tests -- `tests/conftest.py`: pytest fixtures (mocks, test data) -- `docs/TESTING.md`: Testing guide and patterns - -**Documentation:** - -- `docs/CLAUDE.md`: Developer reference (architecture, patterns, quirks) -- `docs/NETRO_API.md`: API endpoint reference with examples -- `docs/API_NOTES.md`: Discovered API quirks and workarounds -- `docs/TROUBLESHOOTING.md`: User issues and solutions -- `README.md`: User-facing overview +- `tests/conftest.py`: Shared fixtures including `indigo` module mock +- `tests/test_api_client.py`: Most comprehensive test file ## Naming Conventions **Files:** - -- Python files: `plugin.py`, `test_*.py`, lowercase with underscores -- XML files: `Devices.xml`, `Actions.xml`, `Events.xml`, `PluginConfig.xml`, `MenuItems.xml` (PascalCase) -- Documentation: `UPPERCASE.md` (NETRO_API.md, API_NOTES.md, TROUBLESHOOTING.md) -- Configuration: `pytest.ini`, `.env`, `.gitignore` +- Python modules: `snake_case.py` (e.g., `api_client.py`, `device_handlers.py`) +- Test files: `test_{module_name}.py` (e.g., `test_api_client.py`) +- XML config files: `PascalCase.xml` (Indigo convention — `Devices.xml`, `Actions.xml`) +- Docs: `UPPER_SNAKE.md` for reference docs, `lower-kebab-date-title.md` for plans **Directories:** +- Indigo bundle: `Plugin Name.indigoPlugin` (spaces allowed, `.indigoPlugin` suffix required) +- Plugin source: `Server Plugin/` (Indigo convention, fixed name) -- Plugin bundle: `PluginName.indigoPlugin` (exact case match required by Indigo) -- Server Plugin: `Server Plugin/` (space-separated, matches Indigo convention) -- Source code: lowercase (`plugin.py`, not `Plugin.py`) -- Documentation: `docs/` (lowercase) -- Tests: `tests/` (lowercase) - -**Functions/Methods:** - -- Module-level: `convert_timestamp()` (line 101), `get_key_from_dict()` (line 117) - lowercase with underscores -- Class methods (Plugin): `_make_api_call()` (private, single underscore prefix) - lowercase with underscores -- Public methods: `actionControlSprinkler()`, `setNoWater()`, `startZoneWithDelay()` (camelCase, matches Indigo convention) -- Private helpers: `_update_from_netro()`, `_get_device_dict()`, `_get_zone_dict()` (single underscore prefix) -- Callback methods: `validatePrefsConfigUi()`, `deviceStartComm()`, `triggerStartProcessing()` (camelCase, matches Indigo callback names) - -**Variables:** - -- Instance: `self.throttle_next_call`, `self.pollingInterval`, `self.netro_devices` (camelCase with underscores for clarity) -- Module-level constants: `NETRO_API_VERSION`, `DEFAULT_API_CALL_TIMEOUT`, `MINIMUM_POLLING_INTERVAL` (UPPER_SNAKE_CASE) -- Local: `dev`, `dev_id`, `zone_dict`, `reply_dict` (lowercase with underscores) - -**Device IDs:** - -- Type IDs (in Devices.xml): `sprinkler` (lowercase), `Whisperer` (PascalCase) -- State IDs (in Devices.xml): `status`, `activeZone`, `nextScheduleTime` (camelCase) -- Action IDs (in Actions.xml): `setStandbyMode`, `setNoWater`, `startZoneWithDelay` (camelCase) -- Event IDs (in Events.xml): `sprinklerError`, `commError` (camelCase) +**Python identifiers:** +- Constants: `SCREAMING_SNAKE_CASE` with `typing.Final` +- Classes: `PascalCase` (e.g., `NetroAPIClient`, `SprinklerHandler`) +- Methods/functions: `snake_case`; private helpers prefixed `_` +- Indigo callback methods: `camelCase` (Indigo SDK convention, e.g., `runConcurrentThread`, `validateDeviceConfigUi`) ## Where to Add New Code -**New Feature (e.g., smart scheduling):** - -1. **Add action definition:** - - Edit `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Actions.xml` - - Add `` block with fields for user input - - Reference your callback method name - -2. **Implement callback:** - - Add method to `Plugin` class in `plugin.py` - - Follow naming: `def myNewAction(self, pluginAction, dev):` - - Check throttle state at start - - Make API call via `_make_api_call()` - - Fire trigger on failure: `self._fireTrigger("myActionFailed", dev.id)` - - Log success/failure: `self.logger.info()` or `self.logger.error()` - -3. **Add event definition:** - - Edit `Events.xml` if feature can fail - - Add `` with IDs for error conditions - -4. **Add tests:** - - Create or update test file in `tests/` - - Mock API responses in `tests/fixtures/` - - Test success and failure paths - - Test parameter validation - - Run: `pytest tests/test_actions.py::test_new_action -v` - -5. **Update documentation:** - - Add to `docs/CLAUDE.md` Architecture section - - Add API endpoint to `docs/NETRO_API.md` - - Add usage example to `README.md` - -**New Device Type (e.g., Smart Hose):** - -1. **Define device type:** - - Edit `Devices.xml` - - Add `` block - - Define states and properties - - Example: `` - -2. **Add update logic:** - - Edit `_update_from_netro()` method - - Add device type check: `if dev.deviceTypeId == "SmartHose":` - - Call appropriate API endpoints - - Build state update list - - Update device: `dev.updateStatesOnServer(update_list)` - -3. **Add validation:** - - Update `validateDeviceConfigUi()` for device type validation - - Check serial number, capabilities, etc. - -4. **Add tests:** - - Test device discovery - - Test state updates - - Test error handling - -**Utility/Helper Function:** - -- Location: Add to top of `plugin.py` before Plugin class (around line 100) -- Scope: Module-level functions for reusable logic -- Example: `convert_timestamp()` (line 101), `get_key_from_dict()` (line 117) -- Pattern: Use type hints where possible, add docstring -- Test: Create unit tests in `tests/test_api_client.py` or new file - -**Error/Exception Handling:** - -- Location: Wrap in try/except in appropriate layer (API, Control, Data Sync) -- Pattern: Log error with context, fire trigger if user action failed, continue execution -- Never: Crash plugin or exit background thread -- Always: Re-raise connection errors in API layer, catch in Control/Data layers +**New API endpoint:** +1. Add URL constant to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` +2. Add convenience method to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` +3. Add processing to the appropriate handler in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` +4. Wire into polling timer logic in `plugin.py` `_update_sprinkler_device()` or `_update_whisperer_device()` +5. Add tests in `tests/test_api_client.py` and `tests/test_device_handlers.py` + +**New device type:** +1. Add device definition to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml` +2. Create new handler class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` +3. Add update method to `plugin.py` (`_update_newtype_device()`) +4. Add branch in `_update_from_netro()` for `dev.deviceTypeId == "newtype"` +5. Add test file `tests/test_newtype_handler.py` + +**New validation:** +- Add function to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` +- Return `ValidationResult` tuple: `(bool, Dict, Dict)` +- Import and call from appropriate `validate*ConfigUi` method in `plugin.py` +- Add tests in `tests/test_validators.py` + +**New Indigo action:** +1. Define action in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Actions.xml` +2. Implement callback in `plugin.py` following naming convention from Indigo SDK +3. Validate input via `validators.validate_action_config()` pattern + +**Utilities:** +- Shared helpers with no plugin dependencies: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py` ## Special Directories -**Netro Sprinklers.indigoPlugin:** -- Purpose: macOS app bundle structure (required format for Indigo) -- Generated: No (hand-authored structure) -- Committed: Yes -- Note: Entire directory is the installable plugin; contains source and all resources +**`Netro Sprinklers.indigoPlugin/`:** +- Purpose: Indigo plugin bundle (directory with `.indigoPlugin` extension loaded as app bundle) +- Generated: No +- Committed: Yes — all source is inside the bundle -**tests/fixtures/:** -- Purpose: Mock Netro API response JSON files -- Generated: No (hand-authored mock data) -- Committed: Yes -- Structure: Filename matches endpoint (e.g., `device_info_response.json`) +**`htmlcov/`:** +- Purpose: HTML coverage report from pytest +- Generated: Yes, by `pytest --cov --cov-report=html` from repo root +- Committed: Yes (tracked in git) -**.planning/codebase/:** -- Purpose: GSD (Generative Software Development) analysis documents -- Generated: Yes (by `/gsd:map-codebase` and `/gsd:map-phase` commands) -- Committed: Yes (part of development planning) -- Created by: Claude Code via GSD orchestrator - -**.github/workflows/:** -- Purpose: CI/CD pipeline automation (GitHub Actions) -- Generated: No (hand-authored workflows) +**`.planning/`:** +- Purpose: GSD project management system +- Generated: Partially (by GSD commands) - Committed: Yes -- Files: Test runs, linting, release automation -**Contents/Packages/:** -- Purpose: Bundled Python dependencies (if used) -- Generated: No in this plugin (uses Indigo-provided requests library) -- Committed: No -- Note: Could contain vendored dependencies if needed +**`tests/`:** +- Purpose: Test suite lives outside the plugin bundle (can't be bundled with plugin) +- Generated: No +- Committed: Yes +- Note: `sys.path` manipulation in `conftest.py` makes plugin source importable for tests --- -*Structure analysis: 2026-02-01* +*Structure analysis: 2026-04-11* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index 8d2975f..e21de2d 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,386 +1,301 @@ # Testing Patterns -**Analysis Date:** 2026-02-01 +**Analysis Date:** 2026-04-11 ## Test Framework **Runner:** -- pytest >= 8.0.0 -- Configuration: `pytest.ini` in project root +- pytest 8.0+ (configured in `pytest.ini` and `pyproject.toml`) +- Config: `/Users/simon/vsCodeProjects/Indigo/netro/pytest.ini` **Assertion Library:** -- pytest's built-in assertions -- pytest-mock for mocking (`pytest-mock >= 3.12.0`) +- pytest's built-in assertions (no separate assertion library) -**Coverage Tools:** -- pytest-cov >= 4.1.0 -- HTML reports generated to `htmlcov/` -- Coverage target: >70% (current status per CLAUDE.md) +**Coverage:** +- `pytest-cov` with branch coverage enabled +- HTML report written to `htmlcov/` +- Minimum required: 85% (`fail_under = 85` in `pytest.ini`) +- Current actual coverage: ~10% for most modules (coverage target is aspirational; plugin.py at 0% because it requires Indigo runtime) **Run Commands:** ```bash -# Run all tests with coverage -pytest tests/ -v --cov="Netro Sprinklers.indigoPlugin/Contents/Server Plugin" --cov-report=term-missing +# Run all tests with coverage (default via pytest.ini addopts) +cd /Users/simon/vsCodeProjects/Indigo/netro && python3 -m pytest -# Run specific test file -pytest tests/test_api_client.py -v +# Run without coverage (faster) +python3 -m pytest --no-cov -# Run specific test -pytest tests/test_api_client.py::test_successful_get_request -v +# Run specific file +python3 -m pytest tests/test_api_client.py -# Run with branch coverage -pytest tests/ --cov-branch +# Run by marker +python3 -m pytest -m api +python3 -m pytest -m handlers +python3 -m pytest -m weather -# Generate HTML coverage report -pytest tests/ --cov --cov-report=html -# View in htmlcov/index.html +# Run single test by name +python3 -m pytest tests/test_api_client.py::TestThrottleState::test_initial_state_not_throttled + +# Run with pattern match +python3 -m pytest -k "throttle" + +# View HTML coverage report +open htmlcov/index.html ``` ## Test File Organization -**Location:** -- `tests/` directory in project root -- Test files are siblings of main plugin +**Location:** Separate `tests/` directory at repo root (not co-located with source) **Naming:** -- Files: `test_*.py` pattern (pytest auto-discovery) -- Classes: `Test*` pattern (e.g., `TestAPIClient`) -- Functions: `test_*` pattern (e.g., `test_successful_get_request`) +- Files: `test_.py` matching the source module name +- Classes: `Test` (e.g., `TestThrottleState`, `TestSprinklerHandlerDeviceInfo`) +- Functions: `test_` with descriptive name (e.g., `test_throttle_until_past_clears_automatically`) -**Structure (from pytest.ini discovery):** +**Structure:** ``` -tests/ -├── conftest.py # pytest fixtures and setup -├── test_api_client.py # API integration tests (17 tests) -├── test_validation.py # Configuration validation tests (24 tests) -├── test_actions.py # Action callback tests (23 tests) -└── fixtures/ # Mock API response data - ├── device_info.json - ├── schedules.json - └── ... +netro/ +├── tests/ +│ ├── conftest.py # Shared fixtures (auto-discovered by pytest) +│ ├── test_api_client.py # NetroAPIClient tests +│ ├── test_base_modules.py # constants, exceptions, utils tests +│ ├── test_device_handlers.py # SprinklerHandler, WhispererHandler tests +│ ├── test_validators.py # validate_* function tests +│ ├── test_tomorrow_client.py # TomorrowClient tests +│ ├── test_weather_integration.py # Weather unit conversion + prefs validation +│ └── test_zone_handler.py # ZoneHandler tests +└── pytest.ini # pytest configuration ``` -**Total: 64 tests covering >70% of code** +**Total tests:** 427 collected (as of 2026-04-11) ## Test Structure -**Markers defined in pytest.ini:** -```ini -[pytest] -markers = - api: Tests for API client functionality - validation: Tests for configuration and action validation - actions: Tests for action callback methods - integration: Integration tests requiring external services - slow: Tests that take more than 1 second -``` - -**Usage pattern:** +**Suite Organization — class-based grouping:** ```python @pytest.mark.api -def test_successful_get_request(mock_plugin): - """Test successful API GET request.""" - # Test implementation +class TestThrottleState: + """Tests for throttle state management.""" + + def test_initial_state_not_throttled(self, client): + """New client should have is_throttled=False.""" + assert client.is_throttled is False + + def test_throttle_until_future_is_throttled(self, client): + """When _throttle_until is in future, is_throttled=True.""" + client._throttle_until = datetime.now() + timedelta(minutes=30) + assert client.is_throttled is True ``` -**Test file categories:** - -### test_api_client.py (17 tests, @pytest.mark.api) -- Test `_make_api_call()` HTTP methods (GET, POST, PUT) -- HTTP status code handling (200, 204, error codes) -- Netro-specific error codes (code 1 = invalid key, code 3 = rate limit) -- Timeout handling -- Connection error handling -- Rate limit (throttle) enforcement and recovery -- JSON response parsing - -### test_validation.py (24 tests, @pytest.mark.validation) -- Device configuration validation: `validateDeviceConfigUi()` -- Action configuration validation: `validateActionConfigUi()` -- Plugin preference validation: `validatePrefsConfigUi()` -- Serial number format validation -- Polling interval constraints -- Zone duration and delay constraints -- Weather data range validation -- Error message generation - -### test_actions.py (23 tests, @pytest.mark.actions) -- Zone on/off actions: `actionControlSprinkler()` -- Custom actions: `startZoneWithDelay()`, `reportWeather()`, `setNoWater()`, `setStandbyMode()` -- Action parameter validation -- Error condition handling -- Trigger firing on success/failure +**Key patterns:** +- Every test method has a one-line docstring describing the expected behavior (the "should" statement) +- Arrange/Act/Assert structure used but not labeled with comments +- Fixtures injected via pytest parameters, not instantiated in test bodies +- Test classes organized by feature/behavior boundary, not by method-under-test + +**Markers defined in `pytest.ini`:** +- `api` — Tests for API client functionality +- `handlers` — Tests for device handler functionality +- `validation` — Tests for configuration and action validation +- `actions` — Tests for action callback methods +- `weather` — Tests for Tomorrow.io weather integration +- `integration` — Integration tests requiring external services +- `slow` — Tests that take more than 1 second ## Mocking -**Framework:** pytest-mock (via `mocker` fixture) +**Framework:** `unittest.mock` (stdlib) — `Mock`, `patch`, `MagicMock` -**Pattern:** Mock Indigo objects and requests library - -**Example (from conftest.py fixture):** +**Standard mock pattern for HTTP requests:** ```python -@pytest.fixture -def mock_plugin(mocker): - """Create a mock Plugin instance.""" - plugin = Plugin( - pluginId="com.simonmikey.netro", - pluginDisplayName="Netro Sprinklers", - pluginVersion="2.0", - pluginPrefs={} - ) - - # Mock logger - plugin.logger = mocker.MagicMock() +def test_make_request_success(self, client): + """Successful GET returns parsed JSON.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "OK", "data": {}} - # Mock Indigo collections - mocker.patch("indigo.devices", MagicMock()) - mocker.patch("indigo.trigger", MagicMock()) + with patch("requests.get", return_value=mock_response): + result = client.get_device_info(serial="ABC123") - return plugin + assert result["status"] == "OK" ``` -**What to Mock:** -- HTTP library: `requests.get()`, `requests.post()`, `requests.put()` via `mocker.patch()` -- Indigo API: `indigo.devices`, `indigo.trigger`, device/action objects -- Logger: `self.logger.info()`, `self.logger.error()`, etc. -- File I/O: Configuration reads, if needed - -**Pattern for mocking requests:** +**Standard mock for logger (used in every test file):** ```python -def test_successful_get_request(mocker): - """Test successful GET request to API.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "status": "OK", - "data": {"device": {...}} - } - - mocker.patch("requests.get", return_value=mock_response) +@pytest.fixture +def mock_logger(): + """Create a mock logger for testing.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + logger.exception = Mock() + return logger +``` - # Test code using API - result = plugin._make_api_call(url) - assert result["status"] == "OK" +**Dependency injection via constructor (preferred over patching):** +The extracted modules (api_client, device_handlers, tomorrow_client) accept `logger`, `prefs_getter`, and `prefs_setter` as constructor args. Tests pass mocks directly rather than patching module-level imports: +```python +@pytest.fixture +def client(mock_logger, mock_prefs): + """Create a NetroAPIClient instance with mocked dependencies.""" + prefs_getter, prefs_setter, _ = mock_prefs + return NetroAPIClient( + logger=mock_logger, + prefs_getter=prefs_getter, + prefs_setter=prefs_setter + ) ``` -**What NOT to Mock:** -- Plugin class instantiation (test against real __init__) -- Core business logic like throttle calculation -- Datetime operations (use freezegun if time control needed) -- Dictionary/list operations +**What to mock:** +- `requests.get` / `requests.post` for all HTTP calls +- `logger` — always inject mock logger in unit tests +- `prefs_getter`/`prefs_setter` callables for API client state persistence +- Indigo module — `indigo` is not installed in test environment; mock it if needed + +**What NOT to mock:** +- `constants.py` values — use real constants +- `exceptions.py` classes — use real exceptions +- Pure utility functions in `utils.py` — test them directly +- `validators.py` functions — test them directly (no side effects) ## Fixtures and Factories -**Test Data Location:** -- `tests/fixtures/` directory contains JSON response files -- Files match API endpoint responses exactly -- Examples: `device_info.json`, `schedules.json`, `moistures.json`, `sensor_data.json` +**Shared fixtures in `conftest.py`** (`/Users/simon/vsCodeProjects/Indigo/netro/tests/conftest.py`): -**Fixture Pattern (conftest.py):** ```python @pytest.fixture -def mock_plugin(mocker): - """Return mock plugin instance with logger and device mocks.""" - # ... setup code ... - return plugin +def mock_logger(): + """Provides Mock with debug/info/warning/error/exception methods.""" + +@pytest.fixture +def sample_api_response(): + """Base successful API v1 response: {status: OK, data: {}, meta: {...}}""" + +@pytest.fixture +def mock_prefs(): + """Returns (prefs_getter, prefs_setter, prefs_data) tuple for API client tests.""" + +@pytest.fixture +def sample_api_v2_response(): + """Base successful API v2 response with extended meta fields.""" + +@pytest.fixture +def sample_v2_device_info(): + """Full device info v2 response with zones array.""" @pytest.fixture -def mock_device(mocker): - """Return mock Indigo device.""" - device = MagicMock() - device.id = 1 - device.name = "Test Controller" - device.address = "0cb8152f9f78" # Valid serial number format - device.states = {"id": "0cb8152f9f78"} - device.pluginProps = {"NumZones": 4} - return device +def sample_v2_schedules(): + """Schedules v2 response with ISO 8601 timestamps.""" @pytest.fixture -def device_info_response(): - """Load real API response from fixture file.""" - with open("tests/fixtures/device_info.json") as f: - return json.load(f) +def sample_v2_sensor_data(): + """Sensor data v2 response.""" ``` -**Factory Pattern:** +**Note:** `mock_logger` and `mock_prefs` are duplicated in `test_api_client.py` and `test_device_handlers.py` as local fixtures. Prefer the shared versions from `conftest.py` for new tests. + +**Test data pattern:** +Fixtures return realistic dict structures matching the actual Netro API response format. Tests modify the returned dict for specific scenarios rather than creating new data from scratch: ```python -def create_test_device(mocker, serial="0cb8152f9f78", name="Test"): - """Factory to create test devices with custom parameters.""" - device = MagicMock() - device.address = serial - device.name = name - return device +def test_device_offline(self, sprinkler_handler, sample_device_info_response): + sample_device_info_response["data"]["device"]["status"] = "OFFLINE" + states, is_online, _ = sprinkler_handler.process_device_info( + sample_device_info_response, "ABC123" + ) + assert is_online is False ``` ## Coverage -**Requirements:** >70% (stated in CLAUDE.md, targeting 85%+) - -**View Coverage:** +**Requirements:** 85% minimum enforced by `pytest.ini` (`fail_under = 85`) + +**Excluded from coverage:** +- `*/tests/*` — test files themselves +- `def __repr__` +- `raise AssertionError`, `raise NotImplementedError` +- `if __name__ == .__main__.:` +- `if TYPE_CHECKING:` +- `@abstractmethod` +- Lines marked `# pragma: no cover` + +**Current coverage gaps:** +- `plugin.py` — 0% (requires Indigo runtime; all tests bypass this file) +- `api_client.py` — 17% (HTTP request paths require extensive mocking) +- `device_handlers.py` — 10% (many handler paths not yet exercised) +- `validators.py` — 10% +- `utils.py` — 17% +- `constants.py` — 100% (trivially satisfied) + +**View coverage:** ```bash -# Terminal report -pytest tests/ --cov --cov-report=term-missing - -# HTML report -pytest tests/ --cov --cov-report=html -open htmlcov/index.html -``` - -**Coverage Configuration (pytest.ini):** -```ini -[coverage:run] -source = . -omit = - */tests/* - */test_* - */__pycache__/* - -[coverage:report] -exclude_lines = - pragma: no cover - def __repr__ - raise AssertionError - raise NotImplementedError - if __name__ == .__main__.: - if TYPE_CHECKING: - @abstractmethod +python3 -m pytest # Generates term-missing + HTML report +open /Users/simon/vsCodeProjects/Indigo/netro/htmlcov/index.html ``` -**Coverage Gaps (from analysis):** -- Empty test files directory suggests tests may not be tracked in repo -- pytest.ini shows full test infrastructure configured -- Cached bytecode shows tests exist/existed (conftest, test_actions, test_validation, test_api_client) - ## Test Types -**Unit Tests** (primary - ~50 tests): -- API call methods with mocked requests -- Validation methods with various input combinations -- Data transformation functions (timestamp conversion, list building) -- Error handling (exceptions, error codes, graceful degradation) -- Scope: Single method or small component -- No external dependencies - -**Integration Tests** (secondary - ~14 tests): -- Test combinations of API calls and state updates -- Mock Indigo device updates (updateStatesOnServer, replacePluginPropsOnServer) +**Unit Tests (all current tests):** +- Test individual modules in isolation +- No Indigo runtime dependency +- Fast, can run offline +- Mock all external I/O + +**Integration Tests (not yet implemented):** - Marked with `@pytest.mark.integration` -- May test data flow across multiple methods +- Would require live Netro API access +- `docs/test_local_api.py` provides a standalone script for manual API testing against real hardware -**E2E Tests** (standalone - docs/test_local_api.py): -- Not part of pytest suite -- Tests against real Netro API -- Uses actual serial numbers from `.env` file -- Location: `docs/test_local_api.py` -- Usage: `python3 test_local_api.py --serial YOUR_SERIAL` -- Read-only by default, write operations require `--full` flag +**E2E Tests:** +- Not implemented +- Manual testing against Indigo server on `jarvis.local` is the current E2E approach ## Common Patterns -**Async Testing Pattern:** -```python -def test_concurrent_thread_exception_handling(mock_plugin): - """Test that runConcurrentThread swallows exceptions.""" - mock_plugin._update_from_netro = MagicMock(side_effect=Exception("Test")) - - # Thread should continue despite exception - mock_plugin.runConcurrentThread() # Within timeout - - # Verify sleep was called (thread loop continued) - mock_plugin.sleep.assert_called() -``` +**Async Testing:** +Not applicable — plugin uses synchronous HTTP (`requests`) with Indigo's threading model. No async test patterns needed. -**Error Testing Pattern:** +**Error Testing:** ```python -def test_api_call_handles_connection_error(mocker, mock_plugin): - """Test error handling for connection failures.""" - mocker.patch( - "requests.get", - side_effect=requests.exceptions.ConnectionError("Connection failed") - ) - - # Should raise exception but log gracefully - with pytest.raises(requests.exceptions.ConnectionError): - mock_plugin._make_api_call(url) +def test_raises_throttle_error_on_429(self, client): + """HTTP 429 response raises ThrottleDelayError.""" + mock_response = MagicMock() + mock_response.status_code = 429 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() - # Verify error logged - mock_plugin.logger.error.assert_called() + with patch("requests.post", return_value=mock_response): + with pytest.raises(ThrottleDelayError): + client.start_zone(serial="ABC123", zone=1, duration=600) ``` -**Validation Testing Pattern:** +**Parametrize pattern (used in validators tests):** ```python -def test_validate_device_config_requires_serial(mock_plugin): - """Test that serial number is required.""" - valuesDict = {"address": ""} - typeId = "sprinkler" - - is_valid, _, errorsDict = mock_plugin.validateDeviceConfigUi( - valuesDict, typeId, 0 - ) - - assert not is_valid - assert "address" in errorsDict - assert "required" in errorsDict["address"].lower() +@pytest.mark.parametrize("invalid_serial", ["", "ABC", "TOOLONGSERIAL1234"]) +def test_invalid_serial_rejected(self, invalid_serial): + is_valid, _, errors = validate_device_config({"address": invalid_serial}, "sprinkler") + assert is_valid is False + assert "address" in errors ``` -**Rate Limit Testing Pattern:** +**State assertion via dict comprehension:** +Handler tests convert the returned state list to a dict for easy assertion: ```python -def test_throttle_delay_prevents_api_calls(mock_plugin): - """Test that ThrottleDelayError blocks API calls.""" - mock_plugin.throttle_next_call = datetime.now() + timedelta(minutes=1) - - with pytest.raises(ThrottleDelayError) as exc_info: - mock_plugin._make_api_call(url) - - assert "throttled" in str(exc_info.value).lower() +states = zone_handler.extract_zone_states(sample_zones, zone_number=1) +state_dict = {s["key"]: s["value"] for s in states} +assert state_dict["enabled"] is True +assert state_dict["smartMode"] == "SMART" ``` -**Fixture Data Pattern:** +**Validation return value unpacking:** +All validator tests use 3-tuple unpacking to check each component separately: ```python -def test_moisture_parsing(mock_plugin, device_info_response): - """Test parsing of moisture data from API response.""" - # Load real API response from fixture - moisture_data = device_info_response["data"]["device"]["zones"][0] - - # Test parsing logic - assert moisture_data["moisture"] == 45 -``` - -## Test Dependencies - -**Runtime Dependencies** (auto-installed): -- `requests==2.32.5` - Mocked in tests - -**Development Dependencies** (from DEPENDENCIES.md): -- `pytest>=8.0.0` - Test runner -- `pytest-cov>=4.1.0` - Coverage reporting -- `pytest-mock>=3.12.0` - Mock/patch fixtures - -**Installation:** -```bash -pip install pytest>=8.0.0 pytest-cov>=4.1.0 pytest-mock>=3.12.0 +is_valid, sanitized, errors = validate_device_config(values, "sprinkler") +assert is_valid is True +assert sanitized["address"] == "0123456789AB" +assert errors == {} ``` -## Notes on Current State - -**Test Infrastructure:** -- pytest.ini fully configured with markers, discovery patterns, and coverage settings -- tests/ directory structure in place with __pycache__ showing compiled tests existed -- Coverage configuration defined but test source files not present in working tree - -**Implications:** -- Tests may have been gitignored or removed -- pytest configuration is ready for test implementation/recovery -- Coverage reporting is set up and ready to use -- Test markers (api, validation, actions, integration, slow) are defined for categorization - -**For Adding New Tests:** -1. Create test files in `tests/` with `test_*.py` names -2. Define test functions with `test_*` names -3. Use fixtures from conftest.py for common setup -4. Mark tests with appropriate marker: `@pytest.mark.api`, etc. -5. Run with `pytest tests/ -v` - --- -*Testing analysis: 2026-02-01* +*Testing analysis: 2026-04-11* From f98951aef24cb4a236637949c3e0e64dbf19afa2 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Tue, 14 Apr 2026 10:06:54 +0100 Subject: [PATCH 7/9] docs: refresh codebase map --- .planning/codebase/ARCHITECTURE.md | 304 +++++++++++++---------- .planning/codebase/CONCERNS.md | 322 ++++++++++-------------- .planning/codebase/CONVENTIONS.md | 253 +++++++------------ .planning/codebase/INTEGRATIONS.md | 253 +++++++++---------- .planning/codebase/STACK.md | 187 +++++++------- .planning/codebase/STRUCTURE.md | 307 +++++++++-------------- .planning/codebase/TESTING.md | 386 +++++++++-------------------- 7 files changed, 869 insertions(+), 1143 deletions(-) diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 80ee96a..d9da8ce 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,154 +1,206 @@ -# Architecture - -**Analysis Date:** 2026-04-11 - -## Pattern Overview - -**Overall:** Indigo Plugin with layered separation of concerns - -**Key Characteristics:** -- Plugin coordinator (`plugin.py`) owns the Indigo lifecycle and orchestrates all data flow -- No-dependency base modules (constants, exceptions, utils) are importable anywhere -- API clients (`api_client.py`, `tomorrow_client.py`) are pure Python — no `indigo` import -- Device handlers (`device_handlers.py`) transform API responses to Indigo state dicts — no `indigo` import -- Validators (`validators.py`) are pure functions with no side effects — fully testable in isolation - -## Layers - -**Base Layer (no dependencies):** -- Purpose: Shared constants, exception types, utility functions -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py`, `exceptions.py`, `utils.py` -- Contains: API URL constants, timing defaults, exception hierarchy, unit conversion functions -- Depends on: Python stdlib only -- Used by: All other modules - -**API Client Layer:** -- Purpose: HTTP communication with external APIs, rate-limit management -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py`, `tomorrow_client.py` -- Contains: `NetroAPIClient` (Netro Public API), `TomorrowClient` (Tomorrow.io weather API) -- Depends on: `constants`, `exceptions`, `requests` -- Used by: `plugin.py` - -**Validation Layer:** -- Purpose: Pure validation of user-supplied config before it reaches the plugin -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` -- Contains: `validate_device_config`, `validate_action_config`, `validate_event_config`, `validate_prefs_config` -- Depends on: `constants` only -- Used by: `plugin.py` in `validateDeviceConfigUi` / `validateActionConfigUi` / `validatePrefsConfigUi` callbacks - -**Handler Layer:** -- Purpose: Transform raw API response dicts into Indigo state-update lists -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` -- Contains: `SprinklerHandler`, `WhispererHandler`, `ZoneHandler` -- Depends on: `constants`, `utils` -- Used by: `plugin.py` - -**Plugin Coordinator:** -- Purpose: Indigo lifecycle, polling loop, Indigo device/variable management -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` -- Contains: `Plugin(indigo.PluginBase)` class -- Depends on: All other layers, `indigo` (Indigo SDK) -- Used by: Indigo server (loaded as plugin bundle) +# ARCHITECTURE.md — Plugin Architecture + +## Overview + +The Netro Sprinklers plugin is a polling-based Indigo plugin. It polls the +Netro Public API on per-endpoint timers and writes state to Indigo device +objects. There is no push/webhook mechanism. + +## Module Dependency Graph + +``` +plugin.py (Plugin class) + ├── api_client.py (NetroAPIClient) + │ ├── constants.py + │ └── exceptions.py + ├── device_handlers.py (SprinklerHandler, WhispererHandler, ZoneHandler) + │ ├── constants.py + │ └── utils.py + ├── validators.py + │ └── constants.py + ├── tomorrow_client.py (TomorrowClient) + ├── utils.py + ├── constants.py + └── exceptions.py +``` + +All modules except `plugin.py` are free of `indigo` imports — they are pure +Python and fully unit-testable without the Indigo runtime. -## Data Flow +--- + +## Plugin Lifecycle + +Defined in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py`: + +``` +__init__() + - Read pluginPrefs (polling intervals, timeout, Tomorrow.io config) + - Compute _loop_interval = min of all per-endpoint intervals + - Create NetroAPIClient (with prefs callbacks for throttle persistence) + - Create SprinklerHandler, WhispererHandler, ZoneHandler + - Create TomorrowClient (or None if not configured) + - Initialise per-endpoint next-update timers (all set to now → fire immediately) + +startup() + - Logs startup message + +runConcurrentThread() + - Loops every _loop_interval minutes using self.sleep() + - On each tick, calls _update_from_netro() if due + - On each tick, calls _update_weather_from_tomorrow() if due + - On each tick, calls _update_forecast_from_tomorrow() if due + +shutdown() + - Clean shutdown (no explicit teardown needed) +``` + +--- + +## Per-Endpoint Polling Timers + +Each data type has an independent timer. The main loop sleeps for the +shortest interval so fast endpoints can fire on schedule: + +| Timer variable | Default interval | Min interval | +|----------------|-----------------|--------------| +| `_next_device_info_update` | 10 min | 5 min | +| `_next_schedules_update` | 30 min | 10 min | +| `_next_moistures_update` | 10 min | 5 min | +| `_next_events_update` | 5 min | 3 min | +| `_next_sensor_update` | 30 min | 10 min | +| `_next_weather_update` | 30 min | 10 min | +| `_next_forecast_update` | 240 min | 60 min | -**Polling Cycle (every `_loop_interval` minutes):** +--- + +## Device Hierarchy + +Three Indigo device types are defined in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml`: -1. `runConcurrentThread()` wakes and calls `_update_from_netro()` -2. `_update_from_netro()` iterates `indigo.devices.iter(filter="self")` for enabled devices -3. For each sprinkler: `_update_sprinkler_device(dev)` checks per-endpoint timers -4. Per-timer endpoint calls fire: `api_client.get_device_info()` → `api_client.get_schedules()` → `api_client.get_moistures()` → `api_client.get_events()` -5. API responses are passed to handler methods: `sprinkler_handler.process_device_info()` → returns `(state_list, is_online, device_data)` -6. `plugin.py` calls `dev.updateStatesOnServer(state_list)` and `dev.replacePluginPropsOnServer(props)` -7. Zone devices are created/updated via `_ensure_zone_devices()` and `_update_zone_devices()` -8. Indigo variables for moisture are maintained via `_ensure_zone_variables()` +### 1. `sprinkler` — Controller device -**Weather Flow (optional, Tomorrow.io):** +- Represents a Netro Sprite, Pixie, or Spark controller +- Inherits Indigo's built-in sprinkler device type +- Key device states: `status`, `activeZone`, `activeScheduleType`, + `nextScheduleZone`, `nextScheduleTime`, `nextScheduleDuration`, + `tokenRemaining`, `tokenReset`, `weather_*` fields +- One controller per Indigo device instance +- Config: `address` = serial number (v1 auth), `apiKey` = optional API key + (v2 auth) -1. `runConcurrentThread()` calls `_update_weather_from_tomorrow()` and `_update_forecast_from_tomorrow()` on their own timers -2. `TomorrowClient.fetch_current_weather()` / `fetch_forecast()` returns metric weather dict -3. `plugin.py` converts units for v1 devices (`convert_weather_metric_to_us`) — v2 stays metric -4. `api_client.report_weather()` posts to Netro to improve smart scheduling -5. Weather device states updated on the sprinkler device +### 2. `Whisperer` — Soil sensor device -**User Action Flow:** +- Represents a Netro Whisperer wireless soil/sunlight sensor +- Key device states: `moisture`, `celsius`, `fahrenheit`, `sunlight`, + `battery_level`, `lastReadingTime` +- Config: `address` = sensor serial number, `apiKey` = optional v2 key -1. User invokes action in Indigo UI -2. `plugin.py` action callback validates parameters via `validators.validate_action_config()` -3. Plugin calls `api_client.start_watering()` / `stop_watering()` / `set_no_water()` etc. -4. API response logged; state updated next polling cycle +### 3. `zone` — Zone sub-device (auto-created) + +- One device per enabled zone on a controller +- Auto-created by `Plugin._ensure_zone_devices()` when controller first seen +- Renamed automatically if the zone is renamed in the Netro app +- Key states: moisture level, enabled status, zone name +- Linked to parent controller via `pluginProps["parentDeviceId"]` +- Zone number stored in `pluginProps["zoneNumber"]` + +--- -**State Management:** -- API throttle state persisted to `pluginPrefs["throttle_state"]` as JSON (survives restarts) -- Per-endpoint timers are in-memory `datetime` attributes on the `Plugin` instance -- Zone-to-variable mapping stored in device `pluginProps["zoneVariableMap"]` as JSON -- Last-seen event ID tracked in `self._last_event_ids` dict (in-memory, keyed by Indigo device ID) +## State Update Flow + +``` +runConcurrentThread() + └── _update_from_netro() + └── for each enabled Indigo device: + ├── Sprinkler: api_client.get_device_info() + │ ├── sprinkler_handler.process_device_info() → state dict + │ ├── sprinkler_handler.process_schedules() → state dict + │ ├── sprinkler_handler.process_moistures() → state dict + │ └── dev.updateStatesOnServer(states) + │ + ├── Sprinkler (v2 only): api_client.get_events() + │ └── fire Indigo triggers on new events + │ + ├── Sprinkler: _ensure_zone_devices() (auto-create zone devs) + │ └── _update_zone_devices() (update zone dev states) + │ + ├── Sprinkler: _ensure_zone_variables() (create Indigo variables) + │ + └── Whisperer: api_client.get_sensor_data() + ├── whisperer_handler.process_sensor_data() → state dict + └── dev.updateStatesOnServer(states) +``` -## Key Abstractions +--- -**NetroAPIClient (`api_client.py`):** -- Purpose: All HTTP communication with Netro Public API; per-device token budget tracking -- Pattern: Dependency-injection for logger and prefs callbacks — no `indigo` import -- Constructor receives `prefs_getter` / `prefs_setter` callbacks to persist throttle state +## API Client (`api_client.py`) -**DeviceTokenState (`api_client.py`):** -- Purpose: Dataclass tracking token budget per device (keyed by API key or serial) -- Pattern: Per-device tracking (2000 tokens/day limit is per-device, not account-wide) +`NetroAPIClient` is a stateful HTTP client that: -**SprinklerHandler / WhispererHandler / ZoneHandler (`device_handlers.py`):** -- Purpose: Stateless transformers — receive API response dict, return list of state update dicts -- Pattern: No `indigo` import, no side effects; fully unit-testable -- Return type: `List[Dict[str, Any]]` matching `updateStatesOnServer()` format +1. Constructs endpoint URLs (v1 or v2) based on `api_version` parameter +2. Enforces the 61-minute throttle lockout (`_throttle_until`) +3. Tracks per-device token budget (`_device_tokens: Dict[str, DeviceTokenState]`) +4. Proactively pauses all calls when any device's `token_remaining < 100` +5. Logs warnings when `token_remaining < 200` +6. Persists throttle state to `pluginPrefs` via injected callbacks + (`prefs_getter` / `prefs_setter`) — survives plugin restarts +7. Suppresses repeated connection error logs (shows first error, then silently + retries until success) -**ValidationResult (`validators.py`):** -- Purpose: Consistent return type from all validators -- Pattern: `Tuple[bool, Dict[str, Any], Dict[str, str]]` — `(is_valid, sanitized_values, errors_dict)` +Error types raised by `make_request()`: +- `ThrottleDelayError` — rate limit hit or proactive pause +- `NetroAPIError` — API returned `"status": "ERROR"` or error code +- `NetroConnectionError` — `requests.ConnectionError` +- `NetroTimeoutError` — `requests.Timeout` -**Dual API version support (`api_client.py`, `device_handlers.py`, `plugin.py`):** -- Purpose: Support both v1 (serial number auth) and v2 (API key auth) simultaneously -- Pattern: `_get_device_auth(dev)` returns `(key, api_version)` — all downstream calls parameterised by version -- Endpoint selection: `_ENDPOINT_MAP` dict keyed by `(name, version)` in `NetroAPIClient` - -## Entry Points +--- -**Plugin Bundle Load:** -- Location: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` -- Triggers: Indigo server loads plugin bundle on startup or enable -- Responsibilities: `Plugin.__init__()` initialises all state, handlers, clients - -**`startup()` (`plugin.py` line ~1002):** -- Triggers: After `__init__`, before first concurrent thread tick -- Responsibilities: Logs API version per device, subscribes to Indigo variable changes +## Device Handlers (`device_handlers.py`) -**`runConcurrentThread()` (`plugin.py` line ~1040):** -- Triggers: Called by Indigo after `startup()`; runs until `StopThread` -- Responsibilities: Per-endpoint timer-based polling loop; sleeps `_loop_interval * 60` seconds +Handlers are pure Python classes that transform API response dicts into Indigo +state update lists. They do not import `indigo` and do not call the API. -**Action Callbacks:** -- Pattern: All action handlers in `plugin.py` validate via `validators.validate_action_config()` then delegate to `api_client` +- **`SprinklerHandler`**: processes `info.json`, `schedules.json`, + `moistures.json` responses into state dicts +- **`WhispererHandler`**: processes `sensor_data.json` into state dicts +- **`ZoneHandler`**: helper for per-zone state extraction -## Error Handling +The separation enables full unit testing without an Indigo runtime. -**Strategy:** Layered — API layer raises typed exceptions; plugin coordinator catches and logs; polling loop continues +--- -**Patterns:** -- `NetroAPIClient.make_request()` raises `ThrottleDelayError` on rate-limit; `NetroAPIError` on API error; `requests` exceptions propagate -- `ThrottleDelayError` caught silently in `_update_sprinkler_device()` — polling just skips the device -- Connection/timeout errors logged once per error type, then silently retried (suppression via `_last_error_type`) -- Device handlers return error state list on `KeyError`/`TypeError` — never raise -- Validators return `(False, values, errors)` — never raise -- Outer try/except in `runConcurrentThread()` ensures loop never exits on unexpected exception +## Trigger System -## Cross-Cutting Concerns +Trigger events are defined in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Events.xml`. -**Logging:** Uses `self.logger` (Indigo's logger) everywhere; API key values masked in debug logs (`key=***`); error suppression avoids log spam on repeated connection failures +`triggerDict` in `Plugin` maps event names to active `indigo.Trigger` objects. +Fired via `Plugin._fireTrigger(event, dev_id)`. -**Validation:** All user-facing config goes through `validators.py` before reaching plugin logic; integer-range helpers enforce minimums +Operational error events (`OPERATIONAL_ERROR_EVENTS`): +- `startZoneFailed`, `stopFailed`, `setStandbyFailed`, `setMoistureFailed` -**Authentication:** Per-device — `_get_device_auth(dev)` reads `pluginProps["apiKey"]`; if present, uses v2 (API key); otherwise v1 (serial from `dev.address`) +Communication error events (`COMM_ERROR_EVENTS`): +- `personCall`, `personInfoCall`, `getScheduleCall`, `forecastCall` -**Rate Limiting:** Proactive — tracks per-device token budget from every response meta; pauses polling when `token_remaining < TOKEN_PAUSE_THRESHOLD` (100); persisted to survive restarts +V2 device event types (from `events.json`): `offline`, `online`, +`schedule_started`, `schedule_ended` — fire corresponding Indigo triggers. --- -*Architecture analysis: 2026-04-11* +## Variable System + +For each zone on a sprinkler controller, the plugin creates an Indigo variable +in a "Netro" folder to expose moisture levels to other Indigo scripts and +triggers. Variable names follow the pattern: + +``` +zone_moisture_{device_slug}_{zone_slug} # multi-zone +zone_moisture_{device_slug} # single-zone / Pixie +``` + +Zone→variable mapping is stored as JSON in `dev.pluginProps["zoneVariableMap"]` +and loaded on each update. Variables are renamed if zones are renamed in Netro. diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md index 8ca6f42..b354da3 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,198 +1,132 @@ -# Codebase Concerns +# CONCERNS.md — Tech Debt, Known Issues, and TODOs -**Analysis Date:** 2026-04-11 +## TODOs in Source + +### `plugin.py` — Sprinkler actions not fully wired + +```python +# plugin.py line 1562 +# TODO: The next sprinkler actions won't currently be called because we haven't +# set the OverrideScheduleActions property. If we wanted to hand off all +# scheduling to the Netro, we would need to use these. However, their current +# API doesn't implement enough required functionality (pause/resume, next/previous +# zone, etc) for us to actually do that at the moment. +``` + +`RunNewSchedule`, `RunPreviousSchedule`, `PauseSchedule`, `ResumeSchedule`, +`StopSchedule`, `PreviousZone`, and `NextZone` sprinkler actions are silently +ignored (`pass`). This is a Netro API limitation — those operations are not +supported. The comment is correct but could be a confusing no-op for users +who try these actions from the Indigo UI. ## Tech Debt -**V2 Status Set Incomplete — SLEEPING and POWEROFF Treated as Offline:** -- Issue: `V2_ONLINE_STATUSES` only includes `"ONLINE"` and `"WATERING"`. The v2 API returns 7 status values: `STANDBY`, `SETUP`, `ONLINE`, `WATERING`, `OFFLINE`, `SLEEPING`, `POWEROFF`. `SLEEPING` (battery-powered deep sleep) is treated as offline, but may be a temporary low-power state where the device is functionally available. `SETUP` is also unhandled. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` (line 193), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (line 99) -- Impact: Users with battery-powered controllers may see spurious offline error states during normal sleep cycles. -- Fix approach: Review Netro v2 documentation for intended semantics of `SLEEPING`/`SETUP`, add appropriate status values or treat `SLEEPING` as a degraded-but-online state. - -**Legacy `ZONE_START_ENDPOINT` Used for Zone On (v1 Only, No v2 Counterpart):** -- Issue: `actionControlSprinkler()` calls `self.api_client.make_request(ZONE_START_ENDPOINT, ...)` with a PUT method — this is the legacy `/zone/start` endpoint and does not route through `_get_device_auth()`. For v2 devices (API key auth), this call uses the wrong auth and the wrong endpoint. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1530), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` (line 76) -- Impact: Zone On action silently fails for v2 devices. No trigger or error clearly attributes the failure to this cause. -- Fix approach: Replace with `self.api_client.start_watering(key, zones, api_version=api_version)` after resolving v2 zone ID format (which may differ between v1 and v2). - -**`import re` Inside a Method:** -- Issue: `_slugify()` in `plugin.py` does `import re` inside the static method body. While Python caches module imports, this is non-standard and could confuse linters or static analysis tools. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 250) -- Impact: Negligible runtime cost, but poor style; `re` should be a top-level import. -- Fix approach: Move `import re` to the top-level imports section of `plugin.py`. - -**`setNoWater` Trigger Name Mismatch:** -- Issue: `_fireTrigger("setNoWater", dev.id)` fires the trigger `"setNoWater"`, but the `COMM_ERROR_EVENTS` and `OPERATIONAL_ERROR_EVENTS` sets use distinct names like `"setStandbyFailed"`. There is no event named `"setNoWater"` registered in `Events.xml` — it would silently fail to match any trigger type. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1629) -- Impact: Rain delay failures do not fire user triggers; users cannot automate responses to rain delay errors. -- Fix approach: Either add `"setNoWaterFailed"` to `OPERATIONAL_ERROR_EVENTS` and `Events.xml`, or correct the trigger name to an existing one like `"commError"`. - -**Dual Headers Dict — Plugin and APIClient Both Define HTTP Headers:** -- Issue: `Plugin.__init__()` initializes `self.headers` (lines 125-129) but never uses it. All actual HTTP calls go through `NetroAPIClient` which has its own headers. The plugin-level headers dict is dead code from the pre-refactor era. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 125-129) -- Impact: Confusing to read; could mislead future developers into calling `requests` directly from `plugin.py`. -- Fix approach: Remove the `self.headers` assignment from `Plugin.__init__()`. - -**Polling Timers Not Reset When Prefs Change:** -- Issue: When polling intervals are updated via `closedPrefsConfigUi()`, the main `_loop_interval` sleep is recalculated, but the per-endpoint `_next_*_update` timers are not reset. An endpoint set to 60-minute polling that fires at T+0 will still fire at T+60 regardless of whether the interval was changed to 5 minutes at T+10. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1196-1220) -- Impact: After changing polling intervals, users must wait for the old interval to expire before seeing the new cadence take effect. This is particularly noticeable for long-interval endpoints like schedules (default 30 min). -- Fix approach: When an interval is reduced in `closedPrefsConfigUi()`, reset the corresponding `_next_*_update` to `datetime.now()` to trigger immediate next-cycle execution. - -**`battery_level` for Whisperer Returns Float (0.0-1.0) in v2 but Int (0-100) in v1:** -- Issue: The v2 API returns `battery_level` as a float `0.0-1.0` (per `NETRO_API_V2.md` line 116), while v1 returns it as an integer 0-100. `WhispererHandler.process_sensor_data()` calls `dev_states.get("battery_level", 0)` without version-aware conversion, meaning v2 devices will show battery as `0.85` instead of `85`. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (line 564) -- Impact: Incorrect battery level display for v2 Whisperer sensors. -- Fix approach: In `WhispererHandler.process_sensor_data()`, check `api_version` and multiply by 100 when v2 float format is detected. - -## Known Bugs - -**Zone On Action Uses Wrong Endpoint for v2 Devices:** -- Symptoms: Starting a zone via Indigo sprinkler controls on a v2-authenticated device sends a PUT to the v1 `/zone/start` endpoint without the v2 API key, causing an authentication failure or incorrect behavior. No clear error is shown. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 1530) -- Trigger: User performs Zone On action on a sprinkler device configured with an API key (v2 mode). -- Workaround: Use the custom "Start Zone with Delay" action (via `startZoneWithDelay()`), which correctly uses `_get_device_auth()`. - -**`person` Dict Overwrites on Each Poll — Multi-Device Support is Broken:** -- Symptoms: `_update_sprinkler_device()` rebuilds `self.person` from each device's API response in sequence. If multiple sprinkler devices exist, each overwrites the shared `self.person` and `self.netro_devices`. Any code that reads `self.person` after the loop sees only the last device's data. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 780-783) -- Trigger: User has two or more sprinkler controller devices configured. -- Workaround: Use separate plugin instances per controller (documented as a known limitation). - -## Security Considerations - -**API Keys Stored in Indigo pluginProps (Plaintext):** -- Risk: v2 API keys are stored as plaintext values in Indigo device pluginProps, which are persisted to Indigo's XML database on disk. If the Indigo database file is accessed by another process or user, the key is exposed. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (line 371), `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` (line 353) -- Current mitigation: API key is masked in debug log output (`masked_url` in `api_client.py` line 285). Indigo itself provides no encryption for pluginProps. -- Recommendations: This is an Indigo platform limitation that cannot be fully addressed at the plugin level. Consider documenting to users that the API key provides only device-level access (not account access) to reduce perceived risk. Do not log the key or include it in trigger payloads. - -**Tomorrow.io API Key Stored in Indigo pluginPrefs (Plaintext):** -- Risk: Same pattern as above — Tomorrow.io API key is stored in pluginPrefs and persisted to disk. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 386-387) -- Current mitigation: None beyond not logging it. -- Recommendations: Same as above — document the limited blast radius of a leaked Tomorrow.io key (rate-limited, not billing-linked on free tier). - -**Serial Number Used as URL Parameter (v1):** -- Risk: The device serial number is embedded in API request URLs as `?key={serial}`. This serial appears in HTTP access logs on any intermediary proxy/router, and in debug logs if `masked_url` logic is bypassed. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` (lines 285, 657) -- Current mitigation: `masked_url` masks only `key=` params in debug logs. v1 serial is still logged correctly. -- Recommendations: v2 API key is treated the same as v1 serial for masking, which is correct. No further mitigation available without Netro API changes. - -## Performance Bottlenecks - -**Zone Variable Scan Iterates All Sprinkler Devices on Every Variable Change:** -- Problem: `variableUpdated()` loops over all `self.sprinkler` devices and parses their `zoneVariableMap` JSON on every Indigo variable change — not just zone moisture variables. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1429-1484) -- Cause: No precomputed reverse index mapping variable IDs to (device, zone) pairs. The reverse scan happens on every `variableUpdated()` call because Indigo subscribes to ALL variable changes at startup (line 1030). -- Improvement path: Build and cache a `{var_id: (dev_id, zone_num)}` lookup dict at startup and update it in `_ensure_zone_variables()`. Early-exit `variableUpdated()` on cache miss without scanning all devices. - -**Schedule Processing Re-Sorts and Re-Scans on Every Polling Cycle:** -- Problem: `process_schedules()` and `process_zone_schedules()` iterate all schedules returned by the API to find the current and next schedule. At 50 schedules per device, this is O(n) per device per poll cycle, run on every device info + schedule refresh. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (lines 169-200, 614-630) -- Cause: No caching of schedule parse results between polls. Data is reprocessed even when the API returns the same response. -- Improvement path: Compare response hash or schedule list length before reprocessing. For typical usage (3-5 devices, 50 schedules each), this is negligible but worth noting as device count grows. - -**Forecast Reporting Makes N API Calls Per Device Per Day:** -- Problem: `_update_forecast_from_tomorrow()` calls `report_weather` once per forecast day per device. With 6 forecast days and 3 sprinkler devices, each forecast cycle consumes 18 Netro API tokens. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 543-575) -- Cause: Netro's report_weather API is per-day, requiring one call per forecast day. No way to batch. -- Improvement path: Reduce default `forecastInterval` or limit forecast days sent. Consider only sending the current + next day rather than all 6. - -## Fragile Areas - -**`_get_device_dict()` Uses `self.person["devices"]` — KeyError if `person` Not Yet Populated:** -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 186-190) -- Why fragile: `self.person` is initialized to `{}` in `__init__`. `_get_device_dict()` immediately accesses `self.person["devices"]` without checking if the key exists. If called before the first successful API poll (e.g. from `setNoWater()` before any poll has run), this raises a `KeyError`. -- Safe modification: Always guard with `self.person.get("devices", [])` instead of `self.person["devices"]`. -- Test coverage: No test exercises `_get_device_dict()` on an uninitialized plugin. - -**Zone Variable Mapping Stored as JSON String in pluginProps:** -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 273-348) -- Why fragile: `zoneVariableMap` is serialized as a JSON string in `dev.pluginProps`. If the JSON is malformed (e.g., due to a partial write), `_ensure_zone_variables()` silently resets the map and recreates variables, potentially creating duplicates with name conflicts. The error path at line 329 catches `Exception` and attempts to recover by looking up the variable by name, which may pick up a wrong variable. -- Safe modification: Add a schema version field to the JSON and validate structure before use. Add a dry-run path that checks for name conflicts before writing. -- Test coverage: No tests cover the corruption/recovery path. - -**`runConcurrentThread()` Catches All Exceptions and Continues:** -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 1074-1076) -- Why fragile: The outer exception handler at line 1074 catches any `Exception` not already handled by the per-device update methods. While this prevents the thread from dying, it means transient bugs (e.g., a `NameError` in new code) will be silently swallowed and retried every polling cycle, flooding the Indigo log with the same error every N minutes without any operator action. -- Safe modification: Consider distinguishing between `Exception` (catch and retry) and programming errors (`AttributeError`, `NameError`) that should be reported at higher severity. The current approach is acceptable for a home automation plugin but makes bug reproduction harder. -- Test coverage: Not directly testable without Indigo runtime. - -**`_update_sprinkler_device()` Only Updates Zone Devices When Device Info Also Fires:** -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 792-840) -- Why fragile: Schedules, moistures, and zone device updates are gated inside the `if now >= self._next_device_info_update` block. If the device info endpoint fires on a different cycle than moistures (e.g., device info every 10 min, moistures every 10 min but offset), moistures data is fetched but never applied to zone devices unless device info also ran. This is by design but the dependency is subtle and not documented in code. -- Safe modification: Cache the last `device_data` response so zone device updates can run independently of device info polling. -- Test coverage: No integration test covers the timer offset scenario. - -**`_ensure_zone_devices()` Calls `indigo.device.create()` Without Checking for Name Collisions:** -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (lines 635-656) -- Why fragile: If a zone device already exists with the same name but different `pluginProps` (e.g., the user manually created a device, or `parentDeviceId` changed), `indigo.device.create()` raises an exception. The error is caught and logged, but the zone device is not created, and subsequent polls will keep trying and logging errors. -- Safe modification: Check `existing` dict before calling `create()`. Verify no existing non-zone device shares the name `expected_name`. -- Test coverage: Not covered. - -## Scaling Limits - -**Single Plugin Instance — One Global `self.person` Dict:** -- Current capacity: Functionally one sprinkler controller per plugin behavior (see multi-device bug above). -- Limit: Two or more sprinkler devices cause `self.person` to only reflect the last-polled device's data. Actions that call `_get_device_dict()` will fail or return incorrect data for all but the last-updated device. -- Scaling path: Replace `self.person` with a `{device_id: person_data}` dict keyed by Indigo device ID, or remove the shared cache entirely since per-device data is already embedded in each device's states/props. - -**API Token Budget — 2000/day per Netro Device:** -- Current capacity: With default intervals (device info 10 min, schedules 30 min, moistures 10 min, events 5 min, sensor 30 min) and 1 device: ~approx. 200-300 calls/day, well within limit. -- Limit: Each additional sprinkler or Whisperer device multiplies token consumption. With 5 devices at default intervals and weather forecast reporting (18 calls per cycle), approaching 1200+ calls/day. -- Scaling path: Token pause threshold (`TOKEN_PAUSE_THRESHOLD = 100`) is the safety valve. Consider per-device configurable intervals as a future enhancement. - -## Dependencies at Risk - -**`requests` Library Bundled as Single Runtime Dependency:** -- Risk: `requirements.txt` pins `requests==2.32.5`. This is a single, stable dependency that Indigo auto-installs. No known active CVEs but `requests` has had HTTP injection vulnerabilities in the past (CVE-2023-32681 fixed in 2.31.0). -- Impact: If Indigo's Python environment drifts or the user has an older version installed, HTTP behaviour may differ. -- Migration plan: No alternative; `requests` is the correct choice for synchronous HTTP in Indigo plugins. Ensure version pinned to 2.31.0+ for the CVE fix. - -## Missing Critical Features - -**No Rate Limit Trigger for Token Depletion (Only for HTTP 429):** -- Problem: The plugin fires `rateLimitExceeded` only on HTTP 429. Token-budget pause (when `token_remaining < TOKEN_PAUSE_THRESHOLD`) does not fire any trigger, so users cannot automate alerts for proactive token warnings. -- Blocks: Users with high polling frequencies or multiple devices have no automated notification before service interruption. - -**No Cleanup When a Zone Device's Parent Controller is Deleted:** -- Problem: Zone devices store their parent's `parentDeviceId` in `pluginProps`, but there is no `deviceStopComm()` or `deviceDeleted()` handler that removes orphaned zone devices when a parent controller is deleted. -- Blocks: Orphaned zone devices accumulate in Indigo after controller removal and require manual cleanup. - -## Test Coverage Gaps - -**`plugin.py` Has 0% Test Coverage:** -- What's not tested: All plugin lifecycle methods (`startup`, `shutdown`, `runConcurrentThread`), all action handlers (`setNoWater`, `setStandbyMode`, `startZoneWithDelay`, `reportWeather`, `setZoneMoisture`), all Indigo callbacks (`variableUpdated`, `triggerStartProcessing`, `actionControlSprinkler`), and the entire polling loop. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` (898 statements, 0 covered per htmlcov) -- Risk: Any regression in the main plugin class will not be caught by CI. All plugin.py bugs reach production. -- Priority: High — this is the highest-impact gap. Even smoke tests with a heavily mocked `indigo` module would catch most action handler bugs. - -**`api_client.py` Has 17% Coverage (Throttle and Error Paths Not Tested):** -- What's not tested: `_handle_http_error()` rate-limit branch, `_restore_throttle_state()` v1→v2 migration path, `_validate_response_schema()`, `should_pause_polling_for()` auto-reset logic. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` (205/255 statements missing) -- Risk: Throttle state corruption after restart or during v1→v2 migration is undetected. -- Priority: High — throttle management is critical to API budget safety. - -**`tomorrow_client.py` Has 7% Coverage:** -- What's not tested: `fetch_current_weather()`, `fetch_forecast()`, `_transform_response()`, `_transform_forecast_response()`, all HTTP error paths. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py` (138/153 statements missing) -- Risk: Weather integration failures will not be caught by tests. Incorrect unit conversion or missing fields could silently corrupt Netro's smart scheduling data. -- Priority: Medium — weather integration is optional but regressions here are hard to detect without live API calls. - -**`device_handlers.py` Has 10% Coverage (Most Handler Logic Untested):** -- What's not tested: `SprinklerHandler.process_moistures()`, `SprinklerHandler.extract_zone_info()`, `SprinklerHandler.process_events()`, `WhispererHandler.process_sensor_data()`, `ZoneHandler.process_zone_schedules()`, all v2 code paths in schedule/timestamp handling. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` (219/250 statements missing) -- Risk: API response parsing bugs will reach production silently. -- Priority: High — these are pure Python functions with no Indigo dependency and are straightforward to unit test. - -**`validators.py` Has 10% Coverage Despite Clean Architecture:** -- What's not tested: `validate_prefs_config()`, `validate_action_config()` for `reportWeather` and `setMoisture` action types, `validate_event_config()`, `validate_api_key()`, `is_indigo_substitution()`. -- Files: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` (193/226 statements missing) -- Risk: Invalid user configurations that should be rejected may be accepted and cause runtime errors. -- Priority: Medium — validators are pure functions that are easy to test. The gap is surprising given the modular design. - ---- - -*Concerns audit: 2026-04-11* +### `plugin.py` has no test coverage (0%) + +The main `Plugin` class cannot be unit-tested because it requires the Indigo +runtime (`import indigo` fails outside the plugin host). The extracted modules +(`api_client.py`, `device_handlers.py`, etc.) are testable, but `plugin.py` +itself is not, meaning the coordination logic is only tested via real Indigo +integration. See `TESTING.md` for coverage breakdown. + +### Type hints absent in `plugin.py` + +The main plugin file has no type annotations. The extracted modules +(`api_client.py`, `validators.py`, `device_handlers.py`) use full type hints. +The inconsistency makes refactoring harder. + +### Pylint disabled rules + +Multiple `# pylint: disable=unused-argument` suppressions in `plugin.py` at +lines 1081, 1103, 1122, 1137, 1151, 1304, 1318, 1835, 1911 — these are +Indigo callback signatures that include parameters not used in the +implementation. Correct by convention but noisy. + +### `docs/TESTING.md` references non-existent `tests/fixtures/` directory + +`docs/TESTING.md` describes JSON fixture files (`info_response.json`, etc.) in +a `tests/fixtures/` directory that does not exist. All fixture data is now +inline in `conftest.py`. The docs are stale but not harmful. + +### `test_api_client.py` duplicates `conftest.py` fixtures + +`test_api_client.py` defines its own `mock_logger` and `mock_prefs` fixtures +locally instead of using the shared ones from `conftest.py`. This is +redundant and should be cleaned up. + +### Legacy `FORECAST_UPDATE_INTERVAL_MINUTES` constant + +```python +# constants.py +FORECAST_UPDATE_INTERVAL_MINUTES: Final[int] = 240 +"""Default forecast interval (use DEFAULT_FORECAST_INTERVAL_MINUTES instead).""" +``` + +Kept for backward compatibility but flagged for removal. No other code should +reference it — new code should use `DEFAULT_FORECAST_INTERVAL_MINUTES`. + +## Known API Issues and Workarounds + +These are Netro API quirks that require ongoing defensive code (documented +fully in `docs/API_NOTES.md`): + +1. **Timestamp strings**: V1 API returns numeric timestamps as JSON strings. + Handled by `float(raw) if isinstance(raw, str) else raw` pattern throughout + `device_handlers.py`. + +2. **`STANDBY` vs `OFFLINE`**: Ambiguous status — the plugin cannot + definitively distinguish between "controller is on standby" and "controller + is unreachable". Only `last_active` timestamp can help infer actual offline. + +3. **Moisture data is always stale**: Netro updates moisture once per day at + most. Plugin cannot force a refresh. Users expect real-time data. + +4. **Whisperer battery drain**: Sensors reporting every 4-6 hours degrade + to unreliable intervals when battery is below 20%. Plugin has no way to + detect this degradation — it just sees fewer readings. + +5. **No pause/resume**: Indigo sprinkler device model supports + `PauseSchedule`/`ResumeSchedule`/`NextZone` but Netro API does not. + These actions are silently no-ops (see TODO above). + +## Rate Limit Risk + +At default polling with all endpoints active and Tomorrow.io enabled: + +| Source | Calls/day | +|--------|-----------| +| Device info (10 min) | ~144 | +| Schedules (30 min) | ~48 | +| Moistures (10 min) | ~144 | +| Events (5 min, v2) | ~288 | +| Sensor (30 min) | ~48 | +| Weather reports | ~48 (30 min × 1 device) | +| Forecast reports | ~6 × 3 days = 18 | +| Total | ~738 | + +Well within the 2,000/day limit for a single device. With multiple controllers +each consuming tokens independently (per-device budget), the total could +multiply. The proactive pause at 100 tokens prevents hard failures. + +## Security + +- Serial numbers and API keys are stored in `pluginPrefs` (Indigo-managed + SQLite). Not exposed in logs (plugin is careful about this). +- `.env` file in repo root is gitignored — used for local test secrets only. +- `test_local_api.py` reads `--serial` from CLI args, not hardcoded. +- `docs/API_NOTES.md` includes a real serial number (`0cb8152f9f78`) in the + "Testing Observations" section — not a security issue (it is the test + controller serial, not a production device), but worth noting. + +## Future Enhancements (from `docs/CLAUDE.md`) + +- Multi-controller support in a single plugin instance (currently requires + separate plugin instances) +- Forecast integration if Netro adds a forecast API +- Historical moisture graphing +- Zone usage statistics +- Webhook support if Netro adds push +- Increase pylint score to 9.0+ (target already set in `pyproject.toml`, + current status unclear for `plugin.py`) +- Increase test coverage to 85%+ +- Add type hints to `plugin.py` diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md index fe81874..ee31bfd 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,201 +1,138 @@ -# Coding Conventions +# CONVENTIONS.md — Code Conventions -**Analysis Date:** 2026-04-11 +## Python Style -## Naming Patterns +- **Style guide**: Google Python Style Guide with Indigo SDK conventions +- **Line length**: 120 characters (set in `pyproject.toml`) +- **String formatting**: f-strings throughout (Python 3.10+) +- **Type hints**: Present in extracted modules (`api_client.py`, `validators.py`, + `device_handlers.py`); absent in `plugin.py` (tech debt) +- **Constants**: `SCREAMING_SNAKE_CASE` with `typing.Final` for immutability +- **Dataclasses**: Used for structured data (`DeviceTokenState` in `api_client.py`, + `ValidationResult` alias in `validators.py`) -**Files:** -- `snake_case.py` for all Python source files: `api_client.py`, `device_handlers.py`, `tomorrow_client.py`, `validators.py`, `utils.py`, `constants.py`, `exceptions.py` -- `test_.py` for test files: `test_api_client.py`, `test_device_handlers.py` -- XML config files use PascalCase: `Devices.xml`, `Actions.xml`, `PluginConfig.xml` +## Naming Conventions -**Functions and Methods:** -- `snake_case` for all module-level functions: `validate_device_config()`, `convert_weather_us_to_metric()`, `get_key_from_dict()` -- `camelCase` for Indigo framework callbacks (required by SDK): `actionControlSprinkler()`, `validateDeviceConfigUi()`, `runConcurrentThread()` -- Private methods prefixed with underscore: `_make_api_call()`, `_get_device_dict()`, `_save_throttle_state()` -- Static helpers prefixed with underscore if module-private: `_slugify()` +Indigo requires camelCase method names for its callbacks; pylint is configured +to allow this: -**Variables:** -- `snake_case` for local variables and instance attributes: `token_remaining`, `prefs_getter`, `device_handlers` -- `camelCase` for Indigo-required attribute names: `pluginPrefs`, `pluginId`, `triggerDict`, `serialNo` -- Timer attributes prefixed with `_next_`: `_next_device_info_update`, `_next_schedules_update` -- Interval attributes prefixed with `_` and suffixed with `_interval`: `_events_interval`, `_weather_update_interval` +``` +# Indigo-required camelCase callbacks +def actionControlSprinkler(self, action, dev): ... +def validateDeviceConfigUi(self, values, type_id, dev_id): ... +def triggerStartProcessing(self, trigger): ... + +# Internal helper methods use snake_case +def _get_device_auth(self, dev): ... +def _update_from_netro(self): ... +def _ensure_zone_devices(self, parent_dev, zones_data): ... +``` -**Constants:** -- `SCREAMING_SNAKE_CASE` throughout `constants.py`: `MAX_ZONE_DURATION_SECONDS`, `DEFAULT_API_TIMEOUT_SECONDS`, `TOKEN_PAUSE_THRESHOLD` -- Annotated with `typing.Final` to indicate immutability: `NETRO_API_VERSION: Final[str] = "1"` -- Each constant has a docstring on the following line explaining purpose +Private helpers are prefixed with `_`. -**Classes:** -- `PascalCase` for all class names: `NetroAPIClient`, `SprinklerHandler`, `WhispererHandler`, `ZoneHandler`, `DeviceTokenState` -- Exception classes named `NetroError` inheriting from `NetroError`: `ThrottleDelayError`, `NetroAPIError` +## Docstrings -**Type Aliases:** -- Defined at module level with docstring: `ValidationResult = Tuple[bool, Dict[str, Any], Dict[str, str]]` +All public methods and classes have Google-style docstrings: -## Code Style +```python +def _get_device_auth(self, dev): + """Get API authentication key and version for a device. -**Formatting:** -- No formatter configured (no `.prettierrc`, `black`, or `autopep8` config) -- Max line length: 120 characters (configured in `pyproject.toml` `[tool.pylint.format]`) -- 4-space indentation (Python standard) + Args: + dev: Indigo device with pluginProps -**Linting:** -- `pylint` with target score 9.0 (`fail-under = 9.0` in `pyproject.toml`) -- Key rules disabled for Indigo plugin patterns: - - `too-many-lines` — large plugin.py by design - - `too-many-public-methods` — Indigo requires many callbacks - - `invalid-name` — Indigo requires camelCase callbacks -- `method-rgx = "[a-z_][a-zA-Z0-9_]{2,}$"` to allow both snake_case and camelCase methods + Returns: + Tuple of (key, api_version) where key is the auth credential + and api_version is "1" or "2" + """ +``` -## Import Organization +Module-level docstrings are required and describe purpose, classes exported, +and any dependency constraints (e.g. "does not import indigo"). + +## Logging -**Order within files:** -1. Python standard library: `import json`, `from datetime import datetime` -2. Third-party: `import requests` -3. Local plugin modules: `from constants import ...`, `from exceptions import ...` +Uses `self.logger` in `Plugin` (injected by `indigo.PluginBase`). All other +modules receive a logger via constructor injection: -**Path management in tests:** -Every test file manually inserts the Server Plugin directory into `sys.path` using `pathlib.Path`: ```python -SERVER_PLUGIN_DIR = ( - Path(__file__).parent.parent - / "Netro Sprinklers.indigoPlugin" - / "Contents" - / "Server Plugin" -) -sys.path.insert(0, str(SERVER_PLUGIN_DIR)) +# Handlers and client accept optional logger +def __init__(self, logger: Optional[logging.Logger] = None) -> None: ``` -**Module `__all__` usage:** -Modules with public APIs declare `__all__`: `validators.py`, `device_handlers.py`, `api_client.py`. This explicitly controls what is exported and documents the public surface. - -**Circular import prevention:** -Each module has explicit rules in its docstring: -- `constants.py` — no dependencies on other plugin modules -- `exceptions.py` — no dependencies on other plugin modules -- `utils.py` — no dependencies on other plugin modules -- `validators.py` — only imports from `constants.py` -- `device_handlers.py` — only imports from `constants.py` and `utils.py` -- `api_client.py` — only imports from `constants.py` and `exceptions.py` +### Log levels -## Error Handling - -**Exception hierarchy:** -All plugin exceptions inherit from `NetroError` (in `exceptions.py`): -``` -NetroError (base) -├── ThrottleDelayError - API rate limit exceeded -├── NetroAPIError - API returned error response -├── NetroConnectionError - Network connection failed -└── NetroTimeoutError - Request timed out -``` +| Level | When to use | +|-------|-------------| +| `debug` | Polling skips, raw API data, state before/after | +| `info` | Successful API calls, device state changes, zone created/renamed | +| `warning` | Token count low, Tomorrow.io misconfigured, non-fatal issues | +| `error` | API errors, action failures, unexpected exceptions | +| `exception` | Use in `except` blocks where traceback is needed — logs full stack | -**Exception constructors:** -All custom exceptions take `message: str` as first arg with a sensible default, plus context-specific optional attributes (`retry_after`, `status_code`, `error_code`, `timeout_seconds`). Always call `super().__init__(message)`. +### Error suppression pattern -**Fail gracefully pattern:** -```python -# From utils.py - silent fallback for API response parsing -try: - return data[key] -except KeyError: - return "unavailable from API" if default is None else default -except (TypeError, AttributeError): - return "unknown error" if default is None else default -``` +Repeated connection errors are suppressed after the first display. The +`_displayed_connection_error` flag in `Plugin.__init__` and equivalent logic +in `NetroAPIClient` prevent log spam during extended outages: -**Connection error suppression:** -Plugin tracks `_displayed_connection_error` to log network errors once then suppress repeats: ```python if not self._displayed_connection_error: - self.logger.error("Timeout - will retry silently") + self.logger.error("Connection failed - will retry silently") self._displayed_connection_error = True +# On next success: self._displayed_connection_error = False ``` -**Throttle management:** -`ThrottleDelayError` is raised and caught at the API client level. Plugin checks `client.is_throttled` before making calls. State is persisted across restarts via `pluginPrefs`. - -## Logging +## Error Handling Philosophy -**Framework:** Indigo's built-in logger via `self.logger` (injected as constructor argument in extracted modules) +"Fail gracefully, log details, continue operation." -**Log methods used:** -- `self.logger.debug(...)` — detailed trace, only shown when debug enabled -- `self.logger.info(...)` — significant state changes, startup, successful operations -- `self.logger.warning(...)` — token budget warnings (<200 remaining), non-fatal anomalies -- `self.logger.error(...)` — failures that degrade functionality, first-occurrence connection errors -- `self.logger.exception(exc)` — unexpected exceptions with full traceback +- API errors do not crash the polling loop — exceptions are caught at the + per-device level +- Actions fire Indigo triggers on failure so users can automate responses +- `traceback.format_exc(10)` is used for debug-level stack traces +- Never expose serial numbers or API keys in log messages -**Logger injection pattern:** -Extracted modules (api_client, device_handlers, tomorrow_client) receive logger as constructor arg with fallback to module logger: -```python -def __init__(self, logger=None): - self.logger = logger or logging.getLogger(__name__) -``` +## Module Isolation Pattern -## Comments +All extracted modules (`api_client.py`, `device_handlers.py`, `validators.py`, +`utils.py`, `constants.py`, `exceptions.py`) are designed with no `indigo` +dependency. This is enforced by convention (not by import guards) and enables +unit testing without the Indigo runtime. -**Module docstrings:** -Every module has a docstring describing purpose, key features, dependency rules, and usage. Format is plain prose, not Google/NumPy style. +`plugin.py` is the only module that imports `indigo`. It acts as the +coordinator that: +1. Reads Indigo device/prefs state +2. Calls extracted modules with plain Python data structures +3. Writes results back to Indigo -**Class docstrings:** -PascalCase classes have docstrings covering purpose, key attributes, and usage examples. +## Configuration Access -**Method docstrings:** -All public methods use Google-style Args/Returns/Raises sections: -```python -def process_device_info(self, api_response, serial, api_version="1"): - """Process device info API response. +Plugin preferences accessed via `self.pluginPrefs` (a dict-like object). +Device configuration accessed via `dev.pluginProps`. Both are persisted by +Indigo automatically. - Args: - api_response: Dict with Netro API response structure - serial: Device serial number for logging - api_version: API version string ("1" or "2") - - Returns: - Tuple of (states_list, is_online, device_data_dict) - """ -``` +Throttle state is persisted to `pluginPrefs` via injected callbacks: -**Section separators:** -Long files use `# =============================================================================` banners to divide logical sections (e.g., "API Configuration", "Default Values", "Event Sets" in `constants.py`). - -**Inline comments:** -Used for non-obvious logic: API quirks, backward compatibility notes, legacy constants. Example: `# Legacy constant — kept for backward compatibility during migration`. - -**pylint inline disables:** -Used sparingly at class/method level with explicit reason: ```python -# pylint: disable=too-many-public-methods,too-many-instance-attributes -class Plugin(indigo.PluginBase): +self.api_client = NetroAPIClient( + prefs_getter=lambda: dict(self.pluginPrefs), + prefs_setter=lambda k, v: self.pluginPrefs.__setitem__(k, v) +) ``` -## Function Design - -**Size:** Large methods are accepted for `plugin.py` given Indigo's callback-driven architecture. Extracted utility modules (api_client, device_handlers, validators, utils) keep functions small and focused. - -**Parameters:** Constructor dependency injection preferred over global state. Callbacks (prefs_getter, prefs_setter) passed as callables rather than direct object references to avoid circular imports. - -**Return Values:** -- Validators return 3-tuple: `(is_valid: bool, sanitized_values: dict, errors: dict)` -- Handlers return lists of state dicts for `updateStatesOnServer()` -- API methods return parsed JSON dict or raise typed exception - -## Module Design - -**Exports:** -Modules declare `__all__` to define their public API surface. +This allows the API client to remain `indigo`-free while still persisting +state across plugin restarts. -**Barrel files:** -Not used. Each module is imported directly by name. +## Version Bumping -**Dataclasses:** -Used for simple value objects: `DeviceTokenState` in `api_client.py`, `ValidationResult` type alias in `validators.py`. +Every PR must bump `PluginVersion` in +`Netro Sprinklers.indigoPlugin/Contents/Info.plist`. -**Constants module:** -All magic numbers and configuration strings live in `constants.py`. Do not define numeric literals in business logic — import the named constant. +Format: `YYYY.R.patch` — e.g. `2026.4.0`. ---- +- Patch bump for fixes and docs +- Minor bump (R) for new features -*Convention analysis: 2026-04-11* +CI fails if the version already exists as a git tag. Do not merge with +failing checks. diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md index 20facdb..2c8a0de 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,129 +1,130 @@ -# External Integrations - -**Analysis Date:** 2026-04-11 - -## APIs & External Services - -**Netro Public API (NPA) — Primary:** -- Netro smart irrigation controller API — device info, schedules, moisture levels, watering control, rain delay, sensor data, weather reporting - - SDK/Client: `NetroAPIClient` class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` - - Auth: Device serial number (v1) passed as `?key=` query param; API key (v2) passed the same way - - Base URLs: - - v1: `https://api.netrohome.com/npa/v1/` (serial auth) - - v2: `https://api.netrohome.com/npa/v2/` (API key auth) - - Rate limit: 2000 tokens/device/day; HTTP 429 or error code 3 triggers throttle - - Throttle handling: state persisted to `pluginPrefs["throttle_state"]` as JSON; auto-restores on plugin restart - - Supported endpoints (both v1 and v2 unless noted): - - `info.json` — device status and zone info - - `schedules.json` — watering schedules - - `moistures.json` — per-zone moisture levels - - `sensor_data.json` — Whisperer soil sensor readings - - `water.json` — start watering (zones, duration, delay) - - `stop_water.json` — stop active watering - - `set_status.json` — online/standby toggle - - `no_water.json` — rain delay (N days) - - `report_weather.json` — push local weather data to improve smart scheduling - - `set_moisture.json` — override zone moisture - - `events.json` — device events (v2 only; online/offline/schedule start/end) - -**Tomorrow.io Weather API — Secondary:** -- Real-time weather and daily forecast fetched to report to Netro's `report_weather` endpoint, improving smart irrigation scheduling - - SDK/Client: `TomorrowClient` class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py` - - Auth: API key passed as `?apikey=` query param; key stored in `pluginPrefs` (configured via `PluginConfig.xml` UI) - - Endpoints used: - - `https://api.tomorrow.io/v4/weather/realtime` — current conditions - - `https://api.tomorrow.io/v4/weather/forecast` — daily forecast (`timesteps=1d`) - - Response format: metric units (Celsius, mm, m/s, hPa) - - Weather codes mapped to Netro conditions (0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind) via `_TOMORROW_TO_NETRO_CONDITION` dict in `tomorrow_client.py` - - Default poll interval: 30 min realtime, 4 hours forecast (configurable via `pluginPrefs`) - - Free tier provides ~6 days of daily forecast data - -## Data Storage - -**Databases:** -- None — no external database - -**Plugin Preferences (Indigo-managed persistence):** -- All persistent state stored in Indigo's `pluginPrefs` dict -- Key entries: - - `throttle_state` — JSON blob with per-device token budgets and throttle expiry - - `showDebugInfo`, `apiTimeout`, polling interval prefs - - Tomorrow.io API key and location -- Access pattern: `self.pluginPrefs.get(key, default)` / `self.pluginPrefs.__setitem__(key, value)` -- Callbacks passed into `NetroAPIClient`: `prefs_getter` and `prefs_setter` lambdas (in `plugin.py` at `NetroAPIClient` instantiation) - -**File Storage:** -- Local filesystem only — plugin icon at `Netro Sprinklers.indigoPlugin/Contents/Resources/icon.png` -- No file-based data storage - -**Caching:** -- In-memory only — device state cached in `self.person`, `self.netro_devices`, `self.zone_handler` during plugin runtime -- Throttle state persisted to `pluginPrefs` across restarts - -## Authentication & Identity - -**Netro v1 Auth:** -- Device serial number — passed as URL query param `?key=` -- No bearer tokens; no user account credentials - -**Netro v2 Auth:** -- API key — passed as URL query param `?key=` -- Configured per Indigo device in device config UI - -**Tomorrow.io Auth:** -- API key — passed as `?apikey=` query param -- Configured at plugin level via `PluginConfig.xml`; stored in `pluginPrefs` -- Key is masked in debug logs: `url.split("key=")[0] + "key=***"` (in `api_client.py` `make_request`) - -## Monitoring & Observability - -**Error Tracking:** -- None (no Sentry, Rollbar, or similar) - -**Logs:** -- Indigo event log via `self.logger` (provided by `indigo.PluginBase`) -- Log levels: `debug`, `info`, `warning`, `error`, `exception` -- Connection errors suppressed after first occurrence to avoid log spam (`_last_error_type` field in `NetroAPIClient`) -- API key values masked before logging - -## CI/CD & Deployment - -**Hosting:** -- Plugin runs on macOS Indigo server (typically `jarvis.local` per workspace CLAUDE.md) -- No cloud hosting - -**CI Pipeline:** -- None detected (no `.github/workflows/`, no CircleCI, no Travis) -- Manual deployment: copy plugin bundle to `/Volumes/Macintosh HD-1/Library/Application Support/Perceptive Automation/Indigo 2025.1/Plugins/` - -**Version Control:** -- GitHub: `https://github.com/simons-plugins/netro-indigo.git` -- Version in `Info.plist`: `PluginVersion = 2026.4.0` - -## Environment Configuration - -**Required configuration (set via Indigo plugin UI, stored in pluginPrefs):** -- Netro device serial number or API key — per Indigo device -- Tomorrow.io API key — plugin-level preference (optional; disables weather reporting if absent) -- Tomorrow.io location string (lat,lon or place name) — plugin-level preference - -**Dev/test environment:** -- `.env` file present but git-ignored; used for local testing (likely contains test API keys) -- `docs/test_local_api.py` — manual local API testing script - -**Secrets location:** -- Runtime: Indigo `pluginPrefs` (encrypted by Indigo/macOS Keychain) -- Development: `.env` file (git-ignored) - -## Webhooks & Callbacks - -**Incoming:** -- None — plugin polls Netro API on a timer; no webhooks received - -**Outgoing:** -- `report_weather` POST to Netro API — plugin pushes local weather data to Netro to influence smart scheduling -- All other interactions are GET/POST polling (not event-driven from the plugin's perspective) +# INTEGRATIONS.md — External API Integrations + +## 1. Netro Public API (NPA) + +### Versions + +Two API versions are supported simultaneously. Per-device version is +auto-detected from device config: if an `apiKey` prop is set, v2 is used; +otherwise v1 serial-number auth is used. Detection happens in +`Plugin._get_device_auth(dev)` in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py`. + +### API v1 + +- **Base URL**: `https://api.netrohome.com/npa/v1/` +- **Auth**: Serial number passed as `?key={serial}` (GET) or `{"key": serial}` (POST body) +- **Official docs**: https://www.netrohome.com/en/shop/articles/10 +- **Timestamp format**: Millisecond Unix epoch — **but often returned as strings** + (see `docs/API_NOTES.md` quirk #1) + +### API v2 + +- **Base URL**: `https://api.netrohome.com/npa/v2/` +- **Auth**: Per-device 32-char API key, same parameter name `key` +- **Obtain**: netrohome.com → Account → API Key → Generate (per device) +- **Official docs**: https://netrohome.com/en/shop/user_guides/7 +- **Timestamp format**: ISO 8601 strings +- **New in v2**: expanded device statuses, `events.json` endpoint, metric units + for weather, `token_limit` field in meta + +### Endpoints + +All defined as `Final[str]` constants in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py`: + +| Constant | v1 URL | v2 URL | Method | +|----------|--------|--------|--------| +| `DEVICE_INFO_ENDPOINT` / `_V2_` | `info.json` | `info.json` | GET | +| `DEVICE_SCHEDULES_ENDPOINT` / `_V2_` | `schedules.json` | `schedules.json` | GET | +| `DEVICE_MOISTURES_ENDPOINT` / `_V2_` | `moistures.json` | `moistures.json` | GET | +| `DEVICE_SENSOR_DATA_ENDPOINT` / `_V2_` | `sensor_data.json` | `sensor_data.json` | GET | +| `DEVICE_WATER_ENDPOINT` / `_V2_` | `water.json` | `water.json` | POST | +| `DEVICE_STOP_WATER_ENDPOINT` / `_V2_` | `stop_water.json` | `stop_water.json` | POST | +| `DEVICE_SET_STATUS_ENDPOINT` / `_V2_` | `set_status.json` | `set_status.json` | POST | +| `DEVICE_NO_WATER_ENDPOINT` / `_V2_` | `no_water.json` | `no_water.json` | POST | +| `DEVICE_REPORT_WEATHER_ENDPOINT` / `_V2_` | `report_weather.json` | `report_weather.json` | POST | +| `DEVICE_SET_MOISTURE_ENDPOINT` / `_V2_` | `set_moisture.json` | `set_moisture.json` | POST | +| `DEVICE_EVENTS_V2_ENDPOINT` | — | `events.json` | GET (v2 only) | + +### Rate Limiting + +- **Daily quota**: 2,000 calls/day per device (shared between v1 and v2 keys for the same device) +- **Reset**: Midnight UTC +- **HTTP 429**: Rate limit exceeded — plugin enforces a 61-minute lockout + (`THROTTLE_LIMIT_MINUTES = 61` in `constants.py`) +- **Proactive pause**: When `token_remaining < TOKEN_PAUSE_THRESHOLD` (100), + polling is suspended before hitting the limit +- **Warning threshold**: `TOKEN_WARNING_THRESHOLD = 200` — logs a warning + +### Polling budgets at default intervals + +| Interval | Calls/day | Safety | +|----------|-----------|--------| +| 3 min (minimum) | ~480 | Safe | +| 5 min (events default) | ~288 | Very safe | +| 10 min (device info/moisture default) | ~144 | Comfortable | +| 30 min (schedules/sensor default) | ~48 | Very conservative | + +### Response envelope + +```json +{ + "status": "OK", + "data": { ... }, + "meta": { + "token_remaining": 1850, + "token_reset": "2026-04-08T00:00:00" // v2 ISO; v1 is Unix timestamp + } +} +``` + +Error codes: `1`=invalid key, `3`=rate limit, `4`=invalid device, +`5`=server error, `6`=parameter error. + +### Known API Quirks (see `docs/API_NOTES.md` for full details) + +1. Timestamps sometimes returned as strings, not numbers — plugin normalises with + `float(raw) if isinstance(raw, str) else raw` +2. Device info response uses singular `device` key, not `devices` array +3. `STANDBY` status means offline OR user-set standby (ambiguous) +4. `zones[].smart` can be boolean (v1) or string enum `SMART`/`ASSISTANT`/`TIMER` (v2) +5. Moisture data updates once per day maximum +6. Whisperer sensor reports every 4-6 hours (no force-refresh API) +7. Weather units differ: v1 uses US units (°F, inches, mph, inHg); v2 uses metric --- -*Integration audit: 2026-04-11* +## 2. Tomorrow.io Weather API (optional) + +- **Purpose**: Fetch real-time and forecast weather to report to Netro for + smarter scheduling decisions +- **Client**: `TomorrowClient` in + `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py` +- **Auth**: API key configured in plugin prefs (`tomorrowApiKey`) +- **Location**: Lat/lon string configured in plugin prefs (`tomorrowLocation`) +- **Endpoints used**: + - Realtime weather (current conditions) + - Daily forecast (multi-day ahead) +- **Units**: Tomorrow.io returns metric; client passes metric through. `plugin.py` + converts to US units when forwarding to Netro v1 via + `convert_weather_metric_to_us()` in `utils.py` +- **Condition mapping**: Tomorrow.io weather codes (1000–8000) mapped to Netro + condition codes (0=Clear, 1=Cloudy, 2=Rain, 3=Snow, 4=Wind) via + `_TOMORROW_TO_NETRO_CONDITION` dict in `tomorrow_client.py` +- **Optional**: Feature disabled if `tomorrowEnabled` pref is False or API key / + location not set; `_tomorrow_client` will be `None` +- **Polling intervals**: Realtime every 30 min (`DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES`), + forecast every 4 hours (`DEFAULT_FORECAST_INTERVAL_MINUTES`) + +### v1 vs v2 unit handling for weather + +`utils.py` provides bidirectional converters: + +| Function | Direction | +|----------|-----------| +| `convert_weather_metric_to_us()` | °C→°F, mm→in, m/s→mph, hPa→inHg | +| `convert_weather_us_to_metric()` | reverse | + +Note: v1 Netro API does not accept `t_dew` field — plugin strips it before +sending to v1 devices. diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md index bc575ad..a17d622 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,87 +1,100 @@ -# Technology Stack - -**Analysis Date:** 2026-04-11 - -## Languages - -**Primary:** -- Python 3.10+ - All plugin logic; `pyproject.toml` sets `requires-python = ">=3.10"` -- Python 3.11 - Development host runtime (3.11.6 detected via `python3 --version`) - -**Secondary:** -- XML - Indigo plugin configuration files (`Devices.xml`, `Actions.xml`, `Events.xml`, `MenuItems.xml`, `PluginConfig.xml`) -- Plist - Plugin metadata (`Info.plist`) - -## Runtime - -**Environment:** -- macOS (Indigo home automation platform runs on macOS only) -- Plugin runs inside Indigo's Python 3.10+ interpreter at `/Library/Frameworks/Python.framework/Versions/Current/bin/python3` - -**Package Manager:** -- pip (no lockfile — `requirements.txt` pins exact versions) -- Lockfile: Not present (only `requirements.txt` with pinned versions) -- Indigo auto-installs packages from `requirements.txt` on plugin load - -## Frameworks - -**Core:** -- `indigo.PluginBase` - Indigo home automation SDK base class; plugin class `Plugin` in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` inherits from it -- No web framework (Indigo provides the runtime and UI via XML config files) - -**Testing:** -- pytest 7.4.3 - Test runner; config in `pytest.ini` and `pyproject.toml` -- pytest-cov 4.1.0 - Coverage reporting (85% minimum enforced via `[coverage:report] fail_under = 85`) - -**Build/Dev:** -- pylint - Static analysis; configured in `pyproject.toml` with minimum score 9.0 -- No build system (plugin is deployed by copying the `.indigoPlugin` bundle) - -## Key Dependencies - -**Critical:** -- `requests==2.32.5` - HTTP client for all Netro API and Tomorrow.io API calls; declared in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/requirements.txt` - -**Standard Library (key usage):** -- `json` - API request/response serialization and throttle state persistence -- `datetime`, `timedelta`, `timezone` - Rate limit token reset tracking -- `dataclasses` - `DeviceTokenState` dataclass in `api_client.py` -- `typing` - Type hints throughout; `Final` for immutable constants -- `logging` - Logger passed as callback into `NetroAPIClient` and `TomorrowClient` -- `unittest.mock` - Mock objects in test suite (`conftest.py`, all test files) -- `pathlib` - Test path manipulation in `conftest.py` - -**Infrastructure:** -- `indigo` - Indigo SDK module; imported directly in `plugin.py`; provides `indigo.PluginBase`, device/trigger APIs, `pluginPrefs` - -## Configuration - -**Environment:** -- `.env` file present (excluded from git per `.gitignore`) -- Plugin user config stored in Indigo's `pluginPrefs` dict (persists across restarts) -- Key plugin prefs: `showDebugInfo`, `apiTimeout`, `eventsInterval`, `deviceInfoInterval`, `moisturesInterval`, `schedulesInterval`, `sensorInterval`, `weatherUpdateInterval`, `forecastInterval`, `maxZoneRunTime`, `throttle_state` (JSON blob) -- Tomorrow.io API key and location configured via `PluginConfig.xml` UI, stored in `pluginPrefs` -- Netro device serial numbers / API keys configured per-device, not globally - -**Build:** -- `pyproject.toml` - pylint and pytest configuration -- `pytest.ini` - Pytest options including coverage targets -- No Makefile or build script; deployment is manual bundle copy - -## Platform Requirements - -**Development:** -- Python 3.10+ (3.11 on development host) -- pytest and pytest-cov installed in dev environment -- pylint installed in dev environment -- Indigo not required to run tests (mocked via `unittest.mock`) - -**Production:** -- Indigo 2023.2+ (requires Python 3.10+) -- macOS running Indigo server -- Active internet connection for Netro API and Tomorrow.io API calls -- Plugin bundle: `Netro Sprinklers.indigoPlugin` copied to Indigo Plugins folder - ---- - -*Stack analysis: 2026-04-11* +# STACK.md — Netro Sprinklers Plugin + +## Runtime Environment + +- **Python**: 3.10+ (Indigo 2023+ requirement). Dev machine runs Python 3.11.6. + - Interpreter: `/Library/Frameworks/Python.framework/Versions/Current/bin/python3` + - All code uses f-strings, `match`-compatible syntax, `typing.Final`, `dataclasses` +- **Platform**: macOS (Indigo runs as a macOS daemon) +- **Indigo SDK**: ServerApiVersion `3.6` (declared in + `Netro Sprinklers.indigoPlugin/Contents/Info.plist`) + - Plugin inherits from `indigo.PluginBase` + - Indigo object model accessed via the `indigo` module (injected at runtime) + - `indigo` is NOT importable outside Indigo runtime — tests must mock it + +## Plugin Identity + +- **CFBundleIdentifier**: `com.simons-plugins.netro` +- **PluginVersion**: `2026.4.0` (format: `YYYY.R.patch`) +- **CFBundleDisplayName**: Netro Smart Sprinklers + +## Runtime Dependencies + +Declared in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/requirements.txt`: + +``` +requests==2.32.5 +``` + +Indigo auto-installs packages from `requirements.txt` when the plugin loads. +No `Contents/Packages/` bundle directory is used — `requests` is installed +to the system Python by Indigo's package manager. + +## Development Dependencies + +Declared in `pyproject.toml` (not `requirements.txt` — kept separate): + +``` +pytest>=7.4 +pytest-cov>=4.1 +pytest-mock>=3.12 +``` + +Install manually: +```bash +pip3 install pytest pytest-cov pytest-mock requests +``` + +## Linting + +Configured via `pyproject.toml`: + +- **Tool**: `pylint` +- **Target score**: 9.0 (`fail-under = 9.0`) +- **Max line length**: 120 +- **py-version**: 3.10 +- **Disabled rules** (Indigo-specific exceptions): + - `too-many-lines` — single-file plugin pattern + - `too-many-public-methods` — required by Indigo callback API + - `invalid-name` — Indigo requires camelCase callbacks +- **Method name regex**: `[a-z_][a-zA-Z0-9_]{2,}$` (allows Indigo camelCase) +- **Run**: `python3 -m pylint plugin.py --max-line-length=120` +- **Ignore paths**: `tests/`, `__pycache__/` + +## Test Runner + +- **Tool**: `pytest` +- **Config**: `pytest.ini` (root) + `pyproject.toml` `[tool.pytest.ini_options]` +- **Test paths**: `tests/` +- **File pattern**: `test_*.py` +- **Coverage**: `pytest-cov` generates HTML to `htmlcov/` +- **Total tests**: 427 collected (as of April 2026) +- **Run**: + ```bash + pytest tests/ + pytest tests/ --cov --cov-report=html + ``` + +## Module Layout (Server Plugin) + +All Python lives in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/`: + +| File | Purpose | +|------|---------| +| `plugin.py` | Main `Plugin(indigo.PluginBase)` class, ~1900 lines | +| `api_client.py` | `NetroAPIClient` — HTTP + throttle management | +| `device_handlers.py` | `SprinklerHandler`, `WhispererHandler`, `ZoneHandler` | +| `validators.py` | Pure validation functions for ConfigUi callbacks | +| `constants.py` | All URL constants, defaults, thresholds | +| `exceptions.py` | `NetroError` hierarchy | +| `utils.py` | Unit conversion helpers | +| `tomorrow_client.py` | Tomorrow.io weather API client | + +## CI / Release + +- **Version check**: CI fails if `Info.plist` `PluginVersion` already exists as a git tag +- **Release**: `create-release` workflow auto-creates a GitHub Release with a `.zip` + bundle on merge to `main` +- **Repo**: https://github.com/simons-plugins/netro-indigo diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md index a755630..3b15adb 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,191 +1,128 @@ -# Codebase Structure +# STRUCTURE.md — Directory Layout -**Analysis Date:** 2026-04-11 +## Top-Level -## Directory Layout +``` +netro/ +├── .planning/codebase/ ← this documentation +├── docs/ ← developer documentation +│ ├── CLAUDE.md ← detailed architecture guide (primary reference) +│ ├── NETRO_API.md ← NPA v1 endpoint reference +│ ├── NETRO_API_V2.md ← NPA v2 endpoint reference +│ ├── API_NOTES.md ← discovered quirks and edge cases +│ ├── TESTING.md ← test suite guide +│ ├── LOCAL_TESTING.md ← standalone API test script guide +│ └── TROUBLESHOOTING.md ← user-facing troubleshooting +├── Netro Sprinklers.indigoPlugin/ ← plugin bundle (macOS double-click to install) +├── tests/ ← pytest suite +├── htmlcov/ ← coverage HTML output (gitignored) +├── CLAUDE.md ← root project instructions +├── README.md +├── pyproject.toml ← pylint + pytest config +├── pytest.ini ← pytest path config +├── test_local_api.py ← standalone CLI API tester (not in tests/) +├── .env ← local secrets (gitignored) +└── .coverage ← coverage data file +``` + +## Plugin Bundle + +``` +Netro Sprinklers.indigoPlugin/ +└── Contents/ + ├── Info.plist ← plugin metadata, version, bundle ID + └── Server Plugin/ ← all Python source + ├── plugin.py ← Plugin(indigo.PluginBase), ~1900 lines + ├── api_client.py ← NetroAPIClient + ├── device_handlers.py ← SprinklerHandler, WhispererHandler, ZoneHandler + ├── validators.py ← pure validation functions + ├── constants.py ← URL constants, defaults, thresholds + ├── exceptions.py ← NetroError hierarchy + ├── utils.py ← unit conversion helpers + ├── tomorrow_client.py ← Tomorrow.io weather client + ├── requirements.txt ← runtime deps (requests==2.32.5) + ├── Devices.xml ← Indigo device type definitions + ├── Actions.xml ← custom action definitions + ├── Events.xml ← trigger/event definitions + ├── PluginConfig.xml ← plugin preferences UI + └── MenuItems.xml ← Indigo plugin menu items +``` + +## XML Configuration Files + +### `Devices.xml` + +Defines three device types: + +| `deviceTypeId` | Display name | Indigo base type | Description | +|----------------|-------------|-----------------|-------------| +| `sprinkler` | Netro Controller | `indigo.kDeviceType.Sprinkler` | Sprite/Pixie/Spark controller | +| `Whisperer` | Netro Whisperer | plugin custom | Soil moisture sensor | +| `zone` | Netro Zone | plugin custom | Auto-created zone sub-device | + +### `Actions.xml` + +Defines custom plugin actions (beyond standard sprinkler start/stop): + +| Action ID | Python callback | Purpose | +|-----------|----------------|---------| +| `startZoneWithDelay` | `startZoneWithDelay()` | Zone start with optional delay | +| `reportWeather` | `reportWeather()` | Submit local weather to Netro | +| `setNoWater` | `setNoWater()` | Rain delay (N days) | +| `setStandbyMode` | `setStandbyMode()` | Pause all automatic scheduling | + +### `Events.xml` + +Trigger event types for user automation: + +- `startZoneFailed` — zone start API call failed +- `stopFailed` — stop watering failed +- `setStandbyFailed` — standby mode change failed +- `setMoistureFailed` — moisture override failed +- `rateLimitExceeded` — HTTP 429 received +- `personCall`, `personInfoCall`, `getScheduleCall`, `forecastCall` — comm errors +- V2 device events: `offline`, `online`, `schedule_started`, `schedule_ended` + +### `PluginConfig.xml` + +Plugin-level preferences: + +- `apiTimeout` — request timeout seconds (default 5, range 1-60) +- `maxZoneRunTime` — max zone duration seconds (default 10800/3hr) +- Per-endpoint polling intervals (all configurable, each with a minimum) +- `tomorrowEnabled` / `tomorrowApiKey` / `tomorrowLocation` — Tomorrow.io +- `showDebugInfo` — debug logging toggle + +## Tests Directory ``` -netro/ # Repo root -├── Netro Sprinklers.indigoPlugin/ # Plugin bundle (loaded by Indigo server) -│ └── Contents/ -│ ├── Info.plist # Plugin metadata, version, bundle ID -│ ├── Resources/ -│ │ └── icon.png # Plugin icon -│ └── Server Plugin/ # All Python source (Indigo loads this) -│ ├── plugin.py # Main plugin class (entry point) -│ ├── api_client.py # Netro API HTTP client -│ ├── tomorrow_client.py # Tomorrow.io weather API client -│ ├── device_handlers.py # API response → Indigo state transformers -│ ├── validators.py # Pure config validation functions -│ ├── constants.py # API URLs, defaults, event sets -│ ├── exceptions.py # Custom exception hierarchy -│ ├── utils.py # Unit conversions, dict helpers -│ ├── Devices.xml # Indigo device type definitions + states -│ ├── Actions.xml # Indigo action definitions -│ ├── Events.xml # Indigo trigger/event definitions -│ ├── MenuItems.xml # Plugin menu item definitions -│ ├── PluginConfig.xml # Plugin-level preferences UI -│ └── requirements.txt # Python dependencies -├── tests/ # Test suite (runs outside Indigo) -│ ├── conftest.py # Pytest fixtures (indigo mock, logger) -│ ├── test_api_client.py # NetroAPIClient unit tests -│ ├── test_base_modules.py # constants, exceptions, utils tests -│ ├── test_device_handlers.py # SprinklerHandler, WhispererHandler tests -│ ├── test_validators.py # validators.py unit tests -│ ├── test_tomorrow_client.py # TomorrowClient unit tests -│ ├── test_weather_integration.py # Weather integration tests -│ └── test_zone_handler.py # ZoneHandler unit tests -├── docs/ # Developer documentation -│ ├── CLAUDE.md # Plugin-specific dev guide (primary reference) -│ ├── NETRO_API.md # Netro API v1 endpoint documentation -│ ├── NETRO_API_V2.md # Netro API v2 endpoint documentation -│ ├── API_NOTES.md # Known API quirks and limitations -│ ├── TESTING.md # Testing guide -│ ├── LOCAL_TESTING.md # Local/manual testing instructions -│ ├── TROUBLESHOOTING.md # Common issues and fixes -│ └── plans/ # Design documents -│ ├── 2026-04-07-zone-devices-design.md -│ └── 2026-04-07-zone-devices-plan.md -├── .planning/ # GSD planning system -│ ├── PROJECT.md # Project goals and scope -│ ├── STATE.md # Current project state -│ ├── MILESTONES.md # Milestone definitions -│ ├── codebase/ # Codebase analysis docs (this dir) -│ ├── milestones/ # Milestone files -│ ├── phases/ # Completed phase plans + summaries -│ └── research/ # Research documents -├── .github/workflows/ -│ ├── version-check.yml # CI: verifies PluginVersion bumped in PRs -│ └── create-release.yml # CI: creates GitHub release on version tag -├── pyproject.toml # Python project config (pytest, coverage) -├── pytest.ini # Pytest configuration -├── htmlcov/ # HTML coverage report (generated, not committed) -└── README.md # Public-facing plugin README +tests/ +├── conftest.py ← shared fixtures (mock_logger, sample_api_response, +│ mock_prefs, sample_api_v2_response, sample_v2_*) +├── test_api_client.py ← NetroAPIClient unit tests +├── test_base_modules.py ← constants, exceptions, utils tests +├── test_device_handlers.py ← SprinklerHandler, WhispererHandler unit tests +├── test_tomorrow_client.py ← TomorrowClient unit tests +├── test_validators.py ← validate_* function tests +├── test_weather_integration.py ← weather unit conversion + integration tests +└── test_zone_handler.py ← ZoneHandler unit tests ``` -## Directory Purposes - -**`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/`:** -- Purpose: All Python source that Indigo loads when the plugin is enabled -- Contains: `plugin.py` (main), plus the extracted module files -- Key files: `plugin.py` (coordinator), `api_client.py` (Netro HTTP), `device_handlers.py` (state transform) -- Note: The `Server Plugin/` name is an Indigo convention — do not rename - -**`tests/`:** -- Purpose: Pytest test suite that runs outside Indigo (no Indigo server required) -- Contains: One test file per source module; `conftest.py` provides `indigo` mock -- Key files: `conftest.py` (mock setup), `test_api_client.py` (most critical coverage) - -**`docs/`:** -- Purpose: Developer-facing documentation and API reference -- Contains: Guides for testing, API quirks, troubleshooting -- Key files: `CLAUDE.md` (primary dev guide), `NETRO_API.md` / `NETRO_API_V2.md` (API reference) - -**`.planning/`:** -- Purpose: GSD planning system — milestones, phases, research -- Generated: No — manually maintained by GSD commands -- Committed: Yes - -**`htmlcov/`:** -- Purpose: Generated HTML coverage report from `pytest --cov` -- Generated: Yes (by `pytest --cov --cov-report=html`) -- Committed: Yes (`.gitignore` inside htmlcov excludes nothing — entire dir tracked) - -## Key File Locations - -**Entry Points:** -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py`: Main plugin class, Indigo lifecycle - -**Configuration:** -- `Netro Sprinklers.indigoPlugin/Contents/Info.plist`: Plugin version (`PluginVersion`), bundle ID -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml`: Device types and state definitions -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml`: Plugin preferences UI -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Actions.xml`: User-invokable actions -- `pyproject.toml`: Test dependencies and coverage settings -- `pytest.ini`: Test paths and options - -**Core Logic:** -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py`: All Netro API HTTP calls -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py`: State transformation -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py`: Config validation -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py`: All magic numbers and URLs -- `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/tomorrow_client.py`: Weather API client - -**Testing:** -- `tests/conftest.py`: Shared fixtures including `indigo` module mock -- `tests/test_api_client.py`: Most comprehensive test file - -## Naming Conventions - -**Files:** -- Python modules: `snake_case.py` (e.g., `api_client.py`, `device_handlers.py`) -- Test files: `test_{module_name}.py` (e.g., `test_api_client.py`) -- XML config files: `PascalCase.xml` (Indigo convention — `Devices.xml`, `Actions.xml`) -- Docs: `UPPER_SNAKE.md` for reference docs, `lower-kebab-date-title.md` for plans - -**Directories:** -- Indigo bundle: `Plugin Name.indigoPlugin` (spaces allowed, `.indigoPlugin` suffix required) -- Plugin source: `Server Plugin/` (Indigo convention, fixed name) - -**Python identifiers:** -- Constants: `SCREAMING_SNAKE_CASE` with `typing.Final` -- Classes: `PascalCase` (e.g., `NetroAPIClient`, `SprinklerHandler`) -- Methods/functions: `snake_case`; private helpers prefixed `_` -- Indigo callback methods: `camelCase` (Indigo SDK convention, e.g., `runConcurrentThread`, `validateDeviceConfigUi`) - -## Where to Add New Code - -**New API endpoint:** -1. Add URL constant to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` -2. Add convenience method to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` -3. Add processing to the appropriate handler in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` -4. Wire into polling timer logic in `plugin.py` `_update_sprinkler_device()` or `_update_whisperer_device()` -5. Add tests in `tests/test_api_client.py` and `tests/test_device_handlers.py` - -**New device type:** -1. Add device definition to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml` -2. Create new handler class in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/device_handlers.py` -3. Add update method to `plugin.py` (`_update_newtype_device()`) -4. Add branch in `_update_from_netro()` for `dev.deviceTypeId == "newtype"` -5. Add test file `tests/test_newtype_handler.py` - -**New validation:** -- Add function to `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py` -- Return `ValidationResult` tuple: `(bool, Dict, Dict)` -- Import and call from appropriate `validate*ConfigUi` method in `plugin.py` -- Add tests in `tests/test_validators.py` - -**New Indigo action:** -1. Define action in `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Actions.xml` -2. Implement callback in `plugin.py` following naming convention from Indigo SDK -3. Validate input via `validators.validate_action_config()` pattern - -**Utilities:** -- Shared helpers with no plugin dependencies: `Netro Sprinklers.indigoPlugin/Contents/Server Plugin/utils.py` - -## Special Directories - -**`Netro Sprinklers.indigoPlugin/`:** -- Purpose: Indigo plugin bundle (directory with `.indigoPlugin` extension loaded as app bundle) -- Generated: No -- Committed: Yes — all source is inside the bundle - -**`htmlcov/`:** -- Purpose: HTML coverage report from pytest -- Generated: Yes, by `pytest --cov --cov-report=html` from repo root -- Committed: Yes (tracked in git) - -**`.planning/`:** -- Purpose: GSD project management system -- Generated: Partially (by GSD commands) -- Committed: Yes - -**`tests/`:** -- Purpose: Test suite lives outside the plugin bundle (can't be bundled with plugin) -- Generated: No -- Committed: Yes -- Note: `sys.path` manipulation in `conftest.py` makes plugin source importable for tests - ---- - -*Structure analysis: 2026-04-11* +Note: `tests/fixtures/` directory referenced in older `docs/TESTING.md` is no +longer present — test data is defined inline in fixtures and test files. + +## Key File Paths (absolute) + +- Main plugin: `/Users/simon/vsCodeProjects/Indigo/netro/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py` +- API client: `/Users/simon/vsCodeProjects/Indigo/netro/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py` +- Constants: `/Users/simon/vsCodeProjects/Indigo/netro/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/constants.py` +- Tests: `/Users/simon/vsCodeProjects/Indigo/netro/tests/` +- Info.plist: `/Users/simon/vsCodeProjects/Indigo/netro/Netro Sprinklers.indigoPlugin/Contents/Info.plist` +- pyproject.toml: `/Users/simon/vsCodeProjects/Indigo/netro/pyproject.toml` + +## Installed Location (Indigo server) + +``` +/Volumes/Macintosh HD-1/Library/Application Support/Perceptive Automation/ + Indigo 2025.1/Plugins/Netro Sprinklers.indigoPlugin/ +``` diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md index e21de2d..71ef2e9 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,301 +1,153 @@ -# Testing Patterns +# TESTING.md — Test Suite + +## Summary + +- **Total tests**: 427 collected (April 2026) +- **Runner**: pytest 7.4.3 on Python 3.11.6 +- **Config**: `pytest.ini` + `pyproject.toml [tool.pytest.ini_options]` +- **Coverage** (from `--co` run): ~10% overall; `constants.py` 100%, + `plugin.py` 0% (requires Indigo runtime) +- **Run**: + ```bash + cd /Users/simon/vsCodeProjects/Indigo/netro + pytest tests/ + pytest tests/ --cov --cov-report=html + ``` + +## Test Files + +All tests live in `/Users/simon/vsCodeProjects/Indigo/netro/tests/`. + +| File | Subject | Tests | +|------|---------|-------| +| `test_api_client.py` | `NetroAPIClient` | ~100+ | +| `test_device_handlers.py` | `SprinklerHandler`, `WhispererHandler` | ~50+ | +| `test_zone_handler.py` | `ZoneHandler` | ~20+ | +| `test_validators.py` | `validate_*` functions | ~60+ | +| `test_base_modules.py` | `constants`, `exceptions`, `utils` | ~30+ | +| `test_tomorrow_client.py` | `TomorrowClient` | ~40+ | +| `test_weather_integration.py` | unit conversion + weather integration | ~30+ | + +## conftest.py Fixtures + +`/Users/simon/vsCodeProjects/Indigo/netro/tests/conftest.py` provides shared +fixtures available to all test files: + +| Fixture | Returns | Used for | +|---------|---------|---------| +| `mock_logger` | `Mock()` with all log methods | Passed to handlers and clients | +| `sample_api_response` | Base v1 success dict `{status, data, meta}` | API response testing | +| `mock_prefs` | `(getter_fn, setter_fn, prefs_dict)` | Testing throttle persistence | +| `sample_api_v2_response` | Base v2 success dict with ISO timestamps | v2 API testing | +| `sample_v2_device_info` | Full v2 device info response | Handler tests | +| `sample_v2_schedules` | v2 schedules response | Schedule processing tests | +| `sample_v2_sensor_data` | v2 sensor data response | Whisperer handler tests | + +## Mocking Strategy + +### No Indigo runtime — stub at import time + +Every test file adds the plugin's `Server Plugin` directory to `sys.path` +before any imports: -**Analysis Date:** 2026-04-11 - -## Test Framework - -**Runner:** -- pytest 8.0+ (configured in `pytest.ini` and `pyproject.toml`) -- Config: `/Users/simon/vsCodeProjects/Indigo/netro/pytest.ini` - -**Assertion Library:** -- pytest's built-in assertions (no separate assertion library) - -**Coverage:** -- `pytest-cov` with branch coverage enabled -- HTML report written to `htmlcov/` -- Minimum required: 85% (`fail_under = 85` in `pytest.ini`) -- Current actual coverage: ~10% for most modules (coverage target is aspirational; plugin.py at 0% because it requires Indigo runtime) - -**Run Commands:** -```bash -# Run all tests with coverage (default via pytest.ini addopts) -cd /Users/simon/vsCodeProjects/Indigo/netro && python3 -m pytest - -# Run without coverage (faster) -python3 -m pytest --no-cov - -# Run specific file -python3 -m pytest tests/test_api_client.py - -# Run by marker -python3 -m pytest -m api -python3 -m pytest -m handlers -python3 -m pytest -m weather - -# Run single test by name -python3 -m pytest tests/test_api_client.py::TestThrottleState::test_initial_state_not_throttled - -# Run with pattern match -python3 -m pytest -k "throttle" - -# View HTML coverage report -open htmlcov/index.html -``` - -## Test File Organization - -**Location:** Separate `tests/` directory at repo root (not co-located with source) - -**Naming:** -- Files: `test_.py` matching the source module name -- Classes: `Test` (e.g., `TestThrottleState`, `TestSprinklerHandlerDeviceInfo`) -- Functions: `test_` with descriptive name (e.g., `test_throttle_until_past_clears_automatically`) - -**Structure:** -``` -netro/ -├── tests/ -│ ├── conftest.py # Shared fixtures (auto-discovered by pytest) -│ ├── test_api_client.py # NetroAPIClient tests -│ ├── test_base_modules.py # constants, exceptions, utils tests -│ ├── test_device_handlers.py # SprinklerHandler, WhispererHandler tests -│ ├── test_validators.py # validate_* function tests -│ ├── test_tomorrow_client.py # TomorrowClient tests -│ ├── test_weather_integration.py # Weather unit conversion + prefs validation -│ └── test_zone_handler.py # ZoneHandler tests -└── pytest.ini # pytest configuration -``` - -**Total tests:** 427 collected (as of 2026-04-11) - -## Test Structure - -**Suite Organization — class-based grouping:** ```python -@pytest.mark.api -class TestThrottleState: - """Tests for throttle state management.""" - - def test_initial_state_not_throttled(self, client): - """New client should have is_throttled=False.""" - assert client.is_throttled is False - - def test_throttle_until_future_is_throttled(self, client): - """When _throttle_until is in future, is_throttled=True.""" - client._throttle_until = datetime.now() + timedelta(minutes=30) - assert client.is_throttled is True +SERVER_PLUGIN_DIR = ( + Path(__file__).parent.parent + / "Netro Sprinklers.indigoPlugin" + / "Contents" + / "Server Plugin" +) +sys.path.insert(0, str(SERVER_PLUGIN_DIR)) ``` -**Key patterns:** -- Every test method has a one-line docstring describing the expected behavior (the "should" statement) -- Arrange/Act/Assert structure used but not labeled with comments -- Fixtures injected via pytest parameters, not instantiated in test bodies -- Test classes organized by feature/behavior boundary, not by method-under-test - -**Markers defined in `pytest.ini`:** -- `api` — Tests for API client functionality -- `handlers` — Tests for device handler functionality -- `validation` — Tests for configuration and action validation -- `actions` — Tests for action callback methods -- `weather` — Tests for Tomorrow.io weather integration -- `integration` — Integration tests requiring external services -- `slow` — Tests that take more than 1 second - -## Mocking - -**Framework:** `unittest.mock` (stdlib) — `Mock`, `patch`, `MagicMock` +`plugin.py` is never imported in tests (it requires the Indigo runtime). +Only the extracted pure-Python modules are imported. -**Standard mock pattern for HTTP requests:** -```python -def test_make_request_success(self, client): - """Successful GET returns parsed JSON.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"status": "OK", "data": {}} - - with patch("requests.get", return_value=mock_response): - result = client.get_device_info(serial="ABC123") +### HTTP requests - assert result["status"] == "OK" -``` +All HTTP calls are patched with `unittest.mock.patch`: -**Standard mock for logger (used in every test file):** ```python -@pytest.fixture -def mock_logger(): - """Create a mock logger for testing.""" - logger = Mock() - logger.debug = Mock() - logger.info = Mock() - logger.warning = Mock() - logger.error = Mock() - logger.exception = Mock() - return logger +@patch("requests.get") +def test_make_request_get_success(self, mock_get, client): + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {...} ``` -**Dependency injection via constructor (preferred over patching):** -The extracted modules (api_client, device_handlers, tomorrow_client) accept `logger`, `prefs_getter`, and `prefs_setter` as constructor args. Tests pass mocks directly rather than patching module-level imports: -```python -@pytest.fixture -def client(mock_logger, mock_prefs): - """Create a NetroAPIClient instance with mocked dependencies.""" - prefs_getter, prefs_setter, _ = mock_prefs - return NetroAPIClient( - logger=mock_logger, - prefs_getter=prefs_getter, - prefs_setter=prefs_setter - ) -``` +No actual network calls are made in the test suite. -**What to mock:** -- `requests.get` / `requests.post` for all HTTP calls -- `logger` — always inject mock logger in unit tests -- `prefs_getter`/`prefs_setter` callables for API client state persistence -- Indigo module — `indigo` is not installed in test environment; mock it if needed +### Logger -**What NOT to mock:** -- `constants.py` values — use real constants -- `exceptions.py` classes — use real exceptions -- Pure utility functions in `utils.py` — test them directly -- `validators.py` functions — test them directly (no side effects) +`mock_logger` fixture provides a `Mock()` with all standard log methods. +Passed to constructors via the `logger=` parameter. -## Fixtures and Factories +### Prefs (throttle persistence) -**Shared fixtures in `conftest.py`** (`/Users/simon/vsCodeProjects/Indigo/netro/tests/conftest.py`): +`mock_prefs` fixture provides getter/setter callables backed by a plain dict, +allowing `NetroAPIClient` to be tested without `pluginPrefs`. -```python -@pytest.fixture -def mock_logger(): - """Provides Mock with debug/info/warning/error/exception methods.""" +## Test Classes and Markers -@pytest.fixture -def sample_api_response(): - """Base successful API v1 response: {status: OK, data: {}, meta: {...}}""" +Tests are organised into classes within each file. `test_api_client.py` uses +`@pytest.mark.api` on class `TestThrottleState`, `TestProactivePause`, +`TestMakeRequest`, `TestSchemaValidation`, `TestConvenienceMethods`. -@pytest.fixture -def mock_prefs(): - """Returns (prefs_getter, prefs_setter, prefs_data) tuple for API client tests.""" +Pattern: one class per behaviour area, one method per scenario. -@pytest.fixture -def sample_api_v2_response(): - """Base successful API v2 response with extended meta fields.""" +## Key Test Scenarios -@pytest.fixture -def sample_v2_device_info(): - """Full device info v2 response with zones array.""" +### `test_api_client.py` -@pytest.fixture -def sample_v2_schedules(): - """Schedules v2 response with ISO 8601 timestamps.""" +- Throttle state: initial, future lockout, expiry auto-clear +- Throttle persistence: save/restore to prefs (v1 and v2 formats) +- Proactive pause: pause below threshold, no-pause at/above, per-device isolation +- `make_request`: GET/POST/PUT success, 204 response, timeout (including + repeated suppression and reset on success), connection errors, 429, + error code 3, error code 1, 500/502/503/504 HTTP errors +- Schema validation: warn on missing keys, debug log on extra keys, no raise +- Multi-device token budget isolation -@pytest.fixture -def sample_v2_sensor_data(): - """Sensor data v2 response.""" -``` +### `test_device_handlers.py` -**Note:** `mock_logger` and `mock_prefs` are duplicated in `test_api_client.py` and `test_device_handlers.py` as local fixtures. Prefer the shared versions from `conftest.py` for new tests. +- `SprinklerHandler.process_device_info`: v1 and v2 responses, online/offline + status, zone extraction +- `process_schedules`: executing zone, next schedule, v2 ISO timestamps, + empty/cancelled schedules +- `process_moistures`: zone moisture, latest date selection, missing zone +- `WhispererHandler.process_sensor_data`: all sensor fields, battery level -**Test data pattern:** -Fixtures return realistic dict structures matching the actual Netro API response format. Tests modify the returned dict for specific scenarios rather than creating new data from scratch: -```python -def test_device_offline(self, sprinkler_handler, sample_device_info_response): - sample_device_info_response["data"]["device"]["status"] = "OFFLINE" - states, is_online, _ = sprinkler_handler.process_device_info( - sample_device_info_response, "ABC123" - ) - assert is_online is False -``` - -## Coverage +### `test_validators.py` -**Requirements:** 85% minimum enforced by `pytest.ini` (`fail_under = 85`) +- Serial number format (12 hex chars) +- API key format +- Polling interval minimums (per-endpoint) +- Action config ranges (duration 1-180, delay 0-60, weather ranges) +- Date format validation -**Excluded from coverage:** -- `*/tests/*` — test files themselves -- `def __repr__` -- `raise AssertionError`, `raise NotImplementedError` -- `if __name__ == .__main__.:` -- `if TYPE_CHECKING:` -- `@abstractmethod` -- Lines marked `# pragma: no cover` +## Standalone API Tester -**Current coverage gaps:** -- `plugin.py` — 0% (requires Indigo runtime; all tests bypass this file) -- `api_client.py` — 17% (HTTP request paths require extensive mocking) -- `device_handlers.py` — 10% (many handler paths not yet exercised) -- `validators.py` — 10% -- `utils.py` — 17% -- `constants.py` — 100% (trivially satisfied) +`/Users/simon/vsCodeProjects/Indigo/netro/test_local_api.py` — a CLI script +for testing against the real Netro API (not part of the pytest suite): -**View coverage:** ```bash -python3 -m pytest # Generates term-missing + HTML report -open /Users/simon/vsCodeProjects/Indigo/netro/htmlcov/index.html +python3 test_local_api.py --serial YOUR_SERIAL +python3 test_local_api.py --serial YOUR_SERIAL --full # includes write ops ``` -## Test Types - -**Unit Tests (all current tests):** -- Test individual modules in isolation -- No Indigo runtime dependency -- Fast, can run offline -- Mock all external I/O - -**Integration Tests (not yet implemented):** -- Marked with `@pytest.mark.integration` -- Would require live Netro API access -- `docs/test_local_api.py` provides a standalone script for manual API testing against real hardware - -**E2E Tests:** -- Not implemented -- Manual testing against Indigo server on `jarvis.local` is the current E2E approach +See `docs/LOCAL_TESTING.md` for details. -## Common Patterns +## Coverage Notes -**Async Testing:** -Not applicable — plugin uses synchronous HTTP (`requests`) with Indigo's threading model. No async test patterns needed. - -**Error Testing:** -```python -def test_raises_throttle_error_on_429(self, client): - """HTTP 429 response raises ThrottleDelayError.""" - mock_response = MagicMock() - mock_response.status_code = 429 - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() - - with patch("requests.post", return_value=mock_response): - with pytest.raises(ThrottleDelayError): - client.start_zone(serial="ABC123", zone=1, duration=600) -``` - -**Parametrize pattern (used in validators tests):** -```python -@pytest.mark.parametrize("invalid_serial", ["", "ABC", "TOOLONGSERIAL1234"]) -def test_invalid_serial_rejected(self, invalid_serial): - is_valid, _, errors = validate_device_config({"address": invalid_serial}, "sprinkler") - assert is_valid is False - assert "address" in errors -``` - -**State assertion via dict comprehension:** -Handler tests convert the returned state list to a dict for easy assertion: -```python -states = zone_handler.extract_zone_states(sample_zones, zone_number=1) -state_dict = {s["key"]: s["value"] for s in states} -assert state_dict["enabled"] is True -assert state_dict["smartMode"] == "SMART" -``` - -**Validation return value unpacking:** -All validator tests use 3-tuple unpacking to check each component separately: -```python -is_valid, sanitized, errors = validate_device_config(values, "sprinkler") -assert is_valid is True -assert sanitized["address"] == "0123456789AB" -assert errors == {} -``` +Current coverage is low for modules with Indigo dependencies or complex +integration paths: ---- +| Module | Coverage | Reason | +|--------|----------|--------| +| `constants.py` | 100% | Pure constants, trivially covered | +| `plugin.py` | 0% | Requires Indigo runtime | +| `device_handlers.py` | ~10% | Many paths still untested | +| `validators.py` | ~10% | Many edge cases untested | +| `api_client.py` | ~17% | Core paths covered, edge cases not | +| `tomorrow_client.py` | ~7% | Tomorrow.io integration minimally tested | -*Testing analysis: 2026-04-11* +Target from `docs/CLAUDE.md`: increase to 85%+. From addbb3b9b630129e86b435ba8fb401c3de485592 Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Tue, 14 Apr 2026 16:20:48 +0100 Subject: [PATCH 8/9] docs: add workspace CLAUDE.md header Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6bd8df4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# CLAUDE.md — Netro Sprinklers + +> **Part of the [Indigo workspace](../CLAUDE.md)** — see root for cross-project map, standards, and tooling. + +## Project Identity + +- **Name**: Netro Sprinklers +- **Type**: Indigo plugin +- **Shortcut**: `netro` +- **GitHub**: https://github.com/simons-plugins/netro-indigo +- **Language**: Python 3.10+ + +## Role in the workspace + +Netro smart irrigation (Sprite/Pixie/Spark controllers + Whisperer soil sensors). Production-ready with comprehensive pytest suite and pylint + custom Indigo rules in `pyproject.toml` — this is the reference plugin for testing and linting standards in this workspace. + +## Related projects + +Standalone — no sibling dependencies in this workspace. + +## Standards + +Inherits workspace standards from [root CLAUDE.md](../CLAUDE.md#common-standards-apply-to-every-project-unless-its-claudemd-overrides). Key points for this project: + +- **Version bump per PR**: `Info.plist` `PluginVersion` +- **Testing**: pytest + `pyproject.toml` (pylint with custom Indigo rules, 120-char lines) +- **Merge**: GitHub PR only, never `--admin`, never squash, wait for CI green, wait for user go-ahead. + +--- + +**Detailed architecture, build, and development notes**: see [`docs/CLAUDE.md`](./docs/CLAUDE.md). From a446c4f2174c0c2335111495175cd13089687a8b Mon Sep 17 00:00:00 2001 From: Simon Clark Date: Wed, 15 Apr 2026 09:17:51 +0100 Subject: [PATCH 9/9] fix: address PR review findings for per-endpoint polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead MINIMUM_POLLING_INTERVAL_MINUTES imports (plugin.py, validators.py) and update stale docstring referencing removed pollingInterval attribute - Seed new per-endpoint fields from legacy pollingInterval on first load so upgrading users don't silently jump to more aggressive defaults (uses max(seed, default) to respect minimum intervals) - Document schedules/moistures coupling to device_info interval in PluginConfig.xml help text (they nest inside the device_info guard since zone updates need fresh device_data) - Promote schedule/moisture fetch exceptions from DEBUG-only traceback to WARNING with exception type + message so operators can diagnose without flipping debug on - Expand token parse-failure warning to state the pause consequence and include device key + reset time — was silent about pausing - Add device-scoped debug log when ThrottleDelayError aborts a sprinkler update cycle mid-flight (replaces bare `pass`) - Add tests for parse-failure near-future reset time and the new warning message content Bump PluginVersion to 2026.4.1. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Contents/Info.plist | 2 +- .../Contents/Server Plugin/PluginConfig.xml | 4 +- .../Contents/Server Plugin/api_client.py | 9 +++- .../Contents/Server Plugin/plugin.py | 54 ++++++++++++++++--- .../Contents/Server Plugin/validators.py | 1 - tests/test_api_client.py | 20 +++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index b2b4388..e286d3d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.4.0 + 2026.4.1 ServerApiVersion 3.6 IwsApiVersion diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml index 5ea15cb..e7528d0 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/PluginConfig.xml @@ -35,7 +35,7 @@ - + @@ -43,7 +43,7 @@ - + diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 8da3a14..10d128e 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py @@ -459,10 +459,15 @@ def _update_token_budget( if reset_str: reset_time = datetime.fromisoformat(reset_str).replace(tzinfo=timezone.utc) except (ValueError, TypeError) as exc: - self.logger.warning(f"Could not parse token info from response: {exc}") - remaining = TOKEN_PAUSE_THRESHOLD - 1 # Set near-future reset so auto-reset can unlock this device reset_time = datetime.now(timezone.utc) + timedelta(hours=1) + remaining = TOKEN_PAUSE_THRESHOLD - 1 + key_display = device_key[:8] + "..." if len(device_key) > 8 else device_key + self.logger.warning( + f"Could not parse token info from response: {exc}. " + f"Pausing device {key_display} until {reset_time:%H:%M:%S UTC} " + f"as a safety fallback." + ) # Update or create per-device state self._device_tokens[device_key] = DeviceTokenState( diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index e2c2c8c..4aff24c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -54,7 +54,6 @@ DEFAULT_SENSOR_INTERVAL_MINUTES, DEFAULT_WEATHER_UPDATE_INTERVAL_MINUTES, DEFAULT_FORECAST_INTERVAL_MINUTES, - MINIMUM_POLLING_INTERVAL_MINUTES, ZONE_START_ENDPOINT, OPERATIONAL_ERROR_EVENTS, COMM_ERROR_EVENTS, @@ -84,7 +83,11 @@ class Plugin(indigo.PluginBase): Attributes: serial_number: Netro controller serial number for API authentication - pollingInterval: Minutes between API polls (default 3, minimum 3) + _events_interval: Minutes between event polls (default 5) + _device_info_interval: Minutes between device_info polls (default 10) + _moistures_interval: Minutes between moisture polls (default 10) + _schedules_interval: Minutes between schedule polls (default 30) + _sensor_interval: Minutes between Whisperer sensor polls (default 30) timeout: API request timeout in seconds (default 5) maxZoneRunTime: Maximum allowed zone runtime in seconds (default 3600) api_client: NetroAPIClient instance for all API communication @@ -103,6 +106,36 @@ def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): self.debug = pluginPrefs.get("showDebugInfo", False) self.timeout = int(pluginPrefs.get("apiTimeout", DEFAULT_API_TIMEOUT_SECONDS)) + # One-time migration: if legacy `pollingInterval` is present and new + # per-endpoint fields are absent, seed the new fields from it so + # upgrading users keep approximately their old call budget instead of + # silently jumping to the more aggressive per-endpoint defaults. + legacy_interval = pluginPrefs.get("pollingInterval") + has_new_fields = any( + pluginPrefs.get(f) for f in ( + "eventsInterval", "deviceInfoInterval", "moisturesInterval", + "schedulesInterval", "sensorInterval", + ) + ) + if legacy_interval and not has_new_fields: + try: + seed = int(legacy_interval) + pluginPrefs["eventsInterval"] = max(seed, DEFAULT_EVENTS_INTERVAL_MINUTES) + pluginPrefs["deviceInfoInterval"] = max(seed, DEFAULT_DEVICE_INFO_INTERVAL_MINUTES) + pluginPrefs["moisturesInterval"] = max(seed, DEFAULT_MOISTURES_INTERVAL_MINUTES) + pluginPrefs["schedulesInterval"] = max(seed, DEFAULT_SCHEDULES_INTERVAL_MINUTES) + pluginPrefs["sensorInterval"] = max(seed, DEFAULT_SENSOR_INTERVAL_MINUTES) + self.logger.info( + f"Migrated legacy pollingInterval={seed}min to per-endpoint " + f"intervals. Review them in Plugin Config if you want to tune further." + ) + pluginPrefs["pollingInterval"] = "" + except (ValueError, TypeError): + self.logger.warning( + f"Could not migrate legacy pollingInterval value " + f"{legacy_interval!r}; using defaults." + ) + # Per-endpoint polling intervals (minutes) self._events_interval = int(pluginPrefs.get("eventsInterval", DEFAULT_EVENTS_INTERVAL_MINUTES)) self._device_info_interval = int(pluginPrefs.get("deviceInfoInterval", DEFAULT_DEVICE_INFO_INTERVAL_MINUTES)) @@ -798,9 +831,13 @@ def _update_sprinkler_device(self, dev): schedule_dict, api_version=api_version ) update_list.extend(schedule_states) - except Exception: + except Exception as exc: update_list.append( {"key": "activeSchedule", "value": "Error getting current schedule"}) + self.logger.warning( + f"Schedule fetch failed for '{dev.name}': " + f"{type(exc).__name__}: {exc}" + ) self.logger.debug(f"API error: \n{traceback.format_exc(10)}") self._fireTrigger("getScheduleCall") @@ -826,8 +863,11 @@ def _update_sprinkler_device(self, dev): if now >= self._next_moistures_update: try: moisture_dict = self.api_client.get_moistures(key, api_version=api_version) - except Exception: - self.logger.warning(f"Moisture API unavailable for '{dev.name}' - zone moisture states may be stale") + except Exception as exc: + self.logger.warning( + f"Moisture fetch failed for '{dev.name}' " + f"({type(exc).__name__}: {exc}) - zone moisture states may be stale" + ) self.logger.debug(f"Moisture API error: \n{traceback.format_exc(10)}") # Update zone devices (uses info + schedule + moisture data) @@ -873,7 +913,9 @@ def _update_sprinkler_device(self, dev): self.logger.warning(f"Events API error for '{dev.name}': \n{traceback.format_exc(10)}") except ThrottleDelayError: - pass + # Already logged in api_client with device-level detail; skip remainder + # of this device's update cycle silently to avoid duplicate warnings. + self.logger.debug(f"Skipping remainder of update for '{dev.name}' due to throttle") except requests.exceptions.HTTPError as exc: self._handle_http_error(exc) self._fireTrigger("personInfoCall") diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py index 99f3347..571faf0 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py @@ -27,7 +27,6 @@ from typing import Any, Dict, List, Optional, Tuple from constants import ( - MINIMUM_POLLING_INTERVAL_MINUTES, MINIMUM_EVENTS_INTERVAL_MINUTES, MINIMUM_DEVICE_INFO_INTERVAL_MINUTES, MINIMUM_SCHEDULES_INTERVAL_MINUTES, diff --git a/tests/test_api_client.py b/tests/test_api_client.py index a7302cc..5da2fda 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -395,6 +395,26 @@ def test_update_token_budget_sets_safe_default_on_parse_failure(self, client, mo assert client.should_pause_polling_for("KEY_A") is True mock_logger.warning.assert_called() + def test_update_token_budget_parse_failure_sets_near_future_reset(self, client): + """Parse failure sets reset_time ~1h in future so device auto-unlocks.""" + before = datetime.now(timezone.utc) + client._update_token_budget({"token_remaining": "invalid"}, device_key="KEY_A") + reset = client._device_tokens["KEY_A"].token_reset + + assert reset is not None + delta = (reset - before).total_seconds() + assert 3500 < delta < 3700 # roughly 1 hour + + def test_update_token_budget_parse_failure_log_states_consequence(self, client, mock_logger): + """Parse-failure warning must mention pause + device + reset time.""" + client._update_token_budget({"token_remaining": "invalid"}, device_key="KEY_ABCD1234") + # First warning is the parse-failure message; a second (threshold) + # warning may also fire because we set remaining below the threshold. + parse_msg = mock_logger.warning.call_args_list[0][0][0] + assert "Pausing" in parse_msg + assert "KEY_ABCD" in parse_msg + assert "safety fallback" in parse_msg + def test_auto_resets_past_reset_time(self, client, mock_logger): """Auto-resets token count when past token_reset time.""" from api_client import DeviceTokenState