From 7f4014c40843a7101ffabcae9f78d2da7e62966b Mon Sep 17 00:00:00 2001 From: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:29:11 +0100 Subject: [PATCH] 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 Assisted-by: Claude Code (claude-fable-5-2) --- CHANGELOG.md | 3 + .../server/dash/configuration.py | 205 ++++---- src/akkudoktoreos/server/dash/wizard.py | 447 ++++++++++++++++++ src/akkudoktoreos/server/eosdash.py | 35 +- tests/test_eosdashbrowser.py | 27 ++ tests/test_eosdashwizard.py | 204 ++++++++ 6 files changed, 832 insertions(+), 89 deletions(-) create mode 100644 src/akkudoktoreos/server/dash/wizard.py create mode 100644 tests/test_eosdashwizard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf02df8..8f8b9edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index d4cb30b3..cf215259 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -457,6 +457,120 @@ def config_options( # ----------------------------------- +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 (dict): Request data with the config name in ``key`` and the new value in ``value``. + """ + key = data["key"] + value_json_str: str = data.get("value", "") + try: + value = json.loads(value_json_str) + except Exception: + if value_json_str in ("None", "none", "Null", "null"): + value = None + else: + value = value_json_str + + error = None + config = None + try: + server = f"http://{eos_host}:{eos_port}" + path = key.replace(".", "/") + response = requests.put(f"{server}/v1/config/{path}", json=value, timeout=10) + response.raise_for_status() + config = response.json() + except requests.exceptions.HTTPError as err: + try: + # Try to get 'detail' from the JSON response + detail = response.json().get( + "detail", f"No error details for value '{value}' '{response.text}'" + ) + except ValueError: + # Response is not JSON + detail = f"No error details for value '{value}' '{response.text}'" + error = f"Can not set {key} on {server}: {err}, {detail}" + # Mark all updates as closed + for k in config_update_latest: + config_update_latest[k]["open"] = False + # Remember this update as latest one + config_update_latest[key] = { + "error": error, + "result": config, + "value": value_json_str, + "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], @@ -486,45 +600,7 @@ def Configuration( 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: - value = json.loads(value_json_str) - except Exception: - if value_json_str in ("None", "none", "Null", "null"): - value = None - else: - value = value_json_str - - error = None - config = None - try: - server = f"http://{eos_host}:{eos_port}" - path = key.replace(".", "/") - response = requests.put(f"{server}/v1/config/{path}", json=value, timeout=10) - response.raise_for_status() - config = response.json() - except requests.exceptions.HTTPError as err: - try: - # Try to get 'detail' from the JSON response - detail = response.json().get( - "detail", f"No error details for value '{value}' '{response.text}'" - ) - except ValueError: - # Response is not JSON - detail = f"No error details for value '{value}' '{response.text}'" - error = f"Can not set {key} on {server}: {err}, {detail}" - # Mark all updates as closed - for k in config_update_latest: - config_update_latest[k]["open"] = False - # Remember this update as latest one - config_update_latest[key] = { - "error": error, - "result": config, - "value": value_json_str, - "open": True, - } + 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 diff --git a/src/akkudoktoreos/server/dash/wizard.py b/src/akkudoktoreos/server/dash/wizard.py new file mode 100644 index 00000000..3ec486d8 --- /dev/null +++ b/src/akkudoktoreos/server/dash/wizard.py @@ -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..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", + ) diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 5dc363ed..a4857e89 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -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. diff --git a/tests/test_eosdashbrowser.py b/tests/test_eosdashbrowser.py index 737c3cf4..fb4a45fb 100644 --- a/tests/test_eosdashbrowser.py +++ b/tests/test_eosdashbrowser.py @@ -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 == [] diff --git a/tests/test_eosdashwizard.py b/tests/test_eosdashwizard.py new file mode 100644 index 00000000..5b55b31a --- /dev/null +++ b/tests/test_eosdashwizard.py @@ -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