diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md index 310e3ec..d9da8ce 100644 --- a/.planning/codebase/ARCHITECTURE.md +++ b/.planning/codebase/ARCHITECTURE.md @@ -1,256 +1,206 @@ -# Architecture - -**Analysis Date:** 2026-02-01 - -## Pattern Overview - -**Overall:** Plugin-based Indigo integration with polling-based state synchronization - -**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 - -## 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 - -## 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) - -## Key Abstractions - -**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 - -**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` - -**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()` - -**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 - -**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` - -## Entry Points - -**Plugin Initialization:** -- Location: `__init__()` (line 157) -- Triggers: Indigo loads plugin -- Responsibilities: Initialize instance variables, parse preferences, set up data structures - -**Plugin Startup:** -- Location: `startup()` (line 793) -- Triggers: Indigo enables plugin -- Responsibilities: Log startup (minimal), defer heavy initialization to concurrent thread - -**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 - -**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 - -**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 - -**Triggers:** -- Location: `triggerStartProcessing()` (line 1203), `triggerStopProcessing()` (line 1218) -- Triggers: User creates/deletes Indigo trigger -- Responsibilities: Register/deregister trigger in `self.triggerDict` - -## Error Handling - -**Strategy:** Fail gracefully without crashing plugin; log details; fire triggers for user automation - -**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 +# 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 + ``` -- 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") +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 ``` -- 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) + +All modules except `plugin.py` are free of `indigo` imports — they are pure +Python and fully unit-testable without the Indigo runtime. + +--- + +## 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) ``` -- 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) - -## 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) --- -*Architecture analysis: 2026-02-01* +## 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 | + +--- + +## Device Hierarchy + +Three Indigo device types are defined in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Devices.xml`: + +### 1. `sprinkler` — Controller device + +- 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) + +### 2. `Whisperer` — Soil sensor device + +- 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 + +### 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 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) +``` + +--- + +## API Client (`api_client.py`) + +`NetroAPIClient` is a stateful HTTP client that: + +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) + +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` + +--- + +## Device Handlers (`device_handlers.py`) + +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. + +- **`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 + +The separation enables full unit testing without an Indigo runtime. + +--- + +## Trigger System + +Trigger events are defined in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/Events.xml`. + +`triggerDict` in `Plugin` maps event names to active `indigo.Trigger` objects. +Fired via `Plugin._fireTrigger(event, dev_id)`. + +Operational error events (`OPERATIONAL_ERROR_EVENTS`): +- `startZoneFailed`, `stopFailed`, `setStandbyFailed`, `setMoistureFailed` + +Communication error events (`COMM_ERROR_EVENTS`): +- `personCall`, `personInfoCall`, `getScheduleCall`, `forecastCall` + +V2 device event types (from `events.json`): `offline`, `online`, +`schedule_started`, `schedule_ended` — fire corresponding Indigo triggers. + +--- + +## 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 8dd07e3..b354da3 100644 --- a/.planning/codebase/CONCERNS.md +++ b/.planning/codebase/CONCERNS.md @@ -1,243 +1,132 @@ -# 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. - -## 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. - -## 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 - ---- - -*Concerns audit: 2026-02-01* +# CONCERNS.md — Tech Debt, Known Issues, and TODOs + +## 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 + +### `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 aa49d20..ee31bfd 100644 --- a/.planning/codebase/CONVENTIONS.md +++ b/.planning/codebase/CONVENTIONS.md @@ -1,314 +1,138 @@ -# Coding Conventions - -**Analysis Date:** 2026-02-01 - -## 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` - -**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()` - -**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` - -**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"` - -## 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 - -**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 - -## Import Organization - -**Order:** -1. Standard library imports (`json`, `copy`, `traceback`, `datetime`) -2. Third-party library imports (`requests`, `dateutil`) -3. Indigo SDK imports (`indigo`) - -**Pattern observed:** -```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 -``` +# CONVENTIONS.md — Code Conventions -**Path Aliases:** -Not used in this codebase. All imports are fully qualified. +## Python Style -## Error Handling +- **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`) -**Patterns:** -- Broad `try/except` blocks with specific exception types handled differently -- Custom exception: `ThrottleDelayError` for rate limit violations -- Defensive exception handling with graceful fallbacks +## Naming Conventions -**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 -``` +Indigo requires camelCase method names for its callbacks; pylint is configured +to allow this: -**Defensive Parsing Pattern:** -```python -try: - value = int(some_value) -except (ValueError, TypeError): - value = default_value - self.logger.debug("Invalid value, using default") ``` - -**Silent Loop Exception Pattern** (`runConcurrentThread()` at `plugin.py:810-829`): -```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) +# 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): ... ``` -## Logging +Private helpers are prefixed with `_`. -**Framework:** Indigo's built-in `self.logger` (inherits from `indigo.PluginBase`) +## Docstrings -**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 +All public methods and classes have Google-style docstrings: -**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") +def _get_device_auth(self, dev): + """Get API authentication key and version for a device. + + Args: + dev: Indigo device with pluginProps + + Returns: + Tuple of (key, api_version) where key is the auth credential + and api_version is "1" or "2" + """ ``` -**Error suppression pattern:** +Module-level docstrings are required and describe purpose, classes exported, +and any dependency constraints (e.g. "does not import indigo"). + +## Logging + +Uses `self.logger` in `Plugin` (injected by `indigo.PluginBase`). All other +modules receive a logger via constructor injection: + ```python -if not self._displayed_connection_error: - self.logger.error("Connection failed. Will retry silently.") - self._displayed_connection_error = True +# Handlers and client accept optional logger +def __init__(self, logger: Optional[logging.Logger] = None) -> None: ``` -## Comments +### Log levels + +| 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 | + +### Error suppression pattern -**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 +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: -**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 +if not self._displayed_connection_error: + self.logger.error("Connection failed - will retry silently") + self._displayed_connection_error = True +# On next success: self._displayed_connection_error = False ``` -**Docstring Format:** Google-style docstrings with triple quotes +## Error Handling Philosophy -```python -def convert_timestamp(timestamp): - """Convert Unix timestamp (milliseconds) to local timezone datetime. +"Fail gracefully, log details, continue operation." - Args: - timestamp: Unix timestamp in milliseconds +- 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 - Returns: - datetime: Timestamp converted to local timezone - """ -``` +## Module Isolation Pattern -**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 - -## 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 - -**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 - -**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) - -## 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 -######################################## +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. -######################################## -# startup, concurrent thread, and shutdown methods -######################################## +`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 -######################################## -# Dialog list callbacks -######################################## -``` +## Configuration Access -## API Constants +Plugin preferences accessed via `self.pluginPrefs` (a dict-like object). +Device configuration accessed via `dev.pluginProps`. Both are persisted by +Indigo automatically. -**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 -``` +Throttle state is persisted to `pluginPrefs` via injected callbacks: -**Error Event Sets:** ```python -ALL_OPERATIONAL_ERROR_EVENTS = { - "startZoneFailed", - "stopFailed", - "setStandbyFailed", -} - -ALL_COMM_ERROR_EVENTS = { - "personCall", - "personInfoCall", - "getScheduleCall", - "forecastCall", -} +self.api_client = NetroAPIClient( + prefs_getter=lambda: dict(self.pluginPrefs), + prefs_setter=lambda k, v: self.pluginPrefs.__setitem__(k, v) +) ``` -## Indigo API Conventions +This allows the API client to remain `indigo`-free while still persisting +state across plugin restarts. -**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) -``` +## Version Bumping -**Device Properties (static config):** -```python -props = copy.deepcopy(dev.pluginProps) -props["NumZones"] = len(zones) -props["ZoneNames"] = zone_names_string -dev.replacePluginPropsOnServer(props) -``` +Every PR must bump `PluginVersion` in +`Netro Sprinklers.indigoPlugin/Contents/Info.plist`. -**Indigo Collections:** -- `indigo.devices` - Device collection -- Filter by plugin: `indigo.devices.iter(filter="self")` -- Lookup by ID: `indigo.devices[device_id]` +Format: `YYYY.R.patch` — e.g. `2026.4.0`. ---- +- Patch bump for fixes and docs +- Minor bump (R) for new features -*Convention analysis: 2026-02-01* +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 c4fb7be..2c8a0de 100644 --- a/.planning/codebase/INTEGRATIONS.md +++ b/.planning/codebase/INTEGRATIONS.md @@ -1,206 +1,130 @@ -# External Integrations - -**Analysis Date:** 2026-02-01 - -## 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 - -## 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 - -**File Storage:** -- None - all configuration in Indigo database - -**Caching:** -- In-memory only, no persistence across plugin restarts -- Cache refreshed every polling interval (default 3 minutes) - -## 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 - -**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 - -## 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 - -## Webhooks & Callbacks - -**Incoming:** -- None - plugin uses pull model (polling) not push (webhooks) - -**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 - -## Environment Configuration - -**Required env vars:** -- None - all configuration via Indigo UI - -**Secrets location:** -- Device serial numbers stored in Indigo device configuration -- Not in environment variables or config files -- Stored encrypted by Indigo database - -**Connection Testing:** -- Plugin includes standalone test utility: `docs/test_local_api.py` -- Useful for debugging API connectivity before plugin integration +# 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-02-01* +## 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 d85617e..a17d622 100644 --- a/.planning/codebase/STACK.md +++ b/.planning/codebase/STACK.md @@ -1,124 +1,100 @@ -# Technology Stack +# STACK.md — Netro Sprinklers Plugin -**Analysis Date:** 2026-02-01 +## Runtime Environment -## Languages +- **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 -**Primary:** -- Python 3.10+ - Main plugin implementation -- XML - Device types, actions, events, plugin configuration +## Plugin Identity -**Secondary:** -- Plist/XML - Plugin metadata and Info.plist +- **CFBundleIdentifier**: `com.simons-plugins.netro` +- **PluginVersion**: `2026.4.0` (format: `YYYY.R.patch`) +- **CFBundleDisplayName**: Netro Smart Sprinklers -## Runtime +## Runtime Dependencies -**Environment:** -- Indigo 2023+ (macOS home automation server) -- Python 3.10+ (managed by Indigo) +Declared in +`Netro Sprinklers.indigoPlugin/Contents/Server Plugin/requirements.txt`: -**Package Manager:** -- pip (Indigo handles automatic installation) -- Lockfile: `requirements.txt` present - -## Frameworks - -**Core:** -- Indigo PluginBase 3.6 - Plugin framework, inherits from `indigo.PluginBase` -- requests 2.32.5 - HTTP client for Netro API calls - -**Testing:** -- pytest 8.0.0+ - Test framework -- pytest-cov 4.1.0+ - Coverage reporting -- pytest-mock 3.12.0+ - Mocking support - -**Build/Dev:** -- pylint - Code quality analysis (target score 8.0) - -## 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 - -**Infrastructure:** -- dateutil - Timezone handling in `plugin.py:47` - - Used for timestamp conversion from UTC to local timezone - - Pre-installed with Indigo - -**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 - -## 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) - -## 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) +``` +requests==2.32.5 +``` -**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 +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. -**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) +## Development Dependencies -## Build & Deployment +Declared in `pyproject.toml` (not `requirements.txt` — kept separate): -**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)/" +``` +pytest>=7.4 +pytest-cov>=4.1 +pytest-mock>=3.12 ``` -**Testing:** +Install manually: ```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 +pip3 install pytest pytest-cov pytest-mock requests ``` -**Distribution:** -- Plugin packaged as `.indigoPlugin` bundle (contains Contents directory) -- Distributed via GitHub releases -- Double-click to install in Indigo - ---- - -*Stack analysis: 2026-02-01* +## 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 7ee23f8..3b15adb 100644 --- a/.planning/codebase/STRUCTURE.md +++ b/.planning/codebase/STRUCTURE.md @@ -1,300 +1,128 @@ -# Codebase Structure +# STRUCTURE.md — Directory Layout -**Analysis Date:** 2026-02-01 - -## Directory Layout +## Top-Level ``` netro/ -├── Netro Sprinklers.indigoPlugin/ # Plugin bundle (macOS app package) -│ └── 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) +├── .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 ``` -## 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 - -**README.md:** -- Purpose: User-facing plugin documentation -- Contains: Features, device types, setup instructions, API usage, troubleshooting links - -## 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` - -**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) - -**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 - -**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 +## Plugin Bundle -**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 - -## 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` - -**Directories:** - -- 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) - -## 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()` +``` +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 +``` -3. **Add event definition:** - - Edit `Events.xml` if feature can fail - - Add `` with IDs for error conditions +## XML Configuration Files -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` +### `Devices.xml` -5. **Update documentation:** - - Add to `docs/CLAUDE.md` Architecture section - - Add API endpoint to `docs/NETRO_API.md` - - Add usage example to `README.md` +Defines three device types: -**New Device Type (e.g., Smart Hose):** +| `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 | -1. **Define device type:** - - Edit `Devices.xml` - - Add `` block - - Define states and properties - - Example: `` +### `Actions.xml` -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)` +Defines custom plugin actions (beyond standard sprinkler start/stop): -3. **Add validation:** - - Update `validateDeviceConfigUi()` for device type validation - - Check serial number, capabilities, etc. +| 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 | -4. **Add tests:** - - Test device discovery - - Test state updates - - Test error handling +### `Events.xml` -**Utility/Helper Function:** +Trigger event types for user automation: -- 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 +- `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` -**Error/Exception Handling:** +### `PluginConfig.xml` -- 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 +Plugin-level preferences: -## Special Directories +- `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 -**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 +## Tests Directory -**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`) +``` +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 +``` -**.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 +Note: `tests/fixtures/` directory referenced in older `docs/TESTING.md` is no +longer present — test data is defined inline in fixtures and test files. -**.github/workflows/:** -- Purpose: CI/CD pipeline automation (GitHub Actions) -- Generated: No (hand-authored workflows) -- Committed: Yes -- Files: Test runs, linting, release automation +## Key File Paths (absolute) -**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 +- 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) -*Structure analysis: 2026-02-01* +``` +/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 8d2975f..71ef2e9 100644 --- a/.planning/codebase/TESTING.md +++ b/.planning/codebase/TESTING.md @@ -1,386 +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-02-01 - -## Test Framework - -**Runner:** -- pytest >= 8.0.0 -- Configuration: `pytest.ini` in project root - -**Assertion Library:** -- pytest's built-in assertions -- pytest-mock for mocking (`pytest-mock >= 3.12.0`) - -**Coverage Tools:** -- pytest-cov >= 4.1.0 -- HTML reports generated to `htmlcov/` -- Coverage target: >70% (current status per CLAUDE.md) - -**Run Commands:** -```bash -# Run all tests with coverage -pytest tests/ -v --cov="Netro Sprinklers.indigoPlugin/Contents/Server Plugin" --cov-report=term-missing - -# Run specific test file -pytest tests/test_api_client.py -v - -# Run specific test -pytest tests/test_api_client.py::test_successful_get_request -v - -# Run with branch coverage -pytest tests/ --cov-branch - -# Generate HTML coverage report -pytest tests/ --cov --cov-report=html -# View in htmlcov/index.html -``` - -## Test File Organization - -**Location:** -- `tests/` directory in project root -- Test files are siblings of main plugin - -**Naming:** -- Files: `test_*.py` pattern (pytest auto-discovery) -- Classes: `Test*` pattern (e.g., `TestAPIClient`) -- Functions: `test_*` pattern (e.g., `test_successful_get_request`) - -**Structure (from pytest.ini discovery):** -``` -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 - └── ... -``` - -**Total: 64 tests covering >70% of code** - -## 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:** ```python -@pytest.mark.api -def test_successful_get_request(mock_plugin): - """Test successful API GET request.""" - # Test implementation +SERVER_PLUGIN_DIR = ( + Path(__file__).parent.parent + / "Netro Sprinklers.indigoPlugin" + / "Contents" + / "Server Plugin" +) +sys.path.insert(0, str(SERVER_PLUGIN_DIR)) ``` -**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 - -## Mocking - -**Framework:** pytest-mock (via `mocker` fixture) - -**Pattern:** Mock Indigo objects and requests library - -**Example (from conftest.py fixture):** -```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() - - # Mock Indigo collections - mocker.patch("indigo.devices", MagicMock()) - mocker.patch("indigo.trigger", MagicMock()) - - return plugin -``` +`plugin.py` is never imported in tests (it requires the Indigo runtime). +Only the extracted pure-Python modules are imported. -**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 +### HTTP requests -**Pattern for mocking requests:** -```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) - - # Test code using API - result = plugin._make_api_call(url) - assert result["status"] == "OK" -``` - -**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 +All HTTP calls are patched with `unittest.mock.patch`: -## 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` - -**Fixture Pattern (conftest.py):** ```python -@pytest.fixture -def mock_plugin(mocker): - """Return mock plugin instance with logger and device mocks.""" - # ... setup code ... - return plugin - -@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 - -@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) +@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 = {...} ``` -**Factory Pattern:** -```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 -``` +No actual network calls are made in the test suite. -## Coverage +### Logger -**Requirements:** >70% (stated in CLAUDE.md, targeting 85%+) +`mock_logger` fixture provides a `Mock()` with all standard log methods. +Passed to constructors via the `logger=` parameter. -**View Coverage:** -```bash -# Terminal report -pytest tests/ --cov --cov-report=term-missing +### Prefs (throttle persistence) -# HTML report -pytest tests/ --cov --cov-report=html -open htmlcov/index.html -``` +`mock_prefs` fixture provides getter/setter callables backed by a plain dict, +allowing `NetroAPIClient` to be tested without `pluginPrefs`. -**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 -``` +## Test Classes and Markers -**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) -- Marked with `@pytest.mark.integration` -- May test data flow across multiple methods - -**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 - -## 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")) +Tests are organised into classes within each file. `test_api_client.py` uses +`@pytest.mark.api` on class `TestThrottleState`, `TestProactivePause`, +`TestMakeRequest`, `TestSchemaValidation`, `TestConvenienceMethods`. - # Thread should continue despite exception - mock_plugin.runConcurrentThread() # Within timeout +Pattern: one class per behaviour area, one method per scenario. - # Verify sleep was called (thread loop continued) - mock_plugin.sleep.assert_called() -``` +## Key Test Scenarios -**Error Testing Pattern:** -```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) - - # Verify error logged - mock_plugin.logger.error.assert_called() -``` +### `test_api_client.py` -**Validation Testing Pattern:** -```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() -``` +- 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 -**Rate Limit Testing Pattern:** -```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) +### `test_device_handlers.py` - with pytest.raises(ThrottleDelayError) as exc_info: - mock_plugin._make_api_call(url) +- `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 - assert "throttled" in str(exc_info.value).lower() -``` +### `test_validators.py` -**Fixture Data Pattern:** -```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 -``` +- 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 -## Test Dependencies +## Standalone API Tester -**Runtime Dependencies** (auto-installed): -- `requests==2.32.5` - Mocked in tests +`/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): -**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 +python3 test_local_api.py --serial YOUR_SERIAL +python3 test_local_api.py --serial YOUR_SERIAL --full # includes write ops ``` -## 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 +See `docs/LOCAL_TESTING.md` for details. -**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 +## Coverage Notes -**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` +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-02-01* +Target from `docs/CLAUDE.md`: increase to 85%+. 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). diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index b2a17dd..e286d3d 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.3.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 8730bfc..e7528d0 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/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 4a1e006..10d128e 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 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: + Minimum tokens remaining across devices, or 2000 if none tracked + """ + 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: - Number of API tokens remaining (max 2000 per day) + Tokens remaining for this device, or 2000 if not yet tracked """ - return self._token_remaining + 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,50 @@ 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 + # 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( + 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 +491,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 @@ -473,11 +540,13 @@ 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"]) - # 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,12 +554,25 @@ 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"]) - - except (json.JSONDecodeError, ValueError, KeyError) as exc: + # V2 format: restore per-device token budgets + if is_v2 and "device_tokens" in state: + for key, token_state in state["device_tokens"].items(): + 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) # ========================================================================= @@ -577,7 +659,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 +671,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 +683,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 +695,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 +722,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 +750,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 +766,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 +784,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 +802,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 +823,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 +850,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/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 25fa412..4aff24c 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -47,9 +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, - MINIMUM_POLLING_INTERVAL_MINUTES, + DEFAULT_FORECAST_INTERVAL_MINUTES, ZONE_START_ENDPOINT, OPERATIONAL_ERROR_EVENTS, COMM_ERROR_EVENTS, @@ -79,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 @@ -96,9 +104,52 @@ 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)) + # 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)) + 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 +163,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 +553,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,86 +781,106 @@ 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 """ try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) - schedule_dict = None - moisture_dict = None - # Get device info - reply_dict = self.api_client.get_device_info(key, api_version=api_version) + # 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 - # 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 - ) + now = datetime.now() - # 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) + # --- 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 + ) - # Set error state based on online status - if not is_online: - dev.setErrorStateOnServer('unavailable') - else: - dev.setErrorStateOnServer('') + # 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"] - # 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") + # Set error state based on online status + if not is_online: + dev.setErrorStateOnServer('unavailable') + else: + dev.setErrorStateOnServer('') - # Send the state updates to the server - if update_list: - dev.updateStatesOnServer(update_list) + # --- 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 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") - # 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)}") + # Send state updates + if update_list: + dev.updateStatesOnServer(update_list) - # 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 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) - # 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) + # --- 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 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) + self._ensure_zone_devices(dev, zones_data) + self._update_zone_devices( + dev, device_data, schedule_dict, moisture_dict, api_version + ) - # Poll device events (v2 only) - if api_version == "2": + # Ensure Indigo variables exist for each zone + self._ensure_zone_variables(dev, zones_data) + + # --- 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) @@ -809,8 +891,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}': " @@ -833,8 +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: - # Already logged detailed error in api_client, just skip this device - 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") @@ -846,12 +927,26 @@ 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) + + # 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: @@ -867,9 +962,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 ) @@ -879,8 +975,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 @@ -981,42 +1082,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). - - Includes proactive pause when API tokens drop below threshold to - prevent exhausting the daily limit. + 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. - 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: - # 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() + 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 @@ -1138,14 +1237,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: @@ -1171,6 +1285,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..571faf0 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/validators.py @@ -26,7 +26,15 @@ 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_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 +588,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 +627,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_api_client.py b/tests/test_api_client.py index 13199ab..5da2fda 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_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(), @@ -130,13 +131,99 @@ def test_restore_throttle_state_from_valid_prefs(self, mock_logger): prefs_setter=lambda k, v: None ) - assert client._token_remaining == 500 + # 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): + """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_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 - # Check throttle is approximately correct (within 1 second) 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(), @@ -152,7 +239,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 +273,102 @@ 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_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 + 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 +382,52 @@ 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_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 + 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 +607,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 +620,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 +896,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 +911,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 # ============================================================================= 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."""