Skip to content

Commit b993747

Browse files
Merge pull request #4652 from springfall2008/fix/teslemetry-ac-coupled-hybrid
fix(teslemetry): model Powerwalls as AC coupled, publish site_info for review
2 parents feba42f + 3d5c833 commit b993747

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)