Skip to content

Commit c0e2c21

Browse files
fix(fox): correct half-kW inverter capacity and round noisy production totals (#4527)
* fix(fox): correct half-kW inverter capacity and round noisy production totals Fox's device/detail 'capacity' field is a truncated integer, so a 10.5kW KH10.5 inverter reported as 10kW everywhere - both the reported inverter_capacity/inverter_limit sensor and the fdpwr_max clamp that was silently capping the correct 10500W scheduler value down to 10000W. Now corrects the value from deviceType when it ends in '.5'. Also round the _today/_month production sensors (e.g. generation_today) which were carrying long floating-point summation artifacts from Fox's raw history data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(fox): clamp fdpwr_max at the corrected capacity instead of a fudge margin Cap at inverter_capacity directly now that capacity_watts() already corrects half-kW deviceTypes - the earlier +999W tolerance was a blunter hack that could let a genuinely bogus fdpwr reading through undetected. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 884d78f commit c0e2c21

2 files changed

Lines changed: 192 additions & 4 deletions

File tree

apps/predbat/fox.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from component_base import ComponentBase
2828
from mock_base import MockBase
2929
from oauth_mixin import OAuthMixin
30+
from utils import dp2
3031

3132
# Define TIME_FORMAT_HA locally to avoid dependency issues
3233
TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z"
@@ -906,8 +907,22 @@ async def get_device_detail(self, deviceSN):
906907
result = await self.request_get(GET_DEVICE_INFO, post=False, datain=query)
907908
if result is not None:
908909
self.device_detail[deviceSN] = result
910+
self.log("Fox: Device detail {}".format(result))
909911
return result
910912

913+
@staticmethod
914+
def capacity_watts(detail):
915+
"""
916+
Return the device's rated capacity in watts, correcting for Fox reporting the
917+
device/detail 'capacity' field as a truncated integer on half-kW models (e.g. a
918+
10.5kW KH10.5 inverter reports capacity=10). If deviceType ends in '.5' and the
919+
raw capacity is an exact multiple of 1000W, bump it up to end in 500.
920+
"""
921+
capacity = detail.get("capacity", 0) * 1000.0
922+
if capacity and capacity % 1000 == 0 and str(detail.get("deviceType", "")).endswith(".5"):
923+
capacity += 500.0
924+
return capacity
925+
911926
async def get_device_settings(self, deviceSN, checkBattery=True):
912927
"""
913928
Get device settings
@@ -1464,7 +1479,7 @@ async def get_scheduler(self, deviceSN, checkBattery=True):
14641479
return {}
14651480

14661481
detail = self.device_detail.get(deviceSN, {})
1467-
inverter_capacity = detail.get("capacity", 0) * 1000.0
1482+
inverter_capacity = self.capacity_watts(detail)
14681483

14691484
# EVO-series devices fail the v1 scheduler API permanently (errno 41200); route
14701485
# them to v2 by productType. Every other device stays on v1, which it supports.
@@ -1477,6 +1492,7 @@ async def get_scheduler(self, deviceSN, checkBattery=True):
14771492
if result is not None:
14781493
self.fdpwr_max[deviceSN] = result.get("properties", {}).get("fdpwr", {}).get("range", {}).get("max", 8000)
14791494
# XXX: Fox seems to be have an issue with FD Power max value being too high, cap it at the inverter capacity
1495+
# (inverter_capacity is already corrected for half-kW deviceTypes by capacity_watts())
14801496
if inverter_capacity:
14811497
self.fdpwr_max[deviceSN] = min(inverter_capacity, self.fdpwr_max[deviceSN])
14821498

@@ -1758,7 +1774,7 @@ async def publish_data(self):
17581774
detail = self.device_detail.get(sn, {})
17591775
hasPV = detail.get("hasPV", False)
17601776
hasBattery = detail.get("hasBattery", False)
1761-
capacity = detail.get("capacity", 0) * 1000.0
1777+
capacity = self.capacity_watts(detail)
17621778
hasScheduler = detail.get("function", {}).get("scheduler", False)
17631779
deviceType = detail.get("deviceType", "Unknown")
17641780
stationName = detail.get("stationName", "Unknown")
@@ -1915,7 +1931,7 @@ async def publish_data(self):
19151931
# Month Total Sensor
19161932
item_name = variable + " (Month)"
19171933
entity_id = entity_name_sensor + "_" + sn.lower() + "_" + variable.lower() + "_month"
1918-
state = sum(values)
1934+
state = dp2(sum(values))
19191935
attributes = {"unit_of_measurement": units, "friendly_name": f"Fox {sn} {item_name}", "values": values}
19201936
if units in ["kWh", "Wh"]:
19211937
attributes["device_class"] = "energy"
@@ -1927,7 +1943,7 @@ async def publish_data(self):
19271943
# Today Total Sensor
19281944
item_name = variable + " (Today)"
19291945
entity_id = entity_name_sensor + "_" + sn.lower() + "_" + variable.lower() + "_today"
1930-
state = values[today - 1] if len(values) >= today else 0
1946+
state = dp2(values[today - 1]) if len(values) >= today else 0
19311947

19321948
attributes = {
19331949
"unit_of_measurement": units,

apps/predbat/tests/test_fox_api.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1558,6 +1558,31 @@ def test_api_get_device_detail(my_predbat):
15581558
return False
15591559

15601560

1561+
def test_capacity_watts_half_kw_device_type(my_predbat):
1562+
"""
1563+
Test capacity_watts() corrects the truncated 'capacity' field for half-kW models.
1564+
1565+
Fox's device/detail 'capacity' field is an integer, so a 10.5kW KH10.5 inverter
1566+
reports capacity=10. Only a deviceType ending in '.5' with a whole-kW capacity
1567+
should be bumped up to end in 500; anything else passes through unchanged.
1568+
"""
1569+
print(" - test_capacity_watts_half_kw_device_type")
1570+
1571+
# Whole-kW model is untouched
1572+
assert FoxAPI.capacity_watts({"capacity": 8, "deviceType": "KH8"}) == 8000
1573+
# Half-kW model gets bumped from a truncated whole-kW capacity
1574+
assert FoxAPI.capacity_watts({"capacity": 10, "deviceType": "KH10.5"}) == 10500
1575+
# Missing/unknown deviceType is left alone
1576+
assert FoxAPI.capacity_watts({"capacity": 10, "deviceType": "Unknown"}) == 10000
1577+
assert FoxAPI.capacity_watts({"capacity": 10}) == 10000
1578+
# Zero capacity is left alone even with a half-kW deviceType
1579+
assert FoxAPI.capacity_watts({"capacity": 0, "deviceType": "KH10.5"}) == 0
1580+
# A capacity that isn't a whole kW multiple is left alone (already carries a fraction)
1581+
assert FoxAPI.capacity_watts({"capacity": 10.3, "deviceType": "KH10.5"}) == 10300
1582+
1583+
return False
1584+
1585+
15611586
def test_api_get_device_history(my_predbat):
15621587
"""
15631588
Test get_device_history API endpoint
@@ -2191,6 +2216,72 @@ def test_api_get_scheduler(my_predbat):
21912216
return False
21922217

21932218

2219+
def test_api_get_scheduler_half_kw_capacity(my_predbat):
2220+
"""
2221+
Test get_scheduler doesn't clamp fdpwr_max below a genuine half-kW rating.
2222+
2223+
Fox's device/detail 'capacity' field is an integer, so a 10.5kW KH10.5 inverter reports
2224+
capacity=10 even though the scheduler API correctly reports a 10500W fdpwr max. The clamp
2225+
uses capacity_watts(), which corrects the half-kW deviceType before capping, so it should
2226+
still clamp to the true 10500W rather than the truncated 10000W.
2227+
"""
2228+
print(" - test_api_get_scheduler_half_kw_capacity")
2229+
2230+
fox = MockFoxAPIWithRequests()
2231+
deviceSN = "TEST123456"
2232+
2233+
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 10, "deviceType": "KH10.5"}
2234+
2235+
fox.set_mock_response(
2236+
"/op/v1/device/scheduler/get",
2237+
{
2238+
"enable": 1,
2239+
"groups": [],
2240+
"properties": {
2241+
"fdpwr": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 10500.0}},
2242+
"fdsoc": {"unit": "%", "precision": 1.0, "range": {"min": 10.0, "max": 100.0}},
2243+
},
2244+
},
2245+
)
2246+
2247+
asyncio.run(fox.get_scheduler(deviceSN))
2248+
2249+
assert fox.fdpwr_max[deviceSN] == 10500
2250+
2251+
return False
2252+
2253+
2254+
def test_api_get_scheduler_still_clamps_bogus_fdpwr(my_predbat):
2255+
"""
2256+
Test get_scheduler still clamps a genuinely bogus fdpwr max that exceeds the
2257+
device's rated capacity.
2258+
"""
2259+
print(" - test_api_get_scheduler_still_clamps_bogus_fdpwr")
2260+
2261+
fox = MockFoxAPIWithRequests()
2262+
deviceSN = "TEST123456"
2263+
2264+
fox.device_detail[deviceSN] = {"hasBattery": True, "capacity": 8}
2265+
2266+
fox.set_mock_response(
2267+
"/op/v1/device/scheduler/get",
2268+
{
2269+
"enable": 1,
2270+
"groups": [],
2271+
"properties": {
2272+
"fdpwr": {"unit": "W", "precision": 1.0, "range": {"min": 0.0, "max": 32000.0}},
2273+
"fdsoc": {"unit": "%", "precision": 1.0, "range": {"min": 10.0, "max": 100.0}},
2274+
},
2275+
},
2276+
)
2277+
2278+
asyncio.run(fox.get_scheduler(deviceSN))
2279+
2280+
assert fox.fdpwr_max[deviceSN] == 8000
2281+
2282+
return False
2283+
2284+
21942285
def test_api_set_scheduler(my_predbat):
21952286
"""
21962287
Test set_scheduler API endpoint
@@ -5515,6 +5606,46 @@ def test_publish_data_device_info(my_predbat):
55155606
return False
55165607

55175608

5609+
def test_publish_data_device_info_half_kw_capacity(my_predbat):
5610+
"""
5611+
Test publish_data corrects a half-kW model's truncated capacity.
5612+
5613+
Fox reports the device/detail 'capacity' field as an integer, so a 10.5kW KH10.5
5614+
inverter reports capacity=10. deviceType ending in '.5' with a whole-kW capacity
5615+
should bump the reported watts up to end in 500 rather than 000.
5616+
"""
5617+
print(" - test_publish_data_device_info_half_kw_capacity")
5618+
5619+
fox = MockFoxAPIWithRequests()
5620+
deviceSN = "TEST123456"
5621+
5622+
fox.device_list = [{"deviceSN": deviceSN}]
5623+
fox.device_detail[deviceSN] = {
5624+
"hasPV": True,
5625+
"hasBattery": True,
5626+
"capacity": 10,
5627+
"function": {"scheduler": True},
5628+
"deviceType": "KH10.5",
5629+
"stationName": "Test Home",
5630+
"batteryList": [{"capacity": 10360}],
5631+
}
5632+
fox.fdpwr_max[deviceSN] = 10500
5633+
fox.fdsoc_min[deviceSN] = 10
5634+
fox.device_values[deviceSN] = {}
5635+
fox.device_settings[deviceSN] = {}
5636+
fox.local_schedule[deviceSN] = {}
5637+
5638+
run_async(fox.publish_data())
5639+
5640+
info_entity = f"sensor.predbat_fox_{deviceSN.lower()}_info"
5641+
assert fox.dashboard_items[info_entity]["attributes"]["inverterCapacity"] == 10500
5642+
5643+
inverter_capacity_entity = f"sensor.predbat_fox_{deviceSN.lower()}_inverter_capacity"
5644+
assert fox.dashboard_items[inverter_capacity_entity]["state"] == 10500
5645+
5646+
return False
5647+
5648+
55185649
def test_publish_data_battery_soh(my_predbat):
55195650
"""
55205651
Test publish_data creates battery_soh sensor with correct value and attributes
@@ -5647,6 +5778,42 @@ def test_publish_data_device_values_dual_soc(my_predbat):
56475778
return False
56485779

56495780

5781+
def test_publish_data_production_today_and_month_rounded(my_predbat):
5782+
"""
5783+
Test publish_data rounds the '_today' and '_month' production sensors to 2dp.
5784+
5785+
Fox's daily history values carry floating-point summation artifacts (e.g.
5786+
33.30000000000109), which without rounding leak through to the today total
5787+
directly and compound further when summed for the month total.
5788+
"""
5789+
print(" - test_publish_data_production_today_and_month_rounded")
5790+
5791+
fox = MockFoxAPIWithRequests()
5792+
deviceSN = "TEST123456"
5793+
5794+
fox.device_list = [{"deviceSN": deviceSN}]
5795+
fox.device_detail[deviceSN] = {"hasPV": True, "hasBattery": True, "capacity": 8, "function": {}, "deviceType": "KH8", "stationName": "Test", "batteryList": []}
5796+
fox.fdpwr_max[deviceSN] = 8000
5797+
fox.fdsoc_min[deviceSN] = 10
5798+
fox.device_values[deviceSN] = {}
5799+
fox.device_settings[deviceSN] = {}
5800+
fox.local_schedule[deviceSN] = {}
5801+
5802+
# Every day carries the same artifact-laden value so the assertion doesn't depend on today's date
5803+
daily_value = 33.30000000000109
5804+
fox.device_production_month[deviceSN] = [{"unit": "kWh", "variable": "generation", "values": [daily_value] * 31}]
5805+
5806+
run_async(fox.publish_data())
5807+
5808+
today_entity = f"sensor.predbat_fox_{deviceSN.lower()}_generation_today"
5809+
assert fox.dashboard_items[today_entity]["state"] == 33.3, f"Expected 33.3 but got {fox.dashboard_items[today_entity]['state']}"
5810+
5811+
month_entity = f"sensor.predbat_fox_{deviceSN.lower()}_generation_month"
5812+
assert fox.dashboard_items[month_entity]["state"] == round(31 * daily_value, 2), f"Got {fox.dashboard_items[month_entity]['state']}"
5813+
5814+
return False
5815+
5816+
56505817
def test_publish_data_device_settings(my_predbat):
56515818
"""
56525819
Test publish_data creates settings entities correctly
@@ -6737,6 +6904,7 @@ def run_fox_api_tests(my_predbat):
67376904
# API endpoint tests with mocked request_get
67386905
failed |= test_api_get_device_list(my_predbat)
67396906
failed |= test_api_get_device_detail(my_predbat)
6907+
failed |= test_capacity_watts_half_kw_device_type(my_predbat)
67406908
failed |= test_api_get_device_history(my_predbat)
67416909
failed |= test_api_get_device_history_empty(my_predbat)
67426910
failed |= test_api_get_available_variables(my_predbat)
@@ -6755,6 +6923,8 @@ def run_fox_api_tests(my_predbat):
67556923
failed |= test_api_get_battery_charging_time(my_predbat)
67566924
failed |= test_api_set_battery_charging_time(my_predbat)
67576925
failed |= test_api_get_scheduler(my_predbat)
6926+
failed |= test_api_get_scheduler_half_kw_capacity(my_predbat)
6927+
failed |= test_api_get_scheduler_still_clamps_bogus_fdpwr(my_predbat)
67586928
failed |= test_api_get_scheduler_v2_evo(my_predbat)
67596929
failed |= test_api_get_scheduler_v2_uses_real_properties(my_predbat)
67606930
failed |= test_api_get_scheduler_derives_settings_from_schedule(my_predbat)
@@ -6875,9 +7045,11 @@ def run_fox_api_tests(my_predbat):
68757045

68767046
# publish_data tests
68777047
failed |= test_publish_data_device_info(my_predbat)
7048+
failed |= test_publish_data_device_info_half_kw_capacity(my_predbat)
68787049
failed |= test_publish_data_battery_soh(my_predbat)
68797050
failed |= test_publish_data_device_values(my_predbat)
68807051
failed |= test_publish_data_device_values_dual_soc(my_predbat)
7052+
failed |= test_publish_data_production_today_and_month_rounded(my_predbat)
68817053
failed |= test_publish_data_device_settings(my_predbat)
68827054
failed |= test_publish_data_workmode_default_publishes_as_select(my_predbat)
68837055
failed |= test_publish_data_derived_export_limit_publishes_as_number(my_predbat)

0 commit comments

Comments
 (0)