feat(eosdash): add a setup assistant for the basic configuration

New users have to find the settings EOS needs among all configuration values
on the Config page, without a hint which ones matter or what is still missing.

The new Setup page takes the user through the configuration step by step.
Each step explains a few settings, gives an example, reports what is still to
do and shows the configuration cards of exactly these settings. The cards are
the ones of the Config page; the code that creates a card and sends an update
moved out of Configuration() into config_card() and config_update().

The assistant keeps no state: the step travels with the requests and the
status is derived from the EOS configuration, which is read and written
through the EOS server only.

First steps: location, horizons and energy management, save the configuration.

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 10:40:57 +01:00
committed by Bobby Noelte
co-authored by Claude Fable 5.2
parent 3bcc902104
commit 7f4014c408
6 changed files with 832 additions and 89 deletions
+3
View File
@@ -63,6 +63,9 @@ compatibility changes and a short test procedure. These changes are not yet a ta
at most two worker processes, one CPU core and a container CPU limit stay free; `1` = one
process as before) and `optimization.genetic.pin_workers` (workers pinned to the last
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,
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.
### Changed / compatibility
+103 -74
View File
@@ -457,36 +457,17 @@ def config_options(
# -----------------------------------
def Configuration(
eos_host: str,
eos_port: Union[str, int],
data: Optional[dict] = None,
) -> Div:
"""Create a visual representation of the configuration.
def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None:
"""Set a configuration value on the EOS server.
The result is remembered in ``config_update_latest``, so that the configuration card of the
value shows the error, if any, and stays open when the page is rendered again.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Incoming data to trigger config actions. Defaults to None.
Returns:
rows: Rows of configuration details.
data (dict): Request data with the config name in ``key`` and the new value in ``value``.
"""
global config_visible, eos_server_address
dark = False
# Remember for usage
eos_server_address = (eos_host, int(eos_port))
if data and data.get("action", None):
if data.get("dark", None) == "true":
dark = True
if data["action"] == "visible":
renderer = data.get("renderer", None)
if renderer:
config_visible[renderer]["visible"] = bool(data.get(f"{renderer}-visible", False))
elif data["action"] == "update":
# This data contains a new value for key
key = data["key"]
value_json_str: str = data.get("value", "")
try:
@@ -526,6 +507,101 @@ def Configuration(
"open": True,
}
def config_card(config: dict[str, Any], config_details: dict[str, dict]) -> Optional[Card]:
"""Create the card to show and update a configuration value.
Args:
config (dict[str, Any]): Configuration details of the value, as created by
``create_config_details``.
config_details (dict[str, dict]): Configuration details of all values.
Returns:
Optional[Card]: The card, or None if the configuration value is deprecated.
"""
if config["deprecated"]:
return None
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items":
return ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
if hint and hint.form == "map_items":
return ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
return ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
config["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
def Configuration(
eos_host: str,
eos_port: Union[str, int],
data: Optional[dict] = None,
) -> Div:
"""Create a visual representation of the configuration.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Incoming data to trigger config actions. Defaults to None.
Returns:
rows: Rows of configuration details.
"""
global config_visible, eos_server_address
dark = False
# Remember for usage
eos_server_address = (eos_host, int(eos_port))
if data and data.get("action", None):
if data.get("dark", None) == "true":
dark = True
if data["action"] == "visible":
renderer = data.get("renderer", None)
if renderer:
config_visible[renderer]["visible"] = bool(data.get(f"{renderer}-visible", False))
elif data["action"] == "update":
config_update(eos_host, eos_port, data)
# (Re-)read configuration details to be shure we display actual data
config = get_config(eos_host, eos_port)
@@ -628,20 +704,6 @@ def Configuration(
category = config["name"].split(".")[0]
sections.setdefault(category, {})
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
if (
# Do not display read only values
not config_visible["config-visible-read-only"]["visible"]
@@ -653,41 +715,8 @@ def Configuration(
# Search value given but does not match
continue
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
card = ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif hint and hint.form == "map_items" and not config["deprecated"]:
card = ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif not config["deprecated"]:
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
card = ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
config["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
else:
card = config_card(config, config_details)
if card is None:
continue
sections[category][config["name"]] = card
+447
View File
@@ -0,0 +1,447 @@
"""Setup wizard for EOSdash.
The wizard walks a new user through the configuration EOS needs before it can predict and
optimize. Every step explains a small group of settings, gives an example, reports what is
still missing and shows the configuration cards of exactly these settings - the same cards as
on the Config page.
The steps are data: add a ``WizardStep`` to ``WIZARD_STEPS`` to add a step.
The wizard keeps no state of its own. The current step travels with the requests
(``wizard_step``) and whether a step is done is derived from the EOS configuration. The
configuration is read and written through the EOS server only.
The configuration cards send their updates to ``/eosdash/configuration``. The wizard content
is wrapped in an element that adds ``wizard_step`` to all requests from within (``hx-vals`` is
inherited), which makes that route answer with the wizard instead of the Config page.
"""
import json
from dataclasses import dataclass
from typing import Any, Callable, Optional, Union
import requests
from fasthtml.common import Div
from monsterui.franken import (
H3,
Card,
DivHStacked,
DivLAligned,
P,
UkIcon,
)
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.server.dash.components import (
Badge,
ConfigButton,
Error,
Success,
)
from akkudoktoreos.server.dash.configuration import (
config_card,
config_update,
create_config_details,
get_config,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
WIZARD_PATH = "/eosdash/wizard"
# ---------------------------------------------------------------------------
# Step definition
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class WizardStep:
"""A step of the setup wizard.
Attributes:
id: Identifier of the step, used in requests.
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.
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.
"""
id: str
title: str
intro: str
example: str = ""
config_names: tuple[str, ...] = ()
check: Optional[Callable[[dict[str, dict]], list[str]]] = None
def config_value(config_details: dict[str, dict], name: str, default: Any = None) -> Any:
"""Return the current value of a configuration value.
Args:
config_details: Configuration details, as created by ``create_config_details``.
name: Dotted name of the configuration value.
default: Value to return if the configuration value is unknown.
"""
try:
return json.loads(config_details[name]["value"])
except (KeyError, TypeError, ValueError):
return default
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]
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
def check_location(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the location."""
latitude = config_value(config_details, "general.latitude")
longitude = config_value(config_details, "general.longitude")
if latitude is None or longitude is None:
return ["Latitude and longitude are not set."]
todo = []
try:
defaults = (
json.loads(config_details["general.latitude"]["default"]),
json.loads(config_details["general.longitude"]["default"]),
)
except (KeyError, TypeError, ValueError):
defaults = None
if defaults == (latitude, longitude):
todo.append(
f"Latitude and longitude are still the default values ({latitude}, {longitude}). "
"Set the location of your home."
)
if not config_value(config_details, "general.timezone"):
todo.append(
"EOS has no time zone for this location. Check latitude and longitude or set "
"general.timezone_override."
)
return todo
def check_horizon(config_details: dict[str, dict]) -> list[str]:
"""Report what is still to do for the horizons and the energy management."""
todo = []
algorithm = str(config_value(config_details, "optimization.algorithm", "")).lower()
horizon_name = f"optimization.{algorithm}.horizon_hours"
horizon = config_value(config_details, horizon_name)
prediction = config_value(config_details, "prediction.hours")
if isinstance(horizon, (int, float)) and (prediction is None or prediction < horizon):
todo.append(
f"prediction.hours ({prediction}) is shorter than {horizon_name} ({horizon}). "
"Optimization runs fail until the predictions cover the optimization horizon."
)
if config_value(config_details, "ems.mode") == "DISABLED":
todo.append(
"ems.mode is DISABLED: EOS does not run the energy management. Set it to "
"OPTIMIZATION to get a plan, or to PREDICTION to only update the predictions."
)
return todo
# ---------------------------------------------------------------------------
# Steps
# ---------------------------------------------------------------------------
WIZARD_STEPS: tuple[WizardStep, ...] = (
WizardStep(
id="start",
title="Start",
intro="""
This assistant takes you through the settings EOS needs before it can predict and optimize.
Each step explains a few settings, gives an example and shows the configuration cards of
these settings. The cards are the same as on the **Config** page:
- Click a card to open it. It shows the description, the type and the default of the setting.
- Type the new value into the **update** field and press Enter, or choose a value and press
**Set**. The value is checked by EOS; if EOS rejects it, the card shows why.
- A new value is used by EOS right away. It is written to the configuration file - and
survives a restart of EOS - only when you save the configuration in the last step.
Above the explanation you see all steps. A check mark means EOS has what it needs from that
step, a warning sign means something is left to do. You can leave the assistant at any time
and come back later: it reads the state from the EOS configuration.
If EOS is already configured, save a copy of the configuration on the **Admin** page before
you change it.
""",
),
WizardStep(
id="location",
title="Location",
intro="""
EOS needs to know where your home is. The weather forecast and the PV forecast are made for
this location, and EOS looks up your **time zone** from it. All times in the predictions and
in the plan are local times of this time zone.
- `general.latitude` - decimal degrees, north is positive.
- `general.longitude` - decimal degrees, east is positive.
- `general.timezone_override` - only needed if the time zone EOS looks up is not the one you
want. Leave it at `null` otherwise.
""",
example="""
A house in Munich: latitude `48.137`, longitude `11.575`. EOS looks up the time zone
`Europe/Berlin`. Three decimal places are precise enough (about 100 m).
To set the time zone yourself use an IANA name such as `Europe/Vienna`.
""",
config_names=("general.latitude", "general.longitude", "general.timezone_override"),
check=check_location,
),
WizardStep(
id="horizon",
title="Horizons and energy management",
intro="""
Two horizons decide how far EOS looks ahead, and the energy management decides whether and
how often EOS works on its own.
- `prediction.hours` - how many hours into the future EOS predicts PV yield, load and prices.
- `optimization.<algorithm>.horizon_hours` - how many hours the plan covers. The predictions
must cover at least this horizon, otherwise the optimization fails.
- `ems.mode` - what the energy management does in each run: `DISABLED` nothing,
`PREDICTION` updates the predictions, `OPTIMIZATION` updates the predictions and
calculates a new plan. Outside of Home Assistant the default is `DISABLED`, so EOS stays
idle until you change it.
- `ems.interval` - seconds between two energy management runs.
""",
example="""
A common setup: `prediction.hours` = `48`, `horizon_hours` = `24`, `ems.mode` =
`OPTIMIZATION` and `ems.interval` = `300`. EOS then predicts two days ahead and calculates
a plan for the next 24 hours every five minutes.
Use `PREDICTION` while you are still setting up the forecasts and want to look at them on the
**Prediction** page before EOS optimizes.
""",
config_names=(
"prediction.hours",
"optimization.algorithm",
"optimization.{algorithm}.horizon_hours",
"ems.mode",
"ems.interval",
),
check=check_horizon,
),
WizardStep(
id="finish",
title="Finish",
intro="""
Below is what the steps of this assistant found. Go back to a step to complete it.
**Save the configuration** to keep your settings when EOS restarts.
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`).
Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the
result of the optimization.
""",
),
)
def wizard_step(step_id: Optional[str]) -> WizardStep:
"""Return the step with the given id, or the first step if there is no such step."""
for step in WIZARD_STEPS:
if step.id == step_id:
return step
return WIZARD_STEPS[0]
def step_todo(step: WizardStep, config_details: dict[str, dict]) -> list[str]:
"""Return what is still to do in a step. An empty list means the step is done."""
return step.check(config_details) if step.check else []
# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------
def save_config(eos_host: str, eos_port: Union[str, int]) -> Any:
"""Let the EOS server save the configuration to its configuration file.
Returns:
A success or an error message.
"""
server = f"http://{eos_host}:{eos_port}"
try:
result = requests.put(f"{server}/v1/config/file", timeout=10)
result.raise_for_status()
config_file_path = result.json()["general"]["config_file_path"]
except requests.exceptions.HTTPError as e:
try:
detail = result.json().get("detail", result.text)
except ValueError:
detail = result.text
return Error(f"Can not save the configuration on '{eos_host}': {e}, {detail}")
except Exception as e:
return Error(f"Can not save the configuration on '{eos_host}': {e}")
return Success(f"Saved the configuration to '{config_file_path}' on '{eos_host}'.")
# ---------------------------------------------------------------------------
# Visual representation
# ---------------------------------------------------------------------------
def _step_button(label: Any, step: WizardStep, **kwargs: Any) -> Any:
"""Button that opens a step."""
return ConfigButton(
label,
hx_get=request_url_for(WIZARD_PATH),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=json.dumps({"wizard_step": step.id}),
**kwargs,
)
def _todo_list(todo: list[str]) -> Any:
"""What is still to do in a step, or that nothing is left to do."""
if not todo:
return DivLAligned(UkIcon("circle-check"), P("Nothing left to do in this step."))
return Div(
*[DivLAligned(UkIcon("triangle-alert", cls="shrink-0"), P(item)) for item in todo],
cls="space-y-2",
)
def WizardSteps(current: WizardStep, config_details: dict[str, dict]) -> Card:
"""Overview of all steps with their state and the navigation between the steps."""
index = WIZARD_STEPS.index(current)
badges = []
for number, step in enumerate(WIZARD_STEPS):
if step.check is None:
icon = "circle"
elif step_todo(step, config_details):
icon = "triangle-alert"
else:
icon = "circle-check"
badges.append(
Div(
Badge(
icon, f"{number + 1}. {step.title}", "accent" if step is current else "muted"
),
hx_get=request_url_for(WIZARD_PATH),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=json.dumps({"wizard_step": step.id}),
id=f"wizard-step-{step.id}",
cls="cursor-pointer",
)
)
return Card(
Div(*badges, cls="flex flex-wrap gap-2"),
DivHStacked(
_step_button("Back", WIZARD_STEPS[index - 1], id="wizard-back") if index > 0 else None,
_step_button("Next", WIZARD_STEPS[index + 1], id="wizard-next")
if index + 1 < len(WIZARD_STEPS)
else None,
cls="gap-2 mt-4",
),
header=H3(f"Step {index + 1} of {len(WIZARD_STEPS)}: {current.title}"),
)
def Wizard(
eos_host: str,
eos_port: Union[str, int],
data: Optional[dict] = None,
) -> Div:
"""Create the setup wizard page.
Args:
eos_host (str): The hostname of the EOS server.
eos_port (Union[str, int]): The port of the EOS server.
data (Optional[dict], optional): Request data: the step to show in ``wizard_step`` and
optionally an ``action`` (``update`` a configuration value, ``save_to_file``).
Returns:
Div: The wizard page showing one step.
"""
data = data or {}
step = wizard_step(data.get("wizard_step"))
save_status = None
action = data.get("action")
if action == "update":
config_update(eos_host, eos_port, data)
elif action == "save_to_file":
save_status = save_config(eos_host, eos_port)
# (Re-)read the configuration to show the actual state
config_details = create_config_details(ConfigEOS, get_config(eos_host, eos_port))
content: list[Any] = [
WizardSteps(step, config_details),
Card(Markdown(step.intro)),
]
if step.example:
content.append(Card(Markdown(step.example), header=H3("Example")))
if step.check:
content.append(Card(_todo_list(step_todo(step, config_details)), header=H3("Status")))
for name in step_config_names(step, config_details):
card = config_card(config_details[name], config_details)
if card is not None:
content.append(card)
if step is WIZARD_STEPS[-1]:
summary = [
Div(
P(f"{WIZARD_STEPS.index(other) + 1}. {other.title}", cls="font-bold"),
_todo_list(step_todo(other, config_details)),
cls="space-y-2",
)
for other in WIZARD_STEPS
if other.check
]
content.append(Card(*summary, header=H3("Status"), cls="space-y-4"))
content.append(
Card(
DivHStacked(
Div(
ConfigButton(
"Save configuration",
hx_post=request_url_for(WIZARD_PATH),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=json.dumps({"action": "save_to_file"}),
id="wizard-save",
),
cls="shrink-0",
),
save_status,
cls="gap-4",
),
)
)
# hx-vals is inherited: all requests from within the wizard carry the current step
return Div(
*content,
hx_vals=json.dumps({"wizard_step": step.id}),
id="wizard",
cls="space-y-4",
)
+34 -1
View File
@@ -33,6 +33,7 @@ from akkudoktoreos.server.dash.footer import Footer
from akkudoktoreos.server.dash.plan import Plan
from akkudoktoreos.server.dash.prediction import Prediction
from akkudoktoreos.server.dash.profile import Profile
from akkudoktoreos.server.dash.wizard import Wizard
from akkudoktoreos.server.server import (
drop_root_privileges,
get_default_host,
@@ -295,6 +296,7 @@ def get_eosdash(request: Request): # type: ignore
"Prediction": "/eosdash/prediction",
"Config": "/eosdash/configuration",
"Profiles": "/eosdash/profile",
"Setup": "/eosdash/wizard",
"Admin": "/eosdash/admin",
"About": "/eosdash/about",
},
@@ -393,8 +395,11 @@ def put_eosdash_configuration(request: Request, data: dict): # type: ignore
data (dict): Dictionary containing 'key' and 'value' to trigger configuration update.
Returns:
Configuration: The Configuration page component with updated configuration.
Configuration: The Configuration page component with updated configuration, or the
setup wizard if the update was sent from the wizard.
"""
if data.get("wizard_step"):
return Wizard(*eos_server(), data)
return Configuration(*eos_server(), data)
@@ -440,6 +445,34 @@ def post_eosdash_profile(request: Request, data: dict): # type: ignore
return Profile(*eos_server(), data)
@app.get("/eosdash/wizard")
def get_eosdash_wizard(request: Request, data: dict): # type: ignore
"""Serve the EOSdash setup wizard.
Args:
request (Request): The incoming FastHTML request.
data (dict): Optional query data, the step to show in 'wizard_step'.
Returns:
Wizard: The setup wizard page component.
"""
return Wizard(*eos_server(), data)
@app.post("/eosdash/wizard")
def post_eosdash_wizard(request: Request, data: dict): # type: ignore
"""Provide control data to the setup wizard.
Args:
request (Request): The incoming FastHTML request.
data (dict): User-submitted data from the setup wizard.
Returns:
Wizard: The setup wizard page component.
"""
return Wizard(*eos_server(), data)
@app.get("/eosdash/plan")
def get_eosdash_plan(request: Request, data: dict): # type: ignore
"""Serve the EOSdash Plan page.
+27
View File
@@ -174,3 +174,30 @@ class TestEOSdashInBrowser:
assert status == 200, f"{path}: HTTP {status}"
assert dash.page_content().strip(), f"{path}: no content rendered"
assert dash.errors == []
def test_setup_wizard_stays_on_step_after_config_update(self, dash: DashPage) -> None:
"""A configuration card in the setup wizard updates the value and returns to the wizard."""
assert dash.open_tab("/eosdash/wizard") == 200
dash.page.click("#wizard-next")
dash.page.wait_for_selector("#wizard-back")
dash.page.click("#wizard summary:has-text('general.latitude')")
value_input = "#general-latitude-update-form input[type='text']"
dash.page.fill(value_input, "48.137")
with dash.page.expect_response(
lambda response: (
urlsplit(response.url).path == "/eosdash/configuration"
and response.request.method == "PUT"
)
) as response_info:
dash.page.press(value_input, "Enter")
assert response_info.value.ok
dash.page.wait_for_function(
"selector => document.querySelector(selector)?.innerHTML.includes('48.137')",
arg=PAGE_CONTENT_SELECTOR,
)
# Still the location step of the wizard, not the Config page
assert dash.page.locator("#wizard").count() == 1
assert "Step 2 of" in dash.page_content()
assert dash.errors == []
+204
View File
@@ -0,0 +1,204 @@
"""Tests for the EOSdash setup wizard."""
import json
from typing import Any
import pytest
from fasthtml.common import to_xml
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.server.dash import wizard
from akkudoktoreos.server.dash.configuration import create_config_details
from akkudoktoreos.server.dash.wizard import (
WIZARD_STEPS,
Wizard,
check_horizon,
check_location,
config_value,
step_config_names,
step_todo,
wizard_step,
)
@pytest.fixture
def config(config_eos) -> dict[str, Any]:
"""The default EOS configuration as the EOS server returns it."""
return json.loads(config_eos.model_dump_json())
@pytest.fixture
def config_details(config_eos, config) -> dict[str, dict]:
"""Configuration details of the default EOS configuration."""
return create_config_details(type(config_eos), config)
def set_value(config_details: dict[str, dict], name: str, value: Any) -> None:
"""Set the current value in configuration details."""
config_details[name]["value"] = json.dumps(value)
class TestWizardSteps:
"""The step definitions."""
def test_step_ids_are_unique(self):
ids = [step.id for step in WIZARD_STEPS]
assert len(ids) == len(set(ids))
def test_steps_have_title_and_intro(self):
for step in WIZARD_STEPS:
assert step.title
assert step.intro.strip()
@pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm])
def test_config_names_exist(self, config_details, algorithm):
"""A renamed configuration value must not silently drop out of the wizard."""
set_value(config_details, "optimization.algorithm", algorithm)
for step in WIZARD_STEPS:
assert len(step_config_names(step, config_details)) == len(step.config_names), (
f"Step '{step.id}' names unknown configuration values for {algorithm}."
)
def test_config_names_are_writable(self, config_details):
for step in WIZARD_STEPS:
for name in step_config_names(step, config_details):
assert config_details[name]["read-only"] == "rw", name
assert not config_details[name]["deprecated"], name
def test_wizard_step(self):
assert wizard_step("location").id == "location"
assert wizard_step(None) is WIZARD_STEPS[0]
assert wizard_step("unknown") is WIZARD_STEPS[0]
def test_config_value(self, config_details):
assert config_value(config_details, "prediction.hours") == 48
assert config_value(config_details, "no.such.value", "default") == "default"
class TestWizardChecks:
"""What the steps report as still to do."""
def test_location_default(self, config_details):
todo = check_location(config_details)
assert len(todo) == 1
assert "default" in todo[0]
def test_location_set(self, config_details):
set_value(config_details, "general.latitude", 48.137)
set_value(config_details, "general.longitude", 11.575)
set_value(config_details, "general.timezone", "Europe/Berlin")
assert check_location(config_details) == []
def test_location_missing(self, config_details):
set_value(config_details, "general.latitude", None)
todo = check_location(config_details)
assert len(todo) == 1
assert "not set" in todo[0]
def test_location_without_timezone(self, config_details):
set_value(config_details, "general.latitude", 48.137)
set_value(config_details, "general.timezone", None)
todo = check_location(config_details)
assert len(todo) == 1
assert "time zone" in todo[0]
def test_horizon_done(self, config_details):
set_value(config_details, "ems.mode", "OPTIMIZATION")
assert check_horizon(config_details) == []
def test_horizon_disabled(self, config_details):
set_value(config_details, "ems.mode", "DISABLED")
todo = check_horizon(config_details)
assert len(todo) == 1
assert "DISABLED" in todo[0]
@pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm])
@pytest.mark.parametrize("hours", [None, 12])
def test_horizon_prediction_too_short(self, config_details, algorithm, hours):
set_value(config_details, "ems.mode", "OPTIMIZATION")
set_value(config_details, "optimization.algorithm", algorithm)
set_value(config_details, f"optimization.{algorithm.lower()}.horizon_hours", 24)
set_value(config_details, "prediction.hours", hours)
todo = check_horizon(config_details)
assert len(todo) == 1
assert f"optimization.{algorithm.lower()}.horizon_hours (24)" in todo[0]
def test_step_without_check_has_nothing_to_do(self, config_details):
assert step_todo(wizard_step("start"), config_details) == []
class TestWizardPage:
"""The rendered wizard page."""
@pytest.fixture
def eos(self, monkeypatch, config) -> dict[str, list]:
"""Replace the access to the EOS server; records the calls."""
calls: dict[str, list] = {"update": [], "save": []}
def save_config(eos_host, eos_port):
calls["save"].append((eos_host, eos_port))
return "saved-by-test"
monkeypatch.setattr(wizard, "get_config", lambda eos_host, eos_port: config)
monkeypatch.setattr(
wizard, "config_update", lambda eos_host, eos_port, data: calls["update"].append(data)
)
monkeypatch.setattr(wizard, "save_config", save_config)
return calls
def test_first_step_is_default(self, eos):
html = to_xml(Wizard("localhost", 8503))
assert f"Step 1 of {len(WIZARD_STEPS)}: Start" in html
assert 'id="wizard-next"' in html
assert 'id="wizard-back"' not in html
def test_step_shows_only_its_config(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "location"}))
assert "Location" in html
assert "general.latitude" in html
assert "general.longitude" in html
# Example, status and navigation
assert "Munich" in html
assert "still the default values" in html
assert 'id="wizard-back"' in html
assert 'id="wizard-next"' in html
# Configuration of other steps is not shown
assert "ems.interval" not in html
assert "server.port" not in html
def test_requests_carry_the_step(self, eos):
"""Updates sent by the configuration cards must come back to the wizard."""
page = Wizard("localhost", 8503, {"wizard_step": "horizon"})
assert page.attrs["id"] == "wizard"
assert json.loads(page.attrs["hx-vals"]) == {"wizard_step": "horizon"}
def test_horizon_step_follows_algorithm(self, eos, config):
config["optimization"]["algorithm"] = "GENETIC0"
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "horizon"}))
assert "optimization.genetic0.horizon_hours" in html
assert "optimization.genetic.horizon_hours" not in html
def test_update(self, eos):
data = {
"wizard_step": "location",
"action": "update",
"key": "general.latitude",
"value": "48.137",
}
html = to_xml(Wizard("localhost", 8503, data))
assert eos["update"] == [data]
assert eos["save"] == []
assert f"Step 2 of {len(WIZARD_STEPS)}: Location" in html
def test_finish_step(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish"}))
assert 'id="wizard-save"' in html
assert 'id="wizard-next"' not in html
# Summary of all steps with a check
assert "still the default values" in html
assert eos["save"] == []
def test_save(self, eos):
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish", "action": "save_to_file"}))
assert eos["save"] == [("localhost", 8503)]
assert "saved-by-test" in html