Skip to content

Commit 54761f4

Browse files
Fox - fix issue with export limit and v2 scheduler (#4193)
1 parent 9d6a752 commit 54761f4

3 files changed

Lines changed: 151 additions & 8 deletions

File tree

apps/predbat/fox.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,20 +1168,36 @@ def get_schedule_extra_param(self, deviceSN):
11681168
extra_param[group_key] = value
11691169
return extra_param
11701170

1171-
def update_settings_from_schedule(self, deviceSN, groups):
1171+
def update_settings_from_schedule(self, deviceSN, groups, properties):
11721172
"""
11731173
Derive settings from a live schedule read that the settings/get endpoint may not
11741174
support (e.g. errno 42015) or that are not part of FOX_SETTINGS at all (ImportLimit,
11751175
PvLimit). exportLimit/importLimit/maxSoc/pvLimit use the max seen across all groups;
11761176
minSocOnGrid uses the min. A setting that already has a working register-backed entry
11771177
is left untouched so its range/precision metadata is not clobbered by a bare value.
1178+
1179+
properties supplies the real range/unit/precision Fox reports for each field, so the
1180+
derived setting publishes as a proper editable number entity - without it, automatic_
1181+
config still wires a hardcoded number.*_setting_xxx entity id (e.g. for export_limit)
1182+
that a bare {"value": ...} stub, having no range, would never actually publish as (it
1183+
would end up a sensor instead, leaving the number entity unresolvable). Required rather
1184+
than defaulted so a caller can't silently omit it and reintroduce that bug - pass {} if
1185+
a read genuinely has none.
11781186
"""
11791187
for group_key, setting_key, aggregate in SCHEDULE_DERIVED_SETTINGS:
11801188
values = [group[group_key] for group in groups if group_key in group]
11811189
if not values:
11821190
continue
11831191
if setting_key not in self.device_settings.get(deviceSN, {}) or self.is_setting_unavailable(deviceSN, setting_key):
1184-
self.device_settings.setdefault(deviceSN, {})[setting_key] = {"value": aggregate(values)}
1192+
entry = {"value": aggregate(values)}
1193+
prop = properties.get(group_key.lower(), {})
1194+
if "range" in prop:
1195+
entry["range"] = prop["range"]
1196+
if "unit" in prop:
1197+
entry["unit"] = prop["unit"]
1198+
if "precision" in prop:
1199+
entry["precision"] = prop["precision"]
1200+
self.device_settings.setdefault(deviceSN, {})[setting_key] = entry
11851201

11861202
async def set_scheduler_enabled(self, deviceSN, enabled):
11871203
"""
@@ -1438,7 +1454,7 @@ async def get_scheduler(self, deviceSN, checkBattery=True):
14381454
self.fdsoc_min[deviceSN] = result.get("properties", {}).get("fdsoc", {}).get("range", {}).get("min", 10)
14391455
self.device_scheduler_count[deviceSN] = len(result.get("groups", []))
14401456
self.device_scheduler[deviceSN] = result
1441-
self.update_settings_from_schedule(deviceSN, result.get("groups", []))
1457+
self.update_settings_from_schedule(deviceSN, result.get("groups", []), result.get("properties", {}))
14421458
return result
14431459
return None
14441460

@@ -1450,8 +1466,9 @@ async def get_scheduler_v2(self, deviceSN):
14501466
inverters (productType 812) even though those devices fully support the
14511467
scheduler. The v2 response nests each group's SOC/power fields inside
14521468
'extraParam'; flatten them back into the group so the rest of the code can
1453-
treat v1 and v2 results identically. v2 has no 'properties' block, so the
1454-
existing fdPwr/fdSoc defaults (capped to inverter capacity) apply.
1469+
treat v1 and v2 results identically. v2 does return a 'properties' block (unlike
1470+
earlier assumed) with real per-field ranges/units, so it is passed through unchanged
1471+
for get_scheduler() and update_settings_from_schedule() to use, exactly like v1.
14551472
14561473
{'enable': 1, 'groups':
14571474
[
@@ -1501,7 +1518,7 @@ async def get_scheduler_v2(self, deviceSN):
15011518
groups.append(flat_group)
15021519
# Default enable to 1 when the key is absent: v2 returned groups, so the scheduler
15031520
# is active (compute_schedule treats a falsy enable as "scheduler disabled")
1504-
return {"enable": result.get("enable", 1), "groups": groups}
1521+
return {"enable": result.get("enable", 1), "groups": groups, "properties": result.get("properties", {})}
15051522

15061523
async def get_device_list(self):
15071524
"""

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import pytz
3636
import asyncio
3737

38-
THIS_VERSION = "v8.44.2"
38+
THIS_VERSION = "v8.44.3"
3939

4040
from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
4141
from const import MINUTE_WATT

apps/predbat/tests/test_fox_api.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2312,14 +2312,55 @@ def test_api_get_scheduler_v2_evo(my_predbat):
23122312
assert group["maxSoc"] == 100.0
23132313
assert "extraParam" not in group
23142314

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

23202320
return False
23212321

23222322

2323+
def test_api_get_scheduler_v2_uses_real_properties(my_predbat):
2324+
"""
2325+
Test get_scheduler uses the real per-field ranges from a v2 response's properties block
2326+
instead of the generic defaults, when the device actually returns one.
2327+
2328+
Regression guard: get_scheduler_v2 used to discard the properties block entirely on the
2329+
(incorrect) assumption that v2 never returns one - production responses do include it.
2330+
"""
2331+
print(" - test_api_get_scheduler_v2_uses_real_properties")
2332+
2333+
fox = MockFoxAPIWithRequests()
2334+
deviceSN = "EVO1234567"
2335+
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 20, "productType": "812"}
2336+
2337+
fox.set_mock_response(
2338+
"/op/v2/device/scheduler/get",
2339+
{
2340+
"enable": 1,
2341+
"groups": [
2342+
{"enable": 1, "startHour": 0, "endHour": 5, "workMode": "ForceCharge", "extraParam": {"fdPwr": 5000.0, "fdSoc": 100.0}},
2343+
],
2344+
"properties": {
2345+
"fdpwr": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 12000.0}},
2346+
"fdsoc": {"unit": "%", "precision": 1.0, "range": {"min": 5.0, "max": 100.0}},
2347+
"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}},
2348+
},
2349+
},
2350+
)
2351+
2352+
result = asyncio.run(fox.get_scheduler(deviceSN))
2353+
2354+
# Real reported max (12000) must win over the generic 8000 default
2355+
assert fox.fdpwr_max[deviceSN] == 12000
2356+
# Real reported min (5) must win over the generic 10 default
2357+
assert fox.fdsoc_min[deviceSN] == 5
2358+
# The properties block itself must be preserved on the result, not discarded
2359+
assert result["properties"]["exportlimit"]["range"] == {"min": 0.0, "max": 100000.0}
2360+
2361+
return False
2362+
2363+
23232364
def test_api_get_scheduler_derives_settings_from_schedule(my_predbat):
23242365
"""
23252366
Test get_scheduler derives ExportLimit/ImportLimit/MaxSoc/PvLimit (max) and MinSocOnGrid
@@ -2378,6 +2419,88 @@ def test_api_get_scheduler_derives_settings_from_schedule(my_predbat):
23782419
return False
23792420

23802421

2422+
def test_api_get_scheduler_derives_settings_with_range_from_properties(my_predbat):
2423+
"""
2424+
Test update_settings_from_schedule attaches the real range/unit/precision from a v2
2425+
response's properties block to a derived setting, not just a bare value.
2426+
2427+
Regression guard: without this, ExportLimit derived purely as {"value": ...} publishes as
2428+
a sensor (no range/enumList), but automatic_config wires export_limit to a hardcoded
2429+
number.*_setting_exportlimit entity id - leaving that entity unresolvable (HA reports
2430+
None) even though Predbat believes it configured a working export limit.
2431+
"""
2432+
print(" - test_api_get_scheduler_derives_settings_with_range_from_properties")
2433+
2434+
fox = MockFoxAPIWithRequests()
2435+
deviceSN = "EVO1234567"
2436+
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 20, "productType": "812"}
2437+
2438+
fox.set_mock_response(
2439+
"/op/v2/device/scheduler/get",
2440+
{
2441+
"enable": 1,
2442+
"groups": [
2443+
{"enable": 1, "startHour": 0, "endHour": 23, "workMode": "SelfUse", "extraParam": {"fdPwr": 5000.0, "fdSoc": 10.0, "exportLimit": 12000.0, "minSocOnGrid": 10.0, "maxSoc": 100.0}},
2444+
],
2445+
"properties": {
2446+
"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}},
2447+
},
2448+
},
2449+
)
2450+
2451+
asyncio.run(fox.get_scheduler(deviceSN))
2452+
2453+
export_limit_setting = fox.device_settings[deviceSN]["ExportLimit"]
2454+
assert export_limit_setting["value"] == 12000.0
2455+
assert export_limit_setting["range"] == {"min": 0.0, "max": 100000.0}
2456+
assert export_limit_setting["unit"] == "W"
2457+
assert export_limit_setting["precision"] == 1.0
2458+
2459+
# A field with no matching properties entry (maxSoc here) still just gets a bare value
2460+
assert fox.device_settings[deviceSN]["MaxSoc"] == {"value": 100.0}
2461+
2462+
return False
2463+
2464+
2465+
def test_publish_data_derived_export_limit_publishes_as_number(my_predbat):
2466+
"""
2467+
End-to-end regression guard: a schedule-derived ExportLimit with real range metadata must
2468+
publish as a number entity, matching the hardcoded number.*_setting_exportlimit entity id
2469+
automatic_config wires export_limit to - not a sensor, which would leave that entity
2470+
unresolvable and fail apps.yaml validation (HA reports state None).
2471+
"""
2472+
print(" - test_publish_data_derived_export_limit_publishes_as_number")
2473+
2474+
fox = MockFoxAPIWithRequests()
2475+
deviceSN = "TEST123456"
2476+
2477+
fox.device_list = [{"deviceSN": deviceSN}]
2478+
fox.device_detail[deviceSN] = {"hasPV": True, "hasBattery": True, "capacity": 8, "function": {}, "deviceType": "KH8", "stationName": "Test", "batteryList": []}
2479+
fox.fdpwr_max[deviceSN] = 8000
2480+
fox.fdsoc_min[deviceSN] = 10
2481+
fox.device_values[deviceSN] = {}
2482+
fox.local_schedule[deviceSN] = {}
2483+
2484+
# Simulate a schedule-derived ExportLimit, with range metadata from a real properties block
2485+
fox.update_settings_from_schedule(
2486+
deviceSN,
2487+
[{"exportLimit": 12000.0}],
2488+
{"exportlimit": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 100000.0}}},
2489+
)
2490+
2491+
run_async(fox.publish_data())
2492+
2493+
export_limit_entity = f"number.predbat_fox_{deviceSN.lower()}_setting_exportlimit"
2494+
assert export_limit_entity in fox.dashboard_items
2495+
assert fox.dashboard_items[export_limit_entity]["state"] == 12000.0
2496+
assert fox.dashboard_items[export_limit_entity]["attributes"]["max"] == 100000.0
2497+
2498+
# Must NOT have also published as a sensor
2499+
assert f"sensor.predbat_fox_{deviceSN.lower()}_setting_exportlimit" not in fox.dashboard_items
2500+
2501+
return False
2502+
2503+
23812504
def test_api_get_scheduler_v2_null_groups(my_predbat):
23822505
"""
23832506
Test get_scheduler_v2 tolerates a present-but-null groups value and defaults enable
@@ -6538,7 +6661,9 @@ def run_fox_api_tests(my_predbat):
65386661
failed |= test_api_set_battery_charging_time(my_predbat)
65396662
failed |= test_api_get_scheduler(my_predbat)
65406663
failed |= test_api_get_scheduler_v2_evo(my_predbat)
6664+
failed |= test_api_get_scheduler_v2_uses_real_properties(my_predbat)
65416665
failed |= test_api_get_scheduler_derives_settings_from_schedule(my_predbat)
6666+
failed |= test_api_get_scheduler_derives_settings_with_range_from_properties(my_predbat)
65426667
failed |= test_api_get_scheduler_v2_null_groups(my_predbat)
65436668
failed |= test_api_get_scheduler_kh_stays_v1(my_predbat)
65446669
failed |= test_api_get_scheduler_v2_evo_fails(my_predbat)
@@ -6658,6 +6783,7 @@ def run_fox_api_tests(my_predbat):
66586783
failed |= test_publish_data_device_values_dual_soc(my_predbat)
66596784
failed |= test_publish_data_device_settings(my_predbat)
66606785
failed |= test_publish_data_workmode_default_publishes_as_select(my_predbat)
6786+
failed |= test_publish_data_derived_export_limit_publishes_as_number(my_predbat)
66616787
failed |= test_publish_data_no_battery_skips_settings(my_predbat)
66626788

66636789
# apply_battery_schedule tests

0 commit comments

Comments
 (0)