diff --git a/docs/faq.md b/docs/faq.md index 1a9b26c5..0444c416 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -185,6 +185,29 @@ setpoint; pure DC battery pools can't be steered to exactly zero the same way. I second unit won't engage, lower `MIN_EFFICIENT_POWER`; for DC-only setups, set it to `0`. See [Battery efficiency optimization](ct002.md#battery-efficiency-optimization). +### I have batteries of different capacities and the smaller one drains much faster. + +A: By default AstraMeter splits the load equally, so a 5 kWh battery receives the same +share as a 15 kWh one and saturates first. Three settings work together to fix this: + +- **`MIN_EFFICIENT_POWER`** is a *per-battery* threshold, not a total. If you set it + to, say, 900 W, AstraMeter won't activate a second battery until demand is high + enough for each battery to handle at least 900 W. For asymmetric packs, lower this + value so both batteries run together across a wider demand range rather than the + smaller one absorbing everything alone. +- **Distribution Weight** (the per-battery slider exposed via MQTT Insights) lets you + split load proportionally to capacity once both batteries are active simultaneously. + For example, a 15 kWh battery paired with a 5 kWh one might use weights of `3.0` + and `1.0` respectively for a 75:25 split. +- **Efficiency Window Weight** (also a per-battery slider via MQTT Insights, shown only + when `MIN_EFFICIENT_POWER > 0`) controls what fraction of the efficiency-rotation + active time each battery holds. When demand is low and only one battery runs at a + time, a larger battery should hold a proportionally bigger slice of the rotation + window — e.g. `75` % and `25` % for the same 3:1 capacity ratio — so it handles + more of the cumulative energy over time. + +See [CT002 / CT003 steering](ct002.md) for details on all three settings. + ### The Marstek app shows the meter offline or doesn't display my real meter values. A: This is expected for purely local operation — the emulated meter typically @@ -198,6 +221,87 @@ answer the app's polls via MQTT. See ## Advanced +### How can I distribute load based on each battery's State of Charge (SoC)? + +A: AstraMeter exposes a **Distribution Weight** entity for every battery in a +CT002/CT003 fleet (requires [MQTT Insights](mqtt-insights.md) with HA discovery +enabled). Raising the weight on a battery makes it receive a larger share of the +charging or discharging target; you can adjust these weights dynamically from a +Home Assistant automation so that emptier batteries are prioritised and fuller +ones are throttled back. + +#### Step 1 — Find the Distribution Weight entity for each battery + +1. In Home Assistant go to **Settings → Devices & Services → MQTT** and open the + **Devices** tab. +2. Look for devices named **AstraMeter Consumer …** (one per battery). Open each + one. +3. Under **Controls** you will find a **Distribution Weight** slider. Note its + entity ID — it looks like + `number.astrameter_consumer__distribution_weight`, where `` is the + battery's MAC address lowercased with all non-alphanumeric characters removed + (e.g. a battery with MAC `AA:BB:CC:DD:EE:FF` produces + `number.astrameter_consumer_aabbccddeeff_distribution_weight`). + + You can also find the entity ID by opening the entity's detail page and + clicking the gear icon → the entity ID is shown at the top of the settings + dialog. + +#### Step 2 — Find the SoC sensor for each battery + +The SoC sensor comes from your battery's native integration (e.g. hm2mqtt, +hame-relay, or any other source). Open the battery device in Home Assistant, +find the **State of Charge** sensor, and note its entity ID +(e.g. `sensor.marstek_b2500_aabbccddeeff_soc`). + +#### Step 3 — Create the automation + +The formula below maps SoC to weight so that an empty battery (0 %) gets weight +2.0 and a full battery (100 %) gets weight 0.0, linearly. Adjust the formula to +taste — for example clamp the minimum above 0 if you never want a battery fully +excluded. + +Go to **Settings → Automations & Scenes → Create Automation → Start with an +empty automation** and paste the following YAML (switch to YAML mode with the +three-dot menu): + +```yaml +alias: AstraMeter – SoC-based distribution weights +description: > + Adjust each battery's distribution weight inversely proportional to its SoC + so that the emptiest battery is charged first. +triggers: + - trigger: state + entity_id: + - sensor.marstek_b2500_aabbccddeeff_soc # battery 1 SoC — replace with yours + - sensor.marstek_b2500_112233445566_soc # battery 2 SoC — replace with yours + for: + seconds: 10 +actions: + - action: number.set_value + target: + entity_id: number.astrameter_consumer_aabbccddeeff_distribution_weight + data: + value: > + {{ [0, 2.0 * (1 - states('sensor.marstek_b2500_aabbccddeeff_soc') | float(100) / 100)] | max | round(1) }} + - action: number.set_value + target: + entity_id: number.astrameter_consumer_112233445566_distribution_weight + data: + value: > + {{ [0, 2.0 * (1 - states('sensor.marstek_b2500_112233445566_soc') | float(100) / 100)] | max | round(1) }} +mode: queued +max: 2 +``` + +Replace the four entity IDs with the real ones you found in steps 1 and 2. Add +one `number.set_value` action block per additional battery. + +> **Tip:** `mode: queued` with `max: 2` ensures that a burst of rapid SoC +> updates doesn't pile up; the 10-second `for:` delay further debounces +> short-lived spikes. Increase `max` if you have more than two batteries so +> that a concurrent trigger for every battery can queue safely. + ### How do signed (positive/negative) power values work with the emulator? A: Powermeters typically report import as positive and export as negative (see diff --git a/ha_addon/run.sh b/ha_addon/run.sh index 1b93fefe..0a416915 100644 --- a/ha_addon/run.sh +++ b/ha_addon/run.sh @@ -260,7 +260,6 @@ else echo "IP=supervisor" echo "PORT=80" echo "API_PATH_PREFIX=/core" - echo "ACCESSTOKEN=$SUPERVISOR_TOKEN" echo "WAIT_FOR_NEXT_MESSAGE=$(bashio::config 'wait_for_next_message')" if bashio::config.has_value 'power_output_alias'; then echo "POWER_CALCULATE=True" diff --git a/src/astrameter/config/config_loader.py b/src/astrameter/config/config_loader.py index 2ad44e60..8b9f40f5 100644 --- a/src/astrameter/config/config_loader.py +++ b/src/astrameter/config/config_loader.py @@ -1,6 +1,7 @@ from __future__ import annotations import configparser +import os from dataclasses import dataclass from ipaddress import IPv4Address, IPv4Network from typing import TYPE_CHECKING @@ -592,11 +593,23 @@ def parse_entities(value: str) -> str | list[str]: config.get(section, "POWER_OUTPUT_ALIAS", fallback="") ) + ip = config.get(section, "IP", fallback="") + if ip == "supervisor": + + def token_getter() -> str: + return os.environ.get("SUPERVISOR_TOKEN", "") + + else: + _static_token = config.get(section, "ACCESSTOKEN", fallback="") + + def token_getter() -> str: # type: ignore[no-redef] + return _static_token + return HomeAssistant( - config.get(section, "IP", fallback=""), + ip, config.get(section, "PORT", fallback=""), config.getboolean(section, "HTTPS", fallback=False), - config.get(section, "ACCESSTOKEN", fallback=""), + token_getter, current_power_entity, config.getboolean(section, "POWER_CALCULATE", fallback=False), power_input_alias, diff --git a/src/astrameter/config/config_loader_test.py b/src/astrameter/config/config_loader_test.py index 96430e45..1b33018c 100644 --- a/src/astrameter/config/config_loader_test.py +++ b/src/astrameter/config/config_loader_test.py @@ -191,6 +191,25 @@ def test_create_homeassistant_powermeter(): raise +def test_create_homeassistant_powermeter_supervisor_token(monkeypatch): + """IP=supervisor reads SUPERVISOR_TOKEN from env at call time, not from config.""" + import configparser as _cp + + monkeypatch.setenv("SUPERVISOR_TOKEN", "initial-token") + config = _cp.ConfigParser() + config["HA"] = { + "IP": "supervisor", + "PORT": "80", + "CURRENT_POWER_ENTITY": "sensor.power", + "ACCESSTOKEN": "stale-token", + } + pm = create_homeassistant_powermeter("HA", config) + assert pm._token() == "initial-token" + + monkeypatch.setenv("SUPERVISOR_TOKEN", "rotated-token") + assert pm._token() == "rotated-token" + + def test_create_vzlogger_powermeter(): """Test VZLogger powermeter creation.""" config = configparser.ConfigParser() diff --git a/src/astrameter/powermeter/homeassistant.py b/src/astrameter/powermeter/homeassistant.py index 8805fd88..283585d5 100644 --- a/src/astrameter/powermeter/homeassistant.py +++ b/src/astrameter/powermeter/homeassistant.py @@ -2,6 +2,7 @@ import contextlib import json import logging +from collections.abc import Callable from typing import Any import aiohttp @@ -27,7 +28,7 @@ def __init__( ip: str, port: str, use_https: bool, - access_token: str, + token: Callable[[], str], current_power_entity: str | list[str], power_calculate: bool, power_input_alias: str | list[str], @@ -37,7 +38,7 @@ def __init__( self.ip = ip self.port = port self.use_https = use_https - self.access_token = access_token + self._token = token self.current_power_entity = ( [current_power_entity] if isinstance(current_power_entity, str) @@ -216,7 +217,7 @@ async def _handle_message( if msg_type == "auth_required": logger.debug("Home Assistant: auth required, sending token") - await ws.send_json({"type": "auth", "access_token": self.access_token}) + await ws.send_json({"type": "auth", "access_token": self._token()}) elif msg_type == "auth_ok": logger.info("Home Assistant: authenticated") self._connected = True @@ -261,7 +262,7 @@ async def _handle_message( async def _fetch_initial_states(self) -> None: if not self._session: return - headers = {"Authorization": f"Bearer {self.access_token}"} + headers = {"Authorization": f"Bearer {self._token()}"} for eid in sorted(self._tracked_entities): if self._entity_values.get(eid) is not None: continue diff --git a/src/astrameter/powermeter/homeassistant_test.py b/src/astrameter/powermeter/homeassistant_test.py index dbbc183d..28a78541 100644 --- a/src/astrameter/powermeter/homeassistant_test.py +++ b/src/astrameter/powermeter/homeassistant_test.py @@ -12,7 +12,7 @@ def _create_powermeter(**overrides): ip="192.168.1.8", port="8123", use_https=False, - access_token="token", + token=lambda: "token", current_power_entity="sensor.current_power", power_calculate=False, power_input_alias="",