Skip to content

Commit f1a5d83

Browse files
Merge pull request #5206 from springfall2008/feat/discovery-phase1-plan2
Discovery catalogue: AlphaESS, Solis, Deye and Sunsynk reporters
2 parents 7a0a44d + d994076 commit f1a5d83

14 files changed

Lines changed: 2791 additions & 9 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ newtok
421421
njpwerner
422422
nobat
423423
nocharge
424+
NODETAIL
424425
nodischarge
425426
nohup
426427
noopener

‎apps/predbat/alphaess.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import aiohttp
4242
from datetime import datetime
4343
from component_base import ComponentBase
44+
from coordinator import inverter_record
4445
from alphaess_const import (
4546
ALPHAESS_BASE_URL,
4647
ALPHAESS_ENDPOINTS,
@@ -865,6 +866,91 @@ async def automatic_config(self):
865866

866867
await self.apply_hybrid_verdict()
867868

869+
def build_discovery(self):
870+
"""
871+
Describe the discovered AlphaESS systems for the discovery catalogue.
872+
873+
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.
878+
879+
self.device_list holds serial strings (sysSn), and only systems that passed has_battery()
880+
at discovery: a battery-less system (the VT1000 family, cobat 0 or missing) is dropped in
881+
get_device_list() and never reaches device_detail. automatic_config() applies no further
882+
test - it registers every serial in device_list as "AlphaESSCloud" - so inverter_type is set
883+
on every record, mirroring automatic_config() as the source of truth.
884+
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.
889+
890+
Deliberately not reported:
891+
- emsStatus: a status that changes. refresh_discovery() compares whole reports, so a
892+
changing value would re-file the report every time it moved.
893+
- usCapacity and surplusCobat: they fit "current SoC" and "configured usable depth" equally
894+
well, which is why publish_data() never maps them to SoC; reporting either as a rating
895+
would assert a meaning nobody knows.
896+
- firmware: the AlphaESS Open API exposes none.
897+
- account_ids: the API has no station, plant or site identifier; every endpoint is keyed by
898+
sysSn alone.
899+
- an AC-coupled verdict: detect_ac_coupled() infers it from live telemetry and
900+
ALPHAESS_AC_COUPLED_MODELS ships empty on purpose, so there is no held fact to report.
901+
902+
An EV charger is reported as a flag only when _ev_present says True. That verdict comes from
903+
live telemetry alone, so a charger not yet seen reports nothing either way rather than a
904+
guess. No chargers record is invented for it: AlphaESS reports a charger's power, not its
905+
identity.
906+
907+
Returns None when no system has been discovered yet, which refresh_discovery() treats as
908+
"nothing to report, ask again next cycle".
909+
"""
910+
if not self.device_list:
911+
return None
912+
913+
inverters = []
914+
for sn in self.device_list:
915+
detail = self.device_detail.get(sn, {}) or {}
916+
pv_kw = self._as_float(detail.get("popv"), 0.0)
917+
918+
functions = ["solar", "battery"] if pv_kw > 0 else ["battery"]
919+
920+
info = {}
921+
if detail.get("minv"):
922+
info["model"] = str(detail["minv"])
923+
if detail.get("mbat"):
924+
info["battery_model"] = str(detail["mbat"])
925+
926+
ratings = {}
927+
inverter_w = self.inverter_limit(sn)
928+
if inverter_w > 0:
929+
ratings["inverter_w"] = inverter_w
930+
if pv_kw > 0:
931+
ratings["pv_w"] = pv_kw * 1000.0
932+
battery_kwh = self.battery_capacity(sn)
933+
if battery_kwh > 0:
934+
ratings["battery_kwh"] = battery_kwh
935+
936+
flags = ["ev_charger"] if self._ev_present.get(sn) is True else []
937+
938+
inverters.append(
939+
inverter_record(
940+
"alphaess:{}".format(sn),
941+
inverter_type="AlphaESSCloud",
942+
composition="direct",
943+
functions=functions,
944+
capabilities=["schedule", "target_soc", "discharge_target", "charge_rate_power"],
945+
flags=flags,
946+
hardware_ids={"serial": sn},
947+
info=info,
948+
ratings=ratings,
949+
)
950+
)
951+
952+
return {"automatic": self.automatic, "inverters": inverters}
953+
868954
@staticmethod
869955
def _empty_schedule():
870956
"""Return a fresh, disabled schedule shape - the single source of truth for its defaults.
@@ -2000,6 +2086,14 @@ async def run(self, seconds, first):
20002086

20012087
await self.publish_data()
20022088

2089+
# Filed right after this cycle's publish and BEFORE `if first and not live_ok:` below:
2090+
# that branch returns False to defer startup when the first telemetry poll fails, and
2091+
# automatic_config() follows it, so a report filed any later would never be filed on
2092+
# exactly the installs whose dump most needs to say what hardware was found. (AlphaESS's
2093+
# automatic_config() does not raise - the early return is the reason.) refresh_discovery()
2094+
# owns the compare/retry/guard loop and never raises.
2095+
self.refresh_discovery()
2096+
20032097
if first and not live_ok:
20042098
# Startup has not really succeeded without telemetry: automatic_config() runs
20052099
# on the first cycle ALONE, so it would map only the args backed by cached

‎apps/predbat/coordinator.py‎

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ class Redactor:
185185
key, which is rewritten by exact match only - see _substitute_key), and a value inside
186186
hardware_ids is only shape-flagged when it is nothing BUT digits, since a letter-prefixed
187187
vendor serial with a long digit tail is that container's entire declared purpose.
188+
189+
A hardware serial is not secret, so a serial a record declares - hardware_ids.serial or an
190+
entry in the structural serials list - is clear wherever it appears as a whole token, however
191+
many digits it has (Solis, Deye and Sunsynk serials are nothing but digits): in hardware_ids,
192+
in a device_id built from it, in a duplicate_serial observation. See _collect_serials.
188193
"""
189194

190195
# Minimum length of an original before it is substituted inside other strings; below this a
@@ -206,6 +211,8 @@ def __init__(self, salt, log=None):
206211
# unrelated text it happens to share a substring with.
207212
self.substring_ok = set()
208213
self._substring_order = []
214+
# Whole-token patterns for every serial the document's records declare - see _collect_serials
215+
self._serial_patterns = []
209216

210217
def token(self, value):
211218
"""The stable pseudonym for one value under this installation's salt."""
@@ -290,6 +297,88 @@ def _numeric_variants(self, text):
290297
variants.add(sign + body[:-2])
291298
return variants
292299

300+
@staticmethod
301+
def _declared_serials(record):
302+
"""The serials one record declares for itself: hardware_ids.serial and every entry in serials."""
303+
declared = set()
304+
hardware_ids = record.get("hardware_ids")
305+
if isinstance(hardware_ids, dict) and hardware_ids.get("serial") not in (None, ""):
306+
declared.add(str(hardware_ids["serial"]))
307+
serials = record.get("serials")
308+
if isinstance(serials, list):
309+
declared |= {str(serial) for serial in serials if serial not in (None, "")}
310+
return declared
311+
312+
def _collect_serials(self, node):
313+
"""Every serial any record in the document declares, anywhere in it.
314+
315+
Collected before the walk so the shape guard can leave a serial readable wherever it
316+
turns up - a device_id built from it, the duplicate_serial observation that names it -
317+
not only inside the record that declared it.
318+
"""
319+
found = set()
320+
if isinstance(node, dict):
321+
found |= self._declared_serials(node)
322+
for value in node.values():
323+
found |= self._collect_serials(value)
324+
elif isinstance(node, list):
325+
for entry in node:
326+
found |= self._collect_serials(entry)
327+
return found
328+
329+
def _strip_serials(self, text):
330+
"""`text` with every declared serial removed where it stands as a whole token.
331+
332+
Whole-token only - bounded by a non-alphanumeric character or the string's ends, matched
333+
case-insensitively as entity ids fold case - so "solis:1031260253072197" and
334+
"sensor.predbat_solis_1031260253072197_soc" lose the serial, but a short serial that
335+
happens to sit inside a longer number does not split that number's digit run and let a
336+
misfiled MPAN through.
337+
"""
338+
for pattern in self._serial_patterns:
339+
text = pattern.sub("", text)
340+
return text
341+
342+
def _pseudonym_values(self, node):
343+
"""Every value held in a pseudonym container (account_ids) anywhere in this record."""
344+
values = set()
345+
if isinstance(node, dict):
346+
for key, value in node.items():
347+
if key in PSEUDONYM_CONTAINERS and isinstance(value, dict):
348+
values |= {str(entry) for entry in value.values()}
349+
else:
350+
values |= self._pseudonym_values(value)
351+
elif isinstance(node, list):
352+
for entry in node:
353+
values |= self._pseudonym_values(entry)
354+
return values
355+
356+
@staticmethod
357+
def _whole_token(text):
358+
"""A case-insensitive pattern matching `text` only where it stands as a whole token.
359+
360+
Bounded by a non-alphanumeric character or the string's ends, so "2306178123" is found in
361+
"deye:2306178123" and "sensor.predbat_solis_2306178123_soc" but not inside "inv98765432".
362+
"""
363+
return re.compile(r"(?<![0-9A-Za-z]){}(?![0-9A-Za-z])".format(re.escape(text)), re.IGNORECASE)
364+
365+
def _serial_derived(self, record):
366+
"""Whether a record's device_id is built from its own serial rather than from an account identifier.
367+
368+
True only when a serial the record declares stands in the device_id as a whole token and
369+
none of the record's own pseudonym values does - whole tokens both ways, as the shape
370+
guard matches serials (see _strip_serials). A serial merely sitting inside a longer token
371+
is coincidence, not construction; and an account id counts however short it is, since one
372+
under MIN_SUBSTITUTE is not otherwise caught by the substring pass. Deye's "deye:{serial}"
373+
beside a station id in account_ids is serial-derived; Octopus's "octopus:{mpan}" declares
374+
no serial and a device_id that embeds an account id does, so both stay identity-derived
375+
exactly as before.
376+
"""
377+
device_id = record["device_id"]
378+
if not any(self._whole_token(serial).search(device_id) for serial in self._declared_serials(record)):
379+
return False
380+
return not any(self._whole_token(value).search(device_id) for value in self._pseudonym_values(record) if value)
381+
293382
def _misfiled(self, value, strict_numeric=False):
294383
"""Whether a value looks like an identifier rather than a measurement, a vendor code, or ordinary text.
295384
@@ -307,6 +396,10 @@ def _misfiled(self, value, strict_numeric=False):
307396
industry-standard shape: "HV2160123456") is that container's entire declared purpose, not
308397
a misfiling; a BARE all-digit string there is still genuinely suspicious, since an MPAN
309398
misfiled where a serial belongs looks exactly like one.
399+
400+
A serial some record declares is never a misfiling: it is removed (as a whole token - see
401+
_strip_serials) before any of the above is judged, so a declared all-digit serial and a
402+
device_id built from one stay readable while anything left beside them is still checked.
310403
"""
311404
if isinstance(value, bool):
312405
return False
@@ -315,8 +408,10 @@ def _misfiled(self, value, strict_numeric=False):
315408
integer_part = abs(int(value))
316409
except (ValueError, OverflowError):
317410
return False
411+
if not self._strip_serials(str(integer_part)):
412+
return False
318413
return len(str(integer_part)) >= 10
319-
text = str(value)
414+
text = self._strip_serials(str(value))
320415
if "@" in text:
321416
return True
322417
stripped = SEPARATOR_RE.sub("", text)
@@ -414,7 +509,7 @@ def _walk(self, node, container=None):
414509
CONTAINER_SPEC.
415510
"""
416511
if isinstance(node, dict):
417-
if isinstance(node.get("device_id"), str) and self._has_pseudonym_container(node):
512+
if isinstance(node.get("device_id"), str) and self._has_pseudonym_container(node) and not self._serial_derived(node):
418513
self._note(node["device_id"], substring=True)
419514
out = {}
420515
for key, value in node.items():
@@ -527,6 +622,9 @@ def redact(self, catalogue):
527622
otherwise be free to corrupt it with.
528623
"""
529624
generated = catalogue.get("generated")
625+
# Longest first, so a serial that contains a shorter one is removed whole
626+
serials = sorted(self._collect_serials(catalogue), key=len, reverse=True)
627+
self._serial_patterns = [self._whole_token(serial) for serial in serials]
530628
walked = self._walk(catalogue)
531629
self._substring_order = sorted(self.substring_ok, key=len, reverse=True)
532630
substituted = self._substitute(walked)

‎apps/predbat/deye.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import json
2525
import os
2626
from component_base import ComponentBase
27+
from coordinator import inverter_record
2728
from mock_base import MockBase
2829
from oauth_mixin import OAuthMixin
2930
from tou_schedule import TouScheduleMixin
@@ -1216,6 +1217,74 @@ async def automatic_config(self):
12161217
self.set_arg("scheduled_discharge_enable", [self._control_name("switch", sn, "battery_schedule_export_enable") for sn in devices])
12171218
self.set_arg("schedule_write_button", [self._control_name("switch", sn, "battery_schedule_charge_write") for sn in devices])
12181219

1220+
def build_discovery(self):
1221+
"""
1222+
Describe the discovered Deye inverters for the discovery catalogue.
1223+
1224+
Reads only state the component already holds - device_list, station_ids,
1225+
device_rated_power and the battery accessors - so this adds no API calls and cannot
1226+
change what Deye does. Reporting is independent of self.automatic.
1227+
1228+
automatic_config() registers every serial in device_list (already filtered to deviceType
1229+
"INVERTER") as "DeyeCloud" with no further test, and binds both PV and battery entities for
1230+
each. Deye's API offers no way to tell a PV-only unit from a hybrid, so neither can this:
1231+
every record carries inverter_type "DeyeCloud" and functions solar and battery, mirroring
1232+
automatic_config() as the source of truth. That is exactly what Predbat believes about the
1233+
device - and the fact a maintainer needs when a PV-only unit has been configured as a
1234+
battery inverter.
1235+
1236+
Ratings: RatedPower (W) as inverter_w; battery_capacity() (kWh) as battery_kwh; and
1237+
config/battery's battCapacity as battery_capacity_ah, the raw Ah the API returned, so a
1238+
reader can check the kWh against its inputs. derive_battery_capacity() is never called
1239+
here: it logs and writes device_pack_voltage/device_capacity, whereas battery_capacity()
1240+
only reads them. Both battery ratings follow the latest poll - device_pack_voltage is set
1241+
from the BMS charge-voltage request each cycle, the same derivation soc_max already relies
1242+
on - so a changed request or a failed battery fetch changes them and re-files the report;
1243+
accepted, because it is the same derivation the component uses for soc_max.
1244+
1245+
station_ids goes in account_ids only when the account has exactly one station:
1246+
get_device_list() queries every station at once and flattens the result, so which device
1247+
belongs to which station is not held, and attributing one of several would be a guess.
1248+
1249+
Deliberately not reported: model and firmware (not held - device/measurePoints and
1250+
station/latest are fetched for debug logging and discarded, and reading them would be a
1251+
new API dependency).
1252+
1253+
Returns None when no inverter has been discovered yet.
1254+
"""
1255+
if not self.device_list:
1256+
return None
1257+
1258+
account_ids = {"station_id": self.station_ids[0]} if len(self.station_ids) == 1 else None
1259+
1260+
inverters = []
1261+
for sn in self.device_list:
1262+
ratings = {}
1263+
rated_w = self._as_float(self.device_rated_power.get(sn), 0.0)
1264+
if rated_w > 0:
1265+
ratings["inverter_w"] = rated_w
1266+
battery_kwh = self.battery_capacity(sn)
1267+
if battery_kwh > 0:
1268+
ratings["battery_kwh"] = battery_kwh
1269+
configured_ah = self._battery_config_value(sn, "capacity")
1270+
if configured_ah > 0:
1271+
ratings["battery_capacity_ah"] = configured_ah
1272+
1273+
inverters.append(
1274+
inverter_record(
1275+
"deye:{}".format(sn),
1276+
inverter_type="DeyeCloud",
1277+
composition="direct",
1278+
functions=["solar", "battery"],
1279+
capabilities=["schedule", "target_soc", "discharge_target", "charge_rate_power"],
1280+
hardware_ids={"serial": sn},
1281+
account_ids=account_ids,
1282+
ratings=ratings,
1283+
)
1284+
)
1285+
1286+
return {"automatic": self.automatic, "inverters": inverters}
1287+
12191288
@staticmethod
12201289
def _age_text(age):
12211290
"""Render a cache age in minutes for logging, tolerating an unknown age."""
@@ -1529,6 +1598,14 @@ async def run(self, seconds, first):
15291598
for sn in self.device_list:
15301599
await self.publish_schedule_settings_ha(sn)
15311600

1601+
# Filed right after this cycle's publish and BEFORE `if first and not live_ok:` below:
1602+
# that branch returns False to defer startup when the first live poll fails, and
1603+
# automatic_config() follows it, so a report filed any later would never be filed on
1604+
# exactly the installs whose dump most needs to say what hardware was found. (Deye's
1605+
# automatic_config() does not raise - the early return is the reason.) refresh_discovery()
1606+
# owns the compare/retry/guard loop and never raises.
1607+
self.refresh_discovery()
1608+
15321609
# Drain any control orders left pending by apply_dynamic_control() every cycle (not
15331610
# just on first run) so a write that is HTTP-accepted but then fails to apply on the
15341611
# device doesn't stay masked forever behind the applied-payload change-detection cache.

0 commit comments

Comments
 (0)