Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
20 changes: 20 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## What

<!-- One or two lines. -->

## Approved roadmap item

Discussion / issue: #

<!--
Bug fixes: leave this blank and go ahead. They never need prior approval.

Features: link a discussion or issue labelled `approved`. Feature PRs without
one get closed with a pointer rather than reviewed — please ask on the
discussion and wait for a green light first. It saves you writing code that
may not fit the roadmap.
-->

## Tests

<!-- What you ran. -->
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ feature-requests-*.md
feature-requests-*.json
feature-poll-draft-*.md

# Project-local Codex skills
/.agents/skills/

CLAUDE.md
.agents/scripts/publish_release.py
.agents/workflows/publish_release.md
Expand All @@ -64,5 +61,6 @@ design_handoff_mvem_dashboard/
MULTI_BATTERY_MIGRATION_PLAN.md
ROADMAP.md
bash.exe.stackdump
.codex/github-review/ffunes--omnibattery.json
.agents/
.codex/
resumen-2026-08-25.md
210 changes: 58 additions & 152 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
- Marstek Venus E and C (v2 and v3), Venus D and Venus A via Modbus TCP
- Zendure SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus and 800 (Local API)
- Anker SOLIX Solarbank Max AC and Solarbank 4 E5000 Pro via Modbus TCP (thanks @wouterbouvy!)
- Huawei SUN2000 + LUNA2000 via Modbus TCP (validated on SUN2000-8K-MAP0; thanks @sphings79!)
- Sessy Home Battery (Looking for testers!!!)
- Hoymiles MS-A2 and HiBattery MQTT models

## Key Features

- **Mix and match different battery brands**: Marstek, Zendure, Anker Solix, Sessy, Hoymiles MQTT and more to come!
- **Mix and match different battery brands**: Marstek, Zendure, Anker Solix, Huawei, Sessy, Hoymiles MQTT and more to come!
- **Zero Export/Import PD Controller**: Keeps grid exchange near zero using a Proportional-Derivative algorithm.
- **Integrated dashboard**: All the controls and adjustments from a single place. Graphs and power flow diagram included!
- **One-Click PD Profiles + Quality Sensor**: Pick a tuning profile (Very smooth → Very aggressive) instead of tuning gains by hand; a control-quality sensor reports whether the result is stable, oscillating or sluggish.
Expand Down Expand Up @@ -54,8 +55,8 @@ Full documentation (configuration, features, entities, troubleshooting) is avail

| Requirement | Details |
|---|---|
| Battery | Marstek Venus E v2/v3, Venus A, Venus D, Zendure SolarFlow 4000 Mix Pro / 4000 Mix AC+, 2400 AC+, 2400 AC Pro, Anker SOLIX Solarbank Max AC / 4 E5000 Pro |
| Modbus bridge | Elfin-EW11 or compatible RS485-to-TCP converter. Venus E v3, Venus A and Venus D can also be connected via Ethernet with native Modbus TCP support. Anker Solarbank Max AC and 4 E5000 Pro use native Modbus TCP (enable in the Anker app under Third-Party Control; only one Modbus client at a time). |
| Battery | Marstek Venus E v2/v3, Venus A, Venus D, Zendure SolarFlow 4000 Mix Pro / 4000 Mix AC+, 2400 AC+, 2400 AC Pro, Anker SOLIX Solarbank Max AC / 4 E5000 Pro, Huawei SUN2000 + LUNA2000 |
| Modbus bridge | Elfin-EW11 or compatible RS485-to-TCP converter. Venus E v3, Venus A and Venus D can also be connected via Ethernet with native Modbus TCP support. Anker Solarbank Max AC and 4 E5000 Pro use native Modbus TCP (enable in the Anker app under Third-Party Control; only one Modbus client at a time). Huawei uses the SUN2000 Modbus TCP endpoint; use a shared Modbus proxy when another client is connected. |
| Wireless connection | Required for Zendure SolarFlow 4000 Mix AC+, 2400 AC+ and 2400 AC Pro |
| Grid sensor | HA sensor measuring total grid consumption (e.g. Shelly EM3, Neurio, smart meter) |
| Network | Battery reachable by IP from Home Assistant |
Expand Down
63 changes: 51 additions & 12 deletions custom_components/omnibattery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@
GRID_CHARGE_NOT_NEEDED,
GRID_CHARGE_SCHEDULED,
)
from .control.pack_soc import soc_vs_ceiling, soc_vs_floor
from .control.weekly_full_charge import WeeklyFullChargeManager
from .control.max_soc_charge import MaxSocChargeManager
from .control.temperature_limit import TemperatureChargeLimitManager
Expand Down Expand Up @@ -1345,6 +1346,18 @@ def _daily_operation_delay_active(self) -> bool:
"waiting for solar",
}

def _daily_operation_setpoint_enabled(self) -> bool:
"""Return whether the Charge Delay SOC setpoint is actually in effect.

The setpoint is a sub-feature of Charge Delay: the controller ignores it
when the delay is off (``is_charge_delayed`` returns early), so the
timeline must not paint the setpoint marker either.
"""
return bool(
getattr(self, "charge_delay_enabled", False)
and getattr(self, "_delay_soc_setpoint_enabled", False)
)

def _daily_operation_hourly_balance_context(self, action_mask: int) -> int:
"""Classify a measured action driven by the hourly net-balance offset.

Expand Down Expand Up @@ -1614,7 +1627,9 @@ def _daily_operation_runtime_decision(
ChargeDischargeController._daily_operation_weekly_delay_bypass(self)
)
delay_active = self._daily_operation_delay_active()
setpoint_enabled = bool(getattr(self, "_delay_soc_setpoint_enabled", False))
setpoint_enabled = (
ChargeDischargeController._daily_operation_setpoint_enabled(self)
)
setpoint_reached = bool(getattr(self, "_delay_setpoint_reached", False))
setpoint_active = (
setpoint_enabled
Expand Down Expand Up @@ -1911,7 +1926,9 @@ def projection_datetime(value: datetime) -> datetime:
system_charge_power_w = configured_charge
if configured_discharge > 0.0:
system_discharge_power_w = configured_discharge
setpoint_enabled = bool(getattr(self, "_delay_soc_setpoint_enabled", False))
setpoint_enabled = (
ChargeDischargeController._daily_operation_setpoint_enabled(self)
)
setpoint_reached = bool(getattr(self, "_delay_setpoint_reached", False))
target_soc_pct = None
solar_t_end = None
Expand Down Expand Up @@ -2104,7 +2121,9 @@ def _refresh_daily_operation_timeline(
}

setpoint = {
"enabled": bool(getattr(self, "_delay_soc_setpoint_enabled", False)),
"enabled": (
ChargeDischargeController._daily_operation_setpoint_enabled(self)
),
"target_soc": self._daily_operation_float(
getattr(self, "_delay_soc_setpoint", None), 0.0
),
Expand Down Expand Up @@ -3382,6 +3401,10 @@ def _refresh_battery_charge_limit_blocks(self) -> None:
coordinator,
weekly_100_unlocked,
)
# A coupled-pack battery is full when its *least* full pack reaches
# the ceiling; its aggregate gets there while the last pack is still
# filling (issue #350). Equals current_soc without pack telemetry.
ceiling_soc = soc_vs_ceiling(coordinator, current_soc)

should_charge_to_bms = getattr(self, "_should_charge_to_bms_cutoff", None)
if should_charge_to_bms is not None and should_charge_to_bms(
Expand Down Expand Up @@ -3433,9 +3456,11 @@ def _refresh_battery_charge_limit_blocks(self) -> None:
coordinator._hysteresis_active = False
coordinator._hysteresis_base_soc = None

if current_soc >= coordinator.max_soc or bms_cutoff or taper_at_top_voltage:
if ceiling_soc >= coordinator.max_soc or bms_cutoff or taper_at_top_voltage:
coordinator._hysteresis_active = True
if coordinator._hysteresis_base_soc is None:
# Base stays the aggregate: it answers "how far must the
# whole battery fall before recharging", not "is it full".
coordinator._hysteresis_base_soc = current_soc

hysteresis_base = (
Expand All @@ -3450,7 +3475,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None:
coordinator._hysteresis_base_soc = None

if coordinator._hysteresis_active:
if current_soc >= effective_max_soc or bms_cutoff:
if ceiling_soc >= effective_max_soc or bms_cutoff:
self.set_charge_block(
"max_soc",
"max_soc",
Expand Down Expand Up @@ -3484,7 +3509,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None:

self.remove_charge_block("charge_hysteresis", coordinator=coordinator)

if current_soc >= effective_max_soc or bms_cutoff:
if ceiling_soc >= effective_max_soc or bms_cutoff:
self.set_charge_block(
"max_soc",
"max_soc",
Expand All @@ -3495,6 +3520,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None:
"effective_max_soc": effective_max_soc,
"source": max_soc_source,
"bms_cutoff": bms_cutoff,
**({"min_pack_soc": ceiling_soc} if ceiling_soc != current_soc else {}),
},
coordinator=coordinator,
)
Expand All @@ -3521,7 +3547,11 @@ def _refresh_battery_discharge_limit_blocks(self) -> None:

current_soc = coordinator.data.get("battery_soc", 0)
effective_min_soc, min_soc_source = self._effective_discharge_min_soc(coordinator)
if current_soc <= effective_min_soc:
# A coupled-pack battery is empty when its *fullest* pack reaches the
# floor, not when its aggregate does (issue #350). Falls back to the
# aggregate on every battery that publishes no per-pack telemetry.
floor_soc = soc_vs_floor(coordinator, current_soc)
if floor_soc <= effective_min_soc:
self.set_discharge_block(
"min_soc",
"min_soc",
Expand All @@ -3531,6 +3561,7 @@ def _refresh_battery_discharge_limit_blocks(self) -> None:
"min_soc": coordinator.min_soc,
"effective_min_soc": effective_min_soc,
"source": min_soc_source,
**({"max_pack_soc": floor_soc} if floor_soc != current_soc else {}),
},
coordinator=coordinator,
)
Expand Down Expand Up @@ -3804,6 +3835,10 @@ def _get_available_batteries(
should_charge_to_bms is not None
and should_charge_to_bms(coordinator, effective_max_soc)
)
# Judged on the least full pack for a coupled-pack battery, so a
# finished first pack cannot end the charge (issue #350).
# Identical to current_soc without pack telemetry.
ceiling_soc = soc_vs_ceiling(coordinator, current_soc)

# Update hysteresis state if enabled
if coordinator.enable_charge_hysteresis:
Expand Down Expand Up @@ -3857,7 +3892,7 @@ def _get_available_batteries(
coordinator._hysteresis_active = False
coordinator._hysteresis_base_soc = None

if current_soc >= coordinator.max_soc or _taper_at_top:
if ceiling_soc >= coordinator.max_soc or _taper_at_top:
coordinator._hysteresis_active = True
# Capture the actual SOC that triggered hysteresis (may be 100% after full charge)
if coordinator._hysteresis_base_soc is None:
Expand Down Expand Up @@ -3919,19 +3954,23 @@ def _get_available_batteries(
continue

# Only charge if below effective max SOC
if current_soc < effective_max_soc or charge_to_bms_cutoff:
if ceiling_soc < effective_max_soc or charge_to_bms_cutoff:
available_batteries.append(coordinator)
else: # discharging
# MIN-SOC RE-ENTRY HYSTERESIS: after emptying to min_soc the
# resting SOC rebounds 1-2% (cell relaxation) and would re-admit
# the battery for a sliver of discharge — relay ping-pong and
# micro-cycles at the worst SOC region. Latch the exclusion at
# min_soc; release only after a real recovery margin.
if current_soc <= coordinator.min_soc:
# Judged on the fullest pack for a coupled-pack battery: the
# aggregate hits the floor while a pack still has charge to give
# (issue #350). Identical to current_soc without pack telemetry.
floor_soc = soc_vs_floor(coordinator, current_soc)
if floor_soc <= coordinator.min_soc:
coordinator._discharge_min_soc_latched = True
elif current_soc >= coordinator.min_soc + DISCHARGE_MIN_SOC_REENTRY_MARGIN:
elif floor_soc >= coordinator.min_soc + DISCHARGE_MIN_SOC_REENTRY_MARGIN:
coordinator._discharge_min_soc_latched = False
if current_soc > coordinator.min_soc:
if floor_soc > coordinator.min_soc:
if getattr(coordinator, "_discharge_min_soc_latched", False):
_LOGGER.debug(
"%s: Skipping discharge - min-SOC re-entry hysteresis "
Expand Down
29 changes: 29 additions & 0 deletions custom_components/omnibattery/const/registers_va.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,32 @@
"scan_interval": "high",
},
]

# --- per-pack SOC (issue #350) ----------------------------------------------
# Venus A/D couple several battery packs and fill them in sequence, so the
# aggregate SOC at 32104 can read 100 % while a later pack is still empty. Each
# pack publishes its own SOC on a stride-100 layout — 34000 + 100·(n−1), SOC at
# offset +2 — in deci-percent (the aggregate is whole percent). The six
# addresses are 100 registers apart, so no block read applies (REGISTER_BLOCKS
# never pads gaps, issue #361) and each costs its own frame: polled at "low"
# because a pack SOC moves ~0.1 %/min in absorption and a handover takes
# minutes. Slots this installation does not have are dropped by the driver's
# start-up probe (MarstekModbusDriver._learn_packs).
PACK_SOC_KEYS = tuple(f"battery_soc_pack_{n}" for n in range(1, 7))

SENSOR_DEFINITIONS_VA.extend(
{
"name": f"Battery SOC Pack {n}",
"register": 34002 + 100 * (n - 1),
"scale": 0.1,
"unit": "%",
"device_class": "battery",
"state_class": "measurement",
"key": key,
"enabled_by_default": False,
"data_type": "uint16",
"precision": 1,
"scan_interval": "low",
}
for n, key in enumerate(PACK_SOC_KEYS, start=1)
)
Loading