Skip to content

Commit 11eb4e1

Browse files
author
CI
committed
Merge remote-tracking branch 'origin/main' into fix/gecloud-ems-inverter-settings-reread-5103
# Conflicts: # apps/predbat/tests/test_ge_cloud.py
2 parents 94e811a + 30cf59e commit 11eb4e1

169 files changed

Lines changed: 28846 additions & 1472 deletions

File tree

Some content is hidden

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

‎.claude/skills/journal-update/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ allowed-tools: You can read anything in this repo and the queue directory, but y
88

99
You maintain `tools/debug-journal.md` — the file every triage, PR-review and PR-cleanup run reads before forming a hypothesis. Each of those runs can leave a finding in a queue directory; once a day you fold the queue in and open a PR.
1010

11-
Arguments: `queue=<dir>`. Every `*.md` directly in that directory is one candidate. `<queue>/processed/` is the archive of candidates already folded in — read it for context if you like, but never treat it as new input.
11+
Arguments: `queue=<dir> limit=<n>`. Every `*.md` directly in that directory is one candidate. `<queue>/processed/` is the archive of candidates already folded in — read it for context if you like, but never treat it as new input.
12+
13+
**Take only the first `limit` candidates, in plain filename sort order, and leave the rest alone.** The daemon archives exactly that slice once your PR is open, so folding in a different set means the ones you skipped get archived unread while the ones you added get offered again tomorrow. A backlog is normal and drains a slice per day; it is not a reason to reach past the limit. Say in the PR body how many were left queued.
1214

1315
**The journal being wrong is worse than it being stale.** Every later run trusts it, so a confidently wrong entry propagates into comments posted to real reporters. One entry recently asserted a credential leak that had already been fixed; left alone it would have had a triage run tell a reporter to rotate keys that never leaked. Your job is as much deleting and correcting as adding.
1416

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ brickatius
6767
bwignore
6868
byok
6969
cadata
70+
calcsize
7071
calib
7172
Cantarell
7273
cbat
@@ -133,12 +134,14 @@ dayname
133134
daynumber
134135
daysymbol
135136
deadband
137+
deadbanding
136138
deadbeefdeadbeef
137139
dedup
138140
dedupe
139141
dend
140142
denorm
141143
derate
144+
derated
142145
derating
143146
desync
144147
desynced
@@ -166,6 +169,7 @@ eauth
166169
echargingpile
167170
Eddi
168171
eddis
172+
efgh
169173
elif
170174
elinter
171175
eload
@@ -250,12 +254,14 @@ GivEnergy
250254
givtcp
251255
gnueabihf
252256
GOELEC
257+
goldens
253258
gridcharge
254259
gridcode
255260
gridconsumption
256261
gridconsumptionpower
257262
Groq
258263
growatt
264+
growattsph
259265
GWMQTT
260266
HACS
261267
hadashboard
@@ -415,6 +421,7 @@ newtok
415421
njpwerner
416422
nobat
417423
nocharge
424+
NODETAIL
418425
nodischarge
419426
nohup
420427
noopener
@@ -490,12 +497,17 @@ preseed
490497
preseeded
491498
prevs
492499
protobuf
500+
pseudonymisation
501+
pseudonymise
502+
pseudonymised
503+
pseudonymises
493504
psum
494505
pthread
495506
pvbat
496507
pvenergytotal
497508
pvlib
498509
pvlimit
510+
PVONLY
499511
pvpower
500512
pvwatts
501513
pwindow
@@ -663,6 +675,7 @@ typecodes
663675
tzfile
664676
tzpath
665677
unasserted
678+
uncalibrated
666679
uncompilable
667680
unconfigured
668681
underivable
@@ -680,6 +693,7 @@ unpushed
680693
unreachability
681694
unredacted
682695
unregisters
696+
unrepresentable
683697
unreproduced
684698
unsmoothed
685699
unstaged
@@ -694,6 +708,7 @@ varh
694708
venv
695709
Victron
696710
visibilitychange
711+
vvwwxxabcdefyyzzuu
697712
waf
698713
waitpid
699714
Wallbox
@@ -706,13 +721,15 @@ WNOHANG
706721
workmode
707722
writeonly
708723
wrongsha
724+
wwxxabcdefyyzz
709725
xaxis
710726
xaxistooltip
711727
xlabel
712728
xlim
713729
xload
714730
xrtkq
715731
xticks
732+
xxabcdefyy
716733
yaxis
717734
yaxistooltip
718735
yday

‎.pre-commit-config.yaml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ repos:
2222
language: python
2323
entry: python .cspell/sort_dictionary.py
2424
files: ^\.cspell\/custom-dictionary-workspace\.txt$
25+
- id: export-limit-encoding
26+
name: export limit encoding
27+
language: python
28+
entry: python tools/check_export_limit_encoding.py
29+
files: ^apps/predbat/.*\.(py|cpp)$
30+
- id: export-limit-encoding-tests
31+
name: export limit encoding checker tests
32+
language: python
33+
entry: python tools/test_check_export_limit_encoding.py
34+
files: ^tools/(check_export_limit_encoding\.py|test_check_export_limit_encoding\.py)$
35+
pass_filenames: false
2536
- id: triage-daemon-tests
2637
name: triage daemon unit tests
2738
language: python

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

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from annual_interpolate import ANCHOR_MONTHS, FAST_MODE_MAX_RATE_CV, build_interpolated_rows, rate_variability
2424
from annual_load import build_load_forecast, OctopusConsumptionLoadProfile, SyntheticLoadProfile
2525
from annual_tariff import AnnualTariff
26+
from utils import mask_secret_args
2627
from annual_weather import AnnualWeather, resolve_postcode
2728
from const import MINUTE_WATT, PREDICT_STEP
2829
from prediction import Prediction
@@ -77,9 +78,6 @@
7778
# arrive here and be rejected.
7879
WEATHER_ARCHIVE_LAG_DAYS = 8
7980

80-
# Substrings that mark a config value as secret and therefore scrubbable
81-
SECRET_MARKERS = ["_key", "password", "token", "secret"]
82-
8381

8482
class AnnualConfigError(ValueError):
8583
"""Raised when the annual prediction config is invalid or self-contradictory."""
@@ -88,20 +86,15 @@ class AnnualConfigError(ValueError):
8886
def scrub_secrets(config):
8987
"""Return a deep copy of the config with secret-looking values replaced by "xxx".
9088
91-
Mirrors the redaction ``create_debug_yaml()`` applies, so a results document or
92-
debug dump can never carry an API key.
89+
Delegates to utils.mask_secret_args() - the one redaction implementation
90+
create_debug_yaml(), web.py and the MCP config routes all share - rather than keeping a
91+
second, independent credential-key list here. This used to duplicate that list
92+
(SECRET_MARKERS) with only the original four substrings and no access to the growable
93+
per-component "secret": True registry (account numbers, serial numbers, etc, GH#4770) that
94+
list has since grown into; a key the registry newly flags would have kept leaking through
95+
this path even after being fixed everywhere else.
9396
"""
94-
if isinstance(config, dict):
95-
scrubbed = {}
96-
for key, value in config.items():
97-
if any(marker in str(key).lower() for marker in SECRET_MARKERS):
98-
scrubbed[key] = "xxx"
99-
else:
100-
scrubbed[key] = scrub_secrets(value)
101-
return scrubbed
102-
if isinstance(config, list):
103-
return [scrub_secrets(item) for item in config]
104-
return config
97+
return mask_secret_args(config)
10598

10699

107100
def _require_number(value, field, minimum=None, maximum=None, integer=False, exclusive_minimum=False):

‎apps/predbat/annual_tariff.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import pytz
1919

2020
from annual_http import fetch_json
21-
from utils import minute_data
21+
from utils import filter_payment_method, minute_data
2222

2323
MINUTES_PER_DAY = 24 * 60
2424

@@ -157,7 +157,7 @@ async def _download_rows(self, url, cache_key, expiry=None):
157157
if self.storage:
158158
cached = await self.storage.load("annual", cache_key)
159159
if isinstance(cached, list) and cached:
160-
return cached, False
160+
return filter_payment_method(cached), False
161161

162162
rows = []
163163
pages = 0
@@ -184,7 +184,11 @@ async def _download_rows(self, url, cache_key, expiry=None):
184184
return [], True
185185
if rows and self.storage:
186186
await self.storage.save("annual", cache_key, rows, format="json", expiry=expiry)
187-
return rows, False
187+
# A Flexible tariff returns a DIRECT_DEBIT and a NON_DIRECT_DEBIT row for each window, which
188+
# both ``_rows_to_stamped_rates`` and ``_rows_to_local_pattern`` would otherwise resolve by
189+
# response order. The cache keeps the API's own rows, so a later change of preferred payment
190+
# method takes effect without re-downloading a year of history.
191+
return filter_payment_method(rows), False
188192

189193
@staticmethod
190194
def _rows_to_stamped_rates(rows, start_utc, days):

‎apps/predbat/chat_tools.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,11 @@ def set_apps_config(base, key, value, apps_yaml_path=APPS_YAML_PATH, backup_path
771771
return {"success": False, "error": "Could not write apps.yaml: {}".format(error), "data": None}
772772

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

775780
description = "Changed apps.yaml key '{}' from {!r} to {!r}. {}".format(key, previous_value, value, APPS_YAML_RESTART_WARNING)
776781
return {

0 commit comments

Comments
 (0)