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
+2 -1
View File
@@ -65,7 +65,8 @@ compatibility changes and a short test procedure. These changes are not yet a ta
cores, default on). For a fixed seed the plan is identical to a single-process run. cores, default on). For a fixed seed the plan is identical to a single-process run.
- EOSdash setup assistant: the new **Setup** page explains the basic settings step by step, - EOSdash setup assistant: the new **Setup** page explains the basic settings step by step,
gives examples, reports what is still missing and shows the configuration cards of the gives examples, reports what is still missing and shows the configuration cards of the
current step. First steps: location, horizons and energy management, save the configuration. current step: location, horizons and energy management, PV system, weather, prices and fees,
load, devices, measurements, Home Assistant, save the configuration.
### Changed / compatibility ### Changed / compatibility
@@ -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: def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None:
"""Set a configuration value on the EOS server. """Set a configuration value on the EOS server.
@@ -586,11 +592,11 @@ def Configuration(
Returns: Returns:
rows: Rows of configuration details. rows: Rows of configuration details.
""" """
global config_visible, eos_server_address global config_visible
dark = False dark = False
# Remember for usage # 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 and data.get("action", None):
if data.get("dark", None) == "true": 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.config import ConfigEOS
from akkudoktoreos.config.configabc import is_home_assistant_addon
from akkudoktoreos.server.dash.components import ( from akkudoktoreos.server.dash.components import (
Badge, Badge,
ConfigButton, ConfigButton,
@@ -43,6 +44,7 @@ from akkudoktoreos.server.dash.configuration import (
config_update, config_update,
create_config_details, create_config_details,
get_config, get_config,
remember_eos_server,
) )
from akkudoktoreos.server.dash.context import request_url_for from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown 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) @dataclass(frozen=True)
class WizardStep: class WizardStep:
"""A step of the setup wizard. """A step of the setup wizard.
@@ -64,8 +83,9 @@ class WizardStep:
title: Title of the step. title: Title of the step.
intro: Explanation of the step (Markdown). intro: Explanation of the step (Markdown).
example: Example for the settings 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 config_names: Dotted names of the configuration values to show.
replaced by the lower case name of the configured optimization algorithm. 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 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. in this step, one sentence each. An empty list means the step is done.
""" """
@@ -75,6 +95,7 @@ class WizardStep:
intro: str intro: str
example: str = "" example: str = ""
config_names: tuple[str, ...] = () config_names: tuple[str, ...] = ()
choices: tuple[WizardChoice, ...] = ()
check: Optional[Callable[[dict[str, dict]], list[str]]] = None 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]: 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.""" """Return the names of the configuration values a step shows for this configuration."""
algorithm = str(config_value(config_details, "optimization.algorithm", "")).lower() names: list[str] = []
names = [name.format(algorithm=algorithm) for name in step.config_names] for name in step.config_names:
return [name for name in names if name in config_details] 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 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 # Steps
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -230,12 +452,447 @@ Use `PREDICTION` while you are still setting up the forecasts and want to look a
config_names=( config_names=(
"prediction.hours", "prediction.hours",
"optimization.algorithm", "optimization.algorithm",
"optimization.{algorithm}.horizon_hours",
"ems.mode", "ems.mode",
"ems.interval", "ems.interval",
), ),
choices=(
WizardChoice(
"optimization.algorithm",
{
"GENETIC": ("optimization.genetic.horizon_hours",),
"GENETIC0": ("optimization.genetic0.horizon_hours",),
},
),
),
check=check_horizon, 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( WizardStep(
id="finish", id="finish",
title="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 are lost when EOS restarts unless you save them. **Save configuration** writes the
configuration file right now in both modes. configuration file right now in both modes.
This assistant does not cover everything yet. Set up the following on the **Config** page: All other settings of EOS are 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`).
Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the
result of the optimization. result of the optimization.
@@ -387,6 +1037,9 @@ def Wizard(
data = data or {} data = data or {}
step = wizard_step(data.get("wizard_step")) 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 save_status = None
action = data.get("action") action = data.get("action")
if action == "update": if action == "update":
+18
View File
@@ -201,3 +201,21 @@ class TestEOSdashInBrowser:
assert dash.page.locator("#wizard").count() == 1 assert dash.page.locator("#wizard").count() == 1
assert "Step 2 of" in dash.page_content() assert "Step 2 of" in dash.page_content()
assert dash.errors == [] assert dash.errors == []
def test_setup_wizard_shows_provider_settings(self, dash: DashPage) -> None:
"""Selecting a provider in the setup wizard shows its settings, which work in the wizard."""
assert dash.open_tab("/eosdash/wizard") == 200
dash.page.click("#wizard-step-pv")
dash.page.wait_for_selector("#wizard[hx-vals*='\"pv\"']")
dash.page.click("#wizard summary:has-text('pvforecast.provider')")
dash.page.select_option("#pvforecast-provider-value-select", "PVForecastAkkudoktor")
dash.page.click("#pvforecast-provider-update-value-form button")
planes = "#wizard summary:has-text('pvforecast.planes')"
dash.page.wait_for_selector(planes)
# The card of a list of items sends its updates to the wizard, too
dash.page.click(f"{planes} button:has-text('Add item')")
dash.page.wait_for_selector(f"{planes} >> text=1 item")
assert dash.page.locator("#wizard[hx-vals*='\"pv\"']").count() == 1
assert dash.errors == []
+314 -15
View File
@@ -10,10 +10,18 @@ from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.server.dash import wizard from akkudoktoreos.server.dash import wizard
from akkudoktoreos.server.dash.configuration import create_config_details from akkudoktoreos.server.dash.configuration import create_config_details
from akkudoktoreos.server.dash.wizard import ( from akkudoktoreos.server.dash.wizard import (
PV_PLANES_PROVIDERS,
WIZARD_STEPS, WIZARD_STEPS,
Wizard, Wizard,
check_devices,
check_homeassistant,
check_horizon, check_horizon,
check_load,
check_location, check_location,
check_measurements,
check_prices,
check_pv,
check_weather,
config_value, config_value,
step_config_names, step_config_names,
step_todo, step_todo,
@@ -50,21 +58,76 @@ class TestWizardSteps:
assert step.title assert step.title
assert step.intro.strip() assert step.intro.strip()
@pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm]) def test_config_names_exist(self, config_details):
def test_config_names_exist(self, config_details, algorithm):
"""A renamed configuration value must not silently drop out of the wizard.""" """A renamed configuration value must not silently drop out of the wizard."""
set_value(config_details, "optimization.algorithm", algorithm)
for step in WIZARD_STEPS: for step in WIZARD_STEPS:
assert len(step_config_names(step, config_details)) == len(step.config_names), ( names = list(step.config_names)
f"Step '{step.id}' names unknown configuration values for {algorithm}." for choice in step.choices:
) assert choice.name in step.config_names, f"{step.id}: {choice.name}"
for chosen in choice.config_names.values():
def test_config_names_are_writable(self, config_details): names.extend(chosen)
for step in WIZARD_STEPS: for name in names:
for name in step_config_names(step, config_details): assert name in config_details, f"Step {step.id}: unknown {name}."
assert config_details[name]["read-only"] == "rw", name assert config_details[name]["read-only"] == "rw", name
assert not config_details[name]["deprecated"], name assert not config_details[name]["deprecated"], name
def test_choice_values_exist(self, config_details):
"""A renamed provider must not silently lose its settings in the wizard."""
for step in WIZARD_STEPS:
for choice in step.choices:
if choice.name == "optimization.algorithm":
available = [algorithm.value for algorithm in OptimizationAlgorithm]
else:
assert choice.name.endswith(".provider")
available = config_value(config_details, f"{choice.name}s")
for value in choice.config_names:
assert value in available, f"Step {step.id}: unknown {value}."
def test_pv_planes_providers_exist(self, config_details):
available = config_value(config_details, "pvforecast.providers")
for provider in PV_PLANES_PROVIDERS:
assert provider in available
def test_choice_follows_value(self, config_details):
step = wizard_step("load")
set_value(config_details, "load.provider", "LoadVrm")
assert step_config_names(step, config_details) == [
"load.provider",
"load.vrm.site_id",
"load.vrm.token",
]
set_value(config_details, "load.provider", None)
assert step_config_names(step, config_details) == ["load.provider"]
def test_choice_with_list_value(self, config_details):
step = wizard_step("measurements")
set_value(config_details, "adapter.provider", ["HomeAssistant", "NodeRED"])
names = step_config_names(step, config_details)
assert "adapter.nodered.host" in names
assert "adapter.nodered.port" in names
assert len(names) == len(set(names))
def test_homeassistant_step_shows_all_adapter_settings(self, config_details):
"""Every writable setting of the Home Assistant adapter is in its step."""
adapter_names = [
name
for name, details in config_details.items()
if name.startswith("adapter.homeassistant.")
and name.count(".") == 2
and details["read-only"] == "rw"
and not details["deprecated"]
]
assert adapter_names
names = step_config_names(wizard_step("homeassistant"), config_details)
assert sorted(names) == sorted(adapter_names)
@pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm])
def test_horizon_follows_algorithm(self, config_details, algorithm):
set_value(config_details, "optimization.algorithm", algorithm)
names = step_config_names(wizard_step("horizon"), config_details)
horizons = [name for name in names if name.endswith(".horizon_hours")]
assert horizons == [f"optimization.{algorithm.lower()}.horizon_hours"]
def test_wizard_step(self): def test_wizard_step(self):
assert wizard_step("location").id == "location" assert wizard_step("location").id == "location"
assert wizard_step(None) is WIZARD_STEPS[0] assert wizard_step(None) is WIZARD_STEPS[0]
@@ -123,6 +186,201 @@ class TestWizardChecks:
assert len(todo) == 1 assert len(todo) == 1
assert f"optimization.{algorithm.lower()}.horizon_hours (24)" in todo[0] assert f"optimization.{algorithm.lower()}.horizon_hours (24)" in todo[0]
def test_pv(self, config_details):
set_value(config_details, "pvforecast.provider", None)
assert "no PV system" in check_pv(config_details)[0]
set_value(config_details, "pvforecast.provider", "PVForecastAkkudoktor")
set_value(config_details, "pvforecast.planes", None)
assert "at least one plane" in check_pv(config_details)[0]
set_value(config_details, "pvforecast.planes", [{"peakpower": None}])
assert "at least one plane" in check_pv(config_details)[0]
set_value(config_details, "pvforecast.planes", [{"peakpower": 8.0}])
assert check_pv(config_details) == []
set_value(config_details, "pvforecast.provider", "PVForecastSolcast")
set_value(config_details, "pvforecast.solcast.api_key", "")
set_value(config_details, "pvforecast.solcast.site_id", "abcd")
assert check_pv(config_details) == ["pvforecast.solcast.api_key is not set."]
set_value(config_details, "pvforecast.provider", "PVForecastVrm")
assert check_pv(config_details) == []
def test_weather(self, config_details):
set_value(config_details, "weather.provider", None)
assert len(check_weather(config_details)) == 1
set_value(config_details, "weather.provider", "BrightSky")
assert check_weather(config_details) == []
def test_prices(self, config_details):
set_value(config_details, "elecprice.provider", None)
set_value(config_details, "elecfee.provider", None)
set_value(config_details, "feedintariff.provider", None)
todo = check_prices(config_details)
assert len(todo) == 2
assert "electricity price provider" in todo[0]
assert "feed-in tariff provider" in todo[1]
set_value(config_details, "elecprice.provider", "ElecPriceEnergyCharts")
set_value(config_details, "feedintariff.provider", "FeedInTariffEnergyCharts")
assert check_prices(config_details) == []
set_value(config_details, "elecprice.provider", "ElecPriceFixed")
set_value(config_details, "elecfee.provider", "ElecFeeFixed")
set_value(config_details, "feedintariff.provider", "FeedInTariffFixed")
for name in (
"elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows",
"elecfee.elecfeefixed.consumption_amt_kwh.windows",
"elecfee.elecfeefixed.consumption_percent_amt.windows",
"elecfee.elecfeefixed.feedin_amt_kwh.windows",
"elecfee.elecfeefixed.feedin_percent_amt.windows",
"feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows",
):
set_value(config_details, name, [])
assert len(check_prices(config_details)) == 3
set_value(config_details, "elecfee.elecfeefixed.consumption_percent_amt.windows", [{}])
assert len(check_prices(config_details)) == 2
def test_load(self, config_details):
set_value(config_details, "load.provider", None)
assert len(check_load(config_details)) == 1
set_value(config_details, "load.provider", "LoadAkkudoktor")
set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", None)
assert len(check_load(config_details)) == 1
set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", 4500)
assert check_load(config_details) == []
set_value(config_details, "load.provider", "LoadAkkudoktorAdjusted")
set_value(config_details, "measurement.load_emr_keys", None)
assert check_load(config_details) == ["measurement.load_emr_keys is not set."]
def test_devices(self, config_details):
for kind in ("batteries", "electric_vehicles", "home_appliances"):
set_value(config_details, f"devices.{kind}", None)
assert len(check_devices(config_details)) == 1
set_value(
config_details,
"devices.batteries",
{"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": 10000}},
)
assert check_devices(config_details) == []
def test_devices_without_size(self, config_details):
"""The packaged profiles are generic: each unit needs its size."""
set_value(
config_details,
"devices.batteries",
{"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": None}},
)
set_value(
config_details,
"devices.electric_vehicles",
{"ev1": {"device_id": "ev1", "effective_nominal_capacity_wh": 60000}},
)
set_value(
config_details,
"devices.inverters",
{"inverter1": {"device_id": "inverter1", "effective_nominal_power_w": None}},
)
todo = check_devices(config_details)
assert len(todo) == 2
assert "battery1 has no nominal_capacity_wh" in todo[0]
assert "profile_adjustment.nominal_capacity_wh" in todo[0]
assert "inverter1 has no nominal_power_w" in todo[1]
def test_devices_size_of_a_configured_device(self, config_eos):
"""The check reads the effective size as the EOS server reports it."""
config_eos.merge_settings_from_dict(
{
"devices": {
"max_batteries": 1,
"max_inverters": 1,
"batteries": {"battery1": {"device_id": "battery1"}},
"inverters": {
"inverter1": {
"device_id": "inverter1",
"profile_adjustment": {"nominal_power_w": 10000},
}
},
}
}
)
details = create_config_details(
type(config_eos), json.loads(config_eos.model_dump_json())
)
todo = check_devices(details)
assert len(todo) == 1
assert todo[0].startswith("battery1 has no nominal_capacity_wh")
def test_measurements(self, config_details):
set_value(config_details, "devices.batteries", {"battery1": {}})
set_value(config_details, "devices.electric_vehicles", {"ev1": {}})
# Without an adapter no source for the SoC is known
set_value(config_details, "adapter.provider", None)
todo = check_measurements(config_details)
assert len(todo) == 1
assert "No adapter is selected" in todo[0]
assert "of battery1, ev1 is known" in todo[0]
# The mapping of the Home Assistant adapter is checked in its own step
for providers in (["NodeRED"], ["HomeAssistant"]):
set_value(config_details, "adapter.provider", providers)
assert check_measurements(config_details) == []
def test_homeassistant_not_selected(self, config_details, monkeypatch):
monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False)
set_value(config_details, "devices.batteries", {"battery1": {}})
for providers in (None, ["NodeRED"]):
set_value(config_details, "adapter.provider", providers)
assert check_homeassistant(config_details) == []
def test_homeassistant(self, config_details, monkeypatch):
monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: True)
set_value(config_details, "devices.batteries", {"battery1": {}})
set_value(config_details, "devices.electric_vehicles", {"ev1": {}})
set_value(config_details, "adapter.provider", ["HomeAssistant"])
measurements = "adapter.homeassistant.device_measurement_entity_ids"
instructions = "adapter.homeassistant.device_instruction_entity_ids"
set_value(config_details, measurements, None)
set_value(config_details, instructions, None)
todo = check_homeassistant(config_details)
assert len(todo) == 3
assert "battery1-soc-factor" in todo[0]
assert "ev1-soc-factor" in todo[1]
assert instructions in todo[2]
set_value(config_details, measurements, {"battery1-soc-factor": "sensor.battery_soc"})
set_value(config_details, instructions, ["sensor.eos_battery1"])
todo = check_homeassistant(config_details)
assert len(todo) == 1
assert "ev1-soc-factor" in todo[0]
set_value(
config_details,
measurements,
{"battery1-soc-factor": "sensor.battery_soc", "ev1-soc-factor": "sensor.ev_soc"},
)
assert check_homeassistant(config_details) == []
def test_homeassistant_outside_of_addon(self, config_details, monkeypatch):
"""The adapter cannot reach Home Assistant outside of an add-on."""
monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False)
set_value(config_details, "adapter.provider", ["HomeAssistant"])
set_value(config_details, "adapter.homeassistant.device_instruction_entity_ids", ["x"])
todo = check_homeassistant(config_details)
assert len(todo) == 1
assert "add-on" in todo[0]
def test_measurements_fresh_system(self, config_details):
"""A fresh system has no adapter and no devices: the step must not look done."""
todo = check_measurements(config_details)
assert len(todo) == 1
assert "state of charge is known" in todo[0]
def test_step_without_check_has_nothing_to_do(self, config_details): def test_step_without_check_has_nothing_to_do(self, config_details):
assert step_todo(wizard_step("start"), config_details) == [] assert step_todo(wizard_step("start"), config_details) == []
@@ -152,6 +410,12 @@ class TestWizardPage:
assert 'id="wizard-next"' in html assert 'id="wizard-next"' in html
assert 'id="wizard-back"' not in html assert 'id="wizard-back"' not in html
@pytest.mark.parametrize("step_id", [step.id for step in WIZARD_STEPS])
def test_every_step_renders(self, eos, step_id):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": step_id}))
number = [step.id for step in WIZARD_STEPS].index(step_id) + 1
assert f"Step {number} of {len(WIZARD_STEPS)}: {wizard_step(step_id).title}" in html
def test_step_shows_only_its_config(self, eos): def test_step_shows_only_its_config(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "location"})) html = to_xml(Wizard("localhost", 8503, {"wizard_step": "location"}))
assert "Location" in html assert "Location" in html
@@ -172,11 +436,11 @@ class TestWizardPage:
assert page.attrs["id"] == "wizard" assert page.attrs["id"] == "wizard"
assert json.loads(page.attrs["hx-vals"]) == {"wizard_step": "horizon"} assert json.loads(page.attrs["hx-vals"]) == {"wizard_step": "horizon"}
def test_horizon_step_follows_algorithm(self, eos, config): def test_provider_settings_are_shown(self, eos, config):
config["optimization"]["algorithm"] = "GENETIC0" config["pvforecast"]["provider"] = "PVForecastSolcast"
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "horizon"})) html = to_xml(Wizard("localhost", 8503, {"wizard_step": "pv"}))
assert "optimization.genetic0.horizon_hours" in html assert "pvforecast.solcast.api_key" in html
assert "optimization.genetic.horizon_hours" not in html assert "pvforecast.vrm.token" not in html
def test_update(self, eos): def test_update(self, eos):
data = { data = {
@@ -202,3 +466,38 @@ class TestWizardPage:
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish", "action": "save_to_file"})) html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish", "action": "save_to_file"}))
assert eos["save"] == [("localhost", 8503)] assert eos["save"] == [("localhost", 8503)]
assert "saved-by-test" in html assert "saved-by-test" in html
def test_devices_step_has_an_example_for_every_device_type(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"}))
for device_type in ("Battery", "Inverter", "Electric vehicle", "Home appliance"):
assert f"<strong>{device_type}</strong>" in html
def test_devices_step_names_existing_settings(self, eos):
"""The device settings the step names exist (they moved into profiles before)."""
from akkudoktoreos.devices.settings.batterysettings import (
BatteriesCommonSettings,
BatteryProfileAdjustment,
)
from akkudoktoreos.devices.settings.invertersettings import (
InverterCommonSettings,
InverterProfileAdjustment,
)
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"}))
for model, names in (
(BatteriesCommonSettings, ("battery_profile", "min_soc_percentage")),
(BatteryProfileAdjustment, ("nominal_capacity_wh", "max_charge_power_w")),
(InverterCommonSettings, ("inverter_profile", "battery_id")),
(InverterProfileAdjustment, ("nominal_power_w",)),
):
for name in names:
assert name in model.model_fields
assert f"<code>{name}</code>" in html or f"profile_adjustment.{name}" in html
for stale in ("<code>capacity_wh</code>", "<code>charging_efficiency</code>", "<code>max_power_w</code>"):
assert stale not in html
def test_homeassistant_step_example_renders_yaml(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "homeassistant"}))
assert "<pre" in html
assert "sensor.eos_battery1" in html
assert "add-on" in html