mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-04-11 16:26:20 +00:00
feat: add fixed electricity prediction with time window support (#930)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Add a fixed electricity prediction that supports prices per time window.
The time windows may flexible be defined by day or date.
The prediction documentation is updated to also cover the ElecPriceFixed
provider.
The feature includes several changes that are not directly related to the
electricity price prediction implementation but are necessary to keep
EOS running properly and to test and document the changes.
* feat: add value time windows
Add time windows with an associated float value.
* feat: harden eos measurements endpoints error detection and reporting
Cover more errors that may be raised during endpoint access. Report the
errors including trace information to ease debugging.
* feat: extend server configuration to cover all arguments
Make the argument controlled options also available in server configuration.
* fix: eos config configuration by cli arguments
Move the command line argument handling to config eos so that it is
excuted whenever eos config is rebuild or reset.
* chore: extend measurement endpoint system test
* chore: refactor time windows
Move time windows to configabc as they are only used in configurations.
Also move all tests to test_configabc.
* chore: provide config update errors in eosdash with summarized error text
If there is an update error provide the error text as a summary. On click
provide the full error text.
* chore: force eosdash ip address and port in makefile dev run
Ensure eosdash ip address and port are correctly set for development runs.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Button, Div, Li, Select
|
||||
@@ -165,6 +166,44 @@ def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def UpdateError(error_text: str) -> Alert:
|
||||
"""Renders a compact error with collapsible full detail.
|
||||
|
||||
Extracts the short pydantic validation message (text after
|
||||
'validation error for ...') as the summary. Falls back to
|
||||
the first line if no match is found.
|
||||
|
||||
Args:
|
||||
error_text: The full error string from a config update failure.
|
||||
|
||||
Returns:
|
||||
Alert: A collapsible error element with error styling.
|
||||
"""
|
||||
short = None
|
||||
match = re.search(r"validation error for [^\n]+\n([^\n]+)\n\s+([^\[]+)", error_text)
|
||||
if match:
|
||||
short = f"Validation error: {match.group(1).strip()}: {match.group(2).strip()}"
|
||||
if not short:
|
||||
short = error_text.splitlines()[0].strip()
|
||||
|
||||
return Alert(
|
||||
Details(
|
||||
Summary(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(short, cls="text-sm ml-2"),
|
||||
),
|
||||
cls="list-none cursor-pointer",
|
||||
),
|
||||
Pre(
|
||||
Code(error_text, cls="language-python"),
|
||||
cls="rounded-lg bg-muted p-3 mt-2 max-h-[30vh] overflow-y-auto overflow-x-hidden whitespace-pre-wrap text-xs",
|
||||
),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def make_config_update_form() -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that sets a single configuration value.
|
||||
|
||||
@@ -456,6 +495,199 @@ def make_config_update_map_form(
|
||||
return ConfigUpdateMapForm
|
||||
|
||||
|
||||
def make_config_update_time_windows_windows_form(
|
||||
value_description: Optional[str] = None,
|
||||
) -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that edits the windows field of a TimeWindowSequence.
|
||||
|
||||
Args:
|
||||
value_description: If given, a numeric value field is included in the form
|
||||
and shown in the column header (e.g. "electricity_price_kwh [Amt/kWh]").
|
||||
If None, no value field is rendered.
|
||||
"""
|
||||
|
||||
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
current_windows: list[dict] = parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
current_windows = []
|
||||
|
||||
DOW_LABELS = [
|
||||
"0 – Monday",
|
||||
"1 – Tuesday",
|
||||
"2 – Wednesday",
|
||||
"3 – Thursday",
|
||||
"4 – Friday",
|
||||
"5 – Saturday",
|
||||
"6 – Sunday",
|
||||
]
|
||||
|
||||
# ---- Existing windows rows ----
|
||||
window_rows = []
|
||||
for idx, win in enumerate(current_windows):
|
||||
start_time = win.get("start_time", "")
|
||||
duration = win.get("duration", "")
|
||||
dow = win.get("day_of_week")
|
||||
date_val = win.get("date")
|
||||
locale_val = win.get("locale")
|
||||
|
||||
dow_str = f" dow={dow}" if dow is not None else ""
|
||||
date_str = f" date={date_val}" if date_val else ""
|
||||
locale_str = f" locale={locale_val}" if locale_val else ""
|
||||
|
||||
if value_description is not None:
|
||||
val = win.get("value", "")
|
||||
val_str = f" | {val} {value_description}"
|
||||
else:
|
||||
val_str = ""
|
||||
|
||||
label = f"{start_time} | {duration}{val_str}{dow_str}{date_str}{locale_str}"
|
||||
|
||||
remaining = [w for i, w in enumerate(current_windows) if i != idx]
|
||||
remaining_json = json.dumps(json.dumps(remaining))
|
||||
window_rows.append(
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
UkIcon("trash-2"),
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}',
|
||||
cls="px-2 py-1",
|
||||
),
|
||||
P(label, cls="ml-2 text-sm font-mono"),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- Column headers and inputs ----
|
||||
num_cols = 5 + (1 if value_description is not None else 0)
|
||||
|
||||
header_cols = [
|
||||
P("start_time *", cls="text-xs text-muted-foreground font-semibold"),
|
||||
P("duration *", cls="text-xs text-muted-foreground font-semibold"),
|
||||
]
|
||||
input_cols = [
|
||||
Input(
|
||||
placeholder="e.g. 08:00 Europe/Berlin",
|
||||
name=f"{config_id}_tw_start_time",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. 8 hours",
|
||||
name=f"{config_id}_tw_duration",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
]
|
||||
|
||||
if value_description is not None:
|
||||
header_cols.append(
|
||||
P(f"{value_description} *", cls="text-xs text-muted-foreground font-semibold")
|
||||
)
|
||||
input_cols.append(
|
||||
Input(
|
||||
placeholder="e.g. 0.288",
|
||||
name=f"{config_id}_tw_value",
|
||||
type="number",
|
||||
step="0.001",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
)
|
||||
)
|
||||
|
||||
header_cols += [
|
||||
P("day_of_week", cls="text-xs text-muted-foreground font-semibold"),
|
||||
P("date (YYYY-MM-DD)", cls="text-xs text-muted-foreground font-semibold"),
|
||||
P("locale", cls="text-xs text-muted-foreground font-semibold"),
|
||||
]
|
||||
input_cols += [
|
||||
Select(
|
||||
Option("— any day —", value="", selected=True),
|
||||
*[Option(lbl, value=str(i)) for i, lbl in enumerate(DOW_LABELS)],
|
||||
name=f"{config_id}_tw_dow",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. 2025-12-24",
|
||||
name=f"{config_id}_tw_date",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. de",
|
||||
name=f"{config_id}_tw_locale",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
]
|
||||
|
||||
# ---- JS for Add button ----
|
||||
current_json = json.dumps(json.dumps(current_windows))
|
||||
if value_description is not None:
|
||||
val_js_read = f"const val = parseFloat(document.querySelector(\"[name='{config_id}_tw_value']\").value);"
|
||||
val_js_guard = "isNaN(val)"
|
||||
val_js_field = "value: val,"
|
||||
else:
|
||||
val_js_read = ""
|
||||
val_js_guard = "false"
|
||||
val_js_field = ""
|
||||
|
||||
add_section = Grid(
|
||||
Grid(*header_cols, cols=num_cols),
|
||||
Grid(*input_cols, cols=num_cols),
|
||||
ConfigButton(
|
||||
UkIcon("plus"),
|
||||
" Add window",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f"""js:{{
|
||||
action: "update",
|
||||
key: "{config_name}",
|
||||
value: (() => {{
|
||||
const start = document.querySelector("[name='{config_id}_tw_start_time']").value.trim();
|
||||
const dur = document.querySelector("[name='{config_id}_tw_duration']").value.trim();
|
||||
{val_js_read}
|
||||
const dowRaw = document.querySelector("[name='{config_id}_tw_dow']").value;
|
||||
const date = document.querySelector("[name='{config_id}_tw_date']").value.trim();
|
||||
const locale = document.querySelector("[name='{config_id}_tw_locale']").value.trim();
|
||||
if (!start || !dur || {val_js_guard}) return {current_json};
|
||||
const newWin = {{
|
||||
start_time: start,
|
||||
duration: dur,
|
||||
{val_js_field}
|
||||
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
||||
date: date !== "" ? date : null,
|
||||
locale: locale !== "" ? locale : null,
|
||||
}};
|
||||
const existing = {json.dumps(current_windows)};
|
||||
existing.push(newWin);
|
||||
return JSON.stringify(existing);
|
||||
}})()
|
||||
}}""",
|
||||
),
|
||||
cols=1,
|
||||
cls="gap-2 mt-2",
|
||||
)
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update time windows")),
|
||||
Grid(
|
||||
*window_rows,
|
||||
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
|
||||
P(
|
||||
"* required | day_of_week: overridden by date if both set",
|
||||
cls="text-xs text-muted-foreground mb-1",
|
||||
),
|
||||
add_section,
|
||||
cols=1,
|
||||
cls="gap-1",
|
||||
),
|
||||
id=f"{config_id}-update-time-windows-windows-form",
|
||||
)
|
||||
|
||||
return ConfigUpdateTimeWindowsWindowsForm
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
@@ -548,7 +780,7 @@ def ConfigCard(
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
TextView(update_error),
|
||||
UpdateError(update_error),
|
||||
)
|
||||
if update_error
|
||||
else None,
|
||||
|
||||
@@ -34,6 +34,7 @@ from akkudoktoreos.server.dash.components import (
|
||||
TextView,
|
||||
make_config_update_list_form,
|
||||
make_config_update_map_form,
|
||||
make_config_update_time_windows_windows_form,
|
||||
make_config_update_value_form,
|
||||
)
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
@@ -730,6 +731,10 @@ def Configuration(
|
||||
update_form_factory = make_config_update_value_form(
|
||||
["OPTIMIZATION", "PREDICTION", "None"]
|
||||
)
|
||||
elif config["name"].endswith("elecpricefixed.time_windows.windows"):
|
||||
update_form_factory = make_config_update_time_windows_windows_form(
|
||||
value_description="electricity_price_kwh [Amt/kWh]"
|
||||
)
|
||||
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
@@ -15,7 +14,7 @@ import psutil
|
||||
import uvicorn
|
||||
from fastapi import Body, FastAPI
|
||||
from fastapi import Path as FastapiPath
|
||||
from fastapi import Query, Request
|
||||
from fastapi import Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
@@ -57,13 +56,13 @@ from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
|
||||
from akkudoktoreos.prediction.load import LoadCommonSettings
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.server.rest.cli import cli_apply_args_to_config, cli_parse_args
|
||||
from akkudoktoreos.server.rest.error import create_error_page
|
||||
from akkudoktoreos.server.rest.starteosdash import supervise_eosdash
|
||||
from akkudoktoreos.server.retentionmanager import RetentionManager
|
||||
from akkudoktoreos.server.server import (
|
||||
drop_root_privileges,
|
||||
fix_data_directories_permissions,
|
||||
get_default_host,
|
||||
get_host_ip,
|
||||
wait_for_port_free,
|
||||
)
|
||||
@@ -458,12 +457,12 @@ def fastapi_config_revert_put(
|
||||
"""
|
||||
try:
|
||||
get_config().revert_settings(backup_id)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on reverting of configuration: {e}",
|
||||
)
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.put("/v1/config/file", tags=["config"])
|
||||
@@ -475,12 +474,12 @@ def fastapi_config_file_put() -> ConfigEOS:
|
||||
"""
|
||||
try:
|
||||
get_config().to_config_file()
|
||||
return get_config()
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Cannot save configuration to file '{get_config().config_file_path}'.",
|
||||
)
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.get("/v1/config", tags=["config"])
|
||||
@@ -490,7 +489,10 @@ def fastapi_config_get() -> ConfigEOS:
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration.
|
||||
"""
|
||||
return get_config()
|
||||
try:
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on configuration retrieval: {e}")
|
||||
|
||||
|
||||
@app.put("/v1/config", tags=["config"])
|
||||
@@ -509,9 +511,9 @@ def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
|
||||
"""
|
||||
try:
|
||||
get_config().merge_settings(settings)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.put("/v1/config/{path:path}", tags=["config"])
|
||||
@@ -534,6 +536,7 @@ def fastapi_config_put_key(
|
||||
"""
|
||||
try:
|
||||
get_config().set_nested_value(path, value)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
raise HTTPException(
|
||||
@@ -541,8 +544,6 @@ def fastapi_config_put_key(
|
||||
detail=f"Error on update of configuration '{path}','{value}':\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.get("/v1/config/{path:path}", tags=["config"])
|
||||
def fastapi_config_get_key(
|
||||
@@ -660,8 +661,17 @@ def fastapi_devices_status_put(
|
||||
|
||||
@app.get("/v1/measurement/keys", tags=["measurement"])
|
||||
def fastapi_measurement_keys_get() -> list[str]:
|
||||
"""Get a list of available measurement keys."""
|
||||
return sorted(get_measurement().record_keys)
|
||||
try:
|
||||
"""Get a list of available measurement keys."""
|
||||
return sorted(get_measurement().record_keys)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception("Unexpected error retieving measurement keys")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/measurement/series", tags=["measurement"])
|
||||
@@ -669,10 +679,22 @@ def fastapi_measurement_series_get(
|
||||
key: Annotated[str, Query(description="Measurement key.")],
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Get the measurements of given key as series."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
try:
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error retieving measurement: {key}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/value", tags=["measurement"])
|
||||
@@ -682,19 +704,42 @@ def fastapi_measurement_value_put(
|
||||
value: Union[float | str],
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Merge the measurement of given key and value into EOS measurements at given datetime."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
if isinstance(value, str):
|
||||
# Try to convert to float
|
||||
try:
|
||||
value = float(value)
|
||||
except:
|
||||
logger.debug(
|
||||
f'/v1/measurement/value key: {key} value: "{value}" - string value not convertable to float'
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value '{value}' cannot be converted to float",
|
||||
)
|
||||
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Key '{key}' not found in measurements",
|
||||
)
|
||||
get_measurement().update_value(datetime, key, value)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
try:
|
||||
dt = to_datetime(datetime)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid datetime '{datetime}': {e}",
|
||||
)
|
||||
|
||||
get_measurement().update_value(dt, key, value)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {datetime}, {key}, {value}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/series", tags=["measurement"])
|
||||
@@ -702,26 +747,56 @@ def fastapi_measurement_series_put(
|
||||
key: Annotated[str, Query(description="Measurement key.")], series: PydanticDateTimeSeries
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Merge measurement given as series into given key."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = series.to_series() # make pandas series from PydanticDateTimeSeries
|
||||
get_measurement().key_from_series(key=key, series=pdseries)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
try:
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = series.to_series() # make pandas series from PydanticDateTimeSeries
|
||||
get_measurement().key_from_series(key=key, series=pdseries)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {key}, {series}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/dataframe", tags=["measurement"])
|
||||
def fastapi_measurement_dataframe_put(data: PydanticDateTimeDataFrame) -> None:
|
||||
"""Merge the measurement data given as dataframe into EOS measurements."""
|
||||
dataframe = data.to_dataframe()
|
||||
get_measurement().import_from_dataframe(dataframe)
|
||||
try:
|
||||
dataframe = data.to_dataframe()
|
||||
get_measurement().import_from_dataframe(dataframe)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/data", tags=["measurement"])
|
||||
def fastapi_measurement_data_put(data: PydanticDateTimeData) -> None:
|
||||
"""Merge the measurement data given as datetime data into EOS measurements."""
|
||||
datetimedata = data.to_dict()
|
||||
get_measurement().import_from_dict(datetimedata)
|
||||
try:
|
||||
datetimedata = data.to_dict()
|
||||
get_measurement().import_from_dict(datetimedata)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/prediction/providers", tags=["prediction"])
|
||||
@@ -1427,52 +1502,42 @@ def run_eos() -> None:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# get_config(init=True) creates the configuration
|
||||
# this should not be done before nor later
|
||||
# get_config(init=True) creates the configuration, includes also ARGV args.
|
||||
# This should not be done before nor later
|
||||
config_eos = get_config(init=True)
|
||||
|
||||
# set logging to what is in config
|
||||
# Set logging to what is in config
|
||||
logger.remove()
|
||||
logging_track_config(config_eos, "logging", None, None)
|
||||
|
||||
# make logger track logging changes in config
|
||||
# Make logger track logging changes in config
|
||||
config_eos.track_nested_value("/logging", logging_track_config)
|
||||
|
||||
# Set config to actual environment variable & config file content
|
||||
config_eos.reset_settings()
|
||||
# Ensure host and port config settings are at least set to default values
|
||||
if config_eos.server.host is None:
|
||||
config_eos.set_nested_value("server/host", get_default_host())
|
||||
if config_eos.server.port is None:
|
||||
config_eos.set_nested_value("server/port", 8503)
|
||||
|
||||
# add arguments to config
|
||||
args: argparse.Namespace
|
||||
args_unknown: list[str]
|
||||
args, args_unknown = cli_parse_args()
|
||||
cli_apply_args_to_config(args)
|
||||
if config_eos.server.port is None: # make mypy happy
|
||||
raise RuntimeError("server.port is None despite default setup")
|
||||
|
||||
# prepare runtime arguments
|
||||
if args:
|
||||
run_as_user = args.run_as_user
|
||||
# Setup EOS reload for development
|
||||
if args.reload is None:
|
||||
reload = False
|
||||
else:
|
||||
logger.debug(f"reload set by argument to {args.reload}")
|
||||
reload = args.reload
|
||||
else:
|
||||
run_as_user = None
|
||||
if config_eos.server.eosdash_host is None:
|
||||
config_eos.set_nested_value("server/eosdash_host", config_eos.server.host)
|
||||
if config_eos.server.eosdash_port is None:
|
||||
config_eos.set_nested_value("server/eosdash_port", config_eos.server.port + 1)
|
||||
|
||||
# Switch data directories ownership to user
|
||||
fix_data_directories_permissions(run_as_user=run_as_user)
|
||||
fix_data_directories_permissions(run_as_user=config_eos.server.run_as_user)
|
||||
|
||||
# Switch privileges to run_as_user
|
||||
drop_root_privileges(run_as_user=run_as_user)
|
||||
drop_root_privileges(run_as_user=config_eos.server.run_as_user)
|
||||
|
||||
# Init the other singletons (besides config_eos)
|
||||
singletons_init()
|
||||
|
||||
# Wait for EOS port to be free - e.g. in case of restart
|
||||
port = config_eos.server.port
|
||||
if port is None:
|
||||
port = 8503
|
||||
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
|
||||
wait_for_port_free(config_eos.server.port, timeout=120, waiting_app_name="EOS")
|
||||
|
||||
# Normalize log_level to uvicorn log level
|
||||
VALID_UVICORN_LEVELS = {"critical", "error", "warning", "info", "debug", "trace"}
|
||||
@@ -1486,15 +1551,21 @@ def run_eos() -> None:
|
||||
elif uv_log_level not in VALID_UVICORN_LEVELS:
|
||||
uv_log_level = "info" # fallback
|
||||
|
||||
logger.info(f"Starting EOS server on {config_eos.server.host}:{config_eos.server.port}")
|
||||
if config_eos.server.startup_eosdash:
|
||||
logger.info(
|
||||
f"EOSdash will be available at {config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Let uvicorn run the fastAPI app
|
||||
uvicorn.run(
|
||||
"akkudoktoreos.server.eos:app",
|
||||
host=str(config_eos.server.host),
|
||||
port=port,
|
||||
port=config_eos.server.port,
|
||||
log_level=uv_log_level,
|
||||
access_log=True, # Fix server access logging to True
|
||||
reload=reload,
|
||||
reload=config_eos.server.reload,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
@@ -1504,12 +1575,7 @@ def run_eos() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse command-line arguments and start the EOS server with the specified options.
|
||||
|
||||
This function sets up the argument parser to accept command-line arguments for
|
||||
host, port, log_level, access_log, and reload. It uses default values from the
|
||||
config_eos module if arguments are not provided. After parsing the arguments,
|
||||
it starts the EOS server with the specified configurations.
|
||||
"""Start the EOS server with the specified options.
|
||||
|
||||
Command-line Arguments:
|
||||
--host (str): Host for the EOS server (default: value from config).
|
||||
|
||||
@@ -485,6 +485,10 @@ def run_eosdash() -> None:
|
||||
elif uv_log_level not in VALID_UVICORN_LEVELS:
|
||||
uv_log_level = "info" # fallback
|
||||
|
||||
logger.info(
|
||||
f"Starting EOSdash server on {config_eosdash['eosdash_host']}:{config_eosdash['eosdash_port']}"
|
||||
)
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
"akkudoktoreos.server.eosdash:app",
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import argparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.server.server import get_default_host
|
||||
from akkudoktoreos.utils.stringutil import str2bool
|
||||
|
||||
|
||||
@@ -71,79 +66,3 @@ def cli_parse_args(
|
||||
"""
|
||||
args, args_unknown = cli_argument_parser().parse_known_args(argv)
|
||||
return args, args_unknown
|
||||
|
||||
|
||||
def cli_apply_args_to_config(args: argparse.Namespace) -> None:
|
||||
"""Apply parsed CLI arguments to the EOS configuration.
|
||||
|
||||
This function updates the EOS configuration with values provided via
|
||||
the command line. For each parameter, the precedence is:
|
||||
|
||||
CLI argument > existing config value > default value
|
||||
|
||||
Currently handled arguments:
|
||||
|
||||
- log_level: Updates "logging/console_level" in config.
|
||||
- host: Updates "server/host" in config.
|
||||
- port: Updates "server/port" in config.
|
||||
- startup_eosdash: Updates "server/startup_eosdash" in config.
|
||||
- eosdash_host/port: Initialized if EOSdash is enabled and not already set.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments from argparse.
|
||||
"""
|
||||
config_eos = get_config()
|
||||
|
||||
# Setup parameters from args, config_eos and default
|
||||
# Remember parameters in config
|
||||
|
||||
# Setup EOS logging level - first to have the other logging messages logged
|
||||
if args.log_level is not None:
|
||||
log_level = args.log_level.upper()
|
||||
# Ensure log_level from command line is in config settings
|
||||
if log_level in LOGGING_LEVELS:
|
||||
# Setup console logging level using nested value
|
||||
# - triggers logging configuration by logging_track_config
|
||||
config_eos.set_nested_value("logging/console_level", log_level)
|
||||
logger.debug(f"logging/console_level configuration set by argument to {log_level}")
|
||||
|
||||
# Setup EOS server host
|
||||
if args.host:
|
||||
host = args.host
|
||||
logger.debug(f"server/host configuration set by argument to {host}")
|
||||
elif config_eos.server.host:
|
||||
host = config_eos.server.host
|
||||
else:
|
||||
host = get_default_host()
|
||||
# Ensure host from command line is in config settings
|
||||
config_eos.set_nested_value("server/host", host)
|
||||
|
||||
# Setup EOS server port
|
||||
if args.port:
|
||||
port = args.port
|
||||
logger.debug(f"server/port configuration set by argument to {port}")
|
||||
elif config_eos.server.port:
|
||||
port = config_eos.server.port
|
||||
else:
|
||||
port = 8503
|
||||
# Ensure port from command line is in config settings
|
||||
config_eos.set_nested_value("server/port", port)
|
||||
|
||||
# Setup EOSdash startup
|
||||
if args.startup_eosdash is not None:
|
||||
# Ensure startup_eosdash from command line is in config settings
|
||||
config_eos.set_nested_value("server/startup_eosdash", args.startup_eosdash)
|
||||
logger.debug(
|
||||
f"server/startup_eosdash configuration set by argument to {args.startup_eosdash}"
|
||||
)
|
||||
|
||||
if config_eos.server.startup_eosdash:
|
||||
# Ensure EOSdash host and port config settings are at least set to default values
|
||||
|
||||
# Setup EOS server host
|
||||
if config_eos.server.eosdash_host is None:
|
||||
config_eos.set_nested_value("server/eosdash_host", host)
|
||||
|
||||
# Setup EOS server host
|
||||
if config_eos.server.eosdash_port is None:
|
||||
config_eos.set_nested_value("server/eosdash_port", port + 1)
|
||||
|
||||
@@ -309,8 +309,14 @@ async def supervise_eosdash() -> None:
|
||||
config_eos = get_config()
|
||||
|
||||
# Skip if EOSdash not configured to start
|
||||
if not getattr(config_eos.server, "startup_eosdash", False):
|
||||
startup_eosdash = config_eos.server.startup_eosdash
|
||||
if not startup_eosdash:
|
||||
logger.debug(
|
||||
f"EOSdash subprocess not monitored - startup_eosdash not set: '{startup_eosdash}'"
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.debug(f"EOSdash subprocess monitored - startup_eosdash set: '{startup_eosdash}'")
|
||||
|
||||
host = config_eos.server.eosdash_host
|
||||
port = config_eos.server.eosdash_port
|
||||
|
||||
@@ -374,6 +374,31 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
],
|
||||
},
|
||||
)
|
||||
run_as_user: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"The name of the target user to switch to. If ``None`` (default), the current "
|
||||
"effective user is used and no privilege change is attempted."
|
||||
),
|
||||
"examples": [
|
||||
None,
|
||||
"user",
|
||||
],
|
||||
},
|
||||
)
|
||||
reload: Optional[bool] = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Enable server auto-reload for debugging or development. Default is False. "
|
||||
"Monitors the package directory for changes and reloads the server."
|
||||
),
|
||||
"examples": [
|
||||
True,
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("host", "eosdash_host", mode="before")
|
||||
def validate_server_host(cls, value: Optional[str]) -> Optional[str]:
|
||||
@@ -386,3 +411,13 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
if value is not None and not (1024 <= value <= 49151):
|
||||
raise ValueError("Server port number must be between 1024 and 49151.")
|
||||
return value
|
||||
|
||||
@field_validator("run_as_user")
|
||||
def validate_user(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is not None:
|
||||
# Resolve target user info
|
||||
try:
|
||||
pw_record = pwd.getpwnam(value)
|
||||
except KeyError:
|
||||
raise ValueError(f"User '{value}' does not exist.")
|
||||
return value
|
||||
|
||||
Reference in New Issue
Block a user