@@ -73,6 +73,7 @@ def __init__(self):
7373 self .ems_device = None
7474 self .gateway_device = None
7575 self .ems_slot_warned = set ()
76+ self .validation_parse_warned = set ()
7677 self ._discovery_report = None
7778 self ._now_utc_exact = datetime .now (timezone .utc )
7879 self .settings_from_cache = False
@@ -294,11 +295,14 @@ def test_ge_cloud(my_predbat=None):
294295 ("switch_event" , _test_switch_event , "Switch event handler" ),
295296 ("number_event" , _test_number_event , "Number event handler" ),
296297 ("select_event" , _test_select_event , "Select event handler" ),
298+ ("select_event_bracketed_options" , _test_select_event_bracketed_options , "Select event with bracketed or unparseable option validation" ),
299+ ("parse_validation_options" , _test_parse_validation_options , "Parse options from validation text" ),
297300 ("publish_status" , _test_publish_status , "Publish status" ),
298301 ("publish_meter" , _test_publish_meter , "Publish meter" ),
299302 ("publish_info" , _test_publish_info , "Publish info" ),
300303 ("publish_info_soh" , _test_publish_info_soh , "Publish info SOH calculation" ),
301304 ("publish_registers" , _test_publish_registers , "Publish registers" ),
305+ ("publish_registers_bracketed_options" , _test_publish_registers_bracketed_options , "Publish registers with bracketed or unparseable option validation" ),
302306 ("publish_evc_data" , _test_publish_evc_data , "Publish EVC data" ),
303307 ("automatic_config" , _test_async_automatic_config , "Automatic config" ),
304308 ("discovery_direct" , _test_build_discovery_battery_only_direct , "build_discovery: single battery, direct composition" ),
@@ -4015,6 +4019,106 @@ async def mock_publish(*args, **kwargs):
40154019 return run_async (test ())
40164020
40174021
4022+ def _test_select_event_bracketed_options (my_predbat ):
4023+ """Test select event accepts option labels that contain brackets and falls back to 'in:' rules on unparseable validation (GH#5217)"""
4024+
4025+ async def test ():
4026+ ge_cloud = MockGECloudDirect ()
4027+ entity_id = "select.predbat_gecloud_test123_ems_mode"
4028+ ge_cloud .register_entity_map [entity_id ] = {"key" : 90 , "device" : "test123" }
4029+ ge_cloud .settings ["test123" ] = {90 : {"value" : "Eco (Paused)" , "validation" : "Value must be one of: (Eco (Paused), Timed Demand, Export (AC3))" , "validation_rules" : []}}
4030+
4031+ write_calls = []
4032+
4033+ async def mock_write (serial , setting_id , value ):
4034+ write_calls .append (value )
4035+ return {"value" : value }
4036+
4037+ async def mock_publish (* args , ** kwargs ):
4038+ pass
4039+
4040+ with patch ("gecloud.asyncio.sleep" , new_callable = AsyncMock ):
4041+ ge_cloud .async_write_inverter_setting = mock_write
4042+ ge_cloud .publish_registers = mock_publish
4043+
4044+ await ge_cloud .select_event (entity_id , "Export (AC3)" )
4045+ if write_calls != ["Export (AC3)" ]:
4046+ print ("ERROR: Expected bracketed option to be written, got {}" .format (write_calls ))
4047+ return 1
4048+
4049+ await ge_cloud .select_event (entity_id , "Bogus" )
4050+ if write_calls != ["Export (AC3)" ]:
4051+ print ("ERROR: Expected invalid option to be rejected, got {}" .format (write_calls ))
4052+ return 1
4053+
4054+ # Validation text without any bracket cannot be parsed: fall back to the 'in:' rule rather than crash
4055+ ge_cloud .settings ["test123" ][90 ] = {"value" : "1" , "validation" : "Value must be one of:" , "validation_rules" : ["in:1,2" ]}
4056+ await ge_cloud .select_event (entity_id , "2" )
4057+ if write_calls != ["Export (AC3)" , "2" ]:
4058+ print ("ERROR: Expected 'in:' fallback to accept '2', got {}" .format (write_calls ))
4059+ return 1
4060+ await ge_cloud .select_event (entity_id , "3" )
4061+ if write_calls != ["Export (AC3)" , "2" ]:
4062+ print ("ERROR: Expected 'in:' fallback to reject '3', got {}" .format (write_calls ))
4063+ return 1
4064+ if not any ("Unable to parse options" in message for message in ge_cloud .log_messages ):
4065+ print ("ERROR: Expected a warning for unparseable validation, got {}" .format (ge_cloud .log_messages ))
4066+ return 1
4067+
4068+ # An empty option list must not parse as [""] and reject every value; it falls back to 'in:' too
4069+ ge_cloud .settings ["test123" ][90 ] = {"value" : "1" , "validation" : "Value must be one of: ()" , "validation_rules" : ["in:1,2" ]}
4070+ await ge_cloud .select_event (entity_id , "1" )
4071+ if write_calls != ["Export (AC3)" , "2" , "1" ]:
4072+ print ("ERROR: Expected empty option list to fall back to 'in:' and accept '1', got {}" .format (write_calls ))
4073+ return 1
4074+
4075+ # A non-string validation value is ignored rather than raising AttributeError
4076+ ge_cloud .settings ["test123" ][90 ] = {"value" : "1" , "validation" : {"unexpected" : True }, "validation_rules" : ["in:1,2" ]}
4077+ await ge_cloud .select_event (entity_id , "2" )
4078+ if write_calls != ["Export (AC3)" , "2" , "1" , "2" ]:
4079+ print ("ERROR: Expected non-string validation to fall back to 'in:' and accept '2', got {}" .format (write_calls ))
4080+ return 1
4081+
4082+ # Unparseable validation with no 'in:' rule has nothing to check against, so the write goes through
4083+ # and the GE Cloud API validates it server-side (the same as any select without option validation text)
4084+ ge_cloud .settings ["test123" ][90 ] = {"value" : "1" , "validation" : "Value must be one of:" , "validation_rules" : []}
4085+ await ge_cloud .select_event (entity_id , "5" )
4086+ if write_calls != ["Export (AC3)" , "2" , "1" , "2" , "5" ]:
4087+ print ("ERROR: Expected write without local validation when no options are known, got {}" .format (write_calls ))
4088+ return 1
4089+
4090+ return 0
4091+
4092+ return run_async (test ())
4093+
4094+
4095+ def _test_parse_validation_options (my_predbat ):
4096+ """Test parsing option labels out of GE Cloud 'Value must be one of:' validation strings (GH#5217)"""
4097+ from gecloud import parse_validation_options
4098+
4099+ cases = [
4100+ ("Value must be one of: (00:00, 00:30, 01:00)" , ["00:00" , "00:30" , "01:00" ]),
4101+ ("Value must be one of: (Eco (Paused), Timed Demand, Export (AC3))" , ["Eco (Paused)" , "Timed Demand" , "Export (AC3)" ]),
4102+ # Commas inside a label's own brackets do not split it
4103+ ("Value must be one of: (Eco, Pause (Battery, Grid), Export)" , ["Eco" , "Pause (Battery, Grid)" , "Export" ]),
4104+ ("Value must be one of: (0,1,2)" , ["0" , "1" , "2" ]),
4105+ # An empty option list is unparseable, not a single empty-string option
4106+ ("Value must be one of: ()" , None ),
4107+ ("Value must be one of: (" , None ),
4108+ ("Value must be one of: ( )" , None ),
4109+ ("Value must be one of:" , None ),
4110+ ("Value must be between 0 and 100" , None ),
4111+ ("" , None ),
4112+ (None , None ),
4113+ ]
4114+ for validation , expected in cases :
4115+ result = parse_validation_options (validation )
4116+ if result != expected :
4117+ print ("ERROR: parse_validation_options({!r}) expected {}, got {}" .format (validation , expected , result ))
4118+ return 1
4119+ return 0
4120+
4121+
40184122# =============================================================================
40194123# Publishing Tests
40204124# =============================================================================
@@ -4220,6 +4324,57 @@ def _test_publish_registers(my_predbat):
42204324 return 0
42214325
42224326
4327+ def _test_publish_registers_bracketed_options (my_predbat ):
4328+ """Test publishing registers whose validation text has bracketed labels or no brackets at all does not abort the device (GH#5217)"""
4329+ ge_cloud = MockGECloudDirect ()
4330+ ge_cloud .config_args ["prefix" ] = "predbat"
4331+
4332+ registers = {
4333+ 90 : {"name" : "EMS Mode" , "validation_rules" : ["in:0,1,2" ], "validation" : "Value must be one of: (Eco (Paused), Timed Demand, Export (AC3))" , "value" : "Timed Demand" },
4334+ 91 : {"name" : "AC3 Mode" , "validation_rules" : ["in:0,1" ], "validation" : "Value must be one of:" , "value" : "0" },
4335+ 92 : {"name" : "Enable AC Charge" , "validation_rules" : ["boolean" ], "validation" : "" , "value" : "1" },
4336+ }
4337+ ge_cloud .register_list ["test123" ] = registers
4338+
4339+ run_async (ge_cloud .publish_registers ("test123" , registers ))
4340+
4341+ ems_entity = "select.predbat_gecloud_test123_ems_mode"
4342+ if ems_entity not in ge_cloud .dashboard_items :
4343+ print ("ERROR: Expected {} to be published" .format (ems_entity ))
4344+ return 1
4345+ options = ge_cloud .dashboard_items [ems_entity ]["attributes" ]["options" ]
4346+ if options != ["Eco (Paused)" , "Timed Demand" , "Export (AC3)" ]:
4347+ print ("ERROR: Expected bracketed labels to survive intact, got {}" .format (options ))
4348+ return 1
4349+
4350+ ac3_entity = "select.predbat_gecloud_test123_ac3_mode"
4351+ if ac3_entity not in ge_cloud .dashboard_items :
4352+ print ("ERROR: Expected {} to be published" .format (ac3_entity ))
4353+ return 1
4354+ options = ge_cloud .dashboard_items [ac3_entity ]["attributes" ]["options" ]
4355+ if options != ["0" , "1" ]:
4356+ print ("ERROR: Expected unparseable validation to fall back to 'in:' options, got {}" .format (options ))
4357+ return 1
4358+ if not any ("Unable to parse options" in message for message in ge_cloud .log_messages ):
4359+ print ("ERROR: Expected a warning for unparseable validation, got {}" .format (ge_cloud .log_messages ))
4360+ return 1
4361+
4362+ # Registers after the problem ones must still be published
4363+ if "switch.predbat_gecloud_test123_enable_ac_charge" not in ge_cloud .dashboard_items :
4364+ print ("ERROR: Expected later registers to still be published" )
4365+ return 1
4366+
4367+ # A permanently unparseable register warns once, not on every settings refresh
4368+ run_async (ge_cloud .publish_registers ("test123" , registers ))
4369+ run_async (ge_cloud .publish_registers ("test123" , registers ))
4370+ warnings = [message for message in ge_cloud .log_messages if "Unable to parse options" in message ]
4371+ if len (warnings ) != 1 :
4372+ print ("ERROR: Expected exactly one unparseable-validation warning across three refreshes, got {}" .format (warnings ))
4373+ return 1
4374+
4375+ return 0
4376+
4377+
42234378def _test_publish_evc_data (my_predbat ):
42244379 """Test publishing EV charger data"""
42254380
0 commit comments