Skip to content

Commit a195669

Browse files
authored
Merge branch 'main' into feat/4907-expose-load-ml-source
2 parents 24b25ca + 130596b commit a195669

44 files changed

Lines changed: 5583 additions & 282 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 3 additions & 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
@@ -506,6 +507,7 @@ pvbat
506507
pvenergytotal
507508
pvlib
508509
pvlimit
510+
PVONLY
509511
pvpower
510512
pvwatts
511513
pwindow
@@ -673,6 +675,7 @@ typecodes
673675
tzfile
674676
tzpath
675677
unasserted
678+
uncalibrated
676679
uncompilable
677680
unconfigured
678681
underivable

‎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/chat_tools.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ def set_apps_config(base, key, value, apps_yaml_path=APPS_YAML_PATH, backup_path
772772

773773
base.args[key] = value
774774
# The written key can itself be a credential (or the redact_strings/redact_strings_labelled
775-
# denylists), so log()'s cached redaction pattern (hass.py) must be rebuilt on next use or a
775+
# denylists), so log()'s cached redaction pattern (log_secrets.py) must be rebuilt on next use or a
776776
# newly saved value keeps leaking into the log under the stale pattern until restart (#5053
777777
# review).
778778
base._invalidate_log_secret_pattern()

‎apps/predbat/components.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ def load_component_class(component_info):
632632
"site_id": {"required": False, "secret": True, "config": "teslemetry_site_id"},
633633
"base_url": {"required": False, "config": "teslemetry_base_url", "default": "https://api.teslemetry.com"},
634634
"automatic": {"required": False, "default": False, "config": "teslemetry_automatic"},
635-
"tbc_control": {"required": False, "default": False, "config": "teslemetry_tbc_control"},
635+
"tbc_control": {"required": False, "default": True, "config": "teslemetry_tbc_control"},
636636
"auth_method": {"required": False, "config": "teslemetry_auth_method", "default": "api_key"},
637637
"token_expires_at": {"required": False, "config": "teslemetry_token_expires_at"},
638638
"token_hash": {"required": False, "secret": True, "config": "teslemetry_token_hash"},
@@ -803,7 +803,16 @@ def initialize(self, only=None, phase=0):
803803
have_all_args = False
804804
missing_config.append(arg_info["config"])
805805
else:
806-
arg_dict[arg] = self.base.get_arg(arg_info["config"], default, indirect=indirect)
806+
value = self.base.get_arg(arg_info["config"], default, indirect=indirect)
807+
if value is None and default is not None:
808+
# A key present in apps.yaml but empty (a bare "teslemetry_tbc_control:") resolves
809+
# to a YAML null, and get_arg's bool branch only coerces strings - unlike its int
810+
# and float branches it has no None fallback, so the null would be passed straight
811+
# to the component and a registry "default": True would silently not apply (GH#5186).
812+
# An empty value is not a setting, so the registry default stands. Args with no
813+
# declared default keep resolving to None, which is how they signal "not set".
814+
value = default
815+
arg_dict[arg] = value
807816
required_or = component_info.get("required_or", [])
808817
# If required_or is set we must have at least one of the listed args
809818
if required_or:

‎apps/predbat/config.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,18 @@
889889
"type": "switch",
890890
"default": True,
891891
},
892+
{
893+
"name": "octopus_saving_auto_join_lead_hours",
894+
"friendly_name": "Octopus Saving Session Auto Join Lead Time",
895+
"type": "input_number",
896+
"min": 0,
897+
"max": 12,
898+
"step": 1,
899+
"unit": "hours",
900+
"icon": "mdi:clock-end",
901+
"enable": "octopus_saving_auto_join",
902+
"default": 0,
903+
},
892904
{
893905
"name": "octopus_intelligent_ignore_unplugged",
894906
"friendly_name": "Ignore Intelligent slots when car is unplugged",

‎apps/predbat/coordinator.py‎

Lines changed: 104 additions & 5 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)
@@ -916,8 +1014,9 @@ def inverter_record(
9161014
Tuples become lists because validate_report() keeps only a list for a structural or
9171015
vocabulary field: a tuple `serials` or `functions` - a module-level constant, say - would be
9181016
silently dropped from the catalogue. A set or frozenset becomes a sorted list for the same
919-
reason, sorted because string hashing is randomised per process, so list(some_set) comes out
920-
in a different order after a restart and two dumps of the same hardware would diff for nothing.
1017+
reason, sorted by string form so that a set mixing types cannot raise and withhold the whole
1018+
report, and sorted because string hashing is randomised per process, so list(some_set) comes
1019+
out in a different order after a restart and two dumps of the same hardware would diff for nothing.
9211020
"""
9221021
fields = {
9231022
"inverter_type": inverter_type,
@@ -939,7 +1038,7 @@ def inverter_record(
9391038
record = {"device_id": device_id}
9401039
for name, value in fields.items():
9411040
if isinstance(value, (set, frozenset)):
942-
value = sorted(value)
1041+
value = sorted(value, key=str)
9431042
elif isinstance(value, (list, tuple)):
9441043
value = list(value)
9451044
elif isinstance(value, dict):

0 commit comments

Comments
 (0)