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
104 changes: 104 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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_<mac>_distribution_weight`, where `<mac>` 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`).
Comment on lines +241 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify MQTT discovery payload for distribution_weight entity
rg -n -B5 -A15 'distribution_weight' src/astrameter/mqtt_insights/discovery.py

Repository: tomquist/AstraMeter

Length of output: 1748


Correct the entity ID example This discovery payload only sets name and unique_id, so the final number.<...> should not be described as number.astrameter_consumer_<mac>_distribution_weight unless object_id is set explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/faq.md` around lines 241 - 244, The entity ID example in the FAQ is
misleading because this discovery payload only defines name and unique_id, so
the final number entity ID should not be presented as
number.astrameter_consumer_<mac>_distribution_weight unless object_id is
explicitly set. Update the example text in docs/faq.md to describe the entity ID
as derived from Home Assistant’s default naming behavior for the relevant
discovery payload, and keep the explanation aligned with the fields shown in the
payload.


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
Expand Down
1 change: 0 additions & 1 deletion ha_addon/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 15 additions & 2 deletions src/astrameter/config/config_loader.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/astrameter/config/config_loader_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions src/astrameter/powermeter/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import contextlib
import json
import logging
from collections.abc import Callable
from typing import Any

import aiohttp
Expand All @@ -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],
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/astrameter/powermeter/homeassistant_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="",
Expand Down