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 <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte 2025-03-27 21:53:01 +01:00 committed by GitHub
parent 61c5efc74f
commit 7aaf193682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 393 additions and 93 deletions

View File

@ -430,7 +430,13 @@ Returns:
**Request Body**: **Request Body**:
- `application/json`: { - `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" "title": "Value"
} }

View File

@ -3453,12 +3453,17 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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" "title": "Value"
} }
} }
}, }
"required": true
}, },
"responses": { "responses": {
"200": { "200": {

View File

@ -529,7 +529,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
if not self.general.config_file_path: if not self.general.config_file_path:
raise ValueError("Configuration file path unknown.") raise ValueError("Configuration file path unknown.")
with self.general.config_file_path.open("w", encoding="utf-8", newline="\n") as f_out: 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) f_out.write(json_str)
def update(self) -> None: def update(self) -> None:

View File

@ -1,3 +1,4 @@
import traceback
from typing import Any, ClassVar, Optional from typing import Any, ClassVar, Optional
import numpy as np import numpy as np
@ -305,12 +306,13 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
if EnergyManagement._last_datetime is None: if EnergyManagement._last_datetime is None:
# Never run before # Never run before
try: try:
# Try to run a first energy management. May fail due to config incomplete.
self.run()
# Remember energy run datetime. # Remember energy run datetime.
EnergyManagement._last_datetime = current_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: 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) logger.error(message)
return return
@ -328,7 +330,8 @@ class EnergyManagement(SingletonMixin, ConfigMixin, PredictionMixin, PydanticBas
try: try:
self.run() self.run()
except Exception as e: 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) logger.error(message)
# Remember the energy management run - keep on interval even if we missed some intervals # Remember the energy management run - keep on interval even if we missed some intervals

View File

@ -1,9 +1,20 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings 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): class ElecPriceCommonSettings(SettingsBaseModel):
@ -21,3 +32,13 @@ class ElecPriceCommonSettings(SettingsBaseModel):
provider_settings: Optional[ElecPriceImportCommonSettings] = Field( provider_settings: Optional[ElecPriceImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None] 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}."
)

View File

@ -2,14 +2,24 @@
from typing import Optional, Union from typing import Optional, Union
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.prediction.loadabc import LoadProvider
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
from akkudoktoreos.prediction.prediction import get_prediction
logger = get_logger(__name__) 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): class LoadCommonSettings(SettingsBaseModel):
@ -24,3 +34,11 @@ class LoadCommonSettings(SettingsBaseModel):
provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
Field(default=None, description="Provider settings", examples=[None]) 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}.")

View File

@ -6,10 +6,20 @@ from pydantic import Field, computed_field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.logging import get_logger 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.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.utils.docs import get_model_structure_from_examples from akkudoktoreos.utils.docs import get_model_structure_from_examples
logger = get_logger(__name__) 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): class PVForecastPlaneSetting(SettingsBaseModel):
@ -130,6 +140,16 @@ class PVForecastCommonSettings(SettingsBaseModel):
max_planes: ClassVar[int] = 6 # Maximum number of planes that can be set 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") @field_validator("planes")
def validate_planes( def validate_planes(
cls, planes: Optional[list[PVForecastPlaneSetting]] cls, planes: Optional[list[PVForecastPlaneSetting]]

View File

@ -2,11 +2,22 @@
from typing import Optional from typing import Optional
from pydantic import Field from pydantic import Field, field_validator
from akkudoktoreos.config.configabc import SettingsBaseModel 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 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): class WeatherCommonSettings(SettingsBaseModel):
"""Weather Forecast Configuration.""" """Weather Forecast Configuration."""
@ -20,3 +31,13 @@ class WeatherCommonSettings(SettingsBaseModel):
provider_settings: Optional[WeatherImportCommonSettings] = Field( provider_settings: Optional[WeatherImportCommonSettings] = Field(
default=None, description="Provider settings", examples=[None] 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}."
)

View File

@ -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")

View File

@ -8,19 +8,19 @@ from bokeh.models import Plot
from monsterui.franken import H4, Card, NotStr, Script from monsterui.franken import H4, Card, NotStr, Script
BokehJS = [ 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( 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", crossorigin="anonymous",
), ),
Script( 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( 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( 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", crossorigin="anonymous",
), ),
] ]

View File

@ -1,8 +1,6 @@
from typing import Any, Optional, Union from typing import Any, Optional, Union
from fasthtml.common import H1, Div, Li from fasthtml.common import H1, Div, Li
# from mdit_py_plugins import plugin1, plugin2
from monsterui.foundations import stringify from monsterui.foundations import stringify
from monsterui.franken import ( from monsterui.franken import (
Button, Button,
@ -13,6 +11,7 @@ from monsterui.franken import (
Details, Details,
DivLAligned, DivLAligned,
DivRAligned, DivRAligned,
Form,
Grid, Grid,
Input, Input,
P, P,
@ -70,8 +69,22 @@ def ScrollArea(
def ConfigCard( 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: ) -> 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( return Card(
Details( Details(
Summary( Summary(
@ -85,24 +98,45 @@ def ConfigCard(
P(read_only), 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", cls="list-none",
), ),
Grid( Grid(
P(description), P(description),
P(config_type), P(config_type),
), ),
# Default
Grid( Grid(
DivRAligned( DivRAligned(P("default")),
P("default") if read_only == "rw" else P(""), P(default),
),
P(default) if read_only == "rw" else P(""),
) )
if read_only == "rw" if read_only == "rw"
else None, 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", cls="space-y-4 gap-4",
open=update_open,
), ),
cls="w-full", cls="w-full",
) )

View File

@ -1,7 +1,8 @@
import json
from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union from typing import Any, Dict, List, Optional, Sequence, TypeVar, Union
import requests 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.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined
@ -15,6 +16,10 @@ config_eos = get_config()
T = TypeVar("T") 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( def get_nested_value(
dictionary: Union[Dict[str, Any], List[Any]], dictionary: Union[Dict[str, Any], List[Any]],
@ -151,8 +156,8 @@ def configuration(values: dict) -> list[dict]:
config["type"] = ( config["type"] = (
type_description.replace("typing.", "") type_description.replace("typing.", "")
.replace("pathlib.", "") .replace("pathlib.", "")
.replace("[", "[ ")
.replace("NoneType", "None") .replace("NoneType", "None")
.replace("<class 'float'>", "float")
) )
configs.append(config) configs.append(config)
found_basic = True found_basic = True
@ -171,20 +176,16 @@ def configuration(values: dict) -> list[dict]:
return sorted(configs, key=lambda x: x["name"]) 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. """Fetch and process configuration data from the specified EOS server.
Args: Args:
eos_host (Optional[str]): The hostname of the server. eos_host (str): The hostname of the EOS server.
eos_port (Optional[Union[str, int]]): The port of the server. eos_port (Union[str, int]): The port of the EOS server.
Returns: Returns:
List[dict]: A list of processed configuration entries. 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}" server = f"http://{eos_host}:{eos_port}"
# Get current configuration from server # 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) 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. """Create a visual representation of the configuration.
Args: Args:
eos_host (Optional[str]): The hostname of the EOS server. eos_host (str): The hostname of the EOS server.
eos_port (Optional[Union[str, int]]): The port 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: 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 = [] rows = []
last_category = "" last_category = ""
for config in get_configuration(eos_host, eos_port): for config in configuration:
category = config["name"].split(".")[0] category = config["name"].split(".")[0]
if category != last_category: if category != last_category:
rows.append(P(category)) rows.append(P(category))
rows.append(DividerLine()) rows.append(DividerLine())
last_category = category 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( rows.append(
ConfigCard( ConfigCard(
config["name"], config["name"],
@ -228,48 +241,59 @@ def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]])
config["value"], config["value"],
config["default"], config["default"],
config["description"], config["description"],
update_error,
update_value,
update_open,
) )
) )
return Div(*rows, cls="space-y-4") return Div(*rows, cls="space-y-4")
def ConfigurationOrg(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table: def ConfigKeyUpdate(eos_host: str, eos_port: Union[str, int], key: str, value: str) -> P:
"""Create a visual representation of the configuration. """Update configuration key and create a visual representation of the configuration.
Args: Args:
eos_host (Optional[str]): The hostname of the EOS server. eos_host (str): The hostname of the EOS server.
eos_port (Optional[Union[str, int]]): The port 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: Returns:
Table: A `monsterui.franken.Table` component displaying configuration details. rows: Rows of configuration details.
""" """
flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" server = f"http://{eos_host}:{eos_port}"
rows = [ path = key.replace(".", "/")
Tr( try:
Td( data = json.loads(value)
config["name"], except:
cls="max-w-64 text-wrap break-all", if value in ("None", "none", "Null", "null"):
), data = None
Td( else:
config["type"], data = value
cls="max-w-48 text-wrap break-all",
), error = None
Td( result = None
config["read-only"], try:
cls="max-w-24 text-wrap break-all", result = requests.put(f"{server}/v1/config/{path}", json=data)
), result.raise_for_status()
Td( except requests.exceptions.HTTPError as err:
config["value"], if result:
cls="max-w-md text-wrap break-all", detail = result.json()["detail"]
), else:
Td(config["default"], cls="max-w-48 text-wrap break-all"), detail = "No details"
Td( error = f"Can not set {key} on {server}: {err}, {detail}"
config["description"], # Mark all updates as closed
cls="max-w-prose text-wrap", for k in config_update_latest:
), config_update_latest[k]["open"] = False
cls="", # Remember this update as latest one
) config_update_latest[key] = {
for config in get_configuration(eos_host, eos_port) "error": error,
] "result": result.json() if result else None,
head = Thead(*map(Th, flds), cls="text-left") "value": value,
return Table(head, Tbody(*rows), cls="w-full uk-table uk-table-divider uk-table-striped") "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()))

View File

@ -486,7 +486,9 @@ def fastapi_config_put_key(
path: str = FastapiPath( path: str = FastapiPath(
..., description="The nested path to the configuration key (e.g., general/latitude)." ..., 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: ) -> ConfigEOS:
"""Update a nested key or index in the config model. """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()) trace = "".join(traceback.TracebackException.from_exception(e).format())
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Error on prediction update: {e}{trace}", detail=f"Error on prediction update: {e}\n{trace}",
) )
return Response() return Response()

View File

@ -12,18 +12,17 @@ from monsterui.core import FastHTML, Theme
from akkudoktoreos.config.config import get_config from akkudoktoreos.config.config import get_config
from akkudoktoreos.core.logging import get_logger from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.server.dash.bokeh import BokehJS
from akkudoktoreos.server.dash.components import Page
# Pages # 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.demo import Demo
from akkudoktoreos.server.dash.footer import Footer from akkudoktoreos.server.dash.footer import Footer
from akkudoktoreos.server.dash.hello import Hello from akkudoktoreos.server.dash.hello import Hello
from akkudoktoreos.server.server import get_default_host, wait_for_port_free from akkudoktoreos.server.server import get_default_host, wait_for_port_free
# from akkudoktoreos.server.dash.altair import AltairJS
logger = get_logger(__name__) logger = get_logger(__name__)
config_eos = get_config() config_eos = get_config()
@ -37,8 +36,7 @@ args: Optional[argparse.Namespace] = None
# Get frankenui and tailwind headers via CDN using Theme.green.headers() # Get frankenui and tailwind headers via CDN using Theme.green.headers()
# Add altair headers # Add Bokeh headers
# hdrs=(Theme.green.headers(highlightjs=True), AltairJS,)
hdrs = ( hdrs = (
Theme.green.headers(highlightjs=True), Theme.green.headers(highlightjs=True),
BokehJS, BokehJS,
@ -94,6 +92,7 @@ def get_eosdash(): # type: ignore
"EOSdash": "/eosdash/hello", "EOSdash": "/eosdash/hello",
"Config": "/eosdash/configuration", "Config": "/eosdash/configuration",
"Demo": "/eosdash/demo", "Demo": "/eosdash/demo",
"Admin": "/eosdash/admin",
}, },
Hello(), Hello(),
Footer(*eos_server()), Footer(*eos_server()),
@ -121,6 +120,21 @@ def get_eosdash_hello(): # type: ignore
return Hello() 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") @app.get("/eosdash/configuration")
def get_eosdash_configuration(): # type: ignore def get_eosdash_configuration(): # type: ignore
"""Serves the EOSdash Configuration page. """Serves the EOSdash Configuration page.
@ -131,6 +145,11 @@ def get_eosdash_configuration(): # type: ignore
return Configuration(*eos_server()) 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") @app.get("/eosdash/demo")
def get_eosdash_demo(): # type: ignore def get_eosdash_demo(): # type: ignore
"""Serves the EOSdash Demo page. """Serves the EOSdash Demo page.

View File

@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos):
}, },
} }
} }
config_eos.merge_settings_from_dict(settings) with pytest.raises(ValueError, match="not a valid electricity price provider"):
assert not provider.enabled() config_eos.merge_settings_from_dict(settings)
# ------------------------------------------------ # ------------------------------------------------

View File

@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos):
}, },
} }
} }
config_eos.merge_settings_from_dict(settings) with pytest.raises(ValueError, match="not a valid PV forecast provider"):
assert not provider.enabled() config_eos.merge_settings_from_dict(settings)
# ------------------------------------------------ # ------------------------------------------------

View File

@ -79,8 +79,8 @@ def test_invalid_provider(provider, config_eos):
"provider": "<invalid>", "provider": "<invalid>",
} }
} }
config_eos.merge_settings_from_dict(settings) with pytest.raises(ValueError, match="not a valid weather provider"):
assert not provider.enabled() config_eos.merge_settings_from_dict(settings)
def test_invalid_coordinates(provider, config_eos): def test_invalid_coordinates(provider, config_eos):

View File

@ -59,8 +59,8 @@ def test_invalid_provider(provider, config_eos, monkeypatch):
}, },
} }
} }
config_eos.merge_settings_from_dict(settings) with pytest.raises(ValueError, match="not a valid weather provider"):
assert provider.enabled() == False config_eos.merge_settings_from_dict(settings)
# ------------------------------------------------ # ------------------------------------------------