mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
committed by
Bobby Noelte
co-authored by
Claude Fable 5.2
parent
3bcc902104
commit
7f4014c408
@@ -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
|
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
|
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.
|
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
|
### Changed / compatibility
|
||||||
|
|
||||||
|
|||||||
@@ -457,36 +457,17 @@ def config_options(
|
|||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
|
|
||||||
|
|
||||||
def Configuration(
|
def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None:
|
||||||
eos_host: str,
|
"""Set a configuration value on the EOS server.
|
||||||
eos_port: Union[str, int],
|
|
||||||
data: Optional[dict] = None,
|
The result is remembered in ``config_update_latest``, so that the configuration card of the
|
||||||
) -> Div:
|
value shows the error, if any, and stays open when the page is rendered again.
|
||||||
"""Create a visual representation of the configuration.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
eos_host (str): The hostname of the EOS server.
|
eos_host (str): The hostname of the EOS server.
|
||||||
eos_port (Union[str, int]): The port 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.
|
data (dict): Request data with the config name in ``key`` and the new value in ``value``.
|
||||||
|
|
||||||
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":
|
|
||||||
# This data contains a new value for key
|
|
||||||
key = data["key"]
|
key = data["key"]
|
||||||
value_json_str: str = data.get("value", "")
|
value_json_str: str = data.get("value", "")
|
||||||
try:
|
try:
|
||||||
@@ -526,6 +507,101 @@ def Configuration(
|
|||||||
"open": True,
|
"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
|
# (Re-)read configuration details to be shure we display actual data
|
||||||
config = get_config(eos_host, eos_port)
|
config = get_config(eos_host, eos_port)
|
||||||
|
|
||||||
@@ -628,20 +704,6 @@ def Configuration(
|
|||||||
category = config["name"].split(".")[0]
|
category = config["name"].split(".")[0]
|
||||||
sections.setdefault(category, {})
|
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 (
|
if (
|
||||||
# Do not display read only values
|
# Do not display read only values
|
||||||
not config_visible["config-visible-read-only"]["visible"]
|
not config_visible["config-visible-read-only"]["visible"]
|
||||||
@@ -653,41 +715,8 @@ def Configuration(
|
|||||||
# Search value given but does not match
|
# Search value given but does not match
|
||||||
continue
|
continue
|
||||||
|
|
||||||
hint = UI_HINTS.get(config["name"])
|
card = config_card(config, config_details)
|
||||||
|
if card is None:
|
||||||
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:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sections[category][config["name"]] = card
|
sections[category][config["name"]] = card
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -33,6 +33,7 @@ from akkudoktoreos.server.dash.footer import Footer
|
|||||||
from akkudoktoreos.server.dash.plan import Plan
|
from akkudoktoreos.server.dash.plan import Plan
|
||||||
from akkudoktoreos.server.dash.prediction import Prediction
|
from akkudoktoreos.server.dash.prediction import Prediction
|
||||||
from akkudoktoreos.server.dash.profile import Profile
|
from akkudoktoreos.server.dash.profile import Profile
|
||||||
|
from akkudoktoreos.server.dash.wizard import Wizard
|
||||||
from akkudoktoreos.server.server import (
|
from akkudoktoreos.server.server import (
|
||||||
drop_root_privileges,
|
drop_root_privileges,
|
||||||
get_default_host,
|
get_default_host,
|
||||||
@@ -295,6 +296,7 @@ def get_eosdash(request: Request): # type: ignore
|
|||||||
"Prediction": "/eosdash/prediction",
|
"Prediction": "/eosdash/prediction",
|
||||||
"Config": "/eosdash/configuration",
|
"Config": "/eosdash/configuration",
|
||||||
"Profiles": "/eosdash/profile",
|
"Profiles": "/eosdash/profile",
|
||||||
|
"Setup": "/eosdash/wizard",
|
||||||
"Admin": "/eosdash/admin",
|
"Admin": "/eosdash/admin",
|
||||||
"About": "/eosdash/about",
|
"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.
|
data (dict): Dictionary containing 'key' and 'value' to trigger configuration update.
|
||||||
|
|
||||||
Returns:
|
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)
|
return Configuration(*eos_server(), data)
|
||||||
|
|
||||||
|
|
||||||
@@ -440,6 +445,34 @@ def post_eosdash_profile(request: Request, data: dict): # type: ignore
|
|||||||
return Profile(*eos_server(), data)
|
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")
|
@app.get("/eosdash/plan")
|
||||||
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
def get_eosdash_plan(request: Request, data: dict): # type: ignore
|
||||||
"""Serve the EOSdash Plan page.
|
"""Serve the EOSdash Plan page.
|
||||||
|
|||||||
@@ -174,3 +174,30 @@ class TestEOSdashInBrowser:
|
|||||||
assert status == 200, f"{path}: HTTP {status}"
|
assert status == 200, f"{path}: HTTP {status}"
|
||||||
assert dash.page_content().strip(), f"{path}: no content rendered"
|
assert dash.page_content().strip(), f"{path}: no content rendered"
|
||||||
assert dash.errors == []
|
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 == []
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user