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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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.
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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