@@ -3684,6 +3684,72 @@ def test_stored_inverter_unread_at_startup_is_still_bound(self):
36843684 assert self ._logged (gw , "Error" , "CE2225G400" , "not being read" ), second
36853685 assert gw .non_fatal_error_occurred .call_count == 1 , second
36863686
3687+ def _empty_status (self ):
3688+ """A status in which the hub names no inverter at all."""
3689+ status = pb .GatewayStatus ()
3690+ status .device_id = "pbgw_two_ac"
3691+ status .firmware = "1.0.34"
3692+ status .schema_version = 1
3693+ return status
3694+
3695+ def test_stored_inverter_is_bound_when_the_hub_reports_no_inverters_at_startup (self ):
3696+ """A single listed inverter with a stored record keeps its slot when the first status names no inverter."""
3697+ gw = self ._listed_gateway (["CE2225G400" ], stored = self ._STORED_400 )
3698+
3699+ gw ._process_telemetry (self ._empty_status ().SerializeToString ())
3700+
3701+ assert gw ._auto_configured is True
3702+ assert gw ._args ["num_inverters" ] == 1
3703+ assert gw ._inverter_slot_serials == ["CE2225G400" ]
3704+ assert gw ._states ["sensor.predbat_gateway_25g400_soc" ] == 0
3705+ assert gw ._states ["sensor.predbat_gateway_25g400_battery_capacity" ] == 9.5
3706+ assert self ._logged (gw , "Error" , "CE2225G400" , "not being read" )
3707+ assert gw .non_fatal_error_occurred .call_count == 1
3708+ # Nothing was read, so the status does not count towards the hub being alive
3709+ gw .update_success_timestamp .assert_not_called ()
3710+
3711+ def test_status_with_no_inverters_leaves_configured_inverters_unread (self ):
3712+ """Once configured, a status naming no inverter zeroes the powers, keeps the rest and is an error."""
3713+ gw = self ._listed_gateway (["CE2223G800" , "CE2225G400" ])
3714+ healthy = self ._two_ac_status ("ok" )
3715+ for inv in healthy .inverters :
3716+ inv .battery .power_w = - 3000
3717+ gw ._process_telemetry (healthy .SerializeToString ())
3718+ assert gw ._states ["sensor.predbat_gateway_23g800_battery_power" ] == 3000
3719+ assert self ._errors (gw ) == []
3720+
3721+ gw ._process_telemetry (self ._empty_status ().SerializeToString ())
3722+
3723+ assert gw ._args ["num_inverters" ] == 2
3724+ for suffix in ("23g800" , "25g400" ):
3725+ assert gw ._states [f"sensor.predbat_gateway_{ suffix } _battery_power" ] == 0 , suffix
3726+ assert gw ._states [f"sensor.predbat_gateway_{ suffix } _soc" ] == 50 , suffix
3727+ assert self ._logged (gw , "Error" , "CE2223G800" , "CE2225G400" , "not being read" )
3728+ assert gw .non_fatal_error_occurred .call_count == 1
3729+
3730+ gw ._process_telemetry (healthy .SerializeToString ())
3731+ assert gw ._states ["sensor.predbat_gateway_23g800_battery_power" ] == 3000
3732+ assert self ._logged (gw , "Info" , "every configured inverter is being read by the hub again" )
3733+
3734+ def test_status_with_no_inverters_is_ignored_without_a_list_or_a_record (self ):
3735+ """With no serial list, or no inverter discovered yet, a status naming no inverter is ignored as before."""
3736+ for listed , stored in (([], None ), (["CE2225G400" ], None )):
3737+ gw = self ._listed_gateway (listed , stored = stored )
3738+
3739+ gw ._process_telemetry (self ._empty_status ().SerializeToString ())
3740+
3741+ assert gw ._last_status is None , listed
3742+ assert gw ._auto_configured is False , listed
3743+ assert gw ._args == {}, listed
3744+
3745+ # No list: inverters come and go, so one that goes is not held on to
3746+ gw = self ._listed_gateway ([])
3747+ gw ._process_telemetry (self ._two_ac_status ("ok" ).SerializeToString ())
3748+ healthy = gw ._last_status
3749+ gw ._process_telemetry (self ._empty_status ().SerializeToString ())
3750+ assert gw ._last_status is healthy
3751+ assert self ._errors (gw ) == []
3752+
36873753 def test_changed_serial_list_wins_over_stored_discovery (self ):
36883754 """A stored record for a serial no longer in the list is not used: the list is the setting."""
36893755 gw = self ._listed_gateway (["CE2223G800" ], stored = self ._STORED_400 )
@@ -6927,6 +6993,63 @@ def status(timestamp, pv_only_wh, with_pv_only=True):
69276993 self ._feed (gw , status (self .BASE_TIME + 1800 , 3500 ))
69286994 assert approx_equal (gw ._integrated_energy ["load_wh" ], 500.0 )
69296995
6996+ def test_pv_only_inverter_promoted_to_a_slot_keeps_its_generation (self ):
6997+ """Auto-config re-running and changing the slots does not lose or mix up any inverter's generation."""
6998+ gw = self ._make_gateway ()
6999+ # The PV-only inverter sorts first, so once it has a slot every slot index moves
7000+ gw .gateway_inverter_serial = ["AA0000P001" , "CE2223G800" ]
7001+
7002+ def status (timestamp , pv_only_wh , has_battery = False ):
7003+ built = self ._status (timestamp , grid_w = 0 , battery_w = (0 ,), pv_today_wh = 1000 , serials = ("CE2223G800" ,))
7004+ inv = built .inverters .add ()
7005+ inv .type = pb .INVERTER_TYPE_GIVENERGY
7006+ inv .serial = "AA0000P001"
7007+ inv .primary = True
7008+ inv .connected = True
7009+ inv .pv .power_w = 1000
7010+ inv .energy .pv_today_wh = pv_only_wh
7011+ if has_battery :
7012+ inv .battery .soc_percent = 40
7013+ inv .battery .capacity_wh = 5000
7014+ return built
7015+
7016+ self ._feed (gw , status (self .BASE_TIME , 3000 ))
7017+ self ._feed (gw , status (self .BASE_TIME + 600 , 3200 ))
7018+ assert gw ._pv_only_serials == ("AA0000P001" ,)
7019+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 200.0 )
7020+
7021+ # Its battery readings arrive: _process_telemetry integrates, then re-runs auto-config
7022+ self ._feed (gw , status (self .BASE_TIME + 1200 , 3300 , has_battery = True ))
7023+ gw .automatic_config ()
7024+ assert gw ._inverter_slot_serials == ["AA0000P001" , "CE2223G800" ]
7025+ assert gw ._pv_only_serials == ()
7026+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 300.0 )
7027+
7028+ self ._feed (gw , status (self .BASE_TIME + 1800 , 3500 , has_battery = True ))
7029+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 500.0 )
7030+
7031+ def test_inverter_given_a_slot_later_has_its_generation_counted_from_then (self ):
7032+ """An inverter that joins the slots after the integration began is counted from its first status, not left out."""
7033+ gw = self ._make_gateway ()
7034+ self ._feed (gw , self ._status (self .BASE_TIME , grid_w = 0 , battery_w = (0 ,), pv_today_wh = 1000 , serials = ("CE2225G400" ,)))
7035+ self ._feed (gw , self ._status (self .BASE_TIME + 600 , grid_w = 0 , battery_w = (0 ,), pv_today_wh = 1100 , serials = ("CE2225G400" ,)))
7036+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 100.0 )
7037+
7038+ def both (timestamp , first_wh , second_wh ):
7039+ built = self ._status (timestamp , grid_w = 0 , battery_w = (0 , 0 ), pv_today_wh = first_wh )
7040+ built .inverters [1 ].energy .pv_today_wh = second_wh
7041+ return built
7042+
7043+ # CE2223G800 appears and takes slot 0; its counter already stands at 7000
7044+ gw ._last_status = both (self .BASE_TIME + 1200 , 7000 , 1200 )
7045+ gw .automatic_config ()
7046+ assert gw ._inverter_slot_serials == ["CE2223G800" , "CE2225G400" ]
7047+
7048+ self ._feed (gw , both (self .BASE_TIME + 1200 , 7000 , 1200 ))
7049+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 200.0 )
7050+ self ._feed (gw , both (self .BASE_TIME + 1800 , 7300 , 1250 ))
7051+ assert approx_equal (gw ._integrated_energy ["load_wh" ], 550.0 )
7052+
69307053 def test_import_and_load_are_integrated_from_power (self ):
69317054 """6 kW of import for 6 minutes is 0.6 kWh; 3 kW of that went into a battery, so load is 0.3 kWh."""
69327055 gw = self ._make_gateway ()
0 commit comments