feat(eosdash): add the remaining steps to the setup assistant - #1515⁠

The setup assistant only covered the location, the horizons and the energy
management, and sent the user to the Config page for everything else.

Add the steps PV system, weather, prices and fees, load, devices and
measurements, each with an explanation, an example and a check of what is
still to do.

Add a Home Assistant step after the measurements step that explains what EOS
reads from and writes to Home Assistant, with a template sensor and an automation
as example. Its status reports a missing state of charge mapping, missing instruction
entities and an adapter that is selected while EOS does not run as an add-on. The
measurements step keeps the choice of the adapter.

Add examples for an inverter, an electric vehicle and a home appliance.

A step can now show configuration values depending on the value of another
one (WizardChoice), so that only the settings of the selected provider are
shown. The horizon of the selected optimization algorithm uses the same
mechanism instead of a placeholder in the configuration name.

Describe the profiles, point to the Profiles page and use the
profile_adjustment settings in the examples. The packaged profiles are
generic and give no size, so the devices step now reports a battery,
electric vehicle or inverter whose effective nominal capacity or power is
unknown. The changelog entry names the Home Assistant step.

Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-fable-5-2)
This commit is contained in:
SchaafAlexander
2026-11-12 13:35:37 +01:00
committed by GitHub
co-authored by Claude Fable 5.2
parent 8e94ab02d1
commit fca524a913
5 changed files with 1009 additions and 32 deletions
@@ -457,6 +457,12 @@ def config_options(
# -----------------------------------
def remember_eos_server(eos_host: str, eos_port: Union[str, int]) -> None:
"""Remember the address of the EOS server for the lazily loaded options of config values."""
global eos_server_address
eos_server_address = (eos_host, int(eos_port))
def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None:
"""Set a configuration value on the EOS server.
@@ -586,11 +592,11 @@ def Configuration(
Returns:
rows: Rows of configuration details.
"""
global config_visible, eos_server_address
global config_visible
dark = False
# Remember for usage
eos_server_address = (eos_host, int(eos_port))
remember_eos_server(eos_host, eos_port)
if data and data.get("action", None):
if data.get("dark", None) == "true":
+667 -14
View File
@@ -32,6 +32,7 @@ from monsterui.franken import (
)
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.config.configabc import is_home_assistant_addon
from akkudoktoreos.server.dash.components import (
Badge,
ConfigButton,
@@ -43,6 +44,7 @@ from akkudoktoreos.server.dash.configuration import (
config_update,
create_config_details,
get_config,
remember_eos_server,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
@@ -55,6 +57,23 @@ WIZARD_PATH = "/eosdash/wizard"
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class WizardChoice:
"""Configuration values a step shows depending on the value of another one.
Typically the settings of the selected provider.
Attributes:
name: Dotted name of the configuration value that decides, e.g. ``load.provider``.
Its value may also be a list of values.
config_names: Dotted names of the configuration values to show, by value of ``name``.
Values that need no further settings are left out.
"""
name: str
config_names: dict[str, tuple[str, ...]]
@dataclass(frozen=True)
class WizardStep:
"""A step of the setup wizard.
@@ -64,8 +83,9 @@ class WizardStep:
title: Title of the step.
intro: Explanation of the step (Markdown).
example: Example for the settings of the step (Markdown).
config_names: Dotted names of the configuration values to show. ``{algorithm}`` is
replaced by the lower case name of the configured optimization algorithm.
config_names: Dotted names of the configuration values to show.
choices: Further configuration values to show depending on the value of another one.
They are shown right after the configuration value that decides.
check: Function that takes the configuration details and returns what is still to do
in this step, one sentence each. An empty list means the step is done.
"""
@@ -75,6 +95,7 @@ class WizardStep:
intro: str
example: str = ""
config_names: tuple[str, ...] = ()
choices: tuple[WizardChoice, ...] = ()
check: Optional[Callable[[dict[str, dict]], list[str]]] = None
@@ -94,9 +115,17 @@ def config_value(config_details: dict[str, dict], name: str, default: Any = None
def step_config_names(step: WizardStep, config_details: dict[str, dict]) -> list[str]:
"""Return the names of the configuration values a step shows for this configuration."""
algorithm = str(config_value(config_details, "optimization.algorithm", "")).lower()
names = [name.format(algorithm=algorithm) for name in step.config_names]
return [name for name in names if name in config_details]
names: list[str] = []
for name in step.config_names:
names.append(name)
for choice in step.choices:
if choice.name != name:
continue
value = config_value(config_details, name)
for chosen in value if isinstance(value, list) else [value]:
names.extend(choice.config_names.get(str(chosen), ()))
# A configuration value is shown once, even if several choices name it
return [name for name in dict.fromkeys(names) if name in config_details]
# ---------------------------------------------------------------------------
@@ -152,6 +181,199 @@ def check_horizon(config_details: dict[str, dict]) -> list[str]:
return todo
# PV forecast providers that calculate the forecast from the planes of the PV system
PV_PLANES_PROVIDERS = (
"PVForecastAkkudoktor",
"PVForecastAdaptiveAkkudoktor",
"PVForecastForecastSolar",
"PVForecastPVLib",
)
def _is_empty(value: Any) -> bool:
"""Whether a configuration value is not set."""
return value is None or value == "" or value == [] or value == {}
def _missing(config_details: dict[str, dict], *names: str) -> list[str]:
"""Report the configuration values of the given names that are not set."""
return [
f"{name} is not set." for name in names if _is_empty(config_value(config_details, name))
]
def check_pv(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the PV system."""
provider = config_value(config_details, "pvforecast.provider")
if not provider:
return [
"No PV forecast provider is selected, so EOS plans without PV yield. "
"Leave it like this only if you have no PV system."
]
if provider in PV_PLANES_PROVIDERS:
planes = config_value(config_details, "pvforecast.planes") or []
if not any(isinstance(plane, dict) and plane.get("peakpower") for plane in planes):
return [f"{provider} needs at least one plane with a peakpower in pvforecast.planes."]
elif provider == "PVForecastSolcast":
return _missing(config_details, "pvforecast.solcast.api_key", "pvforecast.solcast.site_id")
elif provider == "PVForecastPVNode":
return _missing(config_details, "pvforecast.pvnode.api_key")
return []
def check_weather(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the weather forecast."""
if not config_value(config_details, "weather.provider"):
return ["No weather provider is selected."]
return []
def check_prices(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for prices, fees and the feed-in tariff."""
todo = []
provider = config_value(config_details, "elecprice.provider")
if not provider:
todo.append(
"No electricity price provider is selected. EOS needs the price of electricity "
"from the grid to optimize."
)
elif provider == "ElecPriceFixed":
todo += _missing(
config_details, "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows"
)
elif provider == "ElecPriceTibber":
todo += _missing(config_details, "elecprice.tibber.access_token")
if config_value(config_details, "elecfee.provider") == "ElecFeeFixed" and all(
_is_empty(config_value(config_details, f"elecfee.elecfeefixed.{name}.windows"))
for name in (
"consumption_amt_kwh",
"consumption_percent_amt",
"feedin_amt_kwh",
"feedin_percent_amt",
)
):
todo.append("ElecFeeFixed is selected, but no fee is set.")
provider = config_value(config_details, "feedintariff.provider")
if not provider:
todo.append(
"No feed-in tariff provider is selected. Select one if you are paid for the "
"energy you feed into the grid."
)
elif provider == "FeedInTariffFixed":
todo += _missing(
config_details, "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows"
)
return todo
def check_load(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the load forecast."""
provider = config_value(config_details, "load.provider")
if not provider:
return [
"No load provider is selected. EOS needs a forecast of your consumption to optimize."
]
if provider in ("LoadAkkudoktor", "LoadAkkudoktorAdjusted"):
todo = _missing(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh")
if provider == "LoadAkkudoktorAdjusted":
todo += _missing(config_details, "measurement.load_emr_keys")
return todo
return []
def _device_ids(config_details: dict[str, dict], kind: str) -> list[str]:
"""Return the ids of the configured devices of a kind, e.g. ``batteries``."""
devices = config_value(config_details, f"devices.{kind}")
return list(devices) if isinstance(devices, dict) else []
def check_devices(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the devices."""
if not any(
_device_ids(config_details, kind)
for kind in ("batteries", "electric_vehicles", "home_appliances")
):
return [
"No battery, electric vehicle or home appliance is configured, so there is "
"nothing EOS can schedule."
]
# The packaged profiles are generic: the unit has to give its size
todo = []
for kind, setting in (
("batteries", "nominal_capacity_wh"),
("electric_vehicles", "nominal_capacity_wh"),
("inverters", "nominal_power_w"),
):
devices = config_value(config_details, f"devices.{kind}") or {}
for device_id, device in devices.items():
if isinstance(device, dict) and device.get(f"effective_{setting}") is None:
todo.append(
f"{device_id} has no {setting}: its profile does not give one. Set "
f"profile_adjustment.{setting}, or select a profile that has it."
)
return todo
def check_measurements(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for measurements and the home automation."""
providers = config_value(config_details, "adapter.provider") or []
device_ids = [
device_id
for kind in ("batteries", "electric_vehicles")
for device_id in _device_ids(config_details, kind)
]
if not providers:
devices = f" of {', '.join(device_ids)}" if device_ids else ""
return [
"No adapter is selected, so no source for the state of charge"
f"{devices} is known. Select an adapter, or make sure your own system sends the "
"state of charge with PUT /v1/measurement/value."
]
# The mapping of the Home Assistant adapter is checked in the Home Assistant step
return []
def check_homeassistant(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the Home Assistant adapter.
Nothing is to do if the Home Assistant adapter is not selected.
"""
providers = config_value(config_details, "adapter.provider") or []
if "HomeAssistant" not in providers:
return []
todo = []
if not is_home_assistant_addon():
# EOSdash is started by the EOS server and inherits its environment
todo.append(
"EOS does not run as a Home Assistant add-on, so the Home Assistant adapter "
"cannot reach Home Assistant. Run EOS as an add-on, or remove HomeAssistant "
"from adapter.provider and send the measurements with PUT /v1/measurement/value."
)
entity_ids = config_value(config_details, "adapter.homeassistant.device_measurement_entity_ids")
entity_ids = entity_ids if isinstance(entity_ids, dict) else {}
for kind in ("batteries", "electric_vehicles"):
for device_id in _device_ids(config_details, kind):
key = f"{device_id}-soc-factor"
if not entity_ids.get(key):
todo.append(
f"No Home Assistant entity is set for the state of charge of {device_id} "
f"({key}) in adapter.homeassistant.device_measurement_entity_ids."
)
if _is_empty(
config_value(config_details, "adapter.homeassistant.device_instruction_entity_ids")
):
todo.append(
"adapter.homeassistant.device_instruction_entity_ids is not set, so EOS writes "
"no instructions to Home Assistant and your automations get nothing to act on."
)
return todo
# ---------------------------------------------------------------------------
# Steps
# ---------------------------------------------------------------------------
@@ -230,12 +452,447 @@ Use `PREDICTION` while you are still setting up the forecasts and want to look a
config_names=(
"prediction.hours",
"optimization.algorithm",
"optimization.{algorithm}.horizon_hours",
"ems.mode",
"ems.interval",
),
choices=(
WizardChoice(
"optimization.algorithm",
{
"GENETIC": ("optimization.genetic.horizon_hours",),
"GENETIC0": ("optimization.genetic0.horizon_hours",),
},
),
),
check=check_horizon,
),
WizardStep(
id="pv",
title="PV system",
intro="""
EOS predicts the yield of your PV system. Select who makes the forecast with
`pvforecast.provider`; the settings the selected provider needs appear below it.
- `PVForecastAkkudoktor`, `PVForecastAdaptiveAkkudoktor`, `PVForecastForecastSolar` and
`PVForecastPVLib` calculate the forecast from your location and the **planes** of your PV
system. A plane is a group of modules with the same orientation, e.g. one side of the roof.
- `PVForecastSolcast`, `PVForecastPVNode`, `PVForecastVrm` and `PVForecastHomeAssistant` take
the forecast from a service or system that already knows your PV system. They need the
access data of that service.
- `PVForecastImport` takes a forecast you provide as a file or as JSON.
The most important settings of a plane:
- `peakpower` - nominal power of the modules of the plane in kW.
- `surface_azimuth` - orientation, clockwise from north: north = 0, east = 90, south = 180,
west = 270.
- `surface_tilt` - tilt from the horizontal in degrees: 0 is flat, 90 is vertical.
- `inverter_paco` - AC power rating of the inverter in W.
Skip this step if you have no PV system.
""",
example="""
A south-facing roof with 8 kWp, 30° tilt and an 8 kW inverter: select
`PVForecastAkkudoktor`, open `pvforecast.planes`, add a plane and set `peakpower` = `8`,
`surface_azimuth` = `180`, `surface_tilt` = `30` and `inverter_paco` = `8000`.
An east/west roof is two planes, one with `surface_azimuth` = `90` and one with `270`, each
with the peak power of its side.
""",
config_names=("pvforecast.provider",),
choices=(
WizardChoice(
"pvforecast.provider",
{
"PVForecastAkkudoktor": ("pvforecast.planes",),
"PVForecastAdaptiveAkkudoktor": ("pvforecast.planes",),
"PVForecastForecastSolar": (
"pvforecast.planes",
"pvforecast.forecastsolar.api_key",
),
"PVForecastPVLib": ("pvforecast.planes",),
"PVForecastPVNode": (
"pvforecast.pvnode.api_key",
"pvforecast.pvnode.site_id",
"pvforecast.planes",
),
"PVForecastSolcast": (
"pvforecast.solcast.api_key",
"pvforecast.solcast.site_id",
),
"PVForecastVrm": ("pvforecast.vrm.site_id", "pvforecast.vrm.token"),
"PVForecastHomeAssistant": (
"pvforecast.homeassistant.entity_id",
"pvforecast.homeassistant.base_url",
"pvforecast.homeassistant.token",
),
"PVForecastImport": (
"pvforecast.pvforecastimport.import_file_path",
"pvforecast.pvforecastimport.import_json",
),
},
),
),
check=check_pv,
),
WizardStep(
id="weather",
title="Weather",
intro="""
The weather forecast gives EOS the outside temperature, the irradiance and the cloud cover
at your location. You see it on the **Prediction** page, and `PVForecastPVLib` calculates
the PV forecast from it.
Select the service with `weather.provider`: `BrightSky`, `ClearOutside` or `OpenMeteo`
retrieve the forecast for your location, `WeatherImport` takes a forecast you provide.
""",
example="""
To use Open-Meteo set `weather.provider` to `OpenMeteo`. The provider needs no further
settings; it takes the location from the **Location** step.
""",
config_names=("weather.provider",),
choices=(
WizardChoice(
"weather.provider",
{
"WeatherImport": (
"weather.weatherimport.import_file_path",
"weather.weatherimport.import_json",
),
},
),
),
check=check_weather,
),
WizardStep(
id="prices",
title="Prices and fees",
intro="""
EOS optimizes costs, so it needs to know what electricity from the grid costs and what you
get for the energy you feed in. Three predictions work together:
- `elecprice.provider` - the electricity price. The market price providers
(`ElecPriceAkkudoktor`, `ElecPriceEnergyCharts`, `ElecPriceSMARD`, `ElecPriceTibber`)
deliver the raw market price **without** fees. `ElecPriceFixed` uses prices you set for
time windows of the day.
- `elecfee.provider` - what is added to the raw price. With `ElecFeeFixed` you set a fee per
kWh (grid fee, levies, the margin of your supplier) and a surcharge in percent (VAT). The
price EOS uses is `(raw price + fee per kWh) * (100 + percent) / 100`.
- `feedintariff.provider` - what you get per kWh fed into the grid. `FeedInTariffFixed` uses
the tariff you set; the other providers use market prices, for direct marketing.
Prices and fees are amounts of your currency per kWh, e.g. `0.30` for 30 ct/kWh.
""",
example="""
A dynamic tariff in Germany: `elecprice.provider` = `ElecPriceEnergyCharts` with bidding zone
`DE-LU`, `elecfee.provider` = `ElecFeeFixed` with `consumption_amt_kwh` = `0.15` (all fees
per kWh without VAT) and `consumption_percent_amt` = `19` (VAT), each in one time window
that covers the whole day.
A fixed tariff of 32 ct/kWh including everything: `elecprice.provider` = `ElecPriceFixed`
with one time window of `0.32`, and no `elecfee.provider`.
A fixed feed-in tariff of 7.86 ct/kWh: `feedintariff.provider` = `FeedInTariffFixed` with one
time window of `0.0786`.
""",
config_names=("elecprice.provider", "elecfee.provider", "feedintariff.provider"),
choices=(
WizardChoice(
"elecprice.provider",
{
"ElecPriceEnergyCharts": ("elecprice.energycharts.bidding_zone",),
"ElecPriceFixed": (
"elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows",
),
"ElecPriceImport": (
"elecprice.elecpriceimport.import_file_path",
"elecprice.elecpriceimport.import_json",
),
"ElecPriceSMARD": ("elecprice.smard.region", "elecprice.smard.filter_id"),
"ElecPriceTibber": (
"elecprice.tibber.access_token",
"elecprice.tibber.home_id",
),
},
),
WizardChoice(
"elecfee.provider",
{
"ElecFeeFixed": (
"elecfee.elecfeefixed.consumption_amt_kwh.windows",
"elecfee.elecfeefixed.consumption_percent_amt.windows",
"elecfee.elecfeefixed.feedin_amt_kwh.windows",
"elecfee.elecfeefixed.feedin_percent_amt.windows",
),
"ElecFeeImport": (
"elecfee.elecfeeimport.import_file_path",
"elecfee.elecfeeimport.import_json",
),
},
),
WizardChoice(
"feedintariff.provider",
{
"FeedInTariffDvhubOnline": ("feedintariff.dvhubonline.zone",),
"FeedInTariffEnergyCharts": ("feedintariff.energycharts.bidding_zone",),
"FeedInTariffFixed": (
"feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows",
),
"FeedInTariffImport": (
"feedintariff.feedintariffimport.import_file_path",
"feedintariff.feedintariffimport.import_json",
),
},
),
),
check=check_prices,
),
WizardStep(
id="load",
title="Load",
intro="""
EOS needs a forecast of the electricity your household consumes. Select it with
`load.provider`:
- `LoadAkkudoktor` - a generic load profile, scaled to your yearly consumption.
- `LoadAkkudoktorAdjusted` - the same profile, refined with your measured consumption. It
needs load measurements, see the **Measurements** step.
- `LoadVrm` - the forecast of the Victron Remote Management (VRM) portal.
- `LoadImport` - a forecast you provide as a file or as JSON.
""",
example="""
A household that consumes 4500 kWh per year: `load.provider` = `LoadAkkudoktor` and
`load.loadakkudoktor.loadakkudoktor_year_energy_kwh` = `4500`. You find the yearly
consumption on your electricity bill.
""",
config_names=("load.provider",),
choices=(
WizardChoice(
"load.provider",
{
"LoadAkkudoktor": ("load.loadakkudoktor.loadakkudoktor_year_energy_kwh",),
"LoadAkkudoktorAdjusted": (
"load.loadakkudoktor.loadakkudoktor_year_energy_kwh",
),
"LoadVrm": ("load.vrm.site_id", "load.vrm.token"),
"LoadImport": (
"load.loadimport.import_file_path",
"load.loadimport.import_json",
),
},
),
),
check=check_load,
),
WizardStep(
id="devices",
title="Devices",
intro="""
Tell EOS which devices it plans for. Each kind of device has its own card; open it to add a
device or to change one.
- `devices.batteries` - stationary batteries.
- `devices.inverters` - inverters that connect PV and battery to the house.
- `devices.electric_vehicles` - the batteries of electric vehicles.
- `devices.home_appliances` - appliances EOS may shift in time, e.g. a dishwasher.
Every device has a `device_id`. Choose a short name and keep it: EOS names the measurements
of the device after it, e.g. `battery1-soc-factor` for the state of charge of `battery1`.
Batteries, electric vehicles and inverters take their technical data from a **profile**:
`battery_profile` (default `generic-lfp`) and `inverter_profile` (default `generic-hybrid`).
A profile holds the efficiency curves and the power limits of a model; the **Profiles** page
shows and edits them. Whatever differs for your unit goes into `profile_adjustment`. The
packaged profiles are generic and know no size, so set it there:
- battery and electric vehicle: `profile_adjustment.nominal_capacity_wh` - capacity in Wh,
and optionally `profile_adjustment.max_charge_power_w` /
`profile_adjustment.max_discharge_power_w` - the limits of your installation in W.
- inverter: `profile_adjustment.nominal_power_w` - nominal AC power in W.
Also important for a battery: `min_soc_percentage` and `max_soc_percentage` - the range of
the state of charge EOS may use.
""",
example="""
**Battery** - a 10 kWh battery that charges with up to 5 kW and shall never go below 10 %:
add a battery with `device_id` = `battery1`, in `profile_adjustment`
`nominal_capacity_wh` = `10000` and `max_charge_power_w` = `5000`, and
`min_soc_percentage` = `10`, `max_soc_percentage` = `100`.
**Inverter** - a 10 kW inverter the battery is connected to: add an inverter with
`device_id` = `inverter1`, `profile_adjustment.nominal_power_w` = `10000` and
`battery_id` = `battery1`.
**Electric vehicle** - a car with a 60 kWh battery at an 11 kW wallbox that shall be charged
to 80 % by 7 o'clock in the morning: add an electric vehicle with `device_id` = `ev1`, in
`profile_adjustment` `nominal_capacity_wh` = `60000` and `max_charge_power_w` = `11000`, and
`min_soc_percentage` = `80` (for
an electric vehicle this is the state of charge to reach) and
`min_soc_deadline_datetime` = `2026-07-16T07:00:00` (the departure).
**Home appliance** - a dishwasher that needs 1.5 kWh in a run of 3 hours and may run once
between 10:00 and 16:00: add a home appliance with `device_id` = `dishwasher`,
`consumption_wh` = `1500`, `duration_h` = `3` and
`time_windows` = `{"windows": [{"start_time": "10:00", "duration": "6 hours"}]}`.
""",
config_names=(
"devices.batteries",
"devices.inverters",
"devices.electric_vehicles",
"devices.home_appliances",
),
check=check_devices,
),
WizardStep(
id="measurements",
title="Measurements",
intro="""
To plan a battery or an electric vehicle EOS must know its **current state of charge**. The
measurement is named `<device_id>-soc-factor` and is a factor from `0.0` (empty) to `1.0`
(full) - not a percentage. It must be fresh: the GENETIC optimization does not use a state
of charge older than `optimization.genetic.measurement_max_age_seconds` and then assumes an
**empty** battery (0 %).
There are two ways to get measurements into EOS:
- An adapter, selected with `adapter.provider`. With `HomeAssistant` EOS reads the
measurements from Home Assistant entities before each energy management run and writes
its instructions back to Home Assistant - this needs EOS to run as a Home Assistant
add-on, the next step explains it. With `NodeRED` EOS sends its instructions to a
Node-RED flow; the measurements still have to come from your flow.
- Your own system sends each value to the EOS server with `PUT /v1/measurement/value`.
Energy meter readings are optional. They are meter readings in kWh that only count up, not
the current power. With `LoadAkkudoktorAdjusted` EOS uses the load meter readings to adapt
the load forecast to your household; list their measurement keys in
`measurement.load_emr_keys`. The Home Assistant adapter does this on its own for the
entities it reads.
""",
example="""
Your own system, e.g. a Node-RED flow or a script, at least every five minutes:
`PUT /v1/measurement/value?datetime=2026-01-31T12:00:00&key=battery1-soc-factor&value=0.55`
Home Assistant: select `HomeAssistant` in `adapter.provider` and continue with the next
step.
""",
config_names=(
"adapter.provider",
"measurement.load_emr_keys",
"optimization.genetic.measurement_max_age_seconds",
),
choices=(
WizardChoice(
"adapter.provider",
{
"NodeRED": ("adapter.nodered.host", "adapter.nodered.port"),
},
),
),
check=check_measurements,
),
WizardStep(
id="homeassistant",
title="Home Assistant",
intro="""
Skip this step if you do not use Home Assistant.
**EOS must run as a Home Assistant add-on.** The Home Assistant adapter talks to Home
Assistant through the API of the add-on system, with the access Home Assistant grants the
add-on. There is no setting for an address or a token. If EOS runs somewhere else, remove
`HomeAssistant` from `adapter.provider` and let Home Assistant send the measurements with
`PUT /v1/measurement/value`, e.g. with a `rest_command`.
Select `HomeAssistant` in `adapter.provider` (step **Measurements**). Before each energy
management run EOS then **reads** from Home Assistant:
- `device_measurement_entity_ids` - the current state of your devices, above all the state
of charge: map `<device_id>-soc-factor` to an entity whose state is a factor from `0.0` to
`1.0`. Without it EOS plans with an empty battery.
- `load_emr_entity_ids`, `pv_production_emr_entity_ids`, `grid_import_emr_entity_ids`,
`grid_export_emr_entity_ids` - optional energy meter readings in **kWh** that only count
up (`state_class: total_increasing`), not power sensors in W. EOS uses the entity IDs as
measurement keys and adds them to `measurement.*_emr_keys` on its own.
- `config_entity_ids` - optional: configuration values taken from entities, e.g. a battery
capacity an integration reports. The key is the path of the setting with `/`, and devices
are addressed by their `device_id`:
`devices/batteries/battery1/profile_adjustment/nominal_capacity_wh`.
After each run EOS **writes** to Home Assistant:
- `device_instruction_entity_ids` - one entity per device, named `sensor.eos_<device_id>`,
e.g. `sensor.eos_battery1`. The state is the operation mode EOS wants right now in lower
case, e.g. `self_consumption`, `forced_charge` or `idle`; the attribute
`operation_mode_factor` says how much, from `0.0` to `1.0`.
- `solution_entity_ids` - optional values of the current plan for dashboards and debugging,
named `sensor.eos_<solution key>`, e.g. `sensor.eos_battery1_soc_factor`.
EOS only writes the entities you list. The **Config** page shows read-only lists to pick
from: `adapter.homeassistant.homeassistant_entity_ids` has the entities of Home Assistant,
`adapter.homeassistant.eos_device_instruction_entity_ids` and
`adapter.homeassistant.eos_solution_entity_ids` have the names EOS can write. The
instruction names appear after the first optimization.
**EOS does not switch anything itself.** Write an automation in Home Assistant that reacts to
`sensor.eos_<device_id>` and controls your inverter, wallbox or appliance. The entities EOS
writes have no unique ID: to use them in dashboards or the history, wrap them in template
sensors.
""",
example="""
A battery `battery1` whose integration reports the state of charge in percent as
`sensor.battery_soc`. Create a template sensor in Home Assistant that converts it:
```yaml
template:
- sensor:
- name: "Battery1 SoC Factor"
unique_id: "battery1_soc_factor"
state: "{{ states('sensor.battery_soc') | float(0) / 100 }}"
```
In EOS set:
- `device_measurement_entity_ids` = `{"battery1-soc-factor": "sensor.battery1_soc_factor"}`
- `load_emr_entity_ids` = `["sensor.house_consumption_total_kwh"]`
- `device_instruction_entity_ids` = `["sensor.eos_battery1"]`
Then let Home Assistant follow the instruction, e.g. charge the battery from the grid while
EOS asks for it (replace the actions with the ones of your inverter integration):
```yaml
automation:
- alias: "EOS battery1 instruction"
triggers:
- trigger: state
entity_id: sensor.eos_battery1
actions:
- choose:
- conditions: "{{ is_state('sensor.eos_battery1', 'forced_charge') }}"
sequence:
- action: select.select_option
target:
entity_id: select.inverter_battery_mode
data:
option: "Charge from grid"
default:
- action: select.select_option
target:
entity_id: select.inverter_battery_mode
data:
option: "Self consumption"
```
""",
config_names=(
"adapter.homeassistant.device_measurement_entity_ids",
"adapter.homeassistant.load_emr_entity_ids",
"adapter.homeassistant.pv_production_emr_entity_ids",
"adapter.homeassistant.grid_import_emr_entity_ids",
"adapter.homeassistant.grid_export_emr_entity_ids",
"adapter.homeassistant.config_entity_ids",
"adapter.homeassistant.device_instruction_entity_ids",
"adapter.homeassistant.solution_entity_ids",
),
check=check_homeassistant,
),
WizardStep(
id="finish",
title="Finish",
@@ -247,14 +904,7 @@ EOS saves changes of the configuration to the configuration file on its own if
are lost when EOS restarts unless you save them. **Save configuration** writes the
configuration file right now in both modes.
This assistant does not cover everything yet. Set up the following on the **Config** page:
- the PV system (`pvforecast`),
- electricity prices, grid fees and the feed-in tariff (`elecprice`, `elecfee`,
`feedintariff`),
- the load forecast (`load`),
- battery, inverter, electric vehicle and home appliances (`devices`),
- measurements and the connection to your home automation (`measurement`, `adapter`).
All other settings of EOS are on the **Config** page.
Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the
result of the optimization.
@@ -387,6 +1037,9 @@ def Wizard(
data = data or {}
step = wizard_step(data.get("wizard_step"))
# The lazily loaded options of some configuration cards are fetched from the EOS server
remember_eos_server(eos_host, eos_port)
save_status = None
action = data.get("action")
if action == "update":