@@ -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+
28553110def _test_async_get_device_info (my_predbat ):
28563111 """Test getting device info"""
28573112
0 commit comments