From fca524a91357180aabe3ae9d6b4d506eb7164154 Mon Sep 17 00:00:00 2001 From: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:35:37 +0100 Subject: [PATCH] =?UTF-8?q?feat(eosdash):=20add=20the=20remaining=20steps?= =?UTF-8?q?=20to=20the=20setup=20assistant=20-=20#1515=E2=81=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup assistant only covered the location, the horizons and the energy management, and sent the user to the Config page for everything else. Add the steps PV system, weather, prices and fees, load, devices and measurements, each with an explanation, an example and a check of what is still to do. Add a Home Assistant step after the measurements step that explains what EOS reads from and writes to Home Assistant, with a template sensor and an automation as example. Its status reports a missing state of charge mapping, missing instruction entities and an adapter that is selected while EOS does not run as an add-on. The measurements step keeps the choice of the adapter. Add examples for an inverter, an electric vehicle and a home appliance. A step can now show configuration values depending on the value of another one (WizardChoice), so that only the settings of the selected provider are shown. The horizon of the selected optimization algorithm uses the same mechanism instead of a placeholder in the configuration name. Describe the profiles, point to the Profiles page and use the profile_adjustment settings in the examples. The packaged profiles are generic and give no size, so the devices step now reports a battery, electric vehicle or inverter whose effective nominal capacity or power is unknown. The changelog entry names the Home Assistant step. Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Co-Authored-By: Claude Fable 5.2 Assisted-by: Claude Code (claude-fable-5-2) --- CHANGELOG.md | 3 +- .../server/dash/configuration.py | 10 +- src/akkudoktoreos/server/dash/wizard.py | 681 +++++++++++++++++- tests/test_eosdashbrowser.py | 18 + tests/test_eosdashwizard.py | 329 ++++++++- 5 files changed, 1009 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8b9edc..64d8b789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,8 @@ compatibility changes and a short test procedure. These changes are not yet a ta cores, default on). For a fixed seed the plan is identical to a single-process run. - 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. + current step: location, horizons and energy management, PV system, weather, prices and fees, + load, devices, measurements, Home Assistant, save the configuration. ### Changed / compatibility diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index cf215259..fd80daaa 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -457,6 +457,12 @@ def config_options( # ----------------------------------- +def remember_eos_server(eos_host: str, eos_port: Union[str, int]) -> None: + """Remember the address of the EOS server for the lazily loaded options of config values.""" + global eos_server_address + eos_server_address = (eos_host, int(eos_port)) + + def config_update(eos_host: str, eos_port: Union[str, int], data: dict) -> None: """Set a configuration value on the EOS server. @@ -586,11 +592,11 @@ def Configuration( Returns: rows: Rows of configuration details. """ - global config_visible, eos_server_address + global config_visible dark = False # Remember for usage - eos_server_address = (eos_host, int(eos_port)) + remember_eos_server(eos_host, eos_port) if data and data.get("action", None): if data.get("dark", None) == "true": diff --git a/src/akkudoktoreos/server/dash/wizard.py b/src/akkudoktoreos/server/dash/wizard.py index c0603ada..6344cfe1 100644 --- a/src/akkudoktoreos/server/dash/wizard.py +++ b/src/akkudoktoreos/server/dash/wizard.py @@ -32,6 +32,7 @@ from monsterui.franken import ( ) from akkudoktoreos.config.config import ConfigEOS +from akkudoktoreos.config.configabc import is_home_assistant_addon from akkudoktoreos.server.dash.components import ( Badge, ConfigButton, @@ -43,6 +44,7 @@ from akkudoktoreos.server.dash.configuration import ( config_update, create_config_details, get_config, + remember_eos_server, ) from akkudoktoreos.server.dash.context import request_url_for from akkudoktoreos.server.dash.markdown import Markdown @@ -55,6 +57,23 @@ WIZARD_PATH = "/eosdash/wizard" # --------------------------------------------------------------------------- +@dataclass(frozen=True) +class WizardChoice: + """Configuration values a step shows depending on the value of another one. + + Typically the settings of the selected provider. + + Attributes: + name: Dotted name of the configuration value that decides, e.g. ``load.provider``. + Its value may also be a list of values. + config_names: Dotted names of the configuration values to show, by value of ``name``. + Values that need no further settings are left out. + """ + + name: str + config_names: dict[str, tuple[str, ...]] + + @dataclass(frozen=True) class WizardStep: """A step of the setup wizard. @@ -64,8 +83,9 @@ class WizardStep: 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. + config_names: Dotted names of the configuration values to show. + choices: Further configuration values to show depending on the value of another one. + They are shown right after the configuration value that decides. check: Function that takes the configuration details and returns what is still to do in this step, one sentence each. An empty list means the step is done. """ @@ -75,6 +95,7 @@ class WizardStep: intro: str example: str = "" config_names: tuple[str, ...] = () + choices: tuple[WizardChoice, ...] = () check: Optional[Callable[[dict[str, dict]], list[str]]] = None @@ -94,9 +115,17 @@ def config_value(config_details: dict[str, dict], name: str, default: Any = None def step_config_names(step: WizardStep, config_details: dict[str, dict]) -> list[str]: """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] + names: list[str] = [] + for name in step.config_names: + names.append(name) + for choice in step.choices: + if choice.name != name: + continue + value = config_value(config_details, name) + for chosen in value if isinstance(value, list) else [value]: + names.extend(choice.config_names.get(str(chosen), ())) + # A configuration value is shown once, even if several choices name it + return [name for name in dict.fromkeys(names) if name in config_details] # --------------------------------------------------------------------------- @@ -152,6 +181,199 @@ def check_horizon(config_details: dict[str, dict]) -> list[str]: return todo +# PV forecast providers that calculate the forecast from the planes of the PV system +PV_PLANES_PROVIDERS = ( + "PVForecastAkkudoktor", + "PVForecastAdaptiveAkkudoktor", + "PVForecastForecastSolar", + "PVForecastPVLib", +) + + +def _is_empty(value: Any) -> bool: + """Whether a configuration value is not set.""" + return value is None or value == "" or value == [] or value == {} + + +def _missing(config_details: dict[str, dict], *names: str) -> list[str]: + """Report the configuration values of the given names that are not set.""" + return [ + f"{name} is not set." for name in names if _is_empty(config_value(config_details, name)) + ] + + +def check_pv(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the PV system.""" + provider = config_value(config_details, "pvforecast.provider") + if not provider: + return [ + "No PV forecast provider is selected, so EOS plans without PV yield. " + "Leave it like this only if you have no PV system." + ] + if provider in PV_PLANES_PROVIDERS: + planes = config_value(config_details, "pvforecast.planes") or [] + if not any(isinstance(plane, dict) and plane.get("peakpower") for plane in planes): + return [f"{provider} needs at least one plane with a peakpower in pvforecast.planes."] + elif provider == "PVForecastSolcast": + return _missing(config_details, "pvforecast.solcast.api_key", "pvforecast.solcast.site_id") + elif provider == "PVForecastPVNode": + return _missing(config_details, "pvforecast.pvnode.api_key") + return [] + + +def check_weather(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the weather forecast.""" + if not config_value(config_details, "weather.provider"): + return ["No weather provider is selected."] + return [] + + +def check_prices(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for prices, fees and the feed-in tariff.""" + todo = [] + provider = config_value(config_details, "elecprice.provider") + if not provider: + todo.append( + "No electricity price provider is selected. EOS needs the price of electricity " + "from the grid to optimize." + ) + elif provider == "ElecPriceFixed": + todo += _missing( + config_details, "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows" + ) + elif provider == "ElecPriceTibber": + todo += _missing(config_details, "elecprice.tibber.access_token") + + if config_value(config_details, "elecfee.provider") == "ElecFeeFixed" and all( + _is_empty(config_value(config_details, f"elecfee.elecfeefixed.{name}.windows")) + for name in ( + "consumption_amt_kwh", + "consumption_percent_amt", + "feedin_amt_kwh", + "feedin_percent_amt", + ) + ): + todo.append("ElecFeeFixed is selected, but no fee is set.") + + provider = config_value(config_details, "feedintariff.provider") + if not provider: + todo.append( + "No feed-in tariff provider is selected. Select one if you are paid for the " + "energy you feed into the grid." + ) + elif provider == "FeedInTariffFixed": + todo += _missing( + config_details, "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows" + ) + return todo + + +def check_load(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the load forecast.""" + provider = config_value(config_details, "load.provider") + if not provider: + return [ + "No load provider is selected. EOS needs a forecast of your consumption to optimize." + ] + if provider in ("LoadAkkudoktor", "LoadAkkudoktorAdjusted"): + todo = _missing(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh") + if provider == "LoadAkkudoktorAdjusted": + todo += _missing(config_details, "measurement.load_emr_keys") + return todo + return [] + + +def _device_ids(config_details: dict[str, dict], kind: str) -> list[str]: + """Return the ids of the configured devices of a kind, e.g. ``batteries``.""" + devices = config_value(config_details, f"devices.{kind}") + return list(devices) if isinstance(devices, dict) else [] + + +def check_devices(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the devices.""" + if not any( + _device_ids(config_details, kind) + for kind in ("batteries", "electric_vehicles", "home_appliances") + ): + return [ + "No battery, electric vehicle or home appliance is configured, so there is " + "nothing EOS can schedule." + ] + # The packaged profiles are generic: the unit has to give its size + todo = [] + for kind, setting in ( + ("batteries", "nominal_capacity_wh"), + ("electric_vehicles", "nominal_capacity_wh"), + ("inverters", "nominal_power_w"), + ): + devices = config_value(config_details, f"devices.{kind}") or {} + for device_id, device in devices.items(): + if isinstance(device, dict) and device.get(f"effective_{setting}") is None: + todo.append( + f"{device_id} has no {setting}: its profile does not give one. Set " + f"profile_adjustment.{setting}, or select a profile that has it." + ) + return todo + + +def check_measurements(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for measurements and the home automation.""" + providers = config_value(config_details, "adapter.provider") or [] + device_ids = [ + device_id + for kind in ("batteries", "electric_vehicles") + for device_id in _device_ids(config_details, kind) + ] + if not providers: + devices = f" of {', '.join(device_ids)}" if device_ids else "" + return [ + "No adapter is selected, so no source for the state of charge" + f"{devices} is known. Select an adapter, or make sure your own system sends the " + "state of charge with PUT /v1/measurement/value." + ] + # The mapping of the Home Assistant adapter is checked in the Home Assistant step + return [] + + +def check_homeassistant(config_details: dict[str, dict]) -> list[str]: + """Report what is still to do for the Home Assistant adapter. + + Nothing is to do if the Home Assistant adapter is not selected. + """ + providers = config_value(config_details, "adapter.provider") or [] + if "HomeAssistant" not in providers: + return [] + + todo = [] + if not is_home_assistant_addon(): + # EOSdash is started by the EOS server and inherits its environment + todo.append( + "EOS does not run as a Home Assistant add-on, so the Home Assistant adapter " + "cannot reach Home Assistant. Run EOS as an add-on, or remove HomeAssistant " + "from adapter.provider and send the measurements with PUT /v1/measurement/value." + ) + + entity_ids = config_value(config_details, "adapter.homeassistant.device_measurement_entity_ids") + entity_ids = entity_ids if isinstance(entity_ids, dict) else {} + for kind in ("batteries", "electric_vehicles"): + for device_id in _device_ids(config_details, kind): + key = f"{device_id}-soc-factor" + if not entity_ids.get(key): + todo.append( + f"No Home Assistant entity is set for the state of charge of {device_id} " + f"({key}) in adapter.homeassistant.device_measurement_entity_ids." + ) + + if _is_empty( + config_value(config_details, "adapter.homeassistant.device_instruction_entity_ids") + ): + todo.append( + "adapter.homeassistant.device_instruction_entity_ids is not set, so EOS writes " + "no instructions to Home Assistant and your automations get nothing to act on." + ) + return todo + + # --------------------------------------------------------------------------- # Steps # --------------------------------------------------------------------------- @@ -230,12 +452,447 @@ Use `PREDICTION` while you are still setting up the forecasts and want to look a config_names=( "prediction.hours", "optimization.algorithm", - "optimization.{algorithm}.horizon_hours", "ems.mode", "ems.interval", ), + choices=( + WizardChoice( + "optimization.algorithm", + { + "GENETIC": ("optimization.genetic.horizon_hours",), + "GENETIC0": ("optimization.genetic0.horizon_hours",), + }, + ), + ), check=check_horizon, ), + WizardStep( + id="pv", + title="PV system", + intro=""" +EOS predicts the yield of your PV system. Select who makes the forecast with +`pvforecast.provider`; the settings the selected provider needs appear below it. + +- `PVForecastAkkudoktor`, `PVForecastAdaptiveAkkudoktor`, `PVForecastForecastSolar` and + `PVForecastPVLib` calculate the forecast from your location and the **planes** of your PV + system. A plane is a group of modules with the same orientation, e.g. one side of the roof. +- `PVForecastSolcast`, `PVForecastPVNode`, `PVForecastVrm` and `PVForecastHomeAssistant` take + the forecast from a service or system that already knows your PV system. They need the + access data of that service. +- `PVForecastImport` takes a forecast you provide as a file or as JSON. + +The most important settings of a plane: + +- `peakpower` - nominal power of the modules of the plane in kW. +- `surface_azimuth` - orientation, clockwise from north: north = 0, east = 90, south = 180, + west = 270. +- `surface_tilt` - tilt from the horizontal in degrees: 0 is flat, 90 is vertical. +- `inverter_paco` - AC power rating of the inverter in W. + +Skip this step if you have no PV system. +""", + example=""" +A south-facing roof with 8 kWp, 30° tilt and an 8 kW inverter: select +`PVForecastAkkudoktor`, open `pvforecast.planes`, add a plane and set `peakpower` = `8`, +`surface_azimuth` = `180`, `surface_tilt` = `30` and `inverter_paco` = `8000`. + +An east/west roof is two planes, one with `surface_azimuth` = `90` and one with `270`, each +with the peak power of its side. +""", + config_names=("pvforecast.provider",), + choices=( + WizardChoice( + "pvforecast.provider", + { + "PVForecastAkkudoktor": ("pvforecast.planes",), + "PVForecastAdaptiveAkkudoktor": ("pvforecast.planes",), + "PVForecastForecastSolar": ( + "pvforecast.planes", + "pvforecast.forecastsolar.api_key", + ), + "PVForecastPVLib": ("pvforecast.planes",), + "PVForecastPVNode": ( + "pvforecast.pvnode.api_key", + "pvforecast.pvnode.site_id", + "pvforecast.planes", + ), + "PVForecastSolcast": ( + "pvforecast.solcast.api_key", + "pvforecast.solcast.site_id", + ), + "PVForecastVrm": ("pvforecast.vrm.site_id", "pvforecast.vrm.token"), + "PVForecastHomeAssistant": ( + "pvforecast.homeassistant.entity_id", + "pvforecast.homeassistant.base_url", + "pvforecast.homeassistant.token", + ), + "PVForecastImport": ( + "pvforecast.pvforecastimport.import_file_path", + "pvforecast.pvforecastimport.import_json", + ), + }, + ), + ), + check=check_pv, + ), + WizardStep( + id="weather", + title="Weather", + intro=""" +The weather forecast gives EOS the outside temperature, the irradiance and the cloud cover +at your location. You see it on the **Prediction** page, and `PVForecastPVLib` calculates +the PV forecast from it. + +Select the service with `weather.provider`: `BrightSky`, `ClearOutside` or `OpenMeteo` +retrieve the forecast for your location, `WeatherImport` takes a forecast you provide. +""", + example=""" +To use Open-Meteo set `weather.provider` to `OpenMeteo`. The provider needs no further +settings; it takes the location from the **Location** step. +""", + config_names=("weather.provider",), + choices=( + WizardChoice( + "weather.provider", + { + "WeatherImport": ( + "weather.weatherimport.import_file_path", + "weather.weatherimport.import_json", + ), + }, + ), + ), + check=check_weather, + ), + WizardStep( + id="prices", + title="Prices and fees", + intro=""" +EOS optimizes costs, so it needs to know what electricity from the grid costs and what you +get for the energy you feed in. Three predictions work together: + +- `elecprice.provider` - the electricity price. The market price providers + (`ElecPriceAkkudoktor`, `ElecPriceEnergyCharts`, `ElecPriceSMARD`, `ElecPriceTibber`) + deliver the raw market price **without** fees. `ElecPriceFixed` uses prices you set for + time windows of the day. +- `elecfee.provider` - what is added to the raw price. With `ElecFeeFixed` you set a fee per + kWh (grid fee, levies, the margin of your supplier) and a surcharge in percent (VAT). The + price EOS uses is `(raw price + fee per kWh) * (100 + percent) / 100`. +- `feedintariff.provider` - what you get per kWh fed into the grid. `FeedInTariffFixed` uses + the tariff you set; the other providers use market prices, for direct marketing. + +Prices and fees are amounts of your currency per kWh, e.g. `0.30` for 30 ct/kWh. +""", + example=""" +A dynamic tariff in Germany: `elecprice.provider` = `ElecPriceEnergyCharts` with bidding zone +`DE-LU`, `elecfee.provider` = `ElecFeeFixed` with `consumption_amt_kwh` = `0.15` (all fees +per kWh without VAT) and `consumption_percent_amt` = `19` (VAT), each in one time window +that covers the whole day. + +A fixed tariff of 32 ct/kWh including everything: `elecprice.provider` = `ElecPriceFixed` +with one time window of `0.32`, and no `elecfee.provider`. + +A fixed feed-in tariff of 7.86 ct/kWh: `feedintariff.provider` = `FeedInTariffFixed` with one +time window of `0.0786`. +""", + config_names=("elecprice.provider", "elecfee.provider", "feedintariff.provider"), + choices=( + WizardChoice( + "elecprice.provider", + { + "ElecPriceEnergyCharts": ("elecprice.energycharts.bidding_zone",), + "ElecPriceFixed": ( + "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows", + ), + "ElecPriceImport": ( + "elecprice.elecpriceimport.import_file_path", + "elecprice.elecpriceimport.import_json", + ), + "ElecPriceSMARD": ("elecprice.smard.region", "elecprice.smard.filter_id"), + "ElecPriceTibber": ( + "elecprice.tibber.access_token", + "elecprice.tibber.home_id", + ), + }, + ), + WizardChoice( + "elecfee.provider", + { + "ElecFeeFixed": ( + "elecfee.elecfeefixed.consumption_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_percent_amt.windows", + "elecfee.elecfeefixed.feedin_amt_kwh.windows", + "elecfee.elecfeefixed.feedin_percent_amt.windows", + ), + "ElecFeeImport": ( + "elecfee.elecfeeimport.import_file_path", + "elecfee.elecfeeimport.import_json", + ), + }, + ), + WizardChoice( + "feedintariff.provider", + { + "FeedInTariffDvhubOnline": ("feedintariff.dvhubonline.zone",), + "FeedInTariffEnergyCharts": ("feedintariff.energycharts.bidding_zone",), + "FeedInTariffFixed": ( + "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows", + ), + "FeedInTariffImport": ( + "feedintariff.feedintariffimport.import_file_path", + "feedintariff.feedintariffimport.import_json", + ), + }, + ), + ), + check=check_prices, + ), + WizardStep( + id="load", + title="Load", + intro=""" +EOS needs a forecast of the electricity your household consumes. Select it with +`load.provider`: + +- `LoadAkkudoktor` - a generic load profile, scaled to your yearly consumption. +- `LoadAkkudoktorAdjusted` - the same profile, refined with your measured consumption. It + needs load measurements, see the **Measurements** step. +- `LoadVrm` - the forecast of the Victron Remote Management (VRM) portal. +- `LoadImport` - a forecast you provide as a file or as JSON. +""", + example=""" +A household that consumes 4500 kWh per year: `load.provider` = `LoadAkkudoktor` and +`load.loadakkudoktor.loadakkudoktor_year_energy_kwh` = `4500`. You find the yearly +consumption on your electricity bill. +""", + config_names=("load.provider",), + choices=( + WizardChoice( + "load.provider", + { + "LoadAkkudoktor": ("load.loadakkudoktor.loadakkudoktor_year_energy_kwh",), + "LoadAkkudoktorAdjusted": ( + "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", + ), + "LoadVrm": ("load.vrm.site_id", "load.vrm.token"), + "LoadImport": ( + "load.loadimport.import_file_path", + "load.loadimport.import_json", + ), + }, + ), + ), + check=check_load, + ), + WizardStep( + id="devices", + title="Devices", + intro=""" +Tell EOS which devices it plans for. Each kind of device has its own card; open it to add a +device or to change one. + +- `devices.batteries` - stationary batteries. +- `devices.inverters` - inverters that connect PV and battery to the house. +- `devices.electric_vehicles` - the batteries of electric vehicles. +- `devices.home_appliances` - appliances EOS may shift in time, e.g. a dishwasher. + +Every device has a `device_id`. Choose a short name and keep it: EOS names the measurements +of the device after it, e.g. `battery1-soc-factor` for the state of charge of `battery1`. + +Batteries, electric vehicles and inverters take their technical data from a **profile**: +`battery_profile` (default `generic-lfp`) and `inverter_profile` (default `generic-hybrid`). +A profile holds the efficiency curves and the power limits of a model; the **Profiles** page +shows and edits them. Whatever differs for your unit goes into `profile_adjustment`. The +packaged profiles are generic and know no size, so set it there: + +- battery and electric vehicle: `profile_adjustment.nominal_capacity_wh` - capacity in Wh, + and optionally `profile_adjustment.max_charge_power_w` / + `profile_adjustment.max_discharge_power_w` - the limits of your installation in W. +- inverter: `profile_adjustment.nominal_power_w` - nominal AC power in W. + +Also important for a battery: `min_soc_percentage` and `max_soc_percentage` - the range of +the state of charge EOS may use. +""", + example=""" +**Battery** - a 10 kWh battery that charges with up to 5 kW and shall never go below 10 %: +add a battery with `device_id` = `battery1`, in `profile_adjustment` +`nominal_capacity_wh` = `10000` and `max_charge_power_w` = `5000`, and +`min_soc_percentage` = `10`, `max_soc_percentage` = `100`. + +**Inverter** - a 10 kW inverter the battery is connected to: add an inverter with +`device_id` = `inverter1`, `profile_adjustment.nominal_power_w` = `10000` and +`battery_id` = `battery1`. + +**Electric vehicle** - a car with a 60 kWh battery at an 11 kW wallbox that shall be charged +to 80 % by 7 o'clock in the morning: add an electric vehicle with `device_id` = `ev1`, in +`profile_adjustment` `nominal_capacity_wh` = `60000` and `max_charge_power_w` = `11000`, and +`min_soc_percentage` = `80` (for +an electric vehicle this is the state of charge to reach) and +`min_soc_deadline_datetime` = `2026-07-16T07:00:00` (the departure). + +**Home appliance** - a dishwasher that needs 1.5 kWh in a run of 3 hours and may run once +between 10:00 and 16:00: add a home appliance with `device_id` = `dishwasher`, +`consumption_wh` = `1500`, `duration_h` = `3` and +`time_windows` = `{"windows": [{"start_time": "10:00", "duration": "6 hours"}]}`. +""", + config_names=( + "devices.batteries", + "devices.inverters", + "devices.electric_vehicles", + "devices.home_appliances", + ), + check=check_devices, + ), + WizardStep( + id="measurements", + title="Measurements", + intro=""" +To plan a battery or an electric vehicle EOS must know its **current state of charge**. The +measurement is named `-soc-factor` and is a factor from `0.0` (empty) to `1.0` +(full) - not a percentage. It must be fresh: the GENETIC optimization does not use a state +of charge older than `optimization.genetic.measurement_max_age_seconds` and then assumes an +**empty** battery (0 %). + +There are two ways to get measurements into EOS: + +- An adapter, selected with `adapter.provider`. With `HomeAssistant` EOS reads the + measurements from Home Assistant entities before each energy management run and writes + its instructions back to Home Assistant - this needs EOS to run as a Home Assistant + add-on, the next step explains it. With `NodeRED` EOS sends its instructions to a + Node-RED flow; the measurements still have to come from your flow. +- Your own system sends each value to the EOS server with `PUT /v1/measurement/value`. + +Energy meter readings are optional. They are meter readings in kWh that only count up, not +the current power. With `LoadAkkudoktorAdjusted` EOS uses the load meter readings to adapt +the load forecast to your household; list their measurement keys in +`measurement.load_emr_keys`. The Home Assistant adapter does this on its own for the +entities it reads. +""", + example=""" +Your own system, e.g. a Node-RED flow or a script, at least every five minutes: +`PUT /v1/measurement/value?datetime=2026-01-31T12:00:00&key=battery1-soc-factor&value=0.55` + +Home Assistant: select `HomeAssistant` in `adapter.provider` and continue with the next +step. +""", + config_names=( + "adapter.provider", + "measurement.load_emr_keys", + "optimization.genetic.measurement_max_age_seconds", + ), + choices=( + WizardChoice( + "adapter.provider", + { + "NodeRED": ("adapter.nodered.host", "adapter.nodered.port"), + }, + ), + ), + check=check_measurements, + ), + WizardStep( + id="homeassistant", + title="Home Assistant", + intro=""" +Skip this step if you do not use Home Assistant. + +**EOS must run as a Home Assistant add-on.** The Home Assistant adapter talks to Home +Assistant through the API of the add-on system, with the access Home Assistant grants the +add-on. There is no setting for an address or a token. If EOS runs somewhere else, remove +`HomeAssistant` from `adapter.provider` and let Home Assistant send the measurements with +`PUT /v1/measurement/value`, e.g. with a `rest_command`. + +Select `HomeAssistant` in `adapter.provider` (step **Measurements**). Before each energy +management run EOS then **reads** from Home Assistant: + +- `device_measurement_entity_ids` - the current state of your devices, above all the state + of charge: map `-soc-factor` to an entity whose state is a factor from `0.0` to + `1.0`. Without it EOS plans with an empty battery. +- `load_emr_entity_ids`, `pv_production_emr_entity_ids`, `grid_import_emr_entity_ids`, + `grid_export_emr_entity_ids` - optional energy meter readings in **kWh** that only count + up (`state_class: total_increasing`), not power sensors in W. EOS uses the entity IDs as + measurement keys and adds them to `measurement.*_emr_keys` on its own. +- `config_entity_ids` - optional: configuration values taken from entities, e.g. a battery + capacity an integration reports. The key is the path of the setting with `/`, and devices + are addressed by their `device_id`: + `devices/batteries/battery1/profile_adjustment/nominal_capacity_wh`. + +After each run EOS **writes** to Home Assistant: + +- `device_instruction_entity_ids` - one entity per device, named `sensor.eos_`, + e.g. `sensor.eos_battery1`. The state is the operation mode EOS wants right now in lower + case, e.g. `self_consumption`, `forced_charge` or `idle`; the attribute + `operation_mode_factor` says how much, from `0.0` to `1.0`. +- `solution_entity_ids` - optional values of the current plan for dashboards and debugging, + named `sensor.eos_`, e.g. `sensor.eos_battery1_soc_factor`. + +EOS only writes the entities you list. The **Config** page shows read-only lists to pick +from: `adapter.homeassistant.homeassistant_entity_ids` has the entities of Home Assistant, +`adapter.homeassistant.eos_device_instruction_entity_ids` and +`adapter.homeassistant.eos_solution_entity_ids` have the names EOS can write. The +instruction names appear after the first optimization. + +**EOS does not switch anything itself.** Write an automation in Home Assistant that reacts to +`sensor.eos_` and controls your inverter, wallbox or appliance. The entities EOS +writes have no unique ID: to use them in dashboards or the history, wrap them in template +sensors. +""", + example=""" +A battery `battery1` whose integration reports the state of charge in percent as +`sensor.battery_soc`. Create a template sensor in Home Assistant that converts it: + +```yaml +template: + - sensor: + - name: "Battery1 SoC Factor" + unique_id: "battery1_soc_factor" + state: "{{ states('sensor.battery_soc') | float(0) / 100 }}" +``` + +In EOS set: + +- `device_measurement_entity_ids` = `{"battery1-soc-factor": "sensor.battery1_soc_factor"}` +- `load_emr_entity_ids` = `["sensor.house_consumption_total_kwh"]` +- `device_instruction_entity_ids` = `["sensor.eos_battery1"]` + +Then let Home Assistant follow the instruction, e.g. charge the battery from the grid while +EOS asks for it (replace the actions with the ones of your inverter integration): + +```yaml +automation: + - alias: "EOS battery1 instruction" + triggers: + - trigger: state + entity_id: sensor.eos_battery1 + actions: + - choose: + - conditions: "{{ is_state('sensor.eos_battery1', 'forced_charge') }}" + sequence: + - action: select.select_option + target: + entity_id: select.inverter_battery_mode + data: + option: "Charge from grid" + default: + - action: select.select_option + target: + entity_id: select.inverter_battery_mode + data: + option: "Self consumption" +``` +""", + config_names=( + "adapter.homeassistant.device_measurement_entity_ids", + "adapter.homeassistant.load_emr_entity_ids", + "adapter.homeassistant.pv_production_emr_entity_ids", + "adapter.homeassistant.grid_import_emr_entity_ids", + "adapter.homeassistant.grid_export_emr_entity_ids", + "adapter.homeassistant.config_entity_ids", + "adapter.homeassistant.device_instruction_entity_ids", + "adapter.homeassistant.solution_entity_ids", + ), + check=check_homeassistant, + ), WizardStep( id="finish", title="Finish", @@ -247,14 +904,7 @@ EOS saves changes of the configuration to the configuration file on its own if are lost when EOS restarts unless you save them. **Save configuration** writes the configuration file right now in both modes. -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`). +All other settings of EOS are on the **Config** page. Then look at the **Prediction** page to check the forecasts and at the **Plan** page for the result of the optimization. @@ -387,6 +1037,9 @@ def Wizard( data = data or {} step = wizard_step(data.get("wizard_step")) + # The lazily loaded options of some configuration cards are fetched from the EOS server + remember_eos_server(eos_host, eos_port) + save_status = None action = data.get("action") if action == "update": diff --git a/tests/test_eosdashbrowser.py b/tests/test_eosdashbrowser.py index fb4a45fb..8f578365 100644 --- a/tests/test_eosdashbrowser.py +++ b/tests/test_eosdashbrowser.py @@ -201,3 +201,21 @@ class TestEOSdashInBrowser: assert dash.page.locator("#wizard").count() == 1 assert "Step 2 of" in dash.page_content() assert dash.errors == [] + + def test_setup_wizard_shows_provider_settings(self, dash: DashPage) -> None: + """Selecting a provider in the setup wizard shows its settings, which work in the wizard.""" + assert dash.open_tab("/eosdash/wizard") == 200 + dash.page.click("#wizard-step-pv") + dash.page.wait_for_selector("#wizard[hx-vals*='\"pv\"']") + + dash.page.click("#wizard summary:has-text('pvforecast.provider')") + dash.page.select_option("#pvforecast-provider-value-select", "PVForecastAkkudoktor") + dash.page.click("#pvforecast-provider-update-value-form button") + planes = "#wizard summary:has-text('pvforecast.planes')" + dash.page.wait_for_selector(planes) + + # The card of a list of items sends its updates to the wizard, too + dash.page.click(f"{planes} button:has-text('Add item')") + dash.page.wait_for_selector(f"{planes} >> text=1 item") + assert dash.page.locator("#wizard[hx-vals*='\"pv\"']").count() == 1 + assert dash.errors == [] diff --git a/tests/test_eosdashwizard.py b/tests/test_eosdashwizard.py index 5b55b31a..eb908421 100644 --- a/tests/test_eosdashwizard.py +++ b/tests/test_eosdashwizard.py @@ -10,10 +10,18 @@ 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 ( + PV_PLANES_PROVIDERS, WIZARD_STEPS, Wizard, + check_devices, + check_homeassistant, check_horizon, + check_load, check_location, + check_measurements, + check_prices, + check_pv, + check_weather, config_value, step_config_names, step_todo, @@ -50,21 +58,76 @@ class TestWizardSteps: 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): + def test_config_names_exist(self, config_details): """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): + names = list(step.config_names) + for choice in step.choices: + assert choice.name in step.config_names, f"{step.id}: {choice.name}" + for chosen in choice.config_names.values(): + names.extend(chosen) + for name in names: + assert name in config_details, f"Step {step.id}: unknown {name}." assert config_details[name]["read-only"] == "rw", name assert not config_details[name]["deprecated"], name + def test_choice_values_exist(self, config_details): + """A renamed provider must not silently lose its settings in the wizard.""" + for step in WIZARD_STEPS: + for choice in step.choices: + if choice.name == "optimization.algorithm": + available = [algorithm.value for algorithm in OptimizationAlgorithm] + else: + assert choice.name.endswith(".provider") + available = config_value(config_details, f"{choice.name}s") + for value in choice.config_names: + assert value in available, f"Step {step.id}: unknown {value}." + + def test_pv_planes_providers_exist(self, config_details): + available = config_value(config_details, "pvforecast.providers") + for provider in PV_PLANES_PROVIDERS: + assert provider in available + + def test_choice_follows_value(self, config_details): + step = wizard_step("load") + set_value(config_details, "load.provider", "LoadVrm") + assert step_config_names(step, config_details) == [ + "load.provider", + "load.vrm.site_id", + "load.vrm.token", + ] + set_value(config_details, "load.provider", None) + assert step_config_names(step, config_details) == ["load.provider"] + + def test_choice_with_list_value(self, config_details): + step = wizard_step("measurements") + set_value(config_details, "adapter.provider", ["HomeAssistant", "NodeRED"]) + names = step_config_names(step, config_details) + assert "adapter.nodered.host" in names + assert "adapter.nodered.port" in names + assert len(names) == len(set(names)) + + def test_homeassistant_step_shows_all_adapter_settings(self, config_details): + """Every writable setting of the Home Assistant adapter is in its step.""" + adapter_names = [ + name + for name, details in config_details.items() + if name.startswith("adapter.homeassistant.") + and name.count(".") == 2 + and details["read-only"] == "rw" + and not details["deprecated"] + ] + assert adapter_names + names = step_config_names(wizard_step("homeassistant"), config_details) + assert sorted(names) == sorted(adapter_names) + + @pytest.mark.parametrize("algorithm", [algorithm.value for algorithm in OptimizationAlgorithm]) + def test_horizon_follows_algorithm(self, config_details, algorithm): + set_value(config_details, "optimization.algorithm", algorithm) + names = step_config_names(wizard_step("horizon"), config_details) + horizons = [name for name in names if name.endswith(".horizon_hours")] + assert horizons == [f"optimization.{algorithm.lower()}.horizon_hours"] + def test_wizard_step(self): assert wizard_step("location").id == "location" assert wizard_step(None) is WIZARD_STEPS[0] @@ -123,6 +186,201 @@ class TestWizardChecks: assert len(todo) == 1 assert f"optimization.{algorithm.lower()}.horizon_hours (24)" in todo[0] + def test_pv(self, config_details): + set_value(config_details, "pvforecast.provider", None) + assert "no PV system" in check_pv(config_details)[0] + + set_value(config_details, "pvforecast.provider", "PVForecastAkkudoktor") + set_value(config_details, "pvforecast.planes", None) + assert "at least one plane" in check_pv(config_details)[0] + set_value(config_details, "pvforecast.planes", [{"peakpower": None}]) + assert "at least one plane" in check_pv(config_details)[0] + set_value(config_details, "pvforecast.planes", [{"peakpower": 8.0}]) + assert check_pv(config_details) == [] + + set_value(config_details, "pvforecast.provider", "PVForecastSolcast") + set_value(config_details, "pvforecast.solcast.api_key", "") + set_value(config_details, "pvforecast.solcast.site_id", "abcd") + assert check_pv(config_details) == ["pvforecast.solcast.api_key is not set."] + + set_value(config_details, "pvforecast.provider", "PVForecastVrm") + assert check_pv(config_details) == [] + + def test_weather(self, config_details): + set_value(config_details, "weather.provider", None) + assert len(check_weather(config_details)) == 1 + set_value(config_details, "weather.provider", "BrightSky") + assert check_weather(config_details) == [] + + def test_prices(self, config_details): + set_value(config_details, "elecprice.provider", None) + set_value(config_details, "elecfee.provider", None) + set_value(config_details, "feedintariff.provider", None) + todo = check_prices(config_details) + assert len(todo) == 2 + assert "electricity price provider" in todo[0] + assert "feed-in tariff provider" in todo[1] + + set_value(config_details, "elecprice.provider", "ElecPriceEnergyCharts") + set_value(config_details, "feedintariff.provider", "FeedInTariffEnergyCharts") + assert check_prices(config_details) == [] + + set_value(config_details, "elecprice.provider", "ElecPriceFixed") + set_value(config_details, "elecfee.provider", "ElecFeeFixed") + set_value(config_details, "feedintariff.provider", "FeedInTariffFixed") + for name in ( + "elecprice.elecpricefixed.elecprice_marketprice_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_amt_kwh.windows", + "elecfee.elecfeefixed.consumption_percent_amt.windows", + "elecfee.elecfeefixed.feedin_amt_kwh.windows", + "elecfee.elecfeefixed.feedin_percent_amt.windows", + "feedintariff.feedintarifffixed.feed_in_tariff_amt_kwh.windows", + ): + set_value(config_details, name, []) + assert len(check_prices(config_details)) == 3 + + set_value(config_details, "elecfee.elecfeefixed.consumption_percent_amt.windows", [{}]) + assert len(check_prices(config_details)) == 2 + + def test_load(self, config_details): + set_value(config_details, "load.provider", None) + assert len(check_load(config_details)) == 1 + + set_value(config_details, "load.provider", "LoadAkkudoktor") + set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", None) + assert len(check_load(config_details)) == 1 + set_value(config_details, "load.loadakkudoktor.loadakkudoktor_year_energy_kwh", 4500) + assert check_load(config_details) == [] + + set_value(config_details, "load.provider", "LoadAkkudoktorAdjusted") + set_value(config_details, "measurement.load_emr_keys", None) + assert check_load(config_details) == ["measurement.load_emr_keys is not set."] + + def test_devices(self, config_details): + for kind in ("batteries", "electric_vehicles", "home_appliances"): + set_value(config_details, f"devices.{kind}", None) + assert len(check_devices(config_details)) == 1 + set_value( + config_details, + "devices.batteries", + {"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": 10000}}, + ) + assert check_devices(config_details) == [] + + def test_devices_without_size(self, config_details): + """The packaged profiles are generic: each unit needs its size.""" + set_value( + config_details, + "devices.batteries", + {"battery1": {"device_id": "battery1", "effective_nominal_capacity_wh": None}}, + ) + set_value( + config_details, + "devices.electric_vehicles", + {"ev1": {"device_id": "ev1", "effective_nominal_capacity_wh": 60000}}, + ) + set_value( + config_details, + "devices.inverters", + {"inverter1": {"device_id": "inverter1", "effective_nominal_power_w": None}}, + ) + todo = check_devices(config_details) + assert len(todo) == 2 + assert "battery1 has no nominal_capacity_wh" in todo[0] + assert "profile_adjustment.nominal_capacity_wh" in todo[0] + assert "inverter1 has no nominal_power_w" in todo[1] + + def test_devices_size_of_a_configured_device(self, config_eos): + """The check reads the effective size as the EOS server reports it.""" + config_eos.merge_settings_from_dict( + { + "devices": { + "max_batteries": 1, + "max_inverters": 1, + "batteries": {"battery1": {"device_id": "battery1"}}, + "inverters": { + "inverter1": { + "device_id": "inverter1", + "profile_adjustment": {"nominal_power_w": 10000}, + } + }, + } + } + ) + details = create_config_details( + type(config_eos), json.loads(config_eos.model_dump_json()) + ) + todo = check_devices(details) + assert len(todo) == 1 + assert todo[0].startswith("battery1 has no nominal_capacity_wh") + + def test_measurements(self, config_details): + set_value(config_details, "devices.batteries", {"battery1": {}}) + set_value(config_details, "devices.electric_vehicles", {"ev1": {}}) + + # Without an adapter no source for the SoC is known + set_value(config_details, "adapter.provider", None) + todo = check_measurements(config_details) + assert len(todo) == 1 + assert "No adapter is selected" in todo[0] + assert "of battery1, ev1 is known" in todo[0] + + # The mapping of the Home Assistant adapter is checked in its own step + for providers in (["NodeRED"], ["HomeAssistant"]): + set_value(config_details, "adapter.provider", providers) + assert check_measurements(config_details) == [] + + def test_homeassistant_not_selected(self, config_details, monkeypatch): + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False) + set_value(config_details, "devices.batteries", {"battery1": {}}) + for providers in (None, ["NodeRED"]): + set_value(config_details, "adapter.provider", providers) + assert check_homeassistant(config_details) == [] + + def test_homeassistant(self, config_details, monkeypatch): + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: True) + set_value(config_details, "devices.batteries", {"battery1": {}}) + set_value(config_details, "devices.electric_vehicles", {"ev1": {}}) + set_value(config_details, "adapter.provider", ["HomeAssistant"]) + measurements = "adapter.homeassistant.device_measurement_entity_ids" + instructions = "adapter.homeassistant.device_instruction_entity_ids" + + set_value(config_details, measurements, None) + set_value(config_details, instructions, None) + todo = check_homeassistant(config_details) + assert len(todo) == 3 + assert "battery1-soc-factor" in todo[0] + assert "ev1-soc-factor" in todo[1] + assert instructions in todo[2] + + set_value(config_details, measurements, {"battery1-soc-factor": "sensor.battery_soc"}) + set_value(config_details, instructions, ["sensor.eos_battery1"]) + todo = check_homeassistant(config_details) + assert len(todo) == 1 + assert "ev1-soc-factor" in todo[0] + + set_value( + config_details, + measurements, + {"battery1-soc-factor": "sensor.battery_soc", "ev1-soc-factor": "sensor.ev_soc"}, + ) + assert check_homeassistant(config_details) == [] + + def test_homeassistant_outside_of_addon(self, config_details, monkeypatch): + """The adapter cannot reach Home Assistant outside of an add-on.""" + monkeypatch.setattr(wizard, "is_home_assistant_addon", lambda: False) + set_value(config_details, "adapter.provider", ["HomeAssistant"]) + set_value(config_details, "adapter.homeassistant.device_instruction_entity_ids", ["x"]) + todo = check_homeassistant(config_details) + assert len(todo) == 1 + assert "add-on" in todo[0] + + def test_measurements_fresh_system(self, config_details): + """A fresh system has no adapter and no devices: the step must not look done.""" + todo = check_measurements(config_details) + assert len(todo) == 1 + assert "state of charge is known" in todo[0] + def test_step_without_check_has_nothing_to_do(self, config_details): assert step_todo(wizard_step("start"), config_details) == [] @@ -152,6 +410,12 @@ class TestWizardPage: assert 'id="wizard-next"' in html assert 'id="wizard-back"' not in html + @pytest.mark.parametrize("step_id", [step.id for step in WIZARD_STEPS]) + def test_every_step_renders(self, eos, step_id): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": step_id})) + number = [step.id for step in WIZARD_STEPS].index(step_id) + 1 + assert f"Step {number} of {len(WIZARD_STEPS)}: {wizard_step(step_id).title}" in html + def test_step_shows_only_its_config(self, eos): html = to_xml(Wizard("localhost", 8503, {"wizard_step": "location"})) assert "Location" in html @@ -172,11 +436,11 @@ class TestWizardPage: 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_provider_settings_are_shown(self, eos, config): + config["pvforecast"]["provider"] = "PVForecastSolcast" + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "pv"})) + assert "pvforecast.solcast.api_key" in html + assert "pvforecast.vrm.token" not in html def test_update(self, eos): data = { @@ -202,3 +466,38 @@ class TestWizardPage: html = to_xml(Wizard("localhost", 8503, {"wizard_step": "finish", "action": "save_to_file"})) assert eos["save"] == [("localhost", 8503)] assert "saved-by-test" in html + + def test_devices_step_has_an_example_for_every_device_type(self, eos): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"})) + for device_type in ("Battery", "Inverter", "Electric vehicle", "Home appliance"): + assert f"{device_type}" in html + + def test_devices_step_names_existing_settings(self, eos): + """The device settings the step names exist (they moved into profiles before).""" + from akkudoktoreos.devices.settings.batterysettings import ( + BatteriesCommonSettings, + BatteryProfileAdjustment, + ) + from akkudoktoreos.devices.settings.invertersettings import ( + InverterCommonSettings, + InverterProfileAdjustment, + ) + + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "devices"})) + for model, names in ( + (BatteriesCommonSettings, ("battery_profile", "min_soc_percentage")), + (BatteryProfileAdjustment, ("nominal_capacity_wh", "max_charge_power_w")), + (InverterCommonSettings, ("inverter_profile", "battery_id")), + (InverterProfileAdjustment, ("nominal_power_w",)), + ): + for name in names: + assert name in model.model_fields + assert f"{name}" in html or f"profile_adjustment.{name}" in html + for stale in ("capacity_wh", "charging_efficiency", "max_power_w"): + assert stale not in html + + def test_homeassistant_step_example_renders_yaml(self, eos): + html = to_xml(Wizard("localhost", 8503, {"wizard_step": "homeassistant"})) + assert "