diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index b516b41bf..f3f93e863 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -36,7 +36,7 @@ import requests import asyncio -THIS_VERSION = "v8.37.3" +THIS_VERSION = "v8.37.4" from download import predbat_update_move, predbat_update_download, check_install, resolve_predbat_repository, DEFAULT_PREDBAT_REPOSITORY from const import MINUTE_WATT diff --git a/apps/predbat/solis.py b/apps/predbat/solis.py index 7eda5fbee..eb60c690d 100644 --- a/apps/predbat/solis.py +++ b/apps/predbat/solis.py @@ -2134,12 +2134,16 @@ async def publish_entities(self): entity_id = f"number.{prefix}_solis_{inverter_sn_lower}_max_export_power" max_export_power_value = values.get(SOLIS_CID_MAX_EXPORT_POWER, None) try: - max_export_power = float(max_export_power_value) + max_export_power_value = float(max_export_power_value) except (ValueError, TypeError): - max_export_power = 0.0 - if max_export_power == 0.0: + max_export_power_value = 0.0 + if max_export_power_value == 0.0: max_export_power_value = 99999 # Use large number to indicate no limit + # Export power seems to be in 100w units, so convert to watts if value is small + if max_export_power_value < 200: + max_export_power_value *= 100 + self.dashboard_item( entity_id, state=max_export_power_value, @@ -2542,6 +2546,13 @@ async def number_event(self, entity_id, value): } cid = cid_map[field] + if cid == SOLIS_CID_MAX_EXPORT_POWER: + try: + value_str = str(int(value) // 100) # Convert watts to 100w units for inverter + except (ValueError, TypeError): + self.log(f"Warn: Solis API: Invalid value for max export power: {value}") + return + # Write to inverter await self.read_and_write_cid(inverter_sn, cid, value_str, field_description=f"{field} to {value_str}") diff --git a/apps/predbat/tests/test_solis.py b/apps/predbat/tests/test_solis.py index 83ca335de..4c9ec9e1a 100644 --- a/apps/predbat/tests/test_solis.py +++ b/apps/predbat/tests/test_solis.py @@ -380,6 +380,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_publish_entities_export_power_unit_conversion()) except Exception as e: print(f"Error running Solis tests: {e}") @@ -2686,6 +2687,8 @@ async def test_number_event_power_controls(): """Test number_event for power control limits""" print("\n=== Test: number_event power controls ===") + from solis import SOLIS_CID_MAX_EXPORT_POWER + api = MockSolisAPI() inverter_sn = "789012" api.inverter_sn = [inverter_sn] @@ -2703,7 +2706,7 @@ async def mock_read_and_write_cid(sn, cid, value, field_description=None): api.read_and_write_cid = mock_read_and_write_cid - # Test power_limit + # Test power_limit (no unit conversion — value sent as-is) entity_id = f"number.predbat_solis_{inverter_sn}_power_limit" await api.number_event(entity_id, 3000) @@ -2712,6 +2715,29 @@ async def mock_read_and_write_cid(sn, cid, value, field_description=None): assert call["cid"] == SOLIS_CID_POWER_LIMIT, f"Expected CID {SOLIS_CID_POWER_LIMIT}, got {call['cid']}" assert call["value"] == "3000", f"Expected '3000', got {call['value']}" + # Test max_export_power: HA sends watts, inverter expects 100W units (÷100) + api.read_and_write_cid_calls = [] + entity_id = f"number.predbat_solis_{inverter_sn}_max_export_power" + await api.number_event(entity_id, 5000) # 5000 W → 50 (100W units) + + assert len(api.read_and_write_cid_calls) == 1, "Should call read_and_write_cid once for max_export_power" + call = api.read_and_write_cid_calls[0] + assert call["cid"] == SOLIS_CID_MAX_EXPORT_POWER, f"Expected CID {SOLIS_CID_MAX_EXPORT_POWER}, got {call['cid']}" + assert call["value"] == "50", f"Expected '50' (5000÷100), got {call['value']}" + + # Test with a value that truncates (e.g. 550W → 5 in 100W units, not 5.5) + api.read_and_write_cid_calls = [] + await api.number_event(entity_id, 550) + call = api.read_and_write_cid_calls[0] + assert call["value"] == "5", f"Expected '5' (550÷100 truncated), got {call['value']}" + + # Test with an invalid value — str(int(value)) at the top of number_event raises + # ValueError before reaching the max_export_power branch; caught by outer except handler. + api.read_and_write_cid_calls = [] + await api.number_event(entity_id, "not_a_number") + assert len(api.read_and_write_cid_calls) == 0, "Should not write CID for invalid max_export_power value" + assert any("number_event failed" in msg for msg in api.log_messages), "Should log error for invalid value" + print("PASSED: Power controls number event handled correctly") return False @@ -3060,6 +3086,83 @@ def mock_set_arg4(key, value): return False +async def test_publish_entities_export_power_unit_conversion(): + """Test publish_entities converts max export power CID value to watts correctly. + + The Solis API returns CID 499 (max export power) in either 100W units (when value < 200) + or watts (when value >= 200). A value of 0 is treated as 'no limit' and mapped to 99999. + """ + print("\n=== Test: publish_entities export power unit conversion ===") + + from solis import SOLIS_CID_MAX_EXPORT_POWER, SOLIS_CID_STORAGE_MODE + + def _make_api(export_power_cid_value): + """Helper: create a minimal MockSolisAPI with a single inverter.""" + api = MockSolisAPI() + sn = "EXPORT_TEST" + api.inverter_sn = [sn] + api.inverter_details[sn] = {"inverterName": "Test"} + api.cached_values[sn] = { + SOLIS_CID_STORAGE_MODE: "33", + SOLIS_CID_MAX_EXPORT_POWER: export_power_cid_value, + } + api.charge_discharge_time_windows[sn] = {} + api.max_charge_current[sn] = 50 + api.max_discharge_current[sn] = 50 + return api, sn + + # Case 1: value >= 200 — already in watts, no conversion applied + api, sn = _make_api("5000") + await api.publish_entities() + entity_id = f"number.predbat_solis_{sn.lower()}_max_export_power" + item = api.dashboard_items[entity_id] + assert item["state"] == 5000.0, f"Expected 5000W unchanged, got {item['state']}" + + # Case 2: value < 200 — treated as 100W units, multiplied by 100 + api, sn = _make_api("50") + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 5000.0, f"Expected 50 * 100 = 5000W, got {item['state']}" + + # Case 3: value is "0" — no limit sentinel, should become 99999 + api, sn = _make_api("0") + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 99999, f"Expected 99999 for zero value, got {item['state']}" + + # Case 4: value is None (CID missing from cache) — treated as no limit + api, sn = _make_api(None) + # Remove the key entirely so values.get() returns None + del api.cached_values[sn][SOLIS_CID_MAX_EXPORT_POWER] + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 99999, f"Expected 99999 for missing CID, got {item['state']}" + + # Case 5: value is non-numeric string — treated as no limit + api, sn = _make_api("invalid") + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 99999, f"Expected 99999 for invalid value, got {item['state']}" + + # Case 6: boundary — value exactly 200 is NOT multiplied (only < 200 triggers conversion) + api, sn = _make_api("200") + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 200.0, f"Expected 200W unchanged at boundary, got {item['state']}" + + # Case 7: value is 199 — last value that triggers 100W-unit conversion + api, sn = _make_api("199") + await api.publish_entities() + item = api.dashboard_items[entity_id] + assert item["state"] == 19900.0, f"Expected 199 * 100 = 19900W, got {item['state']}" + + # Verify unit of measurement on the published entity + assert item["attributes"]["unit_of_measurement"] == "W", "unit_of_measurement should be W" + + print("PASSED: publish_entities converts max export power CID correctly in all cases") + return False + + async def test_get_solis_mode_enum(): """Test get_solis_mode_enum decodes register values to correct enums""" print("\n=== Test: get_solis_mode_enum ===")