Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/predbat/component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ def get_state_wrapper(self, entity_id=None, default=None, attribute=None, refres
def set_state_wrapper(self, entity_id, state, attributes={}, required_unit=None):
return self.base.set_state_wrapper(entity_id, state, attributes=attributes, required_unit=required_unit)

async def set_state_external(self, entity_id, state, attributes={}):
"""Change one of Predbat's OWN entities as if a user had, updating its CONFIG_ITEMS value.

Distinct from set_state_wrapper, which only writes the entity state: components use this when
auto-discovery has to change a Predbat setting (e.g. teslemetry turning inverter_hybrid off
for an AC-coupled Powerwall), where writing the state alone would move the displayed entity
without changing the value the planner reads.
"""
return await self.base.ha_interface.set_state_external(entity_id, state, attributes=attributes)

def call_notify(self, message):
return self.base.call_notify(message)

Expand Down
17 changes: 3 additions & 14 deletions apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,7 @@ def build_entities(domain, candidates):
break
entity_id = "switch.{}_inverter_hybrid".format(self.prefix)
self.log("GECloud: Detected inverter model {} indicates ac_coupled={}, setting {} to {}".format(model_name, ac_coupled, entity_id, "off" if ac_coupled else "on"))
await self.base.ha_interface.set_state_external(entity_id, not ac_coupled)
await self.set_state_external(entity_id, not ac_coupled)

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

Expand Down Expand Up @@ -2157,23 +2157,12 @@ def get_data(self):
return self.mdata, self.oldest_data_time


class MockHAInterface: # pragma: no cover
"""Mock HA interface for testing"""

def __init__(self):
pass

async def set_state_external(self, entity_id, state):
print(f"Set state external {entity_id} = {state}")


class MockBase(SharedMockBase): # pragma: no cover
"""Mock base for the GE Cloud command-line harness, with its own cache root and HA interface."""
"""Mock base for the GE Cloud command-line harness, with its own cache root."""

def __init__(self):
"""Initialise the shared mock with the GE Cloud cache root and a mock HA interface."""
"""Initialise the shared mock with the GE Cloud cache root."""
super().__init__(config_root="./temp_gecloud")
self.ha_interface = MockHAInterface()


def find_registers_by_name(gecloud_direct, register_name, device=None): # pragma: no cover
Expand Down
15 changes: 15 additions & 0 deletions apps/predbat/mock_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@
import json


class MockHAInterface:
"""Minimal stand-in for the HA interface, used by the standalone CLI harnesses.

ComponentBase.set_state_external forwards here so components can change Predbat's OWN config
entities during auto-discovery (e.g. teslemetry turning inverter_hybrid off) - that is the only
write path that also updates the matching CONFIG_ITEMS value. Without this the harnesses would
crash on any component that auto-configures a Predbat setting.
"""

async def set_state_external(self, entity_id, state, attributes={}):
"""Print an external state write instead of applying it."""
print(f"SET EXTERNAL: {entity_id} = {state}")


class MockBase:
"""Minimal stand-in for the PredBat base object, used by the standalone CLI harnesses."""

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

def log(self, message, quiet=True):
"""Print a timestamped log line.
Expand Down
34 changes: 33 additions & 1 deletion apps/predbat/teslemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@

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

def publish_site_info(self, response):
"""Publish the site_info response as one entity so the device's own view of the site is reviewable.

Capacity, AC rating, battery coupling and the export rule all come from here and all change how
Predbat models the site, but none of it was visible outside a debug log line. The whole response
is published rather than a hand-picked subset, so fields added by future firmware appear without
a code change - and so nothing is lost to a wrong guess about where a field is nested (batteries,
customer_preferred_export_rule and net_meter_mode all live under components, not at the top).
Only the tariff blobs are dropped, the same large nested structures _summarize_for_log hides.
"""
attributes = {key: value for key, value in response.items() if key not in TARIFF_BLOB_KEYS}
attributes["friendly_name"] = "Powerwall Site Info"
attributes["state_class"] = None
self.dashboard_item(self.entity("site_info"), response.get("site_name") or "unknown", attributes, app="teslemetry")
Comment thread
springfall2008 marked this conversation as resolved.

def publish_soc_max(self, kwh, estimate=False):
"""Publish the battery capacity (soc_max) in kWh, preferring a real device value over an estimate.

Expand Down Expand Up @@ -322,6 +340,7 @@ async def fetch_site_info(self):
if not data:
return False
response = data.get("response", {})
self.publish_site_info(response)
nameplate_wh = response.get("nameplate_energy", 0)
battery_count = response.get("battery_count")
if nameplate_wh:
Expand Down Expand Up @@ -634,6 +653,9 @@ async def automatic_config(self):
max_site_meter_power_ac, so wiring them unconditionally would point Predbat at entities
that never exist on a site missing those fields. Predbat falls back to its own defaults
for absent args, so skipping the wiring here is safe.

Unlike the args above, inverter_hybrid is one of Predbat's OWN config switches rather than a
component entity, so it is written through set_state_external (see below).
"""
self.log("Info: Teslemetry automatic configuration - wiring Predbat to the TESLA inverter type")
self.set_arg("inverter_type", ["TESLA"])
Expand Down Expand Up @@ -664,6 +686,16 @@ async def automatic_config(self):
self.set_arg("discharge_target_soc", [self.entity("schedule_discharge_soc", domain="number")])
self.set_arg("scheduled_discharge_enable", [self.entity("schedule_discharge_enable", domain="switch")])
self.set_arg("schedule_write_button", [self.entity("schedule_write", domain="switch")])
# Every Powerwall is an AC-coupled battery, so Predbat must not model it as a hybrid. Left at
# Predbat's default (on), get_total_inverted() folds PV into the inverter_limit budget, so the
# Powerwall's own AC rating is applied as a cap on battery + PV combined - modelling a
# separately inverted solar array as clipping against a limit it never passes through, which
# invents both the clipping and the export windows that "recover" it.
# set_state_external is the write path that updates the matching CONFIG_ITEMS value; a plain
# state write would move the entity without changing the setting Predbat plans with.
hybrid_entity = "switch.{}_inverter_hybrid".format(self.prefix)
self.log("Info: Teslemetry setting {} off - Tesla Powerwall batteries are AC coupled".format(hybrid_entity))
await self.set_state_external(hybrid_entity, False)

async def schedule_event(self, entity_id, value):
"""Stage a schedule entity write into pending_schedule; the write switch commits it.
Expand Down
34 changes: 34 additions & 0 deletions apps/predbat/tests/test_component_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"""

import asyncio
from types import SimpleNamespace
from datetime import timezone
from unittest.mock import patch

Expand Down Expand Up @@ -405,6 +406,38 @@ def test_component_base_set_arg_auto(my_predbat):
return False


def test_component_base_set_state_external(my_predbat):
"""
Test ComponentBase.set_state_external() forwards to the HA interface with the attributes intact.

Components use this (rather than set_state_wrapper) when auto-discovery has to change one of
Predbat's own settings - only this path updates the matching CONFIG_ITEMS value, so writing the
state alone would move the displayed entity without changing what the planner reads.
"""
print("\n*** Test: ComponentBase.set_state_external forwards to the HA interface ***")

calls = []

async def capture(entity_id, state, attributes={}):
"""Record a forwarded external state write."""
calls.append((entity_id, state, attributes))
return "written"

base = MockBase()
base.ha_interface = SimpleNamespace(set_state_external=capture)
component = TestComponent(base)

result = asyncio.run(component.set_state_external("switch.predbat_inverter_hybrid", False))
assert calls == [("switch.predbat_inverter_hybrid", False, {})], f"Unexpected forwarded call {calls}"
assert result == "written", "The HA interface's return value should be passed back to the caller"

asyncio.run(component.set_state_external("sensor.predbat_test", 42, {"unit_of_measurement": "W"}))
assert calls[1] == ("sensor.predbat_test", 42, {"unit_of_measurement": "W"}), f"Attributes not forwarded: {calls[1]}"

print("PASS: set_state_external forwards entity, state and attributes and returns the result")
return False


def test_component_base_all(my_predbat):
"""Run all component_base tests"""
tests = [
Expand All @@ -417,6 +450,7 @@ def test_component_base_all(my_predbat):
("run_timeout", test_component_base_run_timeout, "Hung run() triggers timeout, stack trace, and error count"),
("first_cleared_preset", test_component_base_first_cleared_when_run_presets_api_started, "first flag clears even when run() pre-sets api_started"),
("set_arg_auto", test_component_base_set_arg_auto, "set_arg_auto warns once on an apps.yaml override, silent otherwise"),
("set_state_external", test_component_base_set_state_external, "set_state_external forwards to the HA interface"),
]

failed = []
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/tests/test_ge_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class MockHAInterface:
def __init__(self):
self.external_states = {}

async def set_state_external(self, entity_id, state):
async def set_state_external(self, entity_id, state, attributes={}):
self.external_states[entity_id] = state

class MockBase:
Expand Down
Loading
Loading