Skip to content

Commit cace97d

Browse files
Retain the last good GE Cloud reading when leaf values come back null
GE Cloud Gateway devices intermittently answer inverter/<serial>/system-data/latest with HTTP 200 and a well-formed envelope whose leaf values are explicitly null. publish_status only guarded a whole sub-object being None, and status[key].get("power", 0) does not fire its default for a key that is present with a null value, so the literal None was published straight to the entity. Every consumer then warned and fell back to 0.0, which record_status pinned onto predbat.status, and the fabricated zero reached the optimiser and the inverter write path as "current SoC 0%" while the battery was at 41-45%. Merge each fresh response over the previous one in async_get_inverter_status and async_get_inverter_meter, skipping null leaves so the last good value is retained. A transient null poll now leaves the readings untouched, and the next poll takes over normally, including legitimate zero readings. Where there is no previous reading to fall back on the null is kept as None rather than dropped, so a field that has never had a value carries on reporting "no value" exactly as it does today instead of being turned into a fabricated zero by a .get(field, 0) default. On the affected Gateway that covers grid.frequency, inverter.temperature, inverter.output_voltage and inverter.output_frequency, which appear never to be populated for that model and are not wired into apps.yaml by automatic_config. today and total in the meter response are objects rather than readings, so a null section with nothing cached behind it cannot be kept as None the way a null leaf is - publish_meter would iterate it and raise TypeError. Drop the unusable section and pick the counters up on the next poll, rather than failing the whole read, which would leave a device that nulls one section persistently with no meter data at all. publish_status and publish_meter are unchanged. Fixes #4656 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 28f94f8 commit cace97d

2 files changed

Lines changed: 294 additions & 2 deletions

File tree

apps/predbat/gecloud.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,35 @@ def regname_to_ha(name):
228228
return name
229229

230230

231+
def merge_non_null(fresh, previous):
232+
"""
233+
Overlay a fresh API reading onto the previous one, ignoring null leaves.
234+
235+
GE Cloud (notably on Gateway devices) intermittently answers with HTTP 200 and a well-formed
236+
envelope whose leaf values are explicitly null. Those nulls mean "no fresh datalog sample this
237+
poll", not "zero" - coercing them to 0 is indistinguishable from a real idle inverter or a flat
238+
battery, and passing None through poisons every downstream consumer.
239+
240+
A null leaf keeps the last good value for that field. With no previous reading to fall back on
241+
the null is kept as None rather than dropped, so the field carries on reporting "no value" as it
242+
does today instead of a fabricated zero (dropping the key would let a .get(field, 0) default
243+
invent one).
244+
"""
245+
if fresh is None:
246+
return previous
247+
if not isinstance(fresh, dict):
248+
return fresh
249+
merged = dict(previous) if isinstance(previous, dict) else {}
250+
for key, value in fresh.items():
251+
if value is None:
252+
if key not in merged:
253+
# Never had a good reading for this field - report no value rather than a fake zero
254+
merged[key] = None
255+
continue
256+
merged[key] = merge_non_null(value, merged.get(key))
257+
return merged
258+
259+
231260
class GECloudDirect(ComponentBase):
232261
"""
233262
GivEnergy Cloud Direct API interface
@@ -1799,7 +1828,7 @@ async def async_get_inverter_status(self, serial, previous={}):
17991828
result = await self.async_get_inverter_data_retry(GE_API_INVERTER_STATUS, serial)
18001829
if result is None:
18011830
return previous
1802-
return result
1831+
return merge_non_null(result, previous)
18031832

18041833
async def async_get_inverter_meter(self, serial, previous={}):
18051834
"""
@@ -1808,7 +1837,15 @@ async def async_get_inverter_meter(self, serial, previous={}):
18081837
meter = await self.async_get_inverter_data_retry(GE_API_INVERTER_METER, serial)
18091838
if meter is None:
18101839
return previous
1811-
return meter
1840+
merged = merge_non_null(meter, previous)
1841+
# today/total are objects rather than readings, so a null section with nothing cached to
1842+
# fall back on cannot be kept as None the way a null leaf is - publish_meter would iterate
1843+
# it. Drop it and pick the counters up on the next poll rather than failing the whole read,
1844+
# which would leave a device that nulls one section persistently with no meter data at all.
1845+
for section in ("today", "total"):
1846+
if section in merged and not isinstance(merged[section], dict):
1847+
merged.pop(section)
1848+
return merged
18121849

18131850
async def async_get_inverter_data_retry(self, endpoint, serial="", setting_id="", post=False, datain=None, uuid="", meter_ids="", start_time="", end_time="", command="", measurands=""):
18141851
"""

apps/predbat/tests/test_ge_cloud.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ def test_ge_cloud(my_predbat=None):
240240
("settings_restored_from_cache", _test_settings_restored_from_fresh_cache, "Settings restored from fresh storage cache"),
241241
("inverter_status", _test_async_get_inverter_status, "Get inverter status"),
242242
("inverter_meter", _test_async_get_inverter_meter, "Get inverter meter"),
243+
("status_null_leaves", _test_inverter_status_null_leaves_retained, "Null status leaves retain previous reading"),
244+
("status_null_first_poll", _test_inverter_status_null_leaves_first_poll, "Null status leaves dropped when no previous reading"),
245+
("meter_null_leaves", _test_inverter_meter_null_leaves_retained, "Null meter leaves retain previous totals"),
246+
("meter_null_section", _test_inverter_meter_null_section_first_poll, "Null meter section dropped when no previous data"),
243247
("device_info", _test_async_get_device_info, "Get device info"),
244248
("settings_success", _test_async_get_inverter_settings_success, "Get inverter settings success"),
245249
("settings_partial", _test_async_get_inverter_settings_partial_failure, "Get inverter settings partial failure"),
@@ -2852,6 +2856,257 @@ async def mock_retry(*args, **kwargs):
28522856
return run_async(test())
28532857

28542858

2859+
def _test_inverter_status_null_leaves_retained(my_predbat):
2860+
"""Test null leaves in a Gateway status response retain the previous good reading"""
2861+
2862+
async def test():
2863+
ge_cloud = MockGECloudDirect()
2864+
2865+
previous = {
2866+
"time": "2026-08-22T18:21:41Z",
2867+
"status": "Normal",
2868+
"solar": {"power": 1310, "arrays": [{"array": 1, "voltage": 251.7, "current": 0.3, "power": 77}]},
2869+
"grid": {"voltage": 237.1, "current": 4.2, "power": 151, "frequency": 50.05},
2870+
"battery": {"percent": 41, "power": 902, "temperature": 12},
2871+
"inverter": {"temperature": 27.2, "power": 1029, "output_voltage": 237.8, "output_frequency": 50.06},
2872+
"consumption": 878,
2873+
}
2874+
2875+
# Gateway systems intermittently return HTTP 200 with every leaf explicitly null
2876+
null_payload = {
2877+
"time": "2026-08-22T18:26:41Z",
2878+
"status": "Unknown",
2879+
"solar": {"power": None, "arrays": []},
2880+
"grid": {"voltage": None, "current": None, "power": None, "frequency": None},
2881+
"battery": {"percent": None, "power": None, "temperature": None},
2882+
"inverter": {"temperature": None, "power": None, "output_voltage": None, "output_frequency": None},
2883+
"consumption": None,
2884+
}
2885+
2886+
async def mock_retry(*args, **kwargs):
2887+
return null_payload
2888+
2889+
ge_cloud.async_get_inverter_data_retry = mock_retry
2890+
result = await ge_cloud.async_get_inverter_status("test123", previous=previous)
2891+
2892+
# The fresh timestamp and status must come through
2893+
if result.get("time") != "2026-08-22T18:26:41Z":
2894+
print("ERROR: Expected fresh time to be kept, got {}".format(result.get("time")))
2895+
return 1
2896+
if result.get("status") != "Unknown":
2897+
print("ERROR: Expected fresh status to be kept, got {}".format(result.get("status")))
2898+
return 1
2899+
2900+
# Every null leaf must fall back to the previous good reading, not None and not 0
2901+
checks = [
2902+
(["battery", "percent"], 41),
2903+
(["battery", "power"], 902),
2904+
(["battery", "temperature"], 12),
2905+
(["grid", "power"], 151),
2906+
(["grid", "voltage"], 237.1),
2907+
(["grid", "frequency"], 50.05),
2908+
(["solar", "power"], 1310),
2909+
(["inverter", "power"], 1029),
2910+
(["inverter", "temperature"], 27.2),
2911+
(["consumption"], 878),
2912+
]
2913+
for path, expected in checks:
2914+
value = result
2915+
for part in path:
2916+
value = value.get(part) if isinstance(value, dict) else None
2917+
if value != expected:
2918+
print("ERROR: Expected {} to retain {}, got {}".format("/".join(path), expected, value))
2919+
return 1
2920+
2921+
# The previous dict must not be mutated in place
2922+
if previous["battery"]["percent"] != 41 or previous["status"] != "Normal":
2923+
print("ERROR: Previous status was mutated: {}".format(previous))
2924+
return 1
2925+
2926+
# A subsequent good poll must take the fresh values again
2927+
good_payload = {
2928+
"time": "2026-08-22T18:31:41Z",
2929+
"status": "Normal",
2930+
"battery": {"percent": 45, "power": 0, "temperature": 13},
2931+
"grid": {"power": -200, "voltage": 238.0, "current": 1.0, "frequency": 50.01},
2932+
"solar": {"power": 0, "arrays": []},
2933+
"inverter": {"temperature": 26.0, "power": 100, "output_voltage": 238.0, "output_frequency": 50.01},
2934+
"consumption": 300,
2935+
}
2936+
2937+
async def mock_retry_good(*args, **kwargs):
2938+
return good_payload
2939+
2940+
ge_cloud.async_get_inverter_data_retry = mock_retry_good
2941+
result2 = await ge_cloud.async_get_inverter_status("test123", previous=result)
2942+
2943+
# Zero is a legitimate reading and must not be treated as missing
2944+
if result2["battery"]["power"] != 0 or result2["solar"]["power"] != 0:
2945+
print("ERROR: Expected zero readings to be kept, got {}".format(result2))
2946+
return 1
2947+
if result2["battery"]["percent"] != 45:
2948+
print("ERROR: Expected fresh percent 45, got {}".format(result2["battery"]["percent"]))
2949+
return 1
2950+
2951+
return 0
2952+
2953+
return run_async(test())
2954+
2955+
2956+
def _test_inverter_status_null_leaves_first_poll(my_predbat):
2957+
"""Test null leaves with no previous reading stay None rather than becoming a fabricated zero"""
2958+
2959+
async def test():
2960+
ge_cloud = MockGECloudDirect()
2961+
2962+
null_payload = {
2963+
"time": "2026-08-22T18:26:41Z",
2964+
"status": "Unknown",
2965+
"grid": {"voltage": 242.3, "current": 0.7, "power": 0, "frequency": None},
2966+
"battery": {"percent": None, "power": None, "temperature": None},
2967+
"consumption": None,
2968+
}
2969+
2970+
async def mock_retry(*args, **kwargs):
2971+
return null_payload
2972+
2973+
ge_cloud.async_get_inverter_data_retry = mock_retry
2974+
result = await ge_cloud.async_get_inverter_status("test123", previous={})
2975+
2976+
# A field that has never had a reading must still report "no value". Dropping the key would
2977+
# let the .get(field, 0) defaults in publish_status invent a reading that never happened.
2978+
for section, field in [("grid", "frequency"), ("battery", "percent"), ("battery", "power"), ("battery", "temperature")]:
2979+
if field not in result.get(section, {}):
2980+
print("ERROR: Expected {}/{} to be kept as None, but the key was dropped".format(section, field))
2981+
return 1
2982+
if result[section][field] is not None:
2983+
print("ERROR: Expected {}/{} to be None, got {}".format(section, field, result[section][field]))
2984+
return 1
2985+
2986+
if "consumption" not in result or result["consumption"] is not None:
2987+
print("ERROR: Expected consumption to be kept as None, got {}".format(result.get("consumption", "<missing>")))
2988+
return 1
2989+
2990+
# Readings that did arrive must be unaffected, including a legitimate zero
2991+
if result["grid"]["power"] != 0 or result["grid"]["voltage"] != 242.3:
2992+
print("ERROR: Expected good grid readings to be kept, got {}".format(result["grid"]))
2993+
return 1
2994+
2995+
# Once a good value arrives it is retained through a later null
2996+
good = {"grid": {"frequency": 50.01}, "battery": {"percent": 41}}
2997+
2998+
async def mock_retry_good(*args, **kwargs):
2999+
return good
3000+
3001+
ge_cloud.async_get_inverter_data_retry = mock_retry_good
3002+
result = await ge_cloud.async_get_inverter_status("test123", previous=result)
3003+
3004+
ge_cloud.async_get_inverter_data_retry = mock_retry
3005+
result = await ge_cloud.async_get_inverter_status("test123", previous=result)
3006+
3007+
if result["grid"]["frequency"] != 50.01 or result["battery"]["percent"] != 41:
3008+
print("ERROR: Expected previously good values to be retained, got {}".format(result))
3009+
return 1
3010+
3011+
return 0
3012+
3013+
return run_async(test())
3014+
3015+
3016+
def _test_inverter_meter_null_leaves_retained(my_predbat):
3017+
"""Test null leaves in a meter response retain the previous good totals"""
3018+
3019+
async def test():
3020+
ge_cloud = MockGECloudDirect()
3021+
3022+
previous = {
3023+
"time": "2026-08-22T18:21:41Z",
3024+
"today": {"solar": 15.5, "grid": {"import": 5.2, "export": 10.3}, "battery": {"charge": 8.0, "discharge": 6.5}, "consumption": 12.7},
3025+
"total": {"solar": 6539.5, "grid": {"import": 19508.4, "export": 3230.3}, "battery": {"charge": 7290.95, "discharge": 7290.95}, "consumption": 21566.6},
3026+
}
3027+
3028+
null_payload = {
3029+
"time": "2026-08-22T18:26:41Z",
3030+
"today": {"solar": None, "grid": {"import": None, "export": None}, "battery": {"charge": None, "discharge": None}, "consumption": None},
3031+
"total": None,
3032+
}
3033+
3034+
async def mock_retry(*args, **kwargs):
3035+
return null_payload
3036+
3037+
ge_cloud.async_get_inverter_data_retry = mock_retry
3038+
result = await ge_cloud.async_get_inverter_meter("test123", previous=previous)
3039+
3040+
if result["today"]["solar"] != 15.5:
3041+
print("ERROR: Expected today solar to retain 15.5, got {}".format(result["today"]["solar"]))
3042+
return 1
3043+
if result["today"]["grid"]["import"] != 5.2:
3044+
print("ERROR: Expected today grid import to retain 5.2, got {}".format(result["today"]["grid"]["import"]))
3045+
return 1
3046+
if result["today"]["consumption"] != 12.7:
3047+
print("ERROR: Expected today consumption to retain 12.7, got {}".format(result["today"]["consumption"]))
3048+
return 1
3049+
if result["total"]["solar"] != 6539.5:
3050+
print("ERROR: Expected total solar to retain 6539.5, got {}".format(result["total"]))
3051+
return 1
3052+
if result.get("time") != "2026-08-22T18:26:41Z":
3053+
print("ERROR: Expected fresh meter time, got {}".format(result.get("time")))
3054+
return 1
3055+
3056+
return 0
3057+
3058+
return run_async(test())
3059+
3060+
3061+
def _test_inverter_meter_null_section_first_poll(my_predbat):
3062+
"""Test a null today/total section with no previous data is dropped rather than crashing publish"""
3063+
3064+
async def test():
3065+
ge_cloud = MockGECloudDirect()
3066+
ge_cloud.config_args["prefix"] = "predbat"
3067+
3068+
# today/total are objects rather than readings - a null section left in place would leave
3069+
# publish_meter iterating None
3070+
null_payload = {"time": "2026-08-22T18:26:41Z", "today": {"solar": 15.5, "grid": {"import": 5.2, "export": 10.3}}, "total": None}
3071+
3072+
async def mock_retry(*args, **kwargs):
3073+
return null_payload
3074+
3075+
ge_cloud.async_get_inverter_data_retry = mock_retry
3076+
result = await ge_cloud.async_get_inverter_meter("test123", previous={})
3077+
3078+
if "total" in result:
3079+
print("ERROR: Expected unusable total section to be dropped, got {}".format(result.get("total")))
3080+
return 1
3081+
3082+
# Publishing must not raise and the usable section must still come through
3083+
await ge_cloud.publish_meter("test123", result)
3084+
3085+
if ge_cloud.dashboard_items.get("sensor.predbat_gecloud_test123_solar_today", {}).get("state") != 15.5:
3086+
print("ERROR: Expected solar_today 15.5 to publish, got {}".format(ge_cloud.dashboard_items.get("sensor.predbat_gecloud_test123_solar_today")))
3087+
return 1
3088+
3089+
# Once a good total arrives it is retained through a later null section
3090+
good = {"time": "2026-08-22T18:31:41Z", "today": {"solar": 16.0}, "total": {"solar": 6539.5}}
3091+
3092+
async def mock_retry_good(*args, **kwargs):
3093+
return good
3094+
3095+
ge_cloud.async_get_inverter_data_retry = mock_retry_good
3096+
result = await ge_cloud.async_get_inverter_meter("test123", previous=result)
3097+
3098+
ge_cloud.async_get_inverter_data_retry = mock_retry
3099+
result = await ge_cloud.async_get_inverter_meter("test123", previous=result)
3100+
3101+
if result.get("total", {}).get("solar") != 6539.5:
3102+
print("ERROR: Expected previous total to be retained, got {}".format(result.get("total")))
3103+
return 1
3104+
3105+
return 0
3106+
3107+
return run_async(test())
3108+
3109+
28553110
def _test_async_get_device_info(my_predbat):
28563111
"""Test getting device info"""
28573112

0 commit comments

Comments
 (0)