Skip to content
Merged
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
29 changes: 23 additions & 6 deletions apps/predbat/fox.py
Original file line number Diff line number Diff line change
Expand Up @@ -1168,20 +1168,36 @@ def get_schedule_extra_param(self, deviceSN):
extra_param[group_key] = value
return extra_param

def update_settings_from_schedule(self, deviceSN, groups):
def update_settings_from_schedule(self, deviceSN, groups, properties):
"""
Derive settings from a live schedule read that the settings/get endpoint may not
support (e.g. errno 42015) or that are not part of FOX_SETTINGS at all (ImportLimit,
PvLimit). exportLimit/importLimit/maxSoc/pvLimit use the max seen across all groups;
minSocOnGrid uses the min. A setting that already has a working register-backed entry
is left untouched so its range/precision metadata is not clobbered by a bare value.

properties supplies the real range/unit/precision Fox reports for each field, so the
derived setting publishes as a proper editable number entity - without it, automatic_
config still wires a hardcoded number.*_setting_xxx entity id (e.g. for export_limit)
that a bare {"value": ...} stub, having no range, would never actually publish as (it
would end up a sensor instead, leaving the number entity unresolvable). Required rather
than defaulted so a caller can't silently omit it and reintroduce that bug - pass {} if
a read genuinely has none.
"""
for group_key, setting_key, aggregate in SCHEDULE_DERIVED_SETTINGS:
values = [group[group_key] for group in groups if group_key in group]
if not values:
continue
if setting_key not in self.device_settings.get(deviceSN, {}) or self.is_setting_unavailable(deviceSN, setting_key):
self.device_settings.setdefault(deviceSN, {})[setting_key] = {"value": aggregate(values)}
entry = {"value": aggregate(values)}
prop = properties.get(group_key.lower(), {})
if "range" in prop:
entry["range"] = prop["range"]
if "unit" in prop:
entry["unit"] = prop["unit"]
if "precision" in prop:
entry["precision"] = prop["precision"]
self.device_settings.setdefault(deviceSN, {})[setting_key] = entry

async def set_scheduler_enabled(self, deviceSN, enabled):
"""
Expand Down Expand Up @@ -1438,7 +1454,7 @@ async def get_scheduler(self, deviceSN, checkBattery=True):
self.fdsoc_min[deviceSN] = result.get("properties", {}).get("fdsoc", {}).get("range", {}).get("min", 10)
self.device_scheduler_count[deviceSN] = len(result.get("groups", []))
self.device_scheduler[deviceSN] = result
self.update_settings_from_schedule(deviceSN, result.get("groups", []))
self.update_settings_from_schedule(deviceSN, result.get("groups", []), result.get("properties", {}))
return result
return None

Expand All @@ -1450,8 +1466,9 @@ async def get_scheduler_v2(self, deviceSN):
inverters (productType 812) even though those devices fully support the
scheduler. The v2 response nests each group's SOC/power fields inside
'extraParam'; flatten them back into the group so the rest of the code can
treat v1 and v2 results identically. v2 has no 'properties' block, so the
existing fdPwr/fdSoc defaults (capped to inverter capacity) apply.
treat v1 and v2 results identically. v2 does return a 'properties' block (unlike
earlier assumed) with real per-field ranges/units, so it is passed through unchanged
for get_scheduler() and update_settings_from_schedule() to use, exactly like v1.

{'enable': 1, 'groups':
[
Expand Down Expand Up @@ -1501,7 +1518,7 @@ async def get_scheduler_v2(self, deviceSN):
groups.append(flat_group)
# Default enable to 1 when the key is absent: v2 returned groups, so the scheduler
# is active (compute_schedule treats a falsy enable as "scheduler disabled")
return {"enable": result.get("enable", 1), "groups": groups}
return {"enable": result.get("enable", 1), "groups": groups, "properties": result.get("properties", {})}

async def get_device_list(self):
"""
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
import pytz
import asyncio

THIS_VERSION = "v8.44.2"
THIS_VERSION = "v8.44.3"

from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
from const import MINUTE_WATT
Expand Down
128 changes: 127 additions & 1 deletion apps/predbat/tests/test_fox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2312,14 +2312,55 @@ def test_api_get_scheduler_v2_evo(my_predbat):
assert group["maxSoc"] == 100.0
assert "extraParam" not in group

# v2 has no properties block: fall back to defaults, capped at inverter capacity
# This mocked response has no properties block: fall back to defaults, capped at capacity
assert fox.fdpwr_max[deviceSN] == 8000
assert fox.fdsoc_min[deviceSN] == 10
assert fox.device_scheduler_count[deviceSN] == 1

return False


def test_api_get_scheduler_v2_uses_real_properties(my_predbat):
"""
Test get_scheduler uses the real per-field ranges from a v2 response's properties block
instead of the generic defaults, when the device actually returns one.

Regression guard: get_scheduler_v2 used to discard the properties block entirely on the
(incorrect) assumption that v2 never returns one - production responses do include it.
"""
print(" - test_api_get_scheduler_v2_uses_real_properties")

fox = MockFoxAPIWithRequests()
deviceSN = "EVO1234567"
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 20, "productType": "812"}

fox.set_mock_response(
"/op/v2/device/scheduler/get",
{
"enable": 1,
"groups": [
{"enable": 1, "startHour": 0, "endHour": 5, "workMode": "ForceCharge", "extraParam": {"fdPwr": 5000.0, "fdSoc": 100.0}},
],
"properties": {
"fdpwr": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 12000.0}},
"fdsoc": {"unit": "%", "precision": 1.0, "range": {"min": 5.0, "max": 100.0}},
"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}},
},
},
)

result = asyncio.run(fox.get_scheduler(deviceSN))

# Real reported max (12000) must win over the generic 8000 default
assert fox.fdpwr_max[deviceSN] == 12000
# Real reported min (5) must win over the generic 10 default
assert fox.fdsoc_min[deviceSN] == 5
# The properties block itself must be preserved on the result, not discarded
assert result["properties"]["exportlimit"]["range"] == {"min": 0.0, "max": 100000.0}

return False


def test_api_get_scheduler_derives_settings_from_schedule(my_predbat):
"""
Test get_scheduler derives ExportLimit/ImportLimit/MaxSoc/PvLimit (max) and MinSocOnGrid
Expand Down Expand Up @@ -2378,6 +2419,88 @@ def test_api_get_scheduler_derives_settings_from_schedule(my_predbat):
return False


def test_api_get_scheduler_derives_settings_with_range_from_properties(my_predbat):
"""
Test update_settings_from_schedule attaches the real range/unit/precision from a v2
response's properties block to a derived setting, not just a bare value.

Regression guard: without this, ExportLimit derived purely as {"value": ...} publishes as
a sensor (no range/enumList), but automatic_config wires export_limit to a hardcoded
number.*_setting_exportlimit entity id - leaving that entity unresolvable (HA reports
None) even though Predbat believes it configured a working export limit.
"""
print(" - test_api_get_scheduler_derives_settings_with_range_from_properties")

fox = MockFoxAPIWithRequests()
deviceSN = "EVO1234567"
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 20, "productType": "812"}

fox.set_mock_response(
"/op/v2/device/scheduler/get",
{
"enable": 1,
"groups": [
{"enable": 1, "startHour": 0, "endHour": 23, "workMode": "SelfUse", "extraParam": {"fdPwr": 5000.0, "fdSoc": 10.0, "exportLimit": 12000.0, "minSocOnGrid": 10.0, "maxSoc": 100.0}},
],
"properties": {
"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}},
},
},
)

asyncio.run(fox.get_scheduler(deviceSN))

export_limit_setting = fox.device_settings[deviceSN]["ExportLimit"]
assert export_limit_setting["value"] == 12000.0
assert export_limit_setting["range"] == {"min": 0.0, "max": 100000.0}
assert export_limit_setting["unit"] == "W"
assert export_limit_setting["precision"] == 1.0

# A field with no matching properties entry (maxSoc here) still just gets a bare value
assert fox.device_settings[deviceSN]["MaxSoc"] == {"value": 100.0}

return False


def test_publish_data_derived_export_limit_publishes_as_number(my_predbat):
"""
End-to-end regression guard: a schedule-derived ExportLimit with real range metadata must
publish as a number entity, matching the hardcoded number.*_setting_exportlimit entity id
automatic_config wires export_limit to - not a sensor, which would leave that entity
unresolvable and fail apps.yaml validation (HA reports state None).
"""
print(" - test_publish_data_derived_export_limit_publishes_as_number")

fox = MockFoxAPIWithRequests()
deviceSN = "TEST123456"

fox.device_list = [{"deviceSN": deviceSN}]
fox.device_detail[deviceSN] = {"hasPV": True, "hasBattery": True, "capacity": 8, "function": {}, "deviceType": "KH8", "stationName": "Test", "batteryList": []}
fox.fdpwr_max[deviceSN] = 8000
fox.fdsoc_min[deviceSN] = 10
fox.device_values[deviceSN] = {}
fox.local_schedule[deviceSN] = {}

# Simulate a schedule-derived ExportLimit, with range metadata from a real properties block
fox.update_settings_from_schedule(
deviceSN,
[{"exportLimit": 12000.0}],
{"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}}},
)

run_async(fox.publish_data())

export_limit_entity = f"number.predbat_fox_{deviceSN.lower()}_setting_exportlimit"
assert export_limit_entity in fox.dashboard_items
assert fox.dashboard_items[export_limit_entity]["state"] == 12000.0
assert fox.dashboard_items[export_limit_entity]["attributes"]["max"] == 100000.0

# Must NOT have also published as a sensor
assert f"sensor.predbat_fox_{deviceSN.lower()}_setting_exportlimit" not in fox.dashboard_items

return False


def test_api_get_scheduler_v2_null_groups(my_predbat):
"""
Test get_scheduler_v2 tolerates a present-but-null groups value and defaults enable
Expand Down Expand Up @@ -6538,7 +6661,9 @@ def run_fox_api_tests(my_predbat):
failed |= test_api_set_battery_charging_time(my_predbat)
failed |= test_api_get_scheduler(my_predbat)
failed |= test_api_get_scheduler_v2_evo(my_predbat)
failed |= test_api_get_scheduler_v2_uses_real_properties(my_predbat)
failed |= test_api_get_scheduler_derives_settings_from_schedule(my_predbat)
failed |= test_api_get_scheduler_derives_settings_with_range_from_properties(my_predbat)
failed |= test_api_get_scheduler_v2_null_groups(my_predbat)
failed |= test_api_get_scheduler_kh_stays_v1(my_predbat)
failed |= test_api_get_scheduler_v2_evo_fails(my_predbat)
Expand Down Expand Up @@ -6658,6 +6783,7 @@ def run_fox_api_tests(my_predbat):
failed |= test_publish_data_device_values_dual_soc(my_predbat)
failed |= test_publish_data_device_settings(my_predbat)
failed |= test_publish_data_workmode_default_publishes_as_select(my_predbat)
failed |= test_publish_data_derived_export_limit_publishes_as_number(my_predbat)
failed |= test_publish_data_no_battery_skips_settings(my_predbat)

# apply_battery_schedule tests
Expand Down
Loading