diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 8a6afc2ea..40f49ece6 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -290,6 +290,8 @@ "automatic": {"required": False, "config": "solis_automatic", "default": False}, "base_url": {"required": False, "config": "solis_base_url", "default": "https://www.soliscloud.com:13333"}, "control_enable": {"required": False, "config": "solis_control_enable", "default": True}, + "details_refresh_interval": {"required": False, "config": "solis_details_refresh_interval", "default": 60}, + "charge_discharge_refresh_interval": {"required": False, "config": "solis_charge_discharge_refresh_interval", "default": 300}, }, "phase": 1, "can_restart": True, diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 8c0b9a185..ad94e28d0 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2069,6 +2069,8 @@ "solis_automatic": {"type": "boolean"}, "solis_base_url": {"type": "string", "empty": False}, "solis_control_enable": {"type": "boolean"}, + "solis_details_refresh_interval": {"type": "int"}, + "solis_charge_discharge_refresh_interval": {"type": "int"}, "octopus_intelligent_slot": {"type": "sensor|sensor_list", "sensor_type": "boolean|action", "entries": "num_cars", "optional_entries": True}, "octopus_ready_time": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars", "optional_entries": True}, "octopus_charge_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars", "optional_entries": True}, diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index a39cc1900..7bfcefb00 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -203,7 +203,7 @@ def __init__(self, message, status_code=None, response_code=None): class SolisAPI(ComponentBase): """Solis Cloud API integration component""" - def initialize(self, api_key, api_secret, inverter_sn=None, automatic=False, base_url=SOLIS_BASE_URL, control_enable=True): + def initialize(self, api_key, api_secret, inverter_sn=None, automatic=False, base_url=SOLIS_BASE_URL, control_enable=True, details_refresh_interval=60, charge_discharge_refresh_interval=300): """Initialize the Solis API component""" self.api_key = api_key self.api_secret = api_secret @@ -212,6 +212,9 @@ def initialize(self, api_key, api_secret, inverter_sn=None, automatic=False, bas self.session = None self.nominal_voltage = 48.4 # Default nominal battery voltage self.control_enable = control_enable + # Round to nearest multiple of 5 (component loop ticks every 5s) + self.details_refresh_interval = max(30, int(round(int(details_refresh_interval) / 5) * 5)) + self.charge_discharge_refresh_interval = max(60, int(round(int(charge_discharge_refresh_interval) / 5) * 5)) # Convert inverter_sn to list if inverter_sn is None: @@ -233,7 +236,7 @@ def initialize(self, api_key, api_secret, inverter_sn=None, automatic=False, bas # Tracking self.slots_reset = set() # Track which inverters had slots reset - self.log(f"Solis API: Initialized with inverter_sn={self.inverter_sn} automatic={automatic}") + self.log(f"Solis API: Initialized with inverter_sn={self.inverter_sn} automatic={automatic} details_refresh={self.details_refresh_interval}s charge_discharge_refresh={self.charge_discharge_refresh_interval}s") # ==================== Helper Methods ==================== @@ -1982,6 +1985,24 @@ async def publish_entities(self): except (ValueError, TypeError): self.log("Warn: Failed to convert battery capacity for {}: {}".format(inverter_sn, battery_capacity_ah)) # Debug log + # Data logger timestamp from inverterDetail API (shows when the data logger last reported to SolisCloud) + data_timestamp_ms = detail.get("dataTimestamp") + if data_timestamp_ms is not None: + try: + data_timestamp_dt = datetime.fromtimestamp(int(data_timestamp_ms) / 1000, tz=UTC) + self.dashboard_item( + f"sensor.{prefix}_solis_{inverter_sn_lower}_data_timestamp", + state=data_timestamp_dt.isoformat(), + attributes={ + "friendly_name": f"Solis {inverter_name} Data Logger Timestamp", + "device_class": "timestamp", + "icon": "mdi:clock-check-outline", + }, + app="solis" + ) + except (ValueError, TypeError, OSError): + self.log(f"Warn: Failed to parse dataTimestamp for {inverter_sn}: {data_timestamp_ms}") + # ==================== Control Methods ==================== async def set_storage_mode_if_needed(self, inverter_sn, mode): @@ -2673,15 +2694,15 @@ async def run(self, seconds, first): self.log("Error: Solis API: No inverters to manage after discovery") return False # Stop further processing if no inverters - # Frequent polling (every minute) - if first or (seconds % 60 == 0): + # Frequent polling (configurable, default every 60s) + if first or (seconds % self.details_refresh_interval == 0): for sn in self.inverter_sn: success = await self.fetch_inverter_details(sn) # Get inverter details for all inverters if not success: poll_success = False - # Infrequent polling (every 60 minutes) - if first or (seconds % 3600 == 0): + # Charge/discharge settings polling (configurable, default every 60s) + if first or (seconds % self.charge_discharge_refresh_interval == 0): for sn in self.inverter_sn: self.log(f"Solis API: Performing infrequent data poll for inverter {sn}...") await self.poll_inverter_data(sn, SOLIS_CID_INFREQUENT) @@ -2709,8 +2730,8 @@ async def run(self, seconds, first): else: await self.fetch_entity_data(sn) - # Control mode - if first or (seconds % 60 == 0): + # Control mode (runs at the faster of the two refresh intervals) + if first or (seconds % min(self.details_refresh_interval, self.charge_discharge_refresh_interval) == 0): # Write to inverter using new function (handles both V1 and V2) is_readonly = self.get_state_wrapper(f'switch.{self.prefix}_set_read_only', default='off') == 'on' if self.control_enable and not is_readonly: @@ -2720,8 +2741,8 @@ async def run(self, seconds, first): else: self.log("Solis API: Control disabled, skipping writing time windows") - # Publish entities after polling - if first or (seconds % 60 == 0): + # Publish entities after polling (runs at the faster of the two refresh intervals) + if first or (seconds % min(self.details_refresh_interval, self.charge_discharge_refresh_interval) == 0): await self.publish_entities() # Auto-configure Predbat if enabled diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index 42511ccc2..1b7071704 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -45,6 +45,8 @@ def __init__(self, prefix="predbat"): self.session = None self.nominal_voltage = 48.4 self.control_enable = True + self.details_refresh_interval = 60 + self.charge_discharge_refresh_interval = 60 self.inverter_sn = [] # Mock base object for get_arg calls @@ -355,6 +357,7 @@ def run_solis_tests(my_predbat): failed |= asyncio.run(test_fetch_entity_data_power_clamping()) failed |= asyncio.run(test_fetch_entity_data_invalid_values()) failed |= asyncio.run(test_automatic_config()) + failed |= asyncio.run(test_refresh_intervals()) except Exception as e: print(f"Error running Solis tests: {e}") @@ -2751,3 +2754,39 @@ def mock_set_arg3(key, value): print("PASSED: automatic_config handles empty inverter list") return False + + +async def test_refresh_intervals(): + """Test that refresh interval settings are rounded and clamped correctly""" + print("\n=== Test: refresh_intervals ===") + + # Test defaults + api = MockSolisAPI() + assert api.details_refresh_interval == 60, f"Expected default 60, got {api.details_refresh_interval}" + assert api.charge_discharge_refresh_interval == 60, f"Expected default 60, got {api.charge_discharge_refresh_interval}" + print("PASSED: Default intervals are 60s") + + # Test that initialize() rounds to multiples of 5 and clamps minimums + # Use a fresh MockBase to call initialize() directly + api2 = MockSolisAPI() + # Call initialize directly to test the rounding/clamping logic + SolisAPI.initialize(api2, api_key="k", api_secret="s", details_refresh_interval=33, charge_discharge_refresh_interval=47) + assert api2.details_refresh_interval == 35, f"Expected 33 rounded to 35, got {api2.details_refresh_interval}" + assert api2.charge_discharge_refresh_interval == 60, f"Expected 47 rounded to 45 then clamped to 60, got {api2.charge_discharge_refresh_interval}" + print("PASSED: Intervals are rounded to nearest 5") + + # Test minimum clamping + api3 = MockSolisAPI() + SolisAPI.initialize(api3, api_key="k", api_secret="s", details_refresh_interval=10, charge_discharge_refresh_interval=30) + assert api3.details_refresh_interval == 30, f"Expected min 30, got {api3.details_refresh_interval}" + assert api3.charge_discharge_refresh_interval == 60, f"Expected min 60, got {api3.charge_discharge_refresh_interval}" + print("PASSED: Intervals are clamped to minimums (30s details, 60s charge/discharge)") + + # Test larger values + api4 = MockSolisAPI() + SolisAPI.initialize(api4, api_key="k", api_secret="s", details_refresh_interval=120, charge_discharge_refresh_interval=300) + assert api4.details_refresh_interval == 120, f"Expected 120, got {api4.details_refresh_interval}" + assert api4.charge_discharge_refresh_interval == 300, f"Expected 300, got {api4.charge_discharge_refresh_interval}" + print("PASSED: Larger intervals are accepted as-is") + + return False diff --git a/docs/components.md b/docs/components.md index efe654f27..54a993d6e 100644 --- a/docs/components.md +++ b/docs/components.md @@ -552,6 +552,8 @@ Integrates with Solis inverters for monitoring and controlling Solis battery sys | `automatic` | Boolean | No | False | `solis_automatic` | Set to `true` to automatically configure Predbat to use the Solis inverter (no manual apps.yaml sensor updates required) | | `base_url` | String | No | Auto-detected | `solis_base_url` | Solis Cloud API base URL (automatically selects correct region) | | `control_enable` | Boolean | No | True | `solis_control_enable` | Enable/disable control commands (set to false for monitoring only) | +| `details_refresh_interval` | Integer | No | 60 | `solis_details_refresh_interval` | How often (in seconds) to poll inverter detail data from the Solis Cloud API. Minimum 30, rounded to nearest 5. Lower values give fresher sensor data but increase API usage | +| `charge_discharge_refresh_interval` | Integer | No | 300 | `solis_charge_discharge_refresh_interval` | How often (in seconds) to poll charge/discharge CID settings and write any pending control changes. Minimum 60, rounded to nearest 5 | ---