Skip to content

Commit 99aafa8

Browse files
CIclaude
andcommitted
fix(gecloud): address #5218 review - empty/comma-in-bracket options, warn once, non-string guard
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 35710d7 commit 99aafa8

2 files changed

Lines changed: 64 additions & 5 deletions

File tree

‎apps/predbat/gecloud.py‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,24 @@ def parse_validation_options(validation):
402402
# Split on the first '(' and drop only the final ')' so option labels that contain brackets survive intact
403403
post = validation.split("(", 1)[1]
404404
post = post.rsplit(")", 1)[0]
405-
post = post.replace(", ", ",")
406-
return post.split(",")
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)]
407423

408424

409425
def normalise_register_time(value):
@@ -582,6 +598,8 @@ def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, e
582598
# Battery inverters already reported as overriding the EMS, so a standing misconfiguration
583599
# raises the Predbat status once rather than on every settings refresh
584600
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()
585603
self.devices_dict = {}
586604
self.device_list = []
587605
self.ems_device = None
@@ -776,7 +794,7 @@ async def select_event(self, entity_id, value):
776794
if validation_rule.startswith("in:"):
777795
options_values = validation_rule.split(":")[1].split(",")
778796

779-
if validation and validation.startswith(VALIDATION_OPTIONS_PREFIX):
797+
if isinstance(validation, str) and validation.startswith(VALIDATION_OPTIONS_PREFIX):
780798
options_text = parse_validation_options(validation)
781799
if options_text is None:
782800
self.log("GECloud: Warn: Unable to parse options for setting {} {} from validation '{}'".format(device, key, validation))
@@ -1352,10 +1370,13 @@ async def publish_registers(self, device, registers, select_key=None):
13521370
attributes["device_class"] = "power"
13531371
attributes["unit_of_measurement"] = "W"
13541372

1355-
if validation and validation.startswith(VALIDATION_OPTIONS_PREFIX):
1373+
if isinstance(validation, str) and validation.startswith(VALIDATION_OPTIONS_PREFIX):
13561374
validation_options = parse_validation_options(validation)
13571375
if validation_options is None:
1358-
self.log("GECloud: Warn: Unable to parse options for setting {} {} from validation '{}'".format(device, key, validation))
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))
13591380
else:
13601381
options_text = validation_options
13611382

‎apps/predbat/tests/test_ge_cloud.py‎

Lines changed: 38 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
@@ -4064,6 +4065,28 @@ async def mock_publish(*args, **kwargs):
40644065
print("ERROR: Expected a warning for unparseable validation, got {}".format(ge_cloud.log_messages))
40654066
return 1
40664067

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+
40674090
return 0
40684091

40694092
return run_async(test())
@@ -4076,6 +4099,13 @@ def _test_parse_validation_options(my_predbat):
40764099
cases = [
40774100
("Value must be one of: (00:00, 00:30, 01:00)", ["00:00", "00:30", "01:00"]),
40784101
("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),
40794109
("Value must be one of:", None),
40804110
("Value must be between 0 and 100", None),
40814111
("", None),
@@ -4334,6 +4364,14 @@ def _test_publish_registers_bracketed_options(my_predbat):
43344364
print("ERROR: Expected later registers to still be published")
43354365
return 1
43364366

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+
43374375
return 0
43384376

43394377

0 commit comments

Comments
 (0)