REST: Allow setting single config value

* /v1/config/{path} supports setting single config value (post body). Lists are
   supported as well by using the index:
    - general/latitude (value: 55.55)
    - optimize/ev_available_charge_rates_percent/0 (value: 42)

   Whole tree can be overriden as well (no merge):
    - optimize/ev_available_charge_rates_percent (value: [42, 43, 44]

 * ConfigEOS: Add set_config_value, get_config_value
This commit is contained in:
Dominique Lasserre
2025-01-26 20:54:27 +01:00
committed by Bobby Noelte
parent 1bb74ed836
commit 94618f5f66
6 changed files with 709 additions and 242 deletions

View File

@@ -30,7 +30,7 @@ from akkudoktoreos.core.coreabc import SingletonMixin
from akkudoktoreos.core.decorators import classproperty
from akkudoktoreos.core.logging import get_logger
from akkudoktoreos.core.logsettings import LoggingCommonSettings
from akkudoktoreos.core.pydantic import merge_models
from akkudoktoreos.core.pydantic import access_nested_value, merge_models
from akkudoktoreos.devices.settings import DevicesCommonSettings
from akkudoktoreos.measurement.measurement import MeasurementCommonSettings
from akkudoktoreos.optimization.optimization import OptimizationCommonSettings
@@ -379,6 +379,32 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
"""
self._setup()
def set_config_value(self, path: str, value: Any) -> None:
"""Set a configuration value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
value (Any]): The value to set.
"""
access_nested_value(self, path, True, value)
def get_config_value(self, path: str) -> Any:
"""Get a configuration value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
path (str): The path to the configuration key (e.g., "key1/key2/key3" or key1/key2/0).
Returns:
Any: The retrieved value.
"""
return access_nested_value(self, path, False)
def _create_initial_config_file(self) -> None:
if self.general.config_file_path and not self.general.config_file_path.exists():
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -51,6 +51,70 @@ def merge_models(source: BaseModel, update_dict: dict[str, Any]) -> dict[str, An
return merged_dict
def access_nested_value(
model: BaseModel, path: str, setter: bool, value: Optional[Any] = None
) -> Any:
"""Get or set a nested model value based on the provided path.
Supports string paths (with '/' separators) or sequence paths (list/tuple).
Trims leading and trailing '/' from string paths.
Args:
model (BaseModel): The model object for partial assignment.
path (str): The path to the model key (e.g., "key1/key2/key3" or key1/key2/0).
setter (bool): True to set value at path, False to return value at path.
value (Optional[Any]): The value to set.
Returns:
Any: The retrieved value if acting as a getter, or None if setting a value.
"""
path_elements = path.strip("/").split("/")
cfg: Any = model
parent: BaseModel = model
model_key: str = ""
for i, key in enumerate(path_elements):
is_final_key = i == len(path_elements) - 1
if isinstance(cfg, list):
try:
idx = int(key)
if is_final_key:
if not setter: # Getter
return cfg[idx]
else: # Setter
new_list = list(cfg)
new_list[idx] = value
# Trigger validation
setattr(parent, model_key, new_list)
else:
cfg = cfg[idx]
except ValidationError as e:
raise ValueError(f"Error updating model: {e}") from e
except (ValueError, IndexError) as e:
raise IndexError(f"Invalid list index at {path}: {key}") from e
elif isinstance(cfg, BaseModel):
parent = cfg
model_key = key
if is_final_key:
if not setter: # Getter
return getattr(cfg, key)
else: # Setter
try:
# Verification also if nested value is provided opposed to just setattr
# Will merge partial assignment
cfg = cfg.__pydantic_validator__.validate_assignment(cfg, key, value)
except Exception as e:
raise ValueError(f"Error updating model: {e}") from e
else:
cfg = getattr(cfg, key)
else:
raise KeyError(f"Key '{key}' not found in model.")
class PydanticTypeAdapterDateTime(TypeAdapter[pendulum.DateTime]):
"""Custom type adapter for Pendulum DateTime fields."""

View File

@@ -10,7 +10,9 @@ from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
import httpx
import uvicorn
from fastapi import FastAPI, Query, Request
from fastapi import Body, FastAPI
from fastapi import Path as FastapiPath
from fastapi import Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
@@ -302,6 +304,58 @@ def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
return config_eos
@app.put("/v1/config/{path:path}", tags=["config"])
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."),
) -> ConfigEOS:
"""Update a nested key or index in the config model.
Args:
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
value (Any): The new value to assign to the key or index at path.
Returns:
configuration (ConfigEOS): The current configuration after the update.
"""
try:
config_eos.set_config_value(path, value)
except IndexError as e:
raise HTTPException(status_code=400, detail=str(e))
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return config_eos
@app.get("/v1/config/{path:path}", tags=["config"])
def fastapi_config_get_key(
path: str = FastapiPath(
..., description="The nested path to the configuration key (e.g., general/latitude)."
),
) -> Response:
"""Get the value of a nested key or index in the config model.
Args:
path (str): The nested path to the key (e.g., "general/latitude" or "optimize/nested_list/0").
Returns:
value (Any): The value of the selected nested key.
"""
try:
return config_eos.get_config_value(path)
except IndexError as e:
raise HTTPException(status_code=400, detail=str(e))
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/v1/measurement/keys", tags=["measurement"])
def fastapi_measurement_keys_get() -> list[str]:
"""Get a list of available measurement keys."""