From 7aaf19368253d3678e57dc8f613612afe63287f3 Mon Sep 17 00:00:00 2001 From: Bobby Noelte Date: Thu, 27 Mar 2025 21:53:01 +0100 Subject: [PATCH] EOSdash: Enable EOS configuration by EOSdash. (#477) Improve config page to edit actual configuration used by EOS. Add admin page to save the actual configuration to the configuration file. Signed-off-by: Bobby Noelte --- docs/_generated/openapi.md | 8 +- openapi.json | 11 +- src/akkudoktoreos/config/config.py | 2 +- src/akkudoktoreos/core/ems.py | 11 +- src/akkudoktoreos/prediction/elecprice.py | 23 +++- src/akkudoktoreos/prediction/load.py | 20 ++- src/akkudoktoreos/prediction/pvforecast.py | 20 +++ src/akkudoktoreos/prediction/weather.py | 23 +++- src/akkudoktoreos/server/dash/admin.py | 127 ++++++++++++++++++ src/akkudoktoreos/server/dash/bokeh.py | 10 +- src/akkudoktoreos/server/dash/components.py | 52 +++++-- .../server/dash/configuration.py | 124 ++++++++++------- src/akkudoktoreos/server/eos.py | 6 +- src/akkudoktoreos/server/eosdash.py | 33 ++++- tests/test_elecpriceimport.py | 4 +- tests/test_pvforecastimport.py | 4 +- tests/test_weatherclearoutside.py | 4 +- tests/test_weatherimport.py | 4 +- 18 files changed, 393 insertions(+), 93 deletions(-) create mode 100644 src/akkudoktoreos/server/dash/admin.py diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index dff19f3..76f2c9c 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -430,7 +430,13 @@ Returns: **Request Body**: - `application/json`: { - "description": "The value to assign to the specified configuration path.", + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "The value to assign to the specified configuration path (can be None).", "title": "Value" } diff --git a/openapi.json b/openapi.json index 82891fc..b6c0eb8 100644 --- a/openapi.json +++ b/openapi.json @@ -3453,12 +3453,17 @@ "content": { "application/json": { "schema": { - "description": "The value to assign to the specified configuration path.", + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "The value to assign to the specified configuration path (can be None).", "title": "Value" } } - }, - "required": true + } }, "responses": { "200": { diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index adf3794..1996209 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -529,7 +529,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults): if not self.general.config_file_path: raise ValueError("Configuration file path unknown.") with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: - json_str = super().model_dump_json() + json_str = super().model_dump_json(indent=4) f_out.write(json_str) def update(self) -> None: diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 58ca3a1..d989792 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -1,3 +1,4 @@ +import traceback from typing import Any, ClassVar, Optional import numpy as np @@ -305,12 +306,13 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas if EnergyManagement._last_datetime is None: # Never run before try: - # Try to run a first energy management. May fail due to config incomplete. - self.run() # Remember energy run datetime. EnergyManagement._last_datetime = current_datetime + # Try to run a first energy management. May fail due to config incomplete. + self.run() except Exception as e: - message = f"EOS init: {e}" + trace = "".join(traceback.TracebackException.from_exception(e).format()) + message = f"EOS init: {e}\n{trace}" logger.error(message) return @@ -328,7 +330,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas try: self.run() except Exception as e: - message = f"EOS run: {e}" + trace = "".join(traceback.TracebackException.from_exception(e).format()) + message = f"EOS run: {e}\n{trace}" logger.error(message) # Remember the energy management run - keep on interval even if we missed some intervals diff --git a/src/akkudoktoreos/prediction/elecprice.py b/src/akkudoktoreos/prediction/elecprice.py index b41359b..266ec1e 100644 --- a/src/akkudoktoreos/prediction/elecprice.py +++ b/src/akkudoktoreos/prediction/elecprice.py @@ -1,9 +1,20 @@ from typing import Optional -from pydantic import Field +from pydantic import Field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings +from akkudoktoreos.prediction.prediction import get_prediction + +prediction_eos = get_prediction() + +# Valid elecprice providers +elecprice_providers = [ + provider.provider_id() + for provider in prediction_eos.providers + if isinstance(provider, ElecPriceProvider) +] class ElecPriceCommonSettings(SettingsBaseModel): @@ -21,3 +32,13 @@ class ElecPriceCommonSettings(SettingsBaseModel): provider_settings: Optional[ElecPriceImportCommonSettings] = Field( default=None, description="Provider settings", examples=[None] ) + + # Validators + @field_validator("provider", mode="after") + @classmethod + def validate_provider(cls, value: Optional[str]) -> Optional[str]: + if value is None or value in elecprice_providers: + return value + raise ValueError( + f"Provider '{value}' is not a valid electricity price provider: {elecprice_providers}." + ) diff --git a/src/akkudoktoreos/prediction/load.py b/src/akkudoktoreos/prediction/load.py index 5e25b4c..c46a705 100644 --- a/src/akkudoktoreos/prediction/load.py +++ b/src/akkudoktoreos/prediction/load.py @@ -2,14 +2,24 @@ from typing import Optional, Union -from pydantic import Field +from pydantic import Field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.prediction.loadabc import LoadProvider from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings +from akkudoktoreos.prediction.prediction import get_prediction logger = get_logger(__name__) +prediction_eos = get_prediction() + +# Valid load providers +load_providers = [ + provider.provider_id() + for provider in prediction_eos.providers + if isinstance(provider, LoadProvider) +] class LoadCommonSettings(SettingsBaseModel): @@ -24,3 +34,11 @@ class LoadCommonSettings(SettingsBaseModel): provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( Field(default=None, description="Provider settings", examples=[None]) ) + + # Validators + @field_validator("provider", mode="after") + @classmethod + def validate_provider(cls, value: Optional[str]) -> Optional[str]: + if value is None or value in load_providers: + return value + raise ValueError(f"Provider '{value}' is not a valid load provider: {load_providers}.") diff --git a/src/akkudoktoreos/prediction/pvforecast.py b/src/akkudoktoreos/prediction/pvforecast.py index bbfcc8e..8744f14 100644 --- a/src/akkudoktoreos/prediction/pvforecast.py +++ b/src/akkudoktoreos/prediction/pvforecast.py @@ -6,10 +6,20 @@ from pydantic import Field, computed_field, field_validator, model_validator from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.prediction.prediction import get_prediction +from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings from akkudoktoreos.utils.docs import get_model_structure_from_examples logger = get_logger(__name__) +prediction_eos = get_prediction() + +# Valid PV forecast providers +pvforecast_providers = [ + provider.provider_id() + for provider in prediction_eos.providers + if isinstance(provider, PVForecastProvider) +] class PVForecastPlaneSetting(SettingsBaseModel): @@ -130,6 +140,16 @@ class PVForecastCommonSettings(SettingsBaseModel): max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set + # Validators + @field_validator("provider", mode="after") + @classmethod + def validate_provider(cls, value: Optional[str]) -> Optional[str]: + if value is None or value in pvforecast_providers: + return value + raise ValueError( + f"Provider '{value}' is not a valid PV forecast provider: {pvforecast_providers}." + ) + @field_validator("planes") def validate_planes( cls, planes: Optional[list[PVForecastPlaneSetting]] diff --git a/src/akkudoktoreos/prediction/weather.py b/src/akkudoktoreos/prediction/weather.py index 94f12c3..60a7eba 100644 --- a/src/akkudoktoreos/prediction/weather.py +++ b/src/akkudoktoreos/prediction/weather.py @@ -2,11 +2,22 @@ from typing import Optional -from pydantic import Field +from pydantic import Field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.prediction.prediction import get_prediction +from akkudoktoreos.prediction.weatherabc import WeatherProvider from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings +prediction_eos = get_prediction() + +# Valid weather providers +weather_providers = [ + provider.provider_id() + for provider in prediction_eos.providers + if isinstance(provider, WeatherProvider) +] + class WeatherCommonSettings(SettingsBaseModel): """Weather Forecast Configuration.""" @@ -20,3 +31,13 @@ class WeatherCommonSettings(SettingsBaseModel): provider_settings: Optional[WeatherImportCommonSettings] = Field( default=None, description="Provider settings", examples=[None] ) + + # Validators + @field_validator("provider", mode="after") + @classmethod + def validate_provider(cls, value: Optional[str]) -> Optional[str]: + if value is None or value in weather_providers: + return value + raise ValueError( + f"Provider '{value}' is not a valid weather provider: {weather_providers}." + ) diff --git a/src/akkudoktoreos/server/dash/admin.py b/src/akkudoktoreos/server/dash/admin.py new file mode 100644 index 0000000..1a8fd09 --- /dev/null +++ b/src/akkudoktoreos/server/dash/admin.py @@ -0,0 +1,127 @@ +"""Admin UI components for EOS Dashboard. + +This module provides functions to generate administrative UI components +for the EOS dashboard. +""" + +from typing import Any, Optional, Union + +import requests +from fasthtml.common import Div +from monsterui.foundations import stringify +from monsterui.franken import ( + Button, + ButtonT, + Card, + Details, + DivHStacked, + DividerLine, + Grid, + P, + Summary, + UkIcon, +) + + +def AdminButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: + """Creates a styled button for administrative actions. + + Args: + *c (Any): Positional arguments representing the button's content. + cls (Optional[Union[str, tuple]]): Additional CSS classes for styling. Defaults to None. + **kwargs (Any): Additional keyword arguments passed to the `Button`. + + Returns: + Button: A styled `Button` component for admin actions. + """ + new_cls = f"{ButtonT.primary}" + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + return Button(*c, submit=False, **kwargs) + + +def AdminConfig(eos_host: str, eos_port: Union[str, int], data: Optional[dict]) -> Card: + """Creates a configuration management card with save-to-file functionality. + + Args: + eos_host (str): The hostname of the EOS server. + eos_port (Union[str, int]): The port of the EOS server. + data (Optional[dict]): Incoming data containing action and category for processing. + + Returns: + tuple[str, Card]: A tuple containing the configuration category label and the `Card` UI component. + """ + server = f"http://{eos_host}:{eos_port}" + + category = "configuration" + status = (None,) + if data and data["category"] == category: + # This data is for us + if data["action"] == "save_to_file": + # Safe current configuration to file + try: + result = requests.put(f"{server}/v1/config/file") + result.raise_for_status() + config_file_path = result.json()["general"]["config_file_path"] + status = P( + f"Actual config saved to {config_file_path} on {server}", + cls="text-left", + ) + except requests.exceptions.HTTPError as err: + detail = result.json()["detail"] + status = P( + f"Can not save actual config to file on {server}: {err}, {detail}", + cls="text-left", + ) + return ( + category, + Card( + Details( + Summary( + Grid( + DivHStacked( + UkIcon(icon="play"), + AdminButton( + "Save to file", + hx_post="/eosdash/admin", + hx_target="#page-content", + hx_swap="innerHTML", + hx_vals='{"category": "configuration", "action": "save_to_file"}', + ), + ), + status, + ), + cls="list-none", + ), + P(f"Safe actual configuration to config file on {server}."), + ), + ), + ) + + +def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None) -> Div: + """Generates the administrative dashboard layout. + + This includes configuration management and other administrative tools. + + Args: + eos_host (str): The hostname of the EOS server. + eos_port (Union[str, int]): The port of the EOS server. + data (Optional[dict], optional): Incoming data to trigger admin actions. Defaults to None. + + Returns: + Div: A `Div` component containing the assembled admin interface. + """ + rows = [] + last_category = "" + for category, admin in [ + AdminConfig(eos_host, eos_port, data), + ]: + if category != last_category: + rows.append(P(category)) + rows.append(DividerLine()) + last_category = category + rows.append(admin) + + return Div(*rows, cls="space-y-4") diff --git a/src/akkudoktoreos/server/dash/bokeh.py b/src/akkudoktoreos/server/dash/bokeh.py index 4e27648..560b7f8 100644 --- a/src/akkudoktoreos/server/dash/bokeh.py +++ b/src/akkudoktoreos/server/dash/bokeh.py @@ -8,19 +8,19 @@ from bokeh.models import Plot from monsterui.franken import H4, Card, NotStr, Script BokehJS = [ - Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.6.3.min.js", crossorigin="anonymous"), + Script(src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.0.min.js", crossorigin="anonymous"), Script( - src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.3.min.js", + src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.0.min.js", crossorigin="anonymous", ), Script( - src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.3.min.js", crossorigin="anonymous" + src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.7.0.min.js", crossorigin="anonymous" ), Script( - src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.6.3.min.js", crossorigin="anonymous" + src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.0.min.js", crossorigin="anonymous" ), Script( - src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.6.3.min.js", + src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.7.0.min.js", crossorigin="anonymous", ), ] diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index 325ac3f..eb737c5 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -1,8 +1,6 @@ from typing import Any, Optional, Union from fasthtml.common import H1, Div, Li - -# from mdit_py_plugins import plugin1, plugin2 from monsterui.foundations import stringify from monsterui.franken import ( Button, @@ -13,6 +11,7 @@ from monsterui.franken import ( Details, DivLAligned, DivRAligned, + Form, Grid, Input, P, @@ -70,8 +69,22 @@ def ScrollArea( def ConfigCard( - config_name: str, config_type: str, read_only: str, value: str, default: str, description: str + config_name: str, + config_type: str, + read_only: str, + value: str, + default: str, + description: str, + update_error: Optional[str], + update_value: Optional[str], + update_open: Optional[bool], ) -> Card: + """Creates a styled configuration card.""" + config_id = config_name.replace(".", "-") + if not update_value: + update_value = value + if not update_open: + update_open = False return Card( Details( Summary( @@ -85,24 +98,45 @@ def ConfigCard( P(read_only), ), ), - Input(value=value) if read_only == "rw" else P(value), + P(value), ), - # cls="flex cursor-pointer list-none items-center gap-4", cls="list-none", ), Grid( P(description), P(config_type), ), + # Default Grid( - DivRAligned( - P("default") if read_only == "rw" else P(""), - ), - P(default) if read_only == "rw" else P(""), + DivRAligned(P("default")), + P(default), ) if read_only == "rw" else None, + # Set value + Grid( + DivRAligned(P("update")), + Grid( + Form( + Input(value=config_name, type="hidden", id="key"), + Input(value=update_value, type="text", id="value"), + hx_put="/eosdash/configuration", + hx_target="#page-content", + hx_swap="innerHTML", + ), + ), + ) + if read_only == "rw" + else None, + # Last error + Grid( + DivRAligned(P("update error")), + P(update_error), + ) + if update_error + else None, cls="space-y-4 gap-4", + open=update_open, ), cls="w-full", ) diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py index df29f48..ec0caa2 100644 --- a/src/akkudoktoreos/server/dash/configuration.py +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -1,7 +1,8 @@ +import json from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union import requests -from monsterui.franken import Div, DividerLine, P, Table, Tbody, Td, Th, Thead, Tr +from monsterui.franken import Div, DividerLine, P from pydantic.fields import ComputedFieldInfo, FieldInfo from pydantic_core import PydanticUndefined @@ -15,6 +16,10 @@ config_eos = get_config() T = TypeVar("T") +# Latest configuration update results +# Dictionary of config names and associated dictionary with keys "value", "result", "error", "open". +config_update_latest: dict[str, dict[str, Optional[Union[str, bool]]]] = {} + def get_nested_value( dictionary: Union[Dict[str, Any], List[Any]], @@ -151,8 +156,8 @@ def configuration(values: dict) -> list[dict]: config["type"] = ( type_description.replace("typing.", "") .replace("pathlib.", "") - .replace("[", "[ ") .replace("NoneType", "None") + .replace("", "float") ) configs.append(config) found_basic = True @@ -171,20 +176,16 @@ def configuration(values: dict) -> list[dict]: return sorted(configs, key=lambda x: x["name"]) -def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]: +def get_configuration(eos_host: str, eos_port: Union[str, int]) -> list[dict]: """Fetch and process configuration data from the specified EOS server. Args: - eos_host (Optional[str]): The hostname of the server. - eos_port (Optional[Union[str, int]]): The port of the server. + eos_host (str): The hostname of the EOS server. + eos_port (Union[str, int]): The port of the EOS server. Returns: List[dict]: A list of processed configuration entries. """ - if eos_host is None: - eos_host = config_eos.server.host - if eos_port is None: - eos_port = config_eos.server.port server = f"http://{eos_host}:{eos_port}" # Get current configuration from server @@ -201,25 +202,37 @@ def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int return configuration(config) -def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Div: +def Configuration( + eos_host: str, eos_port: Union[str, int], configuration: Optional[list[dict]] = None +) -> Div: """Create a visual representation of the configuration. Args: - eos_host (Optional[str]): The hostname of the EOS server. - eos_port (Optional[Union[str, int]]): The port of the EOS server. + eos_host (str): The hostname of the EOS server. + eos_port (Union[str, int]): The port of the EOS server. + configuration (Optional[list[dict]]): Optional configuration. If not provided it will be + retrievd from EOS. Returns: - Table: A `monsterui.franken.Table` component displaying configuration details. + rows: Rows of configuration details. """ - flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" + if not configuration: + configuration = get_configuration(eos_host, eos_port) rows = [] last_category = "" - for config in get_configuration(eos_host, eos_port): + for config in configuration: category = config["name"].split(".")[0] if category != last_category: rows.append(P(category)) rows.append(DividerLine()) last_category = 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 + assert isinstance(update_error, (str, type(None))) + assert isinstance(update_value, (str, type(None))) + assert isinstance(update_open, (bool, type(None))) rows.append( ConfigCard( config["name"], @@ -228,48 +241,59 @@ def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) config["value"], config["default"], config["description"], + update_error, + update_value, + update_open, ) ) return Div(*rows, cls="space-y-4") -def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table: - """Create a visual representation of the configuration. +def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P: + """Update configuration key and create a visual representation of the configuration. Args: - eos_host (Optional[str]): The hostname of the EOS server. - eos_port (Optional[Union[str, int]]): The port of the EOS server. + eos_host (str): The hostname of the EOS server. + eos_port (Union[str, int]): The port of the EOS server. + key (str): configuration key in dot notation + value (str): configuration value as json string Returns: - Table: A `monsterui.franken.Table` component displaying configuration details. + rows: Rows of configuration details. """ - flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" - rows = [ - Tr( - Td( - config["name"], - cls="max-w-64 text-wrap break-all", - ), - Td( - config["type"], - cls="max-w-48 text-wrap break-all", - ), - Td( - config["read-only"], - cls="max-w-24 text-wrap break-all", - ), - Td( - config["value"], - cls="max-w-md text-wrap break-all", - ), - Td(config["default"], cls="max-w-48 text-wrap break-all"), - Td( - config["description"], - cls="max-w-prose text-wrap", - ), - cls="", - ) - for config in get_configuration(eos_host, eos_port) - ] - head = Thead(*map(Th, flds), cls="text-left") - return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped") + server = f"http://{eos_host}:{eos_port}" + path = key.replace(".", "/") + try: + data = json.loads(value) + except: + if value in ("None", "none", "Null", "null"): + data = None + else: + data = value + + error = None + result = None + try: + result = requests.put(f"{server}/v1/config/{path}", json=data) + result.raise_for_status() + except requests.exceptions.HTTPError as err: + if result: + detail = result.json()["detail"] + else: + detail = "No details" + 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": result.json() if result else None, + "value": value, + "open": True, + } + if error or result is None: + # Reread configuration to be shure we display actual data + return Configuration(eos_host, eos_port) + # Use configuration already provided + return Configuration(eos_host, eos_port, configuration(result.json())) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 7b0e441..323e1a7 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -486,7 +486,9 @@ def fastapi_config_put_key( path: str = FastapiPath( ..., description="The nested path to the configuration key (e.g., general/latitude)." ), - value: Any = Body(..., description="The value to assign to the specified configuration path."), + value: Optional[Any] = Body( + None, description="The value to assign to the specified configuration path (can be None)." + ), ) -> ConfigEOS: """Update a nested key or index in the config model. @@ -848,7 +850,7 @@ def fastapi_prediction_update( trace = "".join(traceback.TracebackException.from_exception(e).format()) raise HTTPException( status_code=400, - detail=f"Error on prediction update: {e}{trace}", + detail=f"Error on prediction update: {e}\n{trace}", ) return Response() diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index 06a6c79..7f9d4d5 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -12,18 +12,17 @@ from monsterui.core import FastHTML, Theme from akkudoktoreos.config.config import get_config from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.server.dash.bokeh import BokehJS -from akkudoktoreos.server.dash.components import Page # Pages -from akkudoktoreos.server.dash.configuration import Configuration +from akkudoktoreos.server.dash.admin import Admin +from akkudoktoreos.server.dash.bokeh import BokehJS +from akkudoktoreos.server.dash.components import Page +from akkudoktoreos.server.dash.configuration import ConfigKeyUpdate, Configuration from akkudoktoreos.server.dash.demo import Demo from akkudoktoreos.server.dash.footer import Footer from akkudoktoreos.server.dash.hello import Hello from akkudoktoreos.server.server import get_default_host, wait_for_port_free -# from akkudoktoreos.server.dash.altair import AltairJS - logger = get_logger(__name__) config_eos = get_config() @@ -37,8 +36,7 @@ args: Optional[argparse.Namespace] = None # Get frankenui and tailwind headers via CDN using Theme.green.headers() -# Add altair headers -# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,) +# Add Bokeh headers hdrs = ( Theme.green.headers(highlightjs=True), BokehJS, @@ -94,6 +92,7 @@ def get_eosdash(): # type: ignore "EOSdash": "/eosdash/hello", "Config": "/eosdash/configuration", "Demo": "/eosdash/demo", + "Admin": "/eosdash/admin", }, Hello(), Footer(*eos_server()), @@ -121,6 +120,21 @@ def get_eosdash_hello(): # type: ignore return Hello() +@app.get("/eosdash/admin") +def get_eosdash_admin(): # type: ignore + """Serves the EOSdash Admin page. + + Returns: + Admin: The Admin page component. + """ + return Admin(*eos_server()) + + +@app.post("/eosdash/admin") +def post_eosdash_admin(data: dict): # type: ignore + return Admin(*eos_server(), data) + + @app.get("/eosdash/configuration") def get_eosdash_configuration(): # type: ignore """Serves the EOSdash Configuration page. @@ -131,6 +145,11 @@ def get_eosdash_configuration(): # type: ignore return Configuration(*eos_server()) +@app.put("/eosdash/configuration") +def put_eosdash_configuration(data: dict): # type: ignore + return ConfigKeyUpdate(*eos_server(), data["key"], data["value"]) + + @app.get("/eosdash/demo") def get_eosdash_demo(): # type: ignore """Serves the EOSdash Demo page. diff --git a/tests/test_elecpriceimport.py b/tests/test_elecpriceimport.py index a2a09fd..420f15e 100644 --- a/tests/test_elecpriceimport.py +++ b/tests/test_elecpriceimport.py @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos): }, } } - config_eos.merge_settings_from_dict(settings) - assert not provider.enabled() + with pytest.raises(ValueError, match="not a valid electricity price provider"): + config_eos.merge_settings_from_dict(settings) # ------------------------------------------------ diff --git a/tests/test_pvforecastimport.py b/tests/test_pvforecastimport.py index 934e37d..a7664cc 100644 --- a/tests/test_pvforecastimport.py +++ b/tests/test_pvforecastimport.py @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos): }, } } - config_eos.merge_settings_from_dict(settings) - assert not provider.enabled() + with pytest.raises(ValueError, match="not a valid PV forecast provider"): + config_eos.merge_settings_from_dict(settings) # ------------------------------------------------ diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py index 623ed89..fe1b97d 100644 --- a/tests/test_weatherclearoutside.py +++ b/tests/test_weatherclearoutside.py @@ -79,8 +79,8 @@ def test_invalid_provider(provider, config_eos): "provider": "", } } - config_eos.merge_settings_from_dict(settings) - assert not provider.enabled() + with pytest.raises(ValueError, match="not a valid weather provider"): + config_eos.merge_settings_from_dict(settings) def test_invalid_coordinates(provider, config_eos): diff --git a/tests/test_weatherimport.py b/tests/test_weatherimport.py index e445c01..1d66683 100644 --- a/tests/test_weatherimport.py +++ b/tests/test_weatherimport.py @@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos, monkeypatch): }, } } - config_eos.merge_settings_from_dict(settings) - assert provider.enabled() == False + with pytest.raises(ValueError, match="not a valid weather provider"): + config_eos.merge_settings_from_dict(settings) # ------------------------------------------------