Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d88b0b0
docs(discovery): design the inverter record's controls, capabilities …
Sep 24, 2026
ffd230b
docs(discovery): settle the site export limit and assumed-functions q…
Sep 24, 2026
d8007d3
docs(discovery): section 2 derives presence flags from rw entities; s…
Sep 24, 2026
e88e576
docs(discovery): num_load_entities is derived, not dead; GE mode flag…
Sep 24, 2026
5ae6a89
docs(debug-journal): running the harness in a fresh worktree self-upd…
Sep 24, 2026
21ba9a3
docs(discovery): decisions D10-D15 from the reporter prototypes, and …
Sep 24, 2026
eeb0c52
docs(discovery): implementation plan for the inverter record vocabula…
Sep 24, 2026
02d45c5
Merge branch 'docs/discovery-inverter-record-vocabulary' into feat/di…
Sep 25, 2026
1c54d32
feat(discovery): capabilities dict, descriptor access/value/invert an…
Sep 25, 2026
3211d5c
test(discovery): shared completeness and agreement checks for inverte…
Sep 25, 2026
8a55335
feat(discovery): Sunsynk record carries capabilities, entities and Pr…
Sep 25, 2026
81e6586
feat(discovery): Deye record carries capabilities dict, per-device en…
Sep 25, 2026
e54b981
feat(discovery): AlphaESS record states capabilities, entities and Pr…
Sep 25, 2026
38beaef
feat(discovery): Fox record carries entities, capabilities dict and P…
Sep 25, 2026
a0c4d42
feat(discovery): Solis Cloud record carries capabilities, entities an…
Sep 25, 2026
6a00d52
feat(discovery): GE Cloud records carry entities, capabilities dict a…
Sep 25, 2026
3a813e2
feat(discovery): GivTCP record states the GE row's capabilities, boun…
Sep 25, 2026
8a8f630
feat(discovery): drop the transitional capability list; document sche…
Sep 25, 2026
ae4fb2d
docs(discovery): spec errata from the final review - write_and_poll_s…
Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ WEXITSTATUS
WIFEXITED
WNOHANG
workmode
worktree
writeonly
wrongsha
wwxxabcdefyyzz
Expand Down
88 changes: 77 additions & 11 deletions apps/predbat/alphaess.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@

_HOLD_NOT_EVALUATED = object()

# The behaviour an AlphaESSCloud inverter has, stated for the discovery record's capabilities. A
# literal, never read back from INVERTER_DEF: the record has to rebuild the row on its own, or the
# completeness test proves nothing. support_feedin_first is False because the row leaves it out and
# inverter.py defaults it to False; there is no feed-in-first mode in the Open API.
ALPHAESS_CAPABILITIES = {
"support_charge_freeze": True,
"support_discharge_freeze": True,
"support_feedin_first": False,
"can_span_midnight": False,
"charge_discharge_with_rate": False,
"charge_control_immediate": False,
"target_soc_used_for_discharge": True,
}


class AlphaESSAPI(ComponentBase):
"""AlphaESS Open API cloud component."""
Expand Down Expand Up @@ -866,28 +880,79 @@ async def automatic_config(self):

await self.apply_hybrid_verdict()

def _discovery_entities(self, sn):
"""The settings automatic_config() binds for one system, as discovery entity descriptors.
Each entity id is formed with the same _sensor_name()/_control_name() call automatic_config()
makes, so the two cannot drift apart. The record describes this system alone (spec D10): a
setting automatic_config() binds only when a figure is known - an energy counter, the
capacity, poinv - is described whenever this system reports it, although automatic_config()
binds it only once every system does. pv_power and pv_today are described even under
automatic_ignore_pv, which is the user's opt-out rather than a fact about the device (D11).
The three *_power_invert settings are always False, which is the descriptor's default, so no
descriptor carries invert. car_charging_* belong to the chargers and cars sections, and
export_limit and battery_min_soc are never bound (see automatic_config()).
"""
entities = {
"soc_percent": {"entity_id": self._sensor_name(sn, "soc"), "access": "r", "unit": "%"},
"battery_power": {"entity_id": self._sensor_name(sn, "battery_power"), "access": "r", "unit": "W"},
"grid_power": {"entity_id": self._sensor_name(sn, "grid_power"), "access": "r", "unit": "W"},
"load_power": {"entity_id": self._sensor_name(sn, "load_power"), "access": "r", "unit": "W"},
"pv_power": {"entity_id": self._sensor_name(sn, "pv_power"), "access": "r", "unit": "W"},
}
energy = self.device_energy.get(sn, {})
for leaf in ("load_today", "import_today", "export_today", "pv_today"):
if leaf in energy:
entities[leaf] = {"entity_id": self._sensor_name(sn, leaf), "access": "r", "unit": "kWh"}
if self.battery_capacity(sn) > 0:
entities["soc_max"] = {"entity_id": self._sensor_name(sn, "battery_capacity"), "access": "r", "unit": "kWh"}
if self.inverter_limit(sn) > 0:
entities["inverter_limit"] = {"entity_id": self._sensor_name(sn, "inverter_limit"), "access": "r", "unit": "W"}
if self.battery_rate_max(sn) > 0:
entities["battery_rate_max"] = {"entity_id": self._sensor_name(sn, "battery_rate_max"), "access": "r", "unit": "W"}

entities["reserve"] = {"entity_id": self._control_name("number", sn, "battery_schedule_reserve"), "access": "rw", "unit": "%"}
for direction, prefix in (("charge", "charge"), ("export", "discharge")):
entities["{}_start_time".format(prefix)] = {"entity_id": self._control_name("select", sn, "battery_schedule_{}_start_time".format(direction)), "access": "rw", "domain": "select", "format": "HH:MM:SS"}
entities["{}_end_time".format(prefix)] = {"entity_id": self._control_name("select", sn, "battery_schedule_{}_end_time".format(direction)), "access": "rw", "domain": "select", "format": "HH:MM:SS"}
entities["scheduled_{}_enable".format(prefix)] = {"entity_id": self._control_name("switch", sn, "battery_schedule_{}_enable".format(direction)), "access": "rw", "domain": "switch"}
entities["charge_limit"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_soc"), "access": "rw", "unit": "%"}
entities["charge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_power"), "access": "rw", "unit": "W", "step": 100}
entities["discharge_target_soc"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_soc"), "access": "rw", "unit": "%"}
entities["discharge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_power"), "access": "rw", "unit": "W", "step": 100}
entities["schedule_write_button"] = {"entity_id": self._control_name("switch", sn, "battery_schedule_charge_write"), "access": "rw", "domain": "switch"}
return entities

def build_discovery(self):
"""
Describe the discovered AlphaESS systems for the discovery catalogue.
Reads only what get_device_list() already holds - self.device_list and self.device_detail -
plus the EV-charger verdict _apply_live_payload() records, so this adds no API calls and
cannot change what AlphaESS does. Reporting is independent of self.automatic: the catalogue
records the hardware, and the report's own "automatic" flag says whether Predbat wired
apps.yaml to it.
plus the EV-charger verdict _apply_live_payload() records and the energy counters already
read, so this adds no API calls and cannot change what AlphaESS does. Reporting is
independent of self.automatic: the catalogue records the hardware, and the report's own
"automatic" flag says whether Predbat wired apps.yaml to it.
self.device_list holds serial strings (sysSn), and only systems that passed has_battery()
at discovery: a battery-less system (the VT1000 family, cobat 0 or missing) is dropped in
get_device_list() and never reaches device_detail. automatic_config() applies no further
test - it registers every serial in device_list as "AlphaESSCloud" - so inverter_type is set
on every record, mirroring automatic_config() as the source of truth.
Ratings are getEssList's own figures: poinv (kW) as inverter_w via inverter_limit(), popv
(kW) as pv_w, cobat (kWh) as battery_kwh via battery_capacity(). cobat is one scalar per
system, so there is no list of entries to mis-sum. A system reports "solar" only when popv
says PV is attached.
capabilities is the ALPHAESS_CAPABILITIES constant and entities is _discovery_entities(sn):
together they rebuild the AlphaESSCloud INVERTER_DEF row, and entities holds every setting
automatic_config() binds for the system.
Ratings are getEssList's own figures under their Predbat setting names: poinv (kW) as
inverter_limit (W) via inverter_limit(), cobat (kWh) as soc_max via battery_capacity(), and
popv (kW) as the descriptive pv_w. cobat is one scalar per system, so there is no list of
entries to mis-sum. A system reports "solar" only when popv says PV is attached.
Deliberately not reported:
- export_limit: AlphaESS reports no export power limit, and poinv is the inverter rating,
not the grid connection's cap (automatic_config() warns about this).
- battery_rate_max as a rating: the API reports no battery power limit; battery_rate_max()
is poinv or the user's override, so only its entity binding is described.
- emsStatus: a status that changes. refresh_discovery() compares whole reports, so a
changing value would re-file the report every time it moved.
- usCapacity and surplusCobat: they fit "current SoC" and "configured usable depth" equally
Expand Down Expand Up @@ -926,12 +991,12 @@ def build_discovery(self):
ratings = {}
inverter_w = self.inverter_limit(sn)
if inverter_w > 0:
ratings["inverter_w"] = inverter_w
ratings["inverter_limit"] = inverter_w
if pv_kw > 0:
ratings["pv_w"] = pv_kw * 1000.0
battery_kwh = self.battery_capacity(sn)
if battery_kwh > 0:
ratings["battery_kwh"] = battery_kwh
ratings["soc_max"] = battery_kwh

flags = ["ev_charger"] if self._ev_present.get(sn) is True else []

Expand All @@ -941,11 +1006,12 @@ def build_discovery(self):
inverter_type="AlphaESSCloud",
composition="direct",
functions=functions,
capabilities=["schedule", "target_soc", "discharge_target", "charge_rate_power"],
capabilities=dict(ALPHAESS_CAPABILITIES),
flags=flags,
hardware_ids={"serial": sn},
info=info,
ratings=ratings,
entities=self._discovery_entities(sn),
)
)

Expand Down
5 changes: 5 additions & 0 deletions apps/predbat/component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ class ComponentBase(ABC):
the registry (the standalone CLI harnesses), so report_discovery() always has a name.
"""

# Seconds inverter.py waits between writing a setting and reading it back. The component owns the
# entities, so it owns this timing; INVERTER_DEF's write_and_poll_sleep is 2 for every cloud type and
# 10 for the GivEnergy types (GivTCP and GE Cloud override it). See coordinator.inverter_definition().
WRITE_AND_POLL_SLEEP = 2

# Declared on the class, not only assigned in __init__, so they exist even on a component built
# without it - the test harnesses construct components with Cls.__new__(Cls) to exercise one
# method in isolation. refresh_discovery() must not be able to raise on such an instance: an
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1852,7 +1852,7 @@
"has_timed_pause": True,
"charge_time_format": "HH:MM:SS",
"charge_time_entity_is_option": True,
"soc_units": "kWh",
"soc_units": "%",
"num_load_entities": 1,
"has_ge_inverter_mode": False,
"has_ge_eco_toggle": True,
Expand Down Expand Up @@ -1880,7 +1880,7 @@
"has_timed_pause": False,
"charge_time_format": "HH:MM:SS",
"charge_time_entity_is_option": True,
"soc_units": "kWh",
"soc_units": "%",
"num_load_entities": 1,
"has_ge_inverter_mode": False,
"has_ge_eco_toggle": False,
Expand Down
Loading
Loading