Skip to content

Commit 8a8f630

Browse files
CIclaude
andcommitted
feat(discovery): drop the transitional capability list; document schema 2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3a813e2 commit 8a8f630

5 files changed

Lines changed: 32 additions & 24 deletions

File tree

‎apps/predbat/coordinator.py‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,6 @@
3434
# Containers whose value is a list of vocabulary tokens
3535
VOCAB_CONTAINERS = ("functions", "flags", "effects")
3636

37-
# TRANSITIONAL: capabilities was a token list before schema 2. A reporter not yet converted still sends
38-
# one; it is kept as tokens rather than dropped. Removed once every reporter emits the dict (plan Task 10).
39-
LEGACY_TOKEN_CONTAINERS = ("capabilities",)
40-
4137
# The seven INVERTER_DEF fields that describe behaviour rather than whether an entity exists.
4238
# A record's capabilities dict may hold only these.
4339
CAPABILITY_KEYS = (
@@ -592,7 +588,7 @@ def _walk(self, node, container=None):
592588
out[key] = {self._guard_key(key, name): self._note(entry, substring=True) for name, entry in value.items()}
593589
elif key in CLEAR_CONTAINERS and isinstance(value, dict):
594590
out[key] = {self._guard_key(key, name): self._guard_value(key, name, entry) for name, entry in value.items()}
595-
elif key in VOCAB_CONTAINERS or (key in LEGACY_TOKEN_CONTAINERS and isinstance(value, list)):
591+
elif key in VOCAB_CONTAINERS:
596592
# Vocabulary lists are clear too, and a token is free-form enough (digits
597593
# are legal in the pattern) that a misfiled identifier can hide as one.
598594
out[key] = [self._guard_scalar(key, "token", entry) for entry in value]
@@ -961,7 +957,7 @@ def _validate_container(container_name, value, component_name, section, log):
961957
own key since a token list has no separate key/value split. No container can accept a key
962958
whose name trips is_secret_key(), however it is nested.
963959
"""
964-
if container_name in VOCAB_CONTAINERS or (container_name in LEGACY_TOKEN_CONTAINERS and isinstance(value, list)):
960+
if container_name in VOCAB_CONTAINERS:
965961
if not isinstance(value, list):
966962
return None
967963
out = []

‎apps/predbat/tests/test_coordinator.py‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,15 +1729,14 @@ def test_capabilities_drops_unknown_key_and_non_bool():
17291729
return 0
17301730

17311731

1732-
def test_capabilities_legacy_token_list_still_accepted():
1733-
"""TRANSITIONAL (removed in Task 10): a not-yet-converted reporter's token list is kept, not crashed on."""
1732+
def test_capabilities_list_is_dropped():
1733+
"""Schema 2: capabilities is a dict of the seven behaviour keys, so a token list no longer validates."""
17341734
record, _ = _one_inverter(capabilities=["schedule", "target_soc"])
1735-
assert record["capabilities"] == ["schedule", "target_soc"], record
1735+
assert "capabilities" not in record, record
17361736
base, coordinator = _coordinator()
17371737
coordinator.report("test", {"inverters": [{"device_id": "test:SN1", "capabilities": ["schedule"]}]})
1738-
catalogue = coordinator.catalogue()
1739-
assert catalogue["inverters"][0]["capabilities"] == ["schedule"], catalogue
1740-
print("PASS: legacy capability list tolerated")
1738+
assert "capabilities" not in coordinator.catalogue()["inverters"][0]
1739+
print("PASS: capability token list dropped")
17411740
return 0
17421741

17431742

@@ -2022,7 +2021,7 @@ def test_coordinator_all(my_predbat=None):
20222021
failures += test_schema_version_is_two()
20232022
failures += test_capabilities_dict_keeps_known_bool_keys()
20242023
failures += test_capabilities_drops_unknown_key_and_non_bool()
2025-
failures += test_capabilities_legacy_token_list_still_accepted()
2024+
failures += test_capabilities_list_is_dropped()
20262025
failures += test_descriptor_requires_access()
20272026
failures += test_descriptor_needs_exactly_one_of_entity_id_and_value()
20282027
failures += test_descriptor_value_rejects_free_text()

‎apps/predbat/tests/test_web_discovery.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ def catalogue_raw(self):
9797
"inverter_type": "GE",
9898
"composition": "direct",
9999
"functions": ["solar", "battery"],
100-
"capabilities": ["rest_v3", "pause_mode"],
100+
"capabilities": {"support_charge_freeze": True, "can_span_midnight": True},
101+
"flags": ["rest_v3"],
101102
"hardware_ids": {"serial": "CE2143G123"},
102103
"info": {"model": "Gen3", "firmware": "D0.450"},
103-
"ratings": {"battery_kwh": 9.5, "max_charge_w": 3000},
104+
"ratings": {"soc_max": 9.5, "battery_rate_max": 3000},
104105
"entities": {"charge_rate": {"entity_id": "number.givtcp_ce2143g123_charge_rate", "domain": "number", "access": "rw"}},
105106
}
106107
],
@@ -156,7 +157,7 @@ def test_discovery_page_renders_the_catalogue(my_predbat):
156157
try:
157158
body = _render(my_predbat, _real_coordinator(my_predbat))
158159

159-
for expected in ("Inverters", "Meters", "givtcp:CE2143G123", "Gen3", "D0.450", "rest_v3"):
160+
for expected in ("Inverters", "Meters", "givtcp:CE2143G123", "Gen3", "D0.450", "rest_v3", "support_charge_freeze", "soc_max"):
160161
if expected not in body:
161162
print(f" ERROR: expected the page to show {expected!r}")
162163
failed = True

‎docs/discovery-catalogue.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ restart Predbat, or read a fresh debug dump, to see anything reported later than
5858

5959
| Section | What it holds |
6060
| ------- | -------------- |
61-
| `inverters` | Battery inverters and PV-only devices - type, composition (direct/gateway/EMS), which functions it serves (`solar`, `battery`), and - where the reporter has a table mapping Predbat's controls to its entities (GivTCP today) - those entities |
61+
| `inverters` | Battery inverters and PV-only devices - type, composition (direct/gateway/EMS), which functions it serves (`solar`, `battery`), its behaviour (`capabilities`), its fixed ratings under Predbat's own setting names, and every setting Predbat's automatic configuration binds for it (`entities`) |
6262
| `chargers` | EV chargers, cross-linked to the cars they serve |
6363
| `cars` | Electric vehicles, cross-linked to the charger that charges them |
6464
| `meters` | Electricity (and gas) supply points, each with a direction (`import`/`export`) and, where known, a nested tariff record |
@@ -70,8 +70,8 @@ their automatic configuration treats every discovered inverter as a battery inve
7070
records follow that configuration - `solar` and `battery` on every inverter - rather than
7171
evidence about the hardware, and Solis's `solar` works the same way, since every Solis inverter
7272
is configured as a PV source. A PV-only unit that has been configured as a battery inverter will
73-
usually show it in its ratings: `battery` among its functions, but no `battery_kwh` or
74-
`battery_capacity_ah`.
73+
usually show it in its record: `battery` among its functions, but no `battery_capacity_ah` rating
74+
and no `soc_max` entity.
7575

7676
No reporter (GivTCP, GE Cloud, Octopus, Ohme, Solcast, Fox, AlphaESS, Solis, Deye, Sunsynk) populates `programmes` yet - it is part of
7777
the schema for a future Axle/VPP-style reporter - so today it is always present as an empty list
@@ -206,11 +206,12 @@ class* that value gets:
206206
| --------- | ------- | --------- |
207207
| `hardware_ids` | Short strings | Clear |
208208
| `info` | Short strings, no `@` | Clear |
209-
| `ratings` | Numbers and booleans | Clear |
209+
| `ratings` | Numbers and booleans, keyed by Predbat's setting name where one exists (`inverter_limit`, `export_limit`, `import_limit`, `battery_rate_max`, `soc_max`, `battery_min_soc`) | Clear |
210210
| `coverage` | A number/boolean, or a list of lowercase tokens | Clear |
211-
| `entities` | An entity descriptor (`entity_id` plus typed fields like `domain`, `unit`, `min`/`max`) | Clear |
211+
| `entities` | An entity descriptor: `access` (`rw` or `r`, required), exactly one of `entity_id` or a fixed `value`, and typed fields such as `domain`, `unit`, `format`, `min`/`max`, `invert` | Clear |
212+
| `capabilities` | `true`/`false` for the seven behaviour keys of an inverter definition (`support_charge_freeze`, `support_discharge_freeze`, `support_feedin_first`, `can_span_midnight`, `charge_discharge_with_rate`, `charge_control_immediate`, `target_soc_used_for_discharge`) | Clear |
212213
| `account_ids` | Any scalar | Pseudonymised |
213-
| `functions` / `capabilities` / `flags` / `effects` | Lists of short lowercase tokens | Clear |
214+
| `functions` / `flags` / `effects` | Lists of short lowercase tokens | Clear |
214215

215216
A value that does not fit its container's declared type is silently **dropped**, not coerced and
216217
not raised as an error - a string offered to `ratings`, or free text over 64 characters offered to
@@ -220,6 +221,17 @@ introduce, because a container that only accepts numbers structurally cannot car
220221
address or a pasted credential, however the schema grows. Choose whichever container matches the
221222
*kind* of fact you are reporting, not the one that happens to accept the value you have.
222223

224+
### Inverter records and inverter definitions
225+
226+
An inverter record holds enough to rebuild that inverter's definition - the per-type table
227+
(`INVERTER_DEF`) that tells Predbat how to drive it. `capabilities` carries its behaviour; whether it
228+
has a reserve, a target SoC, charge/discharge enable switches, idle times or a timed pause follows
229+
from which settings `entities` binds to a writable (`rw`) entity; and protocol detail such as the time
230+
format or whether the charge rate is set in watts or amps is read from those entities' descriptors.
231+
A setting Predbat replaces with a placeholder for this inverter type - SolisCloud's `reserve`, say - is
232+
left out of `entities`. See the design in
233+
`docs/superpowers/specs/2026-09-24-discovery-inverter-record-vocabulary-design.md`.
234+
223235
### Writing a reporter
224236

225237
A component reports by defining **`build_discovery()`**, returning its report dict - or `None` when

‎docs/superpowers/specs/2026-09-10-discovery-catalogue-design.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,8 @@ that one careless call to the generic masker silently destroys the whole documen
155155
| `composition` | top level | `direct`, `gateway` (fronting `serials`), or `ems` |
156156
| `measures_meter` | cross-link | A meter `device_id`, so double counting is detectable |
157157
| `model`, `firmware` | `info` | |
158-
| `battery_kwh`, `max_charge_w`, `modules`, `dod_percent`, `nominal_voltage` | `ratings` | |
159-
| `capabilities` | vocabulary | Recorded not acted on: `schedule`, `pause_mode`, `pause_slots`, `target_soc`, `discharge_target`, `charge_rate_power`, `charge_rate_percent`, `soh` |
158+
| `inverter_limit`, `export_limit`, `import_limit`, `battery_rate_max`, `soc_max`, `battery_min_soc`, `modules`, `dod_percent`, `nominal_voltage` | `ratings` | Renamed to Predbat setting names on 24 Sep 2026 (was `battery_kwh`, `max_charge_w`) - see `2026-09-24-discovery-inverter-record-vocabulary-design.md` |
159+
| `capabilities` | dict of bool | Superseded on 24 Sep 2026: the seven `INVERTER_DEF` behaviour keys as `True`/`False`, not tokens - see `2026-09-24-discovery-inverter-record-vocabulary-design.md` |
160160
| `entities` | `entities` | Keyed by Predbat standard name |
161161

162162
**Cross-links may dangle, and a component must never invent a record to resolve one.**

0 commit comments

Comments
 (0)