mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
feat(eosdash): add the remaining steps to the setup assistant - #1515
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 <noreply@anthropic.com> Assisted-by: Claude Code (claude-fable-5-2)
This commit is contained in:
co-authored by
Claude Fable 5.2
parent
8e94ab02d1
commit
fca524a913
+314
-15
@@ -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"<strong>{device_type}</strong>" 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"<code>{name}</code>" in html or f"profile_adjustment.{name}" in html
|
||||
for stale in ("<code>capacity_wh</code>", "<code>charging_efficiency</code>", "<code>max_power_w</code>"):
|
||||
assert stale not in html
|
||||
|
||||
def test_homeassistant_step_example_renders_yaml(self, eos):
|
||||
html = to_xml(Wizard("localhost", 8503, {"wizard_step": "homeassistant"}))
|
||||
assert "<pre" in html
|
||||
assert "sensor.eos_battery1" in html
|
||||
assert "add-on" in html
|
||||
|
||||
Reference in New Issue
Block a user