Skip to content

Commit 481e9af

Browse files
CIclaude
andcommitted
Merge main into bot/debug-journal-2026-09-26
Resolve debug-journal conflicts with #5223's fold of the same merge batch: - Solis: keep this PR's #5189 detail and write-skip/test notes, then main's GH#5152 window-scoped hold lever - GE Cloud: main's #5109 wording plus the once-per-episode ems_slot_warned warning and its log text - Teslemetry: this PR's #5188 default-flip consequences plus main's GH#5157 forced re-assert - Manual rates: fold both rows into one (#5173 fix, surviving :59:59 boundary-minute and offset traps, test lesson) - Kraken, Sunsynk, Octopus, Car charging from main; Axle, GivTCP, Compare and the three new rows from this PR Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2 parents 0efddb2 + acc04b8 commit 481e9af

35 files changed

Lines changed: 9998 additions & 369 deletions

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Custom Dictionary Words
22
3hy
33
aaaabbbbccccdddd
4+
acks
45
activepower
56
adata
67
adbase
@@ -721,6 +722,7 @@ WEXITSTATUS
721722
WIFEXITED
722723
WNOHANG
723724
workmode
725+
worktree
724726
writeonly
725727
wrongsha
726728
wwxxabcdefyyzz

‎apps/predbat/alphaess.py‎

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@
9595

9696
_HOLD_NOT_EVALUATED = object()
9797

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

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

867881
await self.apply_hybrid_verdict()
868882

883+
def _discovery_entities(self, sn):
884+
"""The settings automatic_config() binds for one system, as discovery entity descriptors.
885+
886+
Each entity id is formed with the same _sensor_name()/_control_name() call automatic_config()
887+
makes, so the two cannot drift apart. The record describes this system alone (spec D10): a
888+
setting automatic_config() binds only when a figure is known - an energy counter, the
889+
capacity, poinv - is described whenever this system reports it, although automatic_config()
890+
binds it only once every system does. pv_power and pv_today are described even under
891+
automatic_ignore_pv, which is the user's opt-out rather than a fact about the device (D11).
892+
The three *_power_invert settings are always False, which is the descriptor's default, so no
893+
descriptor carries invert. car_charging_* belong to the chargers and cars sections, and
894+
export_limit and battery_min_soc are never bound (see automatic_config()).
895+
"""
896+
entities = {
897+
"soc_percent": {"entity_id": self._sensor_name(sn, "soc"), "access": "r", "unit": "%"},
898+
"battery_power": {"entity_id": self._sensor_name(sn, "battery_power"), "access": "r", "unit": "W"},
899+
"grid_power": {"entity_id": self._sensor_name(sn, "grid_power"), "access": "r", "unit": "W"},
900+
"load_power": {"entity_id": self._sensor_name(sn, "load_power"), "access": "r", "unit": "W"},
901+
"pv_power": {"entity_id": self._sensor_name(sn, "pv_power"), "access": "r", "unit": "W"},
902+
}
903+
energy = self.device_energy.get(sn, {})
904+
for leaf in ("load_today", "import_today", "export_today", "pv_today"):
905+
if leaf in energy:
906+
entities[leaf] = {"entity_id": self._sensor_name(sn, leaf), "access": "r", "unit": "kWh"}
907+
if self.battery_capacity(sn) > 0:
908+
entities["soc_max"] = {"entity_id": self._sensor_name(sn, "battery_capacity"), "access": "r", "unit": "kWh"}
909+
if self.inverter_limit(sn) > 0:
910+
entities["inverter_limit"] = {"entity_id": self._sensor_name(sn, "inverter_limit"), "access": "r", "unit": "W"}
911+
if self.battery_rate_max(sn) > 0:
912+
entities["battery_rate_max"] = {"entity_id": self._sensor_name(sn, "battery_rate_max"), "access": "r", "unit": "W"}
913+
914+
entities["reserve"] = {"entity_id": self._control_name("number", sn, "battery_schedule_reserve"), "access": "rw", "unit": "%"}
915+
for direction, prefix in (("charge", "charge"), ("export", "discharge")):
916+
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"}
917+
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"}
918+
entities["scheduled_{}_enable".format(prefix)] = {"entity_id": self._control_name("switch", sn, "battery_schedule_{}_enable".format(direction)), "access": "rw", "domain": "switch"}
919+
entities["charge_limit"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_soc"), "access": "rw", "unit": "%"}
920+
entities["charge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_charge_power"), "access": "rw", "unit": "W", "step": 100}
921+
entities["discharge_target_soc"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_soc"), "access": "rw", "unit": "%"}
922+
entities["discharge_rate"] = {"entity_id": self._control_name("number", sn, "battery_schedule_export_power"), "access": "rw", "unit": "W", "step": 100}
923+
entities["schedule_write_button"] = {"entity_id": self._control_name("switch", sn, "battery_schedule_charge_write"), "access": "rw", "domain": "switch"}
924+
return entities
925+
869926
def build_discovery(self):
870927
"""
871928
Describe the discovered AlphaESS systems for the discovery catalogue.
872929
873930
Reads only what get_device_list() already holds - self.device_list and self.device_detail -
874-
plus the EV-charger verdict _apply_live_payload() records, so this adds no API calls and
875-
cannot change what AlphaESS does. Reporting is independent of self.automatic: the catalogue
876-
records the hardware, and the report's own "automatic" flag says whether Predbat wired
877-
apps.yaml to it.
931+
plus the EV-charger verdict _apply_live_payload() records and the energy counters already
932+
read, so this adds no API calls and cannot change what AlphaESS does. Reporting is
933+
independent of self.automatic: the catalogue records the hardware, and the report's own
934+
"automatic" flag says whether Predbat wired apps.yaml to it.
878935
879936
self.device_list holds serial strings (sysSn), and only systems that passed has_battery()
880937
at discovery: a battery-less system (the VT1000 family, cobat 0 or missing) is dropped in
881938
get_device_list() and never reaches device_detail. automatic_config() applies no further
882939
test - it registers every serial in device_list as "AlphaESSCloud" - so inverter_type is set
883940
on every record, mirroring automatic_config() as the source of truth.
884941
885-
Ratings are getEssList's own figures: poinv (kW) as inverter_w via inverter_limit(), popv
886-
(kW) as pv_w, cobat (kWh) as battery_kwh via battery_capacity(). cobat is one scalar per
887-
system, so there is no list of entries to mis-sum. A system reports "solar" only when popv
888-
says PV is attached.
942+
capabilities is the ALPHAESS_CAPABILITIES constant and entities is _discovery_entities(sn):
943+
together they rebuild the AlphaESSCloud INVERTER_DEF row, and entities holds every setting
944+
automatic_config() binds for the system.
945+
946+
Ratings are getEssList's own figures under their Predbat setting names: poinv (kW) as
947+
inverter_limit (W) via inverter_limit(), cobat (kWh) as soc_max via battery_capacity(), and
948+
popv (kW) as the descriptive pv_w. cobat is one scalar per system, so there is no list of
949+
entries to mis-sum. A system reports "solar" only when popv says PV is attached.
889950
890951
Deliberately not reported:
952+
- export_limit: AlphaESS reports no export power limit, and poinv is the inverter rating,
953+
not the grid connection's cap (automatic_config() warns about this).
954+
- battery_rate_max as a rating: the API reports no battery power limit; battery_rate_max()
955+
is poinv or the user's override, so only its entity binding is described.
891956
- emsStatus: a status that changes. refresh_discovery() compares whole reports, so a
892957
changing value would re-file the report every time it moved.
893958
- usCapacity and surplusCobat: they fit "current SoC" and "configured usable depth" equally
@@ -926,12 +991,12 @@ def build_discovery(self):
926991
ratings = {}
927992
inverter_w = self.inverter_limit(sn)
928993
if inverter_w > 0:
929-
ratings["inverter_w"] = inverter_w
994+
ratings["inverter_limit"] = inverter_w
930995
if pv_kw > 0:
931996
ratings["pv_w"] = pv_kw * 1000.0
932997
battery_kwh = self.battery_capacity(sn)
933998
if battery_kwh > 0:
934-
ratings["battery_kwh"] = battery_kwh
999+
ratings["soc_max"] = battery_kwh
9351000

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

@@ -941,11 +1006,12 @@ def build_discovery(self):
9411006
inverter_type="AlphaESSCloud",
9421007
composition="direct",
9431008
functions=functions,
944-
capabilities=["schedule", "target_soc", "discharge_target", "charge_rate_power"],
1009+
capabilities=dict(ALPHAESS_CAPABILITIES),
9451010
flags=flags,
9461011
hardware_ids={"serial": sn},
9471012
info=info,
9481013
ratings=ratings,
1014+
entities=self._discovery_entities(sn),
9491015
)
9501016
)
9511017

‎apps/predbat/component_base.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ class ComponentBase(ABC):
5858
the registry (the standalone CLI harnesses), so report_discovery() always has a name.
5959
"""
6060

61+
# Seconds inverter.py waits between writing a setting and reading it back. The component owns the
62+
# entities, so it owns this timing; INVERTER_DEF's write_and_poll_sleep is 2 for every cloud type and
63+
# 10 for the GivEnergy types (GivTCP and GE Cloud override it). See coordinator.inverter_definition().
64+
WRITE_AND_POLL_SLEEP = 2
65+
6166
# Declared on the class, not only assigned in __init__, so they exist even on a component built
6267
# without it - the test harnesses construct components with Cls.__new__(Cls) to exercise one
6368
# method in isolation. refresh_discovery() must not be able to raise on such an instance: an

‎apps/predbat/config.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,7 +1852,7 @@
18521852
"has_timed_pause": True,
18531853
"charge_time_format": "HH:MM:SS",
18541854
"charge_time_entity_is_option": True,
1855-
"soc_units": "kWh",
1855+
"soc_units": "%",
18561856
"num_load_entities": 1,
18571857
"has_ge_inverter_mode": False,
18581858
"has_ge_eco_toggle": True,
@@ -1880,7 +1880,7 @@
18801880
"has_timed_pause": False,
18811881
"charge_time_format": "HH:MM:SS",
18821882
"charge_time_entity_is_option": True,
1883-
"soc_units": "kWh",
1883+
"soc_units": "%",
18841884
"num_load_entities": 1,
18851885
"has_ge_inverter_mode": False,
18861886
"has_ge_eco_toggle": False,

0 commit comments

Comments
 (0)