This document covers the internal architecture of Solar Energy Management (SEM) for developers and contributors.
coordinator/
├── coordinator.py — SEMCoordinator (DataUpdateCoordinator, 10s loop)
├── sensor_reader.py — SensorReader (reads HA sensors → PowerReadings)
├── energy_calculator.py — EnergyCalculator (power → energy integration)
├── flow_calculator.py — FlowCalculator (power/energy flow distribution)
├── charging_control.py — ChargingStateMachine (solar/night/Min+PV FSM)
├── ev_taper_detector.py — EVTaperDetector (taper detection, virtual SOC, skip logic)
├── surplus_controller.py — SurplusController (multi-device surplus routing)
├── forecast_reader.py — ForecastReader (Solcast / Forecast.Solar)
├── notifications.py — NotificationManager (KEBA display + mobile/REST/webhook)
├── storage.py — SEMStorage (persistent state)
└── types.py — All dataclasses (PowerReadings, SessionData, SEMData, etc.)
The SEMCoordinator is a Home Assistant DataUpdateCoordinator that runs a 10-second update loop. Each cycle:
- Reads sensors (
SensorReader) - Calculates energy integration (
EnergyCalculator) - Calculates costs & performance
- Calculates power flows (
FlowCalculator) - Updates session tracking
- Calculates energy flows (daily Sankey totals)
- Builds charging context and updates charging state machine
- Executes EV control (night / solar / Min+PV)
- Applies battery discharge protection
- Runs load management
- Reads forecast, tariff, surplus, PV analytics, energy assistant, utility signals
- Builds
SEMData, sends notifications, persists to storage
ControllableDevice (abstract base)
├── SwitchDevice — on/off (hot water, smart plugs)
├── CurrentControlDevice — variable current (EV chargers)
├── SetpointDevice — numerical target (heat pump temp boost)
│ └── HeatPumpController — SG-Ready 4-state control
└── ScheduleDevice — deadline-based (appliances)
SEM acts as a solar boost layer for hot water — it supplements your existing heating system (boiler, heat pump) rather than replacing it. Your existing system continues its normal heating schedule. SEM only activates when solar surplus is available, heating the water further to store energy that would otherwise be exported.
Legionella prevention complies with DVGW W 551 (Germany), SIA 385/1 (Switzerland), and ÖNORM B 5019 (Austria).
Hot water devices are controlled through one of three HA entity types:
| Entity type | How SEM controls it |
|---|---|
water_heater |
Sets target temperature via water_heater.set_temperature |
climate |
Sets target temperature via climate.set_temperature |
switch |
Simple on/off — used for resistive heating elements |
The solar_target_temp (Solar Boost Target) is the cutoff temperature for solar surplus heating. When the water reaches this temperature, SEM stops heating and releases surplus for other devices. There is no separate "max temperature" — the solar boost target is the ceiling for SEM-controlled heating.
If the solar boost target is set at or above 60°C, the Legionella requirement is naturally satisfied during sunny days without a forced cycle.
SEM tracks the number of hours since the water last reached the Legionella target temperature (60°C+). When the configured interval is exceeded, SEM forces a heating cycle regardless of solar surplus availability:
- Normal operation — during solar surplus, SEM heats water to the solar boost target (e.g., 50-65°C)
- Legionella countdown — a counter tracks hours since the last time the water temperature reached the Legionella target
- Forced disinfection — when the interval expires (default 72 hours), SEM forces heating to the Legionella target temperature, using grid power if necessary
- Hold duration — the system holds the disinfection temperature for a duration that auto-adjusts based on the actual temperature reached (shorter hold at higher temperatures, since thermal kill rate increases exponentially)
| Parameter | Range | Default | Where configured | Description |
|---|---|---|---|---|
| Solar Boost Target | 40-80°C | — | Dashboard slider | Solar surplus heating cutoff |
| Legionella Target | 60-80°C | 65°C | Dashboard slider | Forced heating temperature (legal minimum 60°C) |
| Disinfection interval | 24-168 h | 72 h | Options flow | Maximum hours between disinfection cycles (not on dashboard) |
- Read available surplus (solar - home - battery charge)
- Subtract regulation offset (default 50W export buffer)
- Iterate devices by priority (1=highest, 10=lowest)
- Activate if surplus >= device minimum power threshold
- Variable-power devices get proportional current allocation
- When surplus drops: LIFO deactivation (lowest priority first)
The surplus controller is always-on and runs every coordinator update (~10s). Price-responsive mode is automatic when tariff_mode == "dynamic".
SEM uses a four-zone model (inspired by evcc) to decide how the battery and EV share solar energy:
SOC 100% ─────────────────────────────
│ Zone 4: FULL ASSIST │ Battery assist always on
SOC 90% ─── battery_auto_start_soc ──
│ Zone 3: DISCHARGE ASSIST │ Proportional battery assist
SOC 70% ─── battery_buffer_soc ──────
│ Zone 2: SURPLUS ONLY │ EV gets pure solar surplus only
SOC 30% ─── battery_priority_soc ────
│ Zone 1: BATTERY PRIORITY │ All solar → battery, EV blocked
SOC 0% ─────────────────────────────
Zone 1 — Battery Priority (SOC < 30%): All solar goes to battery. EV blocked.
Zone 2 — Surplus Only (SOC 30-70%): EV gets only pure solar surplus (power that would be exported). Battery is not discharged.
Zone 3 — Discharge Assist (SOC 70-90%): Battery supplements solar for EV. Assist ramps from 50% at SOC 70% to 100% at SOC 90%.
Zone 4 — Full Assist (SOC >= 90%): Full battery assist (default 4500W). EV starts even without surplus.
Hysteresis: Once battery-assist activates (Zone 3/4), it stays active down to battery_assist_floor_soc (default 60%) to prevent cycling.
| Parameter | Default | Description |
|---|---|---|
battery_priority_soc |
30% | Below: all solar to battery, EV blocked |
battery_buffer_soc |
70% | Above: battery can discharge for EV |
battery_auto_start_soc |
90% | Above: start EV without surplus |
battery_assist_floor_soc |
60% | Hysteresis floor for battery assist |
battery_assist_max_power |
4500W | Max battery discharge for EV |
SEM calculates remaining EV charging need from a single unified helper (_calculate_remaining_need()), called from both _build_charging_context() and _determine_charging_strategy():
The target is a Min/Max range (#245). _calculate_remaining_need(bound=...) resolves
either bound via _resolve_target():
floor (Min, bound="min") = ev_target_soc / daily_ev_target (the guaranteed target)
ceiling (Max, bound="max") = ev_target_soc_max / daily_ev_target_max (defaults to full=100; clamped >= Min)
if vehicle_soc is available:
remaining = max(0, (target - vehicle_soc) / 100 × ev_battery_capacity_kwh)
else:
remaining = max(0, target - daily_ev_energy)
- Night/grid charging tops up to the floor (Min) —
night_target_kwh = remaining(bound="min"). - Surplus (solar) charging stops at the ceiling (Max) —
soc_limit_active = remaining(bound="max") <= 0.1. Max defaults to full, so by default surplus charges freely to car-full.
The old ev_limit_surplus switch (#235) was folded into Max (Max < full == limit on); a
setup-time migration sets Max = the prior target for users who had the switch on. All settings
(ev_target_soc, *_max, ev_battery_capacity_kwh) are per-charger; the multi-charger loop
patches ChargingContext.soc_limit_active / night_target_kwh per charger before calling
_execute_ev_control().
Forecast-aware grid-to-battery charging during cheap night hours. Disabled by default — enable via config flow.
- Forecast resolve — 3-tier fallback: fresh Solcast → stale (doubled pessimism) → offline (conservative)
- Deficit calculation —
expected_consumption - corrected_forecast × confidence × (1 - pessimism) - Negative tariff override — force-charge to max SOC during negative prices regardless of forecast
- Threshold check — skip if deficit <
min_deficit_kwh(default 2 kWh) - Break-even check —
(off_peak_rate / efficiency) + (2 × cycle_cost) < peak_rate— includes battery degradation - Target SOC —
current_soc + (deficit / usable_capacity × 100), capped atmax_target_soc - Schedule planning — time-slotted power allocation for battery + EV under peak constraints
- Cheapest hours — dynamic tariff:
find_cheapest_hours(N, 12h)| static: full NT window
Both battery and EV are variable-power loads co-scheduled per hour:
- No peak limit: both charge simultaneously at max power
- EV priority mode: EV gets full demand, battery gets remainder
- Proportional mode: power split by demand ratio
The schedule adapts at runtime when actual EV power differs from planned.
- SOC deviation > 5% from evaluation time
- EV connects/disconnects
| Platform | Adapter | Control Method |
|---|---|---|
| Huawei SUN2000 | HuaweiChargeAdapter |
huawei_solar.forcible_charge_soc service |
| GoodWe | GoodWeChargeAdapter |
Work mode select + SOC target number |
| Generic | GenericChargeAdapter |
Force-charge switch + SOC target number |
| Auto | Factory detection | Checks hass.config.components |
| Parameter | Default | Description |
|---|---|---|
battery_charge_scheduler_enabled |
false |
Feature toggle |
battery_capacity_kwh |
10.0 | Usable battery capacity |
battery_max_charge_power_w |
5000 | Inverter max charge rate |
battery_roundtrip_efficiency |
0.92 | Round-trip efficiency |
battery_cycle_cost |
0.0 | Degradation cost per kWh throughput |
battery_precharge_trigger_hour |
21 | Daily evaluation hour |
battery_max_target_soc |
95% | Never plan above this |
battery_min_deficit_kwh |
2.0 | Minimum deficit to bother charging |
battery_pessimism_weight |
0.3 | Forecast pessimism (0=trust, 1=worst case) |
battery_force_charge_negative_price |
true |
Charge during negative prices |
peak_limit_w |
0 | House connection peak limit |
battery_max_grid_import_w |
0 | Max grid draw during charging |
| Sensor | Type | Description |
|---|---|---|
battery_scheduler_state |
Diagnostic | idle/scheduled/charging/target_reached/not_needed/not_profitable |
battery_scheduler_target_soc |
% | Planned target SOC for tonight |
battery_scheduler_deficit_kwh |
kWh | Calculated energy deficit |
battery_scheduler_reason |
Diagnostic | Human-readable decision explanation |
The battery_scheduler_state sensor has a schedule attribute containing the full time-slotted plan (serialized via NightChargeSchedule.as_dict()).
The coordinator owns all EV control (ev.managed_externally = True). The EV is never managed by the SurplusController due to unique requirements (session lifecycle, minimum 4140W cliff, charger-specific service calls).
- Starts at 10A when night mode activates (after sunset+10min / 20:30)
- Dynamic peak-managed current each cycle (+-2A ramp, min 8A floor)
- W/A ratio calculated from actual readings (fallback 475 W/A)
- Stall detection with 120s cooldown re-enables charger
- Stops when daily EV target reached (sunrise-based tracking)
- Sets current with ramp limiting (+-2A/cycle)
- evcc-style enable/disable delays
- Budget from
FlowCalculator.calculate_ev_budget()+ optional battery assist
- Budget floored to
ev.min_power_threshold - Enable delay = 0 (guaranteed minimum from grid)
- Zero current, keep session alive (no stop/start cycling)
SEM abstracts charger-specific differences through per-integration service profiles:
- Service profiles — each supported charger integration has a
service_param_name(e.g.,currentfor KEBA,charging_currentfor Wallbox) andservice_device_idmapping, so the coordinator can call the correct HA service with the right parameters. - Start/stop abstraction — chargers that require explicit start/stop commands use
start_stop_entity,charge_mode_entity, andstart_serviceto manage session lifecycle. Chargers that only need current=0 to pause do not use these. - Supported chargers (auto-detected): KEBA, Easee, Wallbox, go-eCharger (HTTP + MQTT), Zaptec, ChargePoint, Heidelberg, OpenWB 2.x, OCPP-compatible, Ohme, Peblar, V2C Trydan, Alfen Eve, Blue Current, OpenEVSE
- Manual config: Any charger exposing power/connected/charging sensors in HA can be configured manually via the integration options.
Since v1.6.0, all "how many watts can the EV draw right now" decisions
read from a single canonical EVBudget value computed once per coordinator
cycle by FlowCalculator.calculate_canonical_ev_budget().
Before v1.6.0, three separate paths each computed their own budget
independently (coordinator.py:898 for the published sensors,
coordinator.py:2589 for the state machine's input,
ev_control.py:440-452 for the actuator). Under certain conditions they
disagreed — the symptom most users could see was the dashboard reporting
"Charging active" while the car drew 0 W (issue #282). v1.6.0 collapses
all three into one source of truth; the disagreement is now impossible by
construction.
@dataclass
class EVBudget:
strategy: str # one of EVBudgetStrategy.*
solar_surplus: float # max(0, solar − home − batt_charge)
battery_redirect: float # forecast/SOC-aware battery-charge diversion
battery_assist: float # active battery discharge attributed to EV
net_w: float # solar_surplus + redirect + assist (or override)
current_a: int # floor(net_w / (voltage × phases)), clamped [0,16]The decomposition exists so the dashboard can publish each component as its own diagnostic — answering "where did this watt come from" without re-deriving anything.
current_a always uses math.floor (not round-to-nearest) — round-
to-nearest could push the actuator 0.5 A over the surplus floor and
silently leak grid into the EV at the boundary. Locked in by the
591956f / tests/scenarios/2026-05-28_surplus_leak.yaml regression.
class EVBudgetStrategy:
IDLE = "idle" # 0
SELF_CONSUMPTION = "self_consumption" # Zone 2: raw_surplus only,
# NO redirect (battery has priority)
SOLAR_ONLY = "solar_only" # Zone 3: raw_surplus + battery_redirect
BATTERY_ASSIST = "battery_assist" # Zone 4: raw_surplus + redirect + assist
MIN_PV = "min_pv" # max(min_power_floor, surplus+redirect)
# — grid backfill accepted
NOW = "now" # override_max_w (charger nameplate)The dispatcher raises ValueError on any unknown strategy — silent
fallthrough was exactly the pre-1.6.0 disagreement root, so the
unifier is loud.
_determine_charging_strategy (in coordinator.py) returns one of six
legacy strings. The legacy code returned "solar_only" for both Zone-3
solar_only AND for Zone-2 self_consumption under "Auto" mode, and the
actuator distinguished them via substring matching on the human-readable
charging_strategy_reason text — brittle, and the proximate cause of
#282.
_canonical_strategy_from_legacy(legacy_strategy, legacy_reason) in
coordinator.py is the bridge: it inspects the reason text when
necessary and maps onto the canonical enum cleanly. The substring
matching is now contained in that one helper; downstream consumers
read the canonical enum.
SELF_CONSUMPTION — Zone 2 surplus-only. When battery_soc ≥ auto_start_soc (default 90 %), the battery doesn't need its share, so
the formula skips the batt_charge subtraction (Z4-redirect
sub-mode). Otherwise: max(0, solar − home − batt_charge). No
battery_redirect — the battery keeps the charge it's receiving.
SOLAR_ONLY — Zone 3. Solar surplus PLUS the forecast-aware
battery_redirect from _calculate_battery_redirect(). When forecast
remaining exceeds the battery's remaining capacity-to-100 %, the
redirect proportionally diverts battery-charge power to the EV; without
a forecast, falls back to a SOC threshold (redirect-all at
SOC ≥ 80 %). The Phase C scenarios pin both branches.
BATTERY_ASSIST — Zone 4. Surplus + redirect + active battery
discharge. The assist component uses the measured discharge if the
battery is already discharging ≥ 100 W, otherwise estimates a
proportional ramp (battery_soc − battery_buffer_soc) / (auto_start_soc − battery_buffer_soc) × battery_assist_max_power_w scaled to 50–100 %
of max. Below battery_assist_floor_soc (default 60 %) the assist is
zero. Note: proportional flow attribution may transiently show a
small flow_grid_to_ev_power during the battery's discharge ramp-up
window (LFP BMSes take seconds to ramp). That's the physics of the
proportional split, not SEM commanding grid — see
KNOWN_LIMITATIONS.md.
MIN_PV — User-asked guaranteed minimum. max(min_power_floor_w, surplus + redirect). Grid backfill is intentional in this mode; the
sentinel test tests/live/test_solar_only_no_grid.sh correctly skips
non-solar_only strategies because MIN_PV's whole purpose is to allow
grid.
NOW — User-asked maximum. Bypasses the surplus math entirely
and uses override_max_w (typically the charger's nameplate power).
The decomposition fields (solar_surplus, redirect, assist) stay
zero — they're honest about "this isn't from solar."
┌──────────────────────────────────────────────────────┐
│ calculate_canonical_ev_budget(power, strategy, ...) │
│ → EVBudget(strategy, surplus, redirect, assist, │
│ net_w, current_a) │
└────────────────┬─────────────────────────────────────┘
│ cached as self._cycle_ev_budget
│
┌─────────────────┼─────────────────────────────────────┐
│ │ │
▼ ▼ ▼
ChargingContext SEMData publish _execute_ev_control
.available_power .available_power (single-charger)
.calculated_ .calculated_current budget_w =
current ↓ cycle_budget.net_w
↓ sensor.sem_available_power
state_machine sensor.sem_calculated_ _surplus_controller.
decision current distribute_ev_budget
(multi-charger, Phase B.5)
total_budget =
cycle_budget.net_w
→ per-charger split
by priority
tests/live/test_budget_agreement.sh asserts the canonical relationship
floor(sensor.sem_available_power / (230 × 3)) == sensor.sem_calculated_current
on the live system. If a future refactor accidentally re-introduces a
second budget path that publishes a different number, the sentinel
fires. It runs in seconds against any HA instance and is the
operational guarantee that the unification holds.
When the home battery is charging and SOC is below the buffer, the default behaviour is "all surplus to battery first." But if the forecast says there's enough remaining solar today to fill the battery anyway, some of that battery-bound power can be redirected to the EV without slowing the battery's actual fill. The math:
battery_need_kwh = max(0, (100 − battery_soc) / 100 × battery_capacity_kwh)
if forecast_remaining_kwh >= battery_need_kwh:
ratio = max(0.05, 1 − battery_need_kwh / forecast_remaining_kwh)
redirect = battery_charge_w × ratio
elif battery_need_kwh <= 0.5: # battery nearly full
redirect = battery_charge_w # redirect all
else:
redirect = 0 # forecast can't cover; keep battery fillingWithout a forecast, the SOC threshold fallback gives redirect = battery_charge_w when SOC ≥ 80 %, else 0.
FlowCalculator.calculate_ev_budget(), .calculate_available_power()
and .calculate_charging_current() are kept callable in v1.6.0 for
backward compatibility (one fallback site in ev_control.py for
partial-init paths), but are no longer the source of truth. Phase D.2
removes them in v1.7.0 after additional soak.
EnergyCalculator uses sunrise-based daily buckets, not midnight. Before sunrise = still "yesterday". This keeps night charging sessions (22:00-06:00) in a single bucket.
| Constant | Value | Location |
|---|---|---|
DEFAULT_DAILY_EV_TARGET |
10 kWh | const.py |
DEFAULT_EV_RAMP_RATE_AMPS |
2 | config |
DEFAULT_EV_CHARGING_MODE |
"pv" |
config |
battery_capacity_kwh |
auto-detected from inverter, fallback to config | coordinator |
| Update interval | 10s | coordinator |
| Regulation offset | 50W | surplus controller |
| Peak limit | 6 kW | load management |
SEM includes a comprehensive hardware compatibility test suite (v1.2.3) that verifies every supported inverter + charger combination works correctly end-to-end.
The suite runs 110 end-to-end tests, each executing a 10-step verification sequence per hardware combination:
- Sensor discovery — auto-detect inverter and charger entities
- Sign convention detection — verify grid and battery sign auto-detection
- Power reading — read solar, grid, battery, and EV power sensors
- Energy integration — validate energy accumulation from power readings
- Flow calculation — verify power flow distribution (solar-to-home, solar-to-EV, etc.)
- Charging control — test EV charging state machine transitions
- Battery zone logic — validate SOC zone strategy decisions
- Surplus distribution — verify multi-device surplus allocation
- Service calls — confirm charger service calls use correct parameters
- Round-trip validation — end-to-end cycle from sensor read to hardware command
| Inverter | Charger |
|---|---|
| Huawei SUN2000 | KEBA P30 |
| Huawei SUN2000 | Wallbox Pulsar |
| Huawei SUN2000 | go-eCharger |
| SolarEdge | KEBA P30 |
| SolarEdge | Easee |
| Fronius | KEBA P30 |
| Fronius | go-eCharger |
| Growatt | Wallbox Pulsar |
| SolaX | go-eCharger |
| DEYE/Sunsynk | Zaptec |
| GoodWe | ChargePoint |
All 11 combinations are run in CI on every pull request and release. If your inverter and charger are in this list, SEM has been automatically verified against that exact pairing.
The EVTaperDetector (coordinator/ev_taper_detector.py, ~590 lines) provides six capabilities integrated into the coordinator's 10-second update loop via _update_ev_intelligence():
SEMCoordinator._update_ev_intelligence()
├── EVTaperDetector.update_power_buffer(ev_power, setpoint)
├── EVTaperDetector.detect_taper() → EVTaperData
├── EVTaperDetector.update_virtual_soc(energy, vehicle_soc) → float
├── ConsumptionPredictor.update() / predict() → float
├── EVTaperDetector.should_skip_night_charge(...) → (bool, str)
└── EVTaperDetector.update_battery_health(session) → float
@dataclass
class EVTaperData:
trend: str # "declining", "stable", "rising", "unknown"
taper_ratio_pct: float # Current power / session peak × 100
slope_w_per_min: float # Linear regression slope
minutes_to_full: float # ETA to completion
ev_full_detected: bool # Taper reached 0W
@dataclass
class EVIntelligenceData:
taper: EVTaperData
estimated_soc_pct: float
last_full_charge: Optional[str] # ISO timestamp
energy_since_full_kwh: float
predicted_daily_ev_kwh: float
nights_until_charge: int
charge_needed: bool
ev_battery_health_pct: float
charge_skip_reason: str # Human-readable explanationUses a 20-minute circular power buffer. Linear regression on the buffer detects a declining trend. BMS-initiated reductions are discriminated from SEM setpoint changes via a settling window (samples after a SEM command are excluded). When power reaches 0W during a declining trend, ev_full_detected is set.
Tracks cumulative energy since last known full charge. Calibrates from:
- Taper detection (resets to 100%)
- Vehicle SOC entity (proportional calibration)
- Session bootstrapping (initial estimate from first session)
required_soc = predicted_daily_kwh × temp_correction × 1.3 (safety margin)
available_soc = estimated_soc - daily_decay
solar_credit = forecast_tomorrow × 0.3
charge_needed = (available_soc - solar_credit) < required_soc
Consecutive skip counter enforces a 3-skip safety net.
EV intelligence state (SOC estimate, last full charge, consumption history, skip counter) is persisted via SEMStorage.set/get_ev_intelligence_state() and restored on HA restart.
SEM reads all energy sources from the HA Energy Dashboard instead of only the first entry. This is handled in ha_energy_reader.py and coordinator/sensor_reader.py.
# List fields (new in v1.3.0)
solar_power_list: list[str] # Multiple inverter power sensors
solar_energy_list: list[str] # Multiple inverter energy sensors
battery_power_list: list[str] # Multiple battery power sensors
battery_charge_energy_list: list[str] # Multiple battery charge sensors
battery_discharge_energy_list: list[str] # Multiple battery discharge sensors
grid_import_energy_list: list[str] # Multiple grid import tariffs
grid_export_energy_list: list[str] # Multiple grid export tariffs
grid_power_list: list[str] # Multiple grid power sensorsSensorReader._read_sensors_sum(entity_list)— sums numeric values from multiple sensors; skips unavailable/unknownSensorReader._read_battery_soc_average()— averages SOC across multiple battery units- Primary (single) fields are set from the first source for backward compatibility
Single-device setups are unaffected. The list fields contain exactly one entry, and the primary field points to the same sensor. No configuration changes needed.
v1.4.0 adds active control of multiple EV chargers via coordinator._ev_devices: Dict[str, CurrentControlDevice].
The coordinator iterates chargers in priority order and swaps per-charger state before calling the existing _execute_ev_control():
for cid, ev_dev in sorted_chargers:
# Save coordinator state, swap in per-charger state
self._ev_device = ev_dev
self._ev_stalled_since = self._ev_stalled_since_per_charger[cid]
self._ev_enable_surplus_since = self._ev_enable_surplus_per_charger[cid]
# ... (4 more state variables)
await self._execute_ev_control(state, power, energy, context)
# Save back per-charger state, restore coordinator stateThis minimizes changes to ev_control.py — the control logic works identically for each charger.
SurplusController.distribute_ev_budget() implements priority-based cascade:
- Sort chargers by priority (lower = higher)
- Highest-priority charger:
min(budget, max_power) - Remainder cascades if ≥
min_power_threshold(4140W 3-phase / 1380W 1-phase) - 60s hysteresis between reallocations
Config entry v2→v3: flat ev_* keys wrapped into ev_chargers list automatically. The __init__.py registration loop creates CurrentControlDevice per charger.
Config entry v3→v4 (#255): per-charger is the source of truth for all EV settings; the duplicate GLOBAL EV setting entities (daily_ev_target[_max], ev_target_soc[_max], ev_min_current, ev_night_initial_current, ev_kwh_per_100km, ev_target_type, ev_charging_mode, ev_phases, the night_charging / smart_night_charging switches) were removed. The migration seeds each charger's per-charger value from the matching global so nothing resets. Globals remain only as read-only summary sensors. The night-charging gate is now "any charger enabled" (ChargingStateMachine._any_night_charging_enabled); a one-time reconciliation forces per-charger night switches OFF if the removed global switch was last OFF (so a globally-disabled user isn't silently re-enabled). A few global-context consumers read the primary charger's values via SEMCoordinator._mirror_primary_charger_to_global() (exact for single-charger). ev_stall_cooldown stays global (tuning constant).
Each charger has independent:
SessionData(energy, cost, solar share)- Stall detection timer
- Enable/disable delay timers
EVTaperDetectorinstance (taper detection, virtual SOC)ChargingStateMachineshares mode (all chargers follow same solar/night strategy)
tariff/tariff_provider.py — StaticTariffProvider, DynamicTariffProvider
analytics/pv_performance.py — PVPerformanceAnalyzer
analytics/energy_assistant.py — EnergyAssistant (tips, optimization score)
utility_signals.py — UtilitySignalMonitor (ripple control signal)
utils/time_manager.py — TimeManager (sunrise, night mode/end, meter day)
utils/helpers.py — safe_float, safe_format, convert_power_to_watts
ha_energy_reader.py — Read HA Energy Dashboard config
load_management.py — LoadManagementCoordinator (peak tracking)
hardware_detection.py — Auto-discover inverter/battery/charger
SEM uses a hybrid two-layer translation system because Home Assistant has two different language settings that affect different parts of the UI.
| Setting | Where configured | Scope | Affects |
|---|---|---|---|
| System language | Settings → General → Language | One per HA instance | Config flow, entity names, mushroom card labels, dashboard template strings |
| User language | User profile → Language | Per user | SEM custom cards (sem-flow-card, sem-chart-card, etc.) |
A household may have the system set to German, but one user's profile set to English. In that case, mushroom card titles appear in German (system), while SEM custom cards appear in English (user profile).
When: Dashboard generation (generate_dashboard service call)
Source: hass.config.language (system language)
File: features/dashboard_generator.py → _translate_dashboard()
Translates: All YAML-based card content — mushroom titles, labels, tab names, template strings
How it works:
- Loads
dashboard/translations.json(single source of truth, 759 keys × 15 languages) - Builds a reverse lookup: English text → translated text
- Walks the entire dashboard YAML tree
- Replaces exact-match English strings in translatable fields:
title,subtitle,primary,secondary,name,label,content - For Jinja-templated fields (containing
{{ }}or{% %}): splits on Jinja blocks using regex, translates only the literal text between them - Writes the translated dashboard to Lovelace storage
What this means for users:
- Mushroom cards, section headers, and static labels are translated once at generation time
- All users see the same language for these elements (the system language)
- To change: update system language → re-run
generate_dashboardservice - Third-party cards (mushroom, apexcharts, sankey) cannot use runtime translation — they only read stored YAML
When: Every render cycle in the browser
Source: hass.language (current user's profile language)
File: dashboard/card/sem-localize.js → semLocalize(key, lang)
Translates: All SEM custom card content (labels, status text, error messages)
How it works:
sem-localize.jsis auto-generated fromtranslations.json— contains all 759 keys × 15 languages as a JS object- Loaded as a Lovelace resource, exposes
window.semLocalize(key, lang) - Fires
sem-localize-readyCustomEvent when loaded - SEM cards extend
SEMBaseCard(insem-shared.js) which provides_t(key)→ callssemLocalize(key, hass.language) - Cards re-render when the user's language changes (detected in
_checkLocaleChange())
Placeholder pattern: When Python sensor values contain translatable words (e.g. "tomorrow" in surplus window), the coordinator outputs {tomorrow} as a placeholder. Cards replace it with this._t('tomorrow') before display. This bridges the server→client translation gap without duplicating translation logic in Python.
Fallback chain: user language → English → raw key
What this means for users:
- Each user sees SEM custom cards in their own profile language
- Two users viewing the same dashboard can see different languages for SEM cards
- Language changes take effect immediately (no dashboard regeneration needed)
| Card Type | Translation Layer | Language Source | Example |
|---|---|---|---|
| Mushroom chips/entities | Server-side | System language | "Solar Power", "Battery SOC" |
| Mushroom template cards | Server-side | System language | "Peak Management", "Optimization Score" |
| Section titles (sem-title-card) | Runtime per-user | User profile | Tab headers with live Jinja subtitles |
| sem-flow-card | Runtime per-user | User profile | Node labels, status text |
| sem-chart-card | Runtime per-user | User profile | Chart titles, axis labels, legends |
| sem-battery-card | Runtime per-user | User profile | "Charging", "Discharging", "Idle" |
| sem-ev-status-card | Runtime per-user | User profile | Mode labels, session stats |
| sem-charger-status-card | Runtime per-user | User profile | Charger status, power labels |
| sem-period-selector-card | Runtime per-user | User profile | "Today", "This Week", "This Month" |
| sem-solar-summary-card | Runtime per-user | User profile | Production stats, forecast |
| sem-weather-card | Runtime per-user | User profile | Weather labels |
| Sankey chart (3rd party) | Server-side | System language | Node names in energy flow |
| ApexCharts (3rd party) | Server-side | System language | Chart titles, series names |
Both layers read from the same file: dashboard/translations.json
dashboard/translations.json ← single source (759 keys × 15 languages)
│
├──→ dashboard_generator.py reads at generation time (server-side)
│
└──→ sem-localize.js auto-generated copy for browser (runtime)
Important: sem-localize.js must be regenerated whenever translations.json changes. It is a generated artifact — never edit it directly.
Czech (cs), Danish (da), German (de), English (en), Spanish (es), Finnish (fi), French (fr), Hungarian (hu), Italian (it), Dutch (nl), Norwegian (no), Polish (pl), Portuguese (pt), Romanian (ro), Swedish (sv)
- Add the language code and all 759 keys to
dashboard/translations.json - Create
translations/{lang}.jsonwith config flow and entity translations (mirrorstrings.jsonstructure) - Regenerate
sem-localize.jsfromtranslations.json - Deploy and call
generate_dashboardto apply server-side translations
- Add the key to all 15 languages in
translations.json - Regenerate
sem-localize.js - Use
_t('key_name')in custom card JS, or use the English text directly in dashboard template YAML (server-side translation handles the lookup)