diff --git a/Netro Sprinklers.indigoPlugin/Contents/Info.plist b/Netro Sprinklers.indigoPlugin/Contents/Info.plist index b2a17dd..3356a85 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Info.plist +++ b/Netro Sprinklers.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 2026.3.0 + 2026.3.1 ServerApiVersion 3.6 IwsApiVersion diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/api_client.py index 4a1e006..8da3a14 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. + + 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 + ) - This is PROACTIVE prevention - the plugin should check this - before making API calls to avoid exhausting the daily limit. + 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,45 @@ def _handle_http_error(self, exc: requests.exceptions.HTTPError) -> None: # Token Budget Management # ========================================================================= - def _update_token_budget(self, meta: Dict[str, Any]) -> None: - """Update token tracking from API response metadata. + def _update_token_budget( + self, meta: Dict[str, Any], device_key: Optional[str] = None + ) -> None: + """Update per-device token tracking from API response metadata. Parses token_remaining and token_reset from the response meta section, logs warnings at thresholds, and saves state. - Handles both v1 (strptime) and v2 (fromisoformat) timestamp formats. - Args: meta: Response meta dict containing token info + device_key: Device API key or serial number (None to skip tracking) """ + if device_key is None: + return + try: - self._token_remaining = int(meta.get("token_remaining", 2000)) + remaining = int(meta.get("token_remaining", 2000)) + reset_time = None reset_str = meta.get("token_reset", "") if reset_str: - self._token_reset = datetime.fromisoformat(reset_str).replace(tzinfo=timezone.utc) + reset_time = datetime.fromisoformat(reset_str).replace(tzinfo=timezone.utc) except (ValueError, TypeError) as exc: self.logger.warning(f"Could not parse token info from response: {exc}") - # Set safe default to trigger proactive pause and prevent exhausting token budget - self._token_remaining = TOKEN_PAUSE_THRESHOLD - 1 + remaining = TOKEN_PAUSE_THRESHOLD - 1 + # Set near-future reset so auto-reset can unlock this device + reset_time = datetime.now(timezone.utc) + timedelta(hours=1) + + # Update or create per-device state + self._device_tokens[device_key] = DeviceTokenState( + 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 +486,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 +535,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 +549,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 +654,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 +666,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 +678,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 +690,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 +717,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 +745,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 +761,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 +779,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 +797,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 +818,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 +845,6 @@ def set_moisture( return self.make_request( self._endpoint("set_moisture", api_version), method="post", - data=data + data=data, + device_key=key ) diff --git a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py index 25fa412..c14cfaf 100644 --- a/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Netro Sprinklers.indigoPlugin/Contents/Server Plugin/plugin.py @@ -725,6 +725,15 @@ def _update_sprinkler_device(self, dev): try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) + + # Per-device token pause check + if self.api_client.should_pause_polling_for(key): + self.logger.warning( + f"Polling paused for '{dev.name}': only " + f"{self.api_client.token_remaining_for(key)} tokens remaining" + ) + return + schedule_dict = None moisture_dict = None @@ -852,6 +861,15 @@ def _update_whisperer_device(self, dev): try: # Get auth credentials (API key for v2, serial for v1) key, api_version = self._get_device_auth(dev) + + # Per-device token pause check + if self.api_client.should_pause_polling_for(key): + self.logger.warning( + f"Polling paused for '{dev.name}': only " + f"{self.api_client.token_remaining_for(key)} tokens remaining" + ) + return + self.logger.debug(f"Device ID: {dev.address} (API v{api_version})") if dev.sensorValue is not None: @@ -867,9 +885,10 @@ def _update_whisperer_device(self, dev): else: dev.setErrorStateOnServer('') - # Skip state update if the reading hasn't changed — this keeps + # Skip full state update if the reading hasn't changed — this keeps # Indigo's lastChanged reflecting when new sensor data arrived, - # not when we last polled the API + # not when we last polled the API. Still update token/time states + # which change every poll regardless of sensor readings. new_reading_id = next( (s["value"] for s in states if s["key"] == "readingID"), None ) @@ -879,8 +898,13 @@ def _update_whisperer_device(self, dev): and current_reading_id is not None and str(new_reading_id) == str(current_reading_id) ): + # Update only metadata states that change every poll + meta_keys = {"token_remaining", "token_reset", "time", "last_active", "api_last_active"} + meta_states = [s for s in states if s["key"] in meta_keys] + if meta_states: + dev.updateStatesOnServer(meta_states) self.logger.debug( - f"Sensor '{dev.name}' reading unchanged (ID {new_reading_id}), skipping update" + f"Sensor '{dev.name}' reading unchanged (ID {new_reading_id}), updated metadata only" ) return @@ -997,14 +1021,8 @@ def runConcurrentThread(self): self.logger.debug("Starting concurrent thread") while True: try: - # Check proactive pause before polling - if self.api_client.should_pause_polling: - self.logger.warning( - f"Polling paused: only {self.api_client.token_remaining} tokens " - f"remaining (threshold: 100), will resume when tokens reset" - ) - else: - self._update_from_netro() + # Per-device token pause is checked inside each device update method + self._update_from_netro() # Tomorrow.io uses its own API; run regardless of Netro token pause self._update_weather_from_tomorrow() diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 13199ab..a7302cc 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,32 @@ def test_update_token_budget_saves_state(self, mock_logger, mock_prefs): prefs_data.clear() meta = {"token_remaining": 1500} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") assert "throttle_state" in prefs_data def test_update_token_budget_sets_safe_default_on_parse_failure(self, client, mock_logger): """Sets safe token count on parsing failure to trigger proactive pause.""" - # Start with high token count - client._token_remaining = 1500 - - # Try to parse invalid token data meta = {"token_remaining": "invalid"} - client._update_token_budget(meta) + client._update_token_budget(meta, device_key="KEY_A") - # Should set to safe default (below pause threshold) - assert client._token_remaining == TOKEN_PAUSE_THRESHOLD - 1 - assert client.should_pause_polling is True + assert client._device_tokens["KEY_A"].token_remaining == TOKEN_PAUSE_THRESHOLD - 1 + assert client.should_pause_polling_for("KEY_A") is True mock_logger.warning.assert_called() - def test_should_pause_polling_auto_resets_past_reset_time(self, client, mock_logger): - """Auto-resets token count when past token_reset time to prevent self-locking.""" - # Set low tokens and a reset time in the past - client._token_remaining = 50 # Below threshold - client._token_reset = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) # Past date + def test_auto_resets_past_reset_time(self, client, mock_logger): + """Auto-resets token count when past token_reset time.""" + from api_client import DeviceTokenState + client._device_tokens["KEY_A"] = DeviceTokenState( + token_remaining=50, + token_reset=datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + ) - # Check should_pause_polling - will auto-reset tokens since reset time passed - should_pause = client.should_pause_polling + should_pause = client.should_pause_polling_for("KEY_A") - # Tokens should be auto-reset to 2000 (not paused) assert should_pause is False - assert client._token_remaining == 2000 - assert client._token_reset is None + assert client._device_tokens["KEY_A"].token_remaining == 2000 + assert client._device_tokens["KEY_A"].token_reset is None mock_logger.info.assert_called() assert "reset" in mock_logger.info.call_args[0][0].lower() @@ -454,13 +587,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 +600,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 +876,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 +891,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 # =============================================================================