Skip to content

Commit 889d695

Browse files
Merge pull request #5218 from springfall2008/fix/gecloud-validation-paren-parse-5217
GE Cloud: parse option validation text with bracketed labels without crashing (#5217)
2 parents 6d394b7 + 99aafa8 commit 889d695

2 files changed

Lines changed: 204 additions & 10 deletions

File tree

‎apps/predbat/gecloud.py‎

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,38 @@ def regname_to_ha(name):
390390
return name
391391

392392

393+
VALIDATION_OPTIONS_PREFIX = "Value must be one of:"
394+
395+
396+
def parse_validation_options(validation):
397+
"""
398+
Return the option labels from a 'Value must be one of: (a, b, c)' validation string, or None when it cannot be parsed
399+
"""
400+
if not isinstance(validation, str) or not validation.startswith(VALIDATION_OPTIONS_PREFIX) or "(" not in validation:
401+
return None
402+
# Split on the first '(' and drop only the final ')' so option labels that contain brackets survive intact
403+
post = validation.split("(", 1)[1]
404+
post = post.rsplit(")", 1)[0]
405+
if not post.strip():
406+
return None
407+
# Split only on commas outside brackets so a label such as 'Pause (Battery, Grid)' stays whole
408+
options = []
409+
label = ""
410+
depth = 0
411+
for char in post:
412+
if char == "(":
413+
depth += 1
414+
elif char == ")":
415+
depth = max(depth - 1, 0)
416+
elif char == "," and depth == 0:
417+
options.append(label)
418+
label = ""
419+
continue
420+
label += char
421+
options.append(label)
422+
return [option[1:] if index > 0 and option.startswith(" ") else option for index, option in enumerate(options)]
423+
424+
393425
def normalise_register_time(value):
394426
"""
395427
Return a register time value as an HH:MM string, or None when it is not a time
@@ -566,6 +598,8 @@ def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, e
566598
# Battery inverters already reported as overriding the EMS, so a standing misconfiguration
567599
# raises the Predbat status once rather than on every settings refresh
568600
self.ems_slot_warned = set()
601+
# (device, key) registers whose option validation text could not be parsed, so the warning is logged once
602+
self.validation_parse_warned = set()
569603
self.devices_dict = {}
570604
self.device_list = []
571605
self.ems_device = None
@@ -760,11 +794,12 @@ async def select_event(self, entity_id, value):
760794
if validation_rule.startswith("in:"):
761795
options_values = validation_rule.split(":")[1].split(",")
762796

763-
if validation and validation.startswith("Value must be one of:"):
764-
pre, post = validation.split("(")
765-
post = post.replace(")", "")
766-
post = post.replace(", ", ",")
767-
options_text = post.split(",")
797+
if isinstance(validation, str) and validation.startswith(VALIDATION_OPTIONS_PREFIX):
798+
options_text = parse_validation_options(validation)
799+
if options_text is None:
800+
self.log("GECloud: Warn: Unable to parse options for setting {} {} from validation '{}'".format(device, key, validation))
801+
802+
if options_text is not None:
768803
if new_value not in options_text:
769804
self.log("GECloud: Error: Invalid option {} for setting {} {}, valid values are {}".format(new_value, device, key, options_text))
770805
return
@@ -1335,11 +1370,15 @@ async def publish_registers(self, device, registers, select_key=None):
13351370
attributes["device_class"] = "power"
13361371
attributes["unit_of_measurement"] = "W"
13371372

1338-
if validation and validation.startswith("Value must be one of:"):
1339-
pre, post = validation.split("(")
1340-
post = post.replace(")", "")
1341-
post = post.replace(", ", ",")
1342-
options_text = post.split(",")
1373+
if isinstance(validation, str) and validation.startswith(VALIDATION_OPTIONS_PREFIX):
1374+
validation_options = parse_validation_options(validation)
1375+
if validation_options is None:
1376+
# Warn once per register, this runs on every settings refresh
1377+
if (device, key) not in self.validation_parse_warned:
1378+
self.log("GECloud: Warn: Unable to parse options for setting {} {} from validation '{}'".format(device, key, validation))
1379+
self.validation_parse_warned.add((device, key))
1380+
else:
1381+
options_text = validation_options
13431382

13441383
if is_select_time or is_select_options:
13451384
entity_name = f"select.{self.prefix}_gecloud_{device}"

‎apps/predbat/tests/test_ge_cloud.py‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
42234378
def _test_publish_evc_data(my_predbat):
42244379
"""Test publishing EV charger data"""
42254380

0 commit comments

Comments
 (0)