Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/predbat/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
41 changes: 31 additions & 10 deletions apps/predbat/solis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 ====================

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions apps/predbat/tests/test_solis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down