diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index 7cbd002b0..677cf807f 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -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): """ @@ -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 @@ -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': [ @@ -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): """ diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index b0131ba9c..40ba3c19c 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -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 diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index 3be5ff7ae..abd1b457d 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -2312,7 +2312,7 @@ 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 @@ -2320,6 +2320,47 @@ def test_api_get_scheduler_v2_evo(my_predbat): 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 @@ -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 @@ -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) @@ -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