Skip to content

Commit 3d5c833

Browse files
fix(teslemetry): model Powerwalls as AC coupled, publish site_info
Tesla Powerwall batteries are AC coupled, but nothing in the Teslemetry component or the TESLA inverter type set inverter_hybrid, so it stayed at Predbat's default of on. That matters because of how the two settings combine. Teslemetry publishes inverter_limit from site_info, and max_site_meter_power_ac is usually the 1e9 "unlimited" sentinel, so it falls back to nameplate_power - the Powerwall's own AC rating (5 kW on a PW2). With inverter_hybrid on, get_total_inverted() folds pv_ac into the same budget, so that 5 kW became a cap on battery + PV combined and a separately inverted solar array was modelled as clipping against a limit it never passes through. On a reported site (13.5 kWh PW2, solar measured at 24 kW, 48.6 kWh exported in one day) that invented 17.7 kWh of clipping and, with it, a 05:00-10:00 export window whose only purpose was to "recover" the phantom clipped solar. Turning inverter_hybrid off takes clipping to 0.00 kWh and the morning export window disappears; the plan keeps only its genuine overnight exports. The nameplate_power fallback itself is left alone - it is correct once hybrid is off, because a non-hybrid inverter_limit caps only the battery path, which is exactly the Powerwall's rating (and 11.5 kW on a PW3). Also publishes site_info as sensor.<prefix>_teslemetry_site_info so the device's own view of the site - capacity, AC rating, coupling, export rule - is reviewable without reading a debug log. The response is republished wholesale rather than as a hand-picked subset, so future firmware fields appear without a code change and nothing is lost to a wrong guess about nesting (batteries, customer_preferred_export_rule and net_meter_mode all live under components). The large tariff blobs are dropped, the same ones _summarize_for_log already hides. Supporting refactor: set_state_external moves onto ComponentBase, so components call self.set_state_external() instead of reaching through self.base.ha_interface. All 11 call sites across teslemetry, gecloud, web and web_mcp are migrated, the shared MockBase gains an ha_interface so the standalone CLI harnesses no longer crash on a component that auto-configures a Predbat setting, and GECloud's now-redundant local MockHAInterface is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3d27f9d commit 3d5c833

9 files changed

Lines changed: 237 additions & 25 deletions

File tree

apps/predbat/component_base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,16 @@ def get_state_wrapper(self, entity_id=None, default=None, attribute=None, refres
313313
def set_state_wrapper(self, entity_id, state, attributes={}, required_unit=None):
314314
return self.base.set_state_wrapper(entity_id, state, attributes=attributes, required_unit=required_unit)
315315

316+
async def set_state_external(self, entity_id, state, attributes={}):
317+
"""Change one of Predbat's OWN entities as if a user had, updating its CONFIG_ITEMS value.
318+
319+
Distinct from set_state_wrapper, which only writes the entity state: components use this when
320+
auto-discovery has to change a Predbat setting (e.g. teslemetry turning inverter_hybrid off
321+
for an AC-coupled Powerwall), where writing the state alone would move the displayed entity
322+
without changing the value the planner reads.
323+
"""
324+
return await self.base.ha_interface.set_state_external(entity_id, state, attributes=attributes)
325+
316326
def call_notify(self, message):
317327
return self.base.call_notify(message)
318328

apps/predbat/gecloud.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,7 @@ def build_entities(domain, candidates):
11651165
break
11661166
entity_id = "switch.{}_inverter_hybrid".format(self.prefix)
11671167
self.log("GECloud: Detected inverter model {} indicates ac_coupled={}, setting {} to {}".format(model_name, ac_coupled, entity_id, "off" if ac_coupled else "on"))
1168-
await self.base.ha_interface.set_state_external(entity_id, not ac_coupled)
1168+
await self.set_state_external(entity_id, not ac_coupled)
11691169

11701170
self.log("GECloud: Automatic configuration complete")
11711171

@@ -2157,23 +2157,12 @@ def get_data(self):
21572157
return self.mdata, self.oldest_data_time
21582158

21592159

2160-
class MockHAInterface: # pragma: no cover
2161-
"""Mock HA interface for testing"""
2162-
2163-
def __init__(self):
2164-
pass
2165-
2166-
async def set_state_external(self, entity_id, state):
2167-
print(f"Set state external {entity_id} = {state}")
2168-
2169-
21702160
class MockBase(SharedMockBase): # pragma: no cover
2171-
"""Mock base for the GE Cloud command-line harness, with its own cache root and HA interface."""
2161+
"""Mock base for the GE Cloud command-line harness, with its own cache root."""
21722162

21732163
def __init__(self):
2174-
"""Initialise the shared mock with the GE Cloud cache root and a mock HA interface."""
2164+
"""Initialise the shared mock with the GE Cloud cache root."""
21752165
super().__init__(config_root="./temp_gecloud")
2176-
self.ha_interface = MockHAInterface()
21772166

21782167

21792168
def find_registers_by_name(gecloud_direct, register_name, device=None): # pragma: no cover

apps/predbat/mock_base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@
2626
import json
2727

2828

29+
class MockHAInterface:
30+
"""Minimal stand-in for the HA interface, used by the standalone CLI harnesses.
31+
32+
ComponentBase.set_state_external forwards here so components can change Predbat's OWN config
33+
entities during auto-discovery (e.g. teslemetry turning inverter_hybrid off) - that is the only
34+
write path that also updates the matching CONFIG_ITEMS value. Without this the harnesses would
35+
crash on any component that auto-configures a Predbat setting.
36+
"""
37+
38+
async def set_state_external(self, entity_id, state, attributes={}):
39+
"""Print an external state write instead of applying it."""
40+
print(f"SET EXTERNAL: {entity_id} = {state}")
41+
42+
2943
class MockBase:
3044
"""Minimal stand-in for the PredBat base object, used by the standalone CLI harnesses."""
3145

@@ -51,6 +65,7 @@ def __init__(self, config_root="./temp_predbat", local_tz=None, **kwargs):
5165
self.currency_symbols = "£p"
5266
self.arg_errors = {}
5367
self.args = {key: value for key, value in kwargs.items() if value is not None}
68+
self.ha_interface = MockHAInterface()
5469

5570
def log(self, message, quiet=True):
5671
"""Print a timestamped log line.

apps/predbat/teslemetry.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757

5858
OPERATION_MODES = ["self_consumption", "autonomous", "backup"]
5959
EXPORT_RULES = ["never", "pv_only", "battery_ok"]
60+
# Large nested tariff structures that are never worth republishing - hidden from both the debug log
61+
# summary and the site_info review entity.
62+
TARIFF_BLOB_KEYS = ("tariff_content", "tariff_content_v2")
6063
# tou_settings.optimization_strategy: the OTHER dial the Fleet API exposes alongside the tariff itself,
6164
# deciding whether Time-Based Control actually acts on price. "balanced" (the device default, and what
6265
# is left in place if this is never sent) only discharges to offset house load and never exports stored
@@ -240,7 +243,7 @@ def _summarize_for_log(data):
240243
for key in ("time_series", "SmartBreakerEnergyLogs"):
241244
if isinstance(response.get(key), list):
242245
response[key] = "[{} entries hidden]".format(len(response[key]))
243-
for key in ("tariff_content", "tariff_content_v2"):
246+
for key in TARIFF_BLOB_KEYS:
244247
if isinstance(response.get(key), dict):
245248
response[key] = "[hidden, code={}]".format(response[key].get("code"))
246249
return {"response": response}
@@ -252,6 +255,21 @@ def publish_sensor(self, suffix, state, unit=None, state_class="measurement", fr
252255
attributes["unit_of_measurement"] = unit
253256
self.dashboard_item(self.entity(suffix), state, attributes, app="teslemetry")
254257

258+
def publish_site_info(self, response):
259+
"""Publish the site_info response as one entity so the device's own view of the site is reviewable.
260+
261+
Capacity, AC rating, battery coupling and the export rule all come from here and all change how
262+
Predbat models the site, but none of it was visible outside a debug log line. The whole response
263+
is published rather than a hand-picked subset, so fields added by future firmware appear without
264+
a code change - and so nothing is lost to a wrong guess about where a field is nested (batteries,
265+
customer_preferred_export_rule and net_meter_mode all live under components, not at the top).
266+
Only the tariff blobs are dropped, the same large nested structures _summarize_for_log hides.
267+
"""
268+
attributes = {key: value for key, value in response.items() if key not in TARIFF_BLOB_KEYS}
269+
attributes["friendly_name"] = "Powerwall Site Info"
270+
attributes["state_class"] = None
271+
self.dashboard_item(self.entity("site_info"), response.get("site_name") or "unknown", attributes, app="teslemetry")
272+
255273
def publish_soc_max(self, kwh, estimate=False):
256274
"""Publish the battery capacity (soc_max) in kWh, preferring a real device value over an estimate.
257275
@@ -322,6 +340,7 @@ async def fetch_site_info(self):
322340
if not data:
323341
return False
324342
response = data.get("response", {})
343+
self.publish_site_info(response)
325344
nameplate_wh = response.get("nameplate_energy", 0)
326345
battery_count = response.get("battery_count")
327346
if nameplate_wh:
@@ -634,6 +653,9 @@ async def automatic_config(self):
634653
max_site_meter_power_ac, so wiring them unconditionally would point Predbat at entities
635654
that never exist on a site missing those fields. Predbat falls back to its own defaults
636655
for absent args, so skipping the wiring here is safe.
656+
657+
Unlike the args above, inverter_hybrid is one of Predbat's OWN config switches rather than a
658+
component entity, so it is written through set_state_external (see below).
637659
"""
638660
self.log("Info: Teslemetry automatic configuration - wiring Predbat to the TESLA inverter type")
639661
self.set_arg("inverter_type", ["TESLA"])
@@ -664,6 +686,16 @@ async def automatic_config(self):
664686
self.set_arg("discharge_target_soc", [self.entity("schedule_discharge_soc", domain="number")])
665687
self.set_arg("scheduled_discharge_enable", [self.entity("schedule_discharge_enable", domain="switch")])
666688
self.set_arg("schedule_write_button", [self.entity("schedule_write", domain="switch")])
689+
# Every Powerwall is an AC-coupled battery, so Predbat must not model it as a hybrid. Left at
690+
# Predbat's default (on), get_total_inverted() folds PV into the inverter_limit budget, so the
691+
# Powerwall's own AC rating is applied as a cap on battery + PV combined - modelling a
692+
# separately inverted solar array as clipping against a limit it never passes through, which
693+
# invents both the clipping and the export windows that "recover" it.
694+
# set_state_external is the write path that updates the matching CONFIG_ITEMS value; a plain
695+
# state write would move the entity without changing the setting Predbat plans with.
696+
hybrid_entity = "switch.{}_inverter_hybrid".format(self.prefix)
697+
self.log("Info: Teslemetry setting {} off - Tesla Powerwall batteries are AC coupled".format(hybrid_entity))
698+
await self.set_state_external(hybrid_entity, False)
667699

668700
async def schedule_event(self, entity_id, value):
669701
"""Stage a schedule entity write into pending_schedule; the write switch commits it.

apps/predbat/tests/test_component_base.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"""
1414

1515
import asyncio
16+
from types import SimpleNamespace
1617
from datetime import timezone
1718
from unittest.mock import patch
1819

@@ -405,6 +406,38 @@ def test_component_base_set_arg_auto(my_predbat):
405406
return False
406407

407408

409+
def test_component_base_set_state_external(my_predbat):
410+
"""
411+
Test ComponentBase.set_state_external() forwards to the HA interface with the attributes intact.
412+
413+
Components use this (rather than set_state_wrapper) when auto-discovery has to change one of
414+
Predbat's own settings - only this path updates the matching CONFIG_ITEMS value, so writing the
415+
state alone would move the displayed entity without changing what the planner reads.
416+
"""
417+
print("\n*** Test: ComponentBase.set_state_external forwards to the HA interface ***")
418+
419+
calls = []
420+
421+
async def capture(entity_id, state, attributes={}):
422+
"""Record a forwarded external state write."""
423+
calls.append((entity_id, state, attributes))
424+
return "written"
425+
426+
base = MockBase()
427+
base.ha_interface = SimpleNamespace(set_state_external=capture)
428+
component = TestComponent(base)
429+
430+
result = asyncio.run(component.set_state_external("switch.predbat_inverter_hybrid", False))
431+
assert calls == [("switch.predbat_inverter_hybrid", False, {})], f"Unexpected forwarded call {calls}"
432+
assert result == "written", "The HA interface's return value should be passed back to the caller"
433+
434+
asyncio.run(component.set_state_external("sensor.predbat_test", 42, {"unit_of_measurement": "W"}))
435+
assert calls[1] == ("sensor.predbat_test", 42, {"unit_of_measurement": "W"}), f"Attributes not forwarded: {calls[1]}"
436+
437+
print("PASS: set_state_external forwards entity, state and attributes and returns the result")
438+
return False
439+
440+
408441
def test_component_base_all(my_predbat):
409442
"""Run all component_base tests"""
410443
tests = [
@@ -417,6 +450,7 @@ def test_component_base_all(my_predbat):
417450
("run_timeout", test_component_base_run_timeout, "Hung run() triggers timeout, stack trace, and error count"),
418451
("first_cleared_preset", test_component_base_first_cleared_when_run_presets_api_started, "first flag clears even when run() pre-sets api_started"),
419452
("set_arg_auto", test_component_base_set_arg_auto, "set_arg_auto warns once on an apps.yaml override, silent otherwise"),
453+
("set_state_external", test_component_base_set_state_external, "set_state_external forwards to the HA interface"),
420454
]
421455

422456
failed = []

apps/predbat/tests/test_ge_cloud.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class MockHAInterface:
7272
def __init__(self):
7373
self.external_states = {}
7474

75-
async def set_state_external(self, entity_id, state):
75+
async def set_state_external(self, entity_id, state, attributes={}):
7676
self.external_states[entity_id] = state
7777

7878
class MockBase:

0 commit comments

Comments
 (0)