Skip to content

Commit 9a380e4

Browse files
author
CI
committed
Merge remote-tracking branch 'origin/main' into fix/smart-control-off-planned-slots-5339
# Conflicts: # apps/predbat/octopus.py
2 parents 9bb7309 + 74c9c13 commit 9a380e4

56 files changed

Lines changed: 2305 additions & 223 deletions

Some content is hidden

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

‎.claude/skills/issue-triage/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ Search existing issues (`gh issue list --search ...`, both open and closed) for
8080
- High confidence match: label `duplicate`, comment linking the original issue, and close this one.
8181
- Low confidence: mention "possibly related to #N" in your triage comment; don't close.
8282

83+
Then look for **similar** issues and PRs as well, whether or not they are duplicates: search both open and closed ones for the symptom, the integration and the component it touches (`gh issue list --state all --search ...` and `gh pr list --state all --search ...`). Read the findings in the best matches — what was diagnosed, what was fixed, and what was ruled out — before forming a hypothesis, and link the relevant ones in your triage comment as "related to #N". An issue that shares a symptom or a trigger is not necessarily the same problem: only call it a duplicate when the cause matches, not just the symptom, and treat a past finding as a lead to confirm against current main, not as a conclusion.
84+
8385
## 5. Investigate against current main
8486

8587
- Sync the clone first and discard anything left over from a previous run, so you are reading the code you think you are:

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Arguments: `queue=<dir> limit=<n>`. Every `*.md` directly in that directory is o
1818

1919
Read it end to end before touching anything. You need to know what is already covered — most candidates restate something the journal says, and the right outcome for those is to drop them, or to sharpen the existing entry rather than add a second one.
2020

21-
Note its own "Adding to this file" section: name the symbol rather than the line number, cite the issue number, keep it short.
21+
Note its own "What belongs here" and "Adding to this file" sections: keep techniques and traps, one-line device or API facts, and short dated findings that explain a class of report; leave per-issue narrative (what was tried, the fix, rejected alternatives) to the PR and issue. Name the symbol rather than the line number, cite the issue number, keep it short.
2222

2323
## 2. Verify every candidate against current main
2424

@@ -32,7 +32,7 @@ For each candidate, check the claim still holds — grep the symbol it names, re
3232

3333
- **Fold it in** — place it in the right table row or section, rewritten in the journal's voice. Say what was verified and how.
3434
- **Rewrite it** — the observation is real but the explanation was wrong or has been overtaken.
35-
- **Drop it** — already covered, unverifiable, or fixed since. Dropping is a normal outcome; say so in the PR body with the reason.
35+
- **Drop it** — already covered, unverifiable, fixed since, or a per-issue narrative that belongs in the PR or issue rather than the journal. Dropping is a normal outcome; say so in the PR body with the reason.
3636

3737
Anything the candidate flagged as suspected rather than verified stays marked that way, or comes out. Do not upgrade a hypothesis to a fact because it reads well.
3838

@@ -46,7 +46,7 @@ git log --oneline --since="2 weeks ago" origin/main
4646

4747
Look for merges touching areas the journal makes claims about. For each, ask whether an existing entry is now wrong: a bug it calls "still live" that has been fixed, a line citation that has drifted onto unrelated code, a `confirmed on main (checked ...)` that a later merge has invalidated. Correct those in the same PR, and say plainly in the body which entries changed and why.
4848

49-
If a fix has landed, say so and keep the mechanism — "this was the bug, it was fixed in PR #N" is useful to a reader holding an older log. Do not simply delete the entry.
49+
If a fix has landed, say so and keep the mechanism — "this was the bug, it was fixed in PR #N" is useful to a reader holding an older log. Cut the entry down to that rather than leaving the full story, and do not simply delete it. Where the code an entry describes has since changed, correct or delete the entry; do not leave it marked stale.
5050

5151
## 4. Spelling
5252

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ BLAS
6565
boostable
6666
Bottlecap
6767
brickatius
68+
britkat1980
6869
bwignore
6970
byok
7071
cadata

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,7 @@
18521852
"can_span_midnight": True,
18531853
"charge_discharge_with_rate": False,
18541854
"target_soc_used_for_discharge": False,
1855+
"rate_step_percent_of_capacity": 1,
18551856
},
18561857
"GEC": {
18571858
"name": "GivEnergy Cloud",

‎apps/predbat/const.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2
3434
DYNAMIC_LOAD_CAR_START_MINUTES = 3
3535
DYNAMIC_LOAD_CAR_LOAD_MINUTES = 5
36+
# A car_charging_now reading only confirms the half hour it is in (keeping that half hour cheap once the car
37+
# stops, GH#5316) from this many minutes into it: the sensor can lag a car that stopped just before the boundary
38+
DYNAMIC_LOAD_CAR_CONFIRM_MINUTES = 2
3639

3740
# Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the
3841
# downside case diverges harder than the central one

‎apps/predbat/coordinator.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ def _clean_number(value):
100100
return None
101101

102102

103+
def _clean_percent(value):
104+
"""A percentage above 0 and up to 100, or None. Bools are refused, unlike _clean_number."""
105+
if isinstance(value, bool) or not isinstance(value, (int, float)):
106+
return None
107+
return value if 0 < value <= 100 else None
108+
109+
103110
def _clean_token(value):
104111
"""A vocabulary token (lower case, no spaces), or None."""
105112
return value if isinstance(value, str) and VOCAB_RE.match(value) else None
@@ -159,6 +166,7 @@ def _clean_measure_or_tokens(value):
159166
"min": _clean_number,
160167
"max": _clean_number,
161168
"step": _clean_number,
169+
"step_percent_of_capacity": _clean_percent,
162170
"precision": _clean_number,
163171
"options": _clean_option_list,
164172
"format": _clean_string,
@@ -1319,6 +1327,12 @@ def not_applicable_field(field):
13191327
else:
13201328
gaps.append("output_charge_control")
13211329
not_applicable_field("current_dp")
1330+
# charge_rate's step stands for both rates - an inverter holding them coarsely holds both alike. Most
1331+
# inverters have no such step, so its absence is not a gap: base's value stands, else none
1332+
if rate is not None and rate.get("step_percent_of_capacity"):
1333+
definition["rate_step_percent_of_capacity"] = rate["step_percent_of_capacity"]
1334+
else:
1335+
definition.setdefault("rate_step_percent_of_capacity", 0)
13221336

13231337
definition["time_button_press"] = _bound_entity(entities, "schedule_write_button", access="rw") is not None
13241338

‎apps/predbat/fetch.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2641,7 +2641,7 @@ def get_car_charging_planned(self):
26412641
self.car_charging_exclusive = [False for c in range(self.num_cars)]
26422642

26432643
self.car_charging_planned_response = [str(response).lower() for response in self.get_arg("car_charging_planned_response", ["yes", "on", "enable", "true"])]
2644-
self.car_charging_now_response = [str(response).lower() for response in self.get_arg("car_charging_now_response", ["yes", "on", "enable", "true"])]
2644+
self.car_charging_now_response = [str(response).lower() for response in self.get_arg("car_charging_now_response", ["yes", "on", "enable", "true", "charging"])]
26452645
self.car_charging_from_battery = self.get_arg("car_charging_from_battery")
26462646

26472647
# Car charging planned sensor

‎apps/predbat/gateway.py‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ def _serial_suffix(serial):
100100
PLAN_MODE_CHARGE = 1
101101
PLAN_MODE_DISCHARGE = 2
102102

103+
# GivEnergy holds the charge and discharge rates as a whole percent of nominal battery capacity,
104+
# rounded down - 1300W on a 13.41kWh battery reads back 1206W (#5324)
105+
GIVENERGY_RATE_STEP_PERCENT_OF_CAPACITY = 1
106+
GIVENERGY_INVERTER_TYPES = (pb.INVERTER_TYPE_GIVENERGY, pb.INVERTER_TYPE_GIVENERGY_EMS, pb.INVERTER_TYPE_GIVENERGY_GATEWAY)
107+
103108
# Entity attribute table — keyed by the semantic suffix used in dashboard_item calls
104109
GATEWAY_ATTRIBUTE_TABLE = {
105110
# Binary sensors
@@ -979,8 +984,15 @@ def _inject_inverter_entities(self, inv, suffix):
979984
_raw_export_limit = control.export_limit_w
980985
export_limit_publish = 99999 if _raw_export_limit == 0 else (0 if _raw_export_limit == 1 else _raw_export_limit)
981986
self.dashboard_item(f"sensor.{pfx}_export_limit_w", export_limit_publish, attributes=GATEWAY_ATTRIBUTE_TABLE.get("export_limit_w", {}), app="gateway")
982-
self.dashboard_item(f"number.{pfx}_charge_rate", control.charge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("charge_rate", {}), app="gateway")
983-
self.dashboard_item(f"number.{pfx}_discharge_rate", control.discharge_rate_w, attributes=GATEWAY_ATTRIBUTE_TABLE.get("discharge_rate", {}), app="gateway")
987+
# The hub reports the rate the inverter holds, so a GivEnergy one reads back short of the rate written
988+
# (#5324). Marked per inverter, on a copy of the table entry: GWMQTT is one type for every brand
989+
charge_rate_attributes = dict(GATEWAY_ATTRIBUTE_TABLE.get("charge_rate", {}))
990+
discharge_rate_attributes = dict(GATEWAY_ATTRIBUTE_TABLE.get("discharge_rate", {}))
991+
if inv.type in GIVENERGY_INVERTER_TYPES:
992+
charge_rate_attributes["step_percent_of_capacity"] = GIVENERGY_RATE_STEP_PERCENT_OF_CAPACITY
993+
discharge_rate_attributes["step_percent_of_capacity"] = GIVENERGY_RATE_STEP_PERCENT_OF_CAPACITY
994+
self.dashboard_item(f"number.{pfx}_charge_rate", control.charge_rate_w, attributes=charge_rate_attributes, app="gateway")
995+
self.dashboard_item(f"number.{pfx}_discharge_rate", control.discharge_rate_w, attributes=discharge_rate_attributes, app="gateway")
984996
# The reserve ceiling is per-inverter, so it overrides the table's 100: GivEnergy
985997
# firmware refuses a reserve of 100 and the gateway reports 98 for it (gateway
986998
# issue #346). adjust_reserve() honours this entity's "max" through
@@ -1239,10 +1251,19 @@ def automatic_config(self):
12391251
# NOTE: control commands are addressed to the Gateway/EMS serial — the firmware
12401252
# must fan these out to the AIOs (tracked separately in command_handler.cpp).
12411253
inverters = gateway_units[:1]
1242-
else:
1254+
elif aios:
12431255
inverters = aios
1244-
if not inverters:
1245-
inverters = candidate_aios or list(all_inverters) # last resort
1256+
else:
1257+
# A retained startup frame can contain the discovered inverter list before
1258+
# battery telemetry has been populated. Treating every discovered unit as
1259+
# a last-resort control target makes a PV-only inverter writable and the
1260+
# decision then stays sticky because its serial is not new on later frames.
1261+
# Keep auto-config incomplete so _needs_reconfigure() retries when the next
1262+
# status supplies enough capability data to identify a battery inverter.
1263+
available = [inv.serial for inv in candidate_aios]
1264+
self.log(f"Warn: GatewayMQTT: no battery-capable inverter telemetry yet (discovered serials: {available}); auto-config deferred")
1265+
self._auto_configured = False
1266+
return
12461267

12471268
# Apply serial filter if configured. A no-match is an error — configuring the
12481269
# wrong inverter set is worse than not configuring at all. Leave _auto_configured
@@ -1291,6 +1312,9 @@ def automatic_config(self):
12911312
discharge_enable_entities = []
12921313
export_limit_entities = []
12931314
inverter_limit_entities = []
1315+
battery_scaling_entities = []
1316+
battery_rate_max_entities = []
1317+
inverter_time_entities = []
12941318

12951319
for inv in inverters:
12961320
suffix = inv.serial[-6:].lower()
@@ -1323,6 +1347,10 @@ def automatic_config(self):
13231347
self.log(f"Warn: GatewayMQTT: inverter {inv.serial} has no battery capacity, setting to None for automatic discovery")
13241348

13251349
inverter_limit_entities.append(f"sensor.{base}_inverter_rate_max")
1350+
battery_scaling_entities.append(f"sensor.{base}_battery_dod")
1351+
battery_rate_max_entities.append(f"sensor.{base}_battery_rate_max")
1352+
# Clock drift detection — uses GatewayStatus.timestamp
1353+
inverter_time_entities.append(f"sensor.{base}_inverter_time")
13261354

13271355
# Map entity lists to PredBat args
13281356
self.set_arg("soc_percent", soc_entities)
@@ -1355,13 +1383,11 @@ def automatic_config(self):
13551383

13561384
# Battery health (first inverter)
13571385
self.set_arg("battery_temperature_history", f"sensor.{base0}_battery_temperature")
1358-
self.set_arg("battery_scaling", [f"sensor.{base0}_battery_dod"])
1359-
1360-
# Battery rate max
1361-
self.set_arg("battery_rate_max", [f"sensor.{base0}_battery_rate_max"])
13621386

1363-
# Inverter time (clock drift detection — uses GatewayStatus.timestamp)
1364-
self.set_arg("inverter_time", [f"sensor.{base0}_inverter_time"])
1387+
# Per-inverter: inverter.py reads these at index=self.id, so each needs one entry per inverter
1388+
self.set_arg("battery_scaling", battery_scaling_entities)
1389+
self.set_arg("battery_rate_max", battery_rate_max_entities)
1390+
self.set_arg("inverter_time", inverter_time_entities)
13651391

13661392
# EMS aggregate entities (GivEnergy EMS only)
13671393
inv0 = inverters[0]

‎apps/predbat/givtcp.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
# restart. Only endpoints not already being managed are probed, and only ever to add them.
9393
GIVTCP_REDISCOVER_SECONDS = 3600
9494

95+
# GivEnergy holds the charge and discharge rates as a whole percent of nominal battery capacity,
96+
# rounded down - 1300W on a 13.41kWh battery reads back 1206W (#5324)
97+
GIVTCP_RATE_STEP_PERCENT_OF_CAPACITY = 1
98+
9599
# control name -> (domain, GivTCPRest write method name, HA entity attributes)
96100
GIVTCP_CONTROLS = {
97101
"charge_rate": ("number", "set_charge_rate", {"unit_of_measurement": "W", "device_class": "power", "icon": "mdi:battery-charging", "min": 0, "max": 20000, "step": 100}),
@@ -1038,6 +1042,7 @@ def _discovery_descriptor(self, n, name, domain, access, attrs, max_battery_rate
10381042
elif options:
10391043
descriptor["options"] = options
10401044
if name in ("charge_rate", "discharge_rate"):
1045+
descriptor["step_percent_of_capacity"] = GIVTCP_RATE_STEP_PERCENT_OF_CAPACITY
10411046
if max_battery_rate:
10421047
descriptor["max"] = max_battery_rate
10431048
else:

0 commit comments

Comments
 (0)