mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-19 20:31:58 +00:00
feat: add pvlib pv forecast provider (#1214)
Add a PV forecast provider that calculates the forecast using a PVLib system model and weather forecast from the EOS weather forecast provider. Additional module and inverter models can be easily added as the database is build from PVLib and SAM databases and a bundled csv file. The module model and inververt model names are provided by new endpoints to be used in configuration. The provider is based on the fantastic work of EMHASS. See https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py A short description of the provider is added to the documentation. Besides the new features there are the fixes and improvements: * feat: improve EOSdash config page * fix: kex_to_series for start_datetime Make key_to_series always start the series at start_datetime. * fix: default provider for GENETIC and GENETIC0 optimization To make the default less dependent on internet servers (with API changes and availability issues) the default for PVForecast is set to PVForecastPVLib and for ElecPrice to ElecPriceFixed. The default weather provider is changed to OpenMeteo. * fix: EOSdash display resampled prediction values Make EOSdash display resampled prediction values where resampling fits to the prediction value type. Use bar width that fits to 15 minutes value samples. * chore: add a UI hints system to EOSdash The UI hints system eases the definition of forms for configuration items. There are also forms for items in maps and lists. These forms allow to add and delete items to/ from maps and lists. The forms ensure that all required fields of newly added items are filled. * chore: Create an enum for valid optimization algorithms * chore. Make config also provide the available energy management modes. Used for configuration hints. * chore: Randomize default device id in configuration Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
"""Generic expandable map-of-sub-model configuration card for EOSdash.
|
||||
|
||||
This module provides `ConfigMapCard`, a reusable FastHTML/MonsterUI
|
||||
card component that renders any ``dict[str, PydanticSubModel]`` config field
|
||||
as a collapsible outer card containing one collapsible inner card per map
|
||||
entry, keyed by a user-supplied string name.
|
||||
|
||||
It is intentionally free of imports from ``configuration.py`` to avoid
|
||||
circular dependencies. The one runtime dependency on
|
||||
``create_config_details`` is injected by the caller.
|
||||
|
||||
The structure mirrors ``itemscard.py`` with these key differences:
|
||||
|
||||
- The stored value is ``dict[str, dict]`` rather than ``list[dict]``.
|
||||
- The "Add entry" control includes a text input for the key name.
|
||||
- Delete removes by key rather than by index.
|
||||
- Inner card headers display the string key instead of a numeric index.
|
||||
- ``create_config_details`` is called with the string key as the final
|
||||
``values_prefix`` segment.
|
||||
|
||||
Typical usage in ``configuration.py``::
|
||||
|
||||
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
|
||||
|
||||
hint = UI_HINTS.get(config["name"])
|
||||
if hint and hint.form == "map_items" and not config["deprecated"]:
|
||||
rows.append(
|
||||
ConfigMapCard(
|
||||
config=config,
|
||||
hint=hint,
|
||||
config_details=config_details,
|
||||
config_update_latest=config_update_latest,
|
||||
create_config_details=create_config_details,
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from loguru import logger
|
||||
from monsterui.franken import (
|
||||
H4,
|
||||
Card,
|
||||
Details,
|
||||
Div,
|
||||
DivHStacked,
|
||||
DivLAligned,
|
||||
DivRAligned,
|
||||
Form,
|
||||
Grid,
|
||||
Input,
|
||||
Kbd,
|
||||
P,
|
||||
Summary,
|
||||
UkIcon,
|
||||
)
|
||||
|
||||
from akkudoktoreos.server.dash.carditems import (
|
||||
item_model_defaults,
|
||||
required_field_inputs,
|
||||
)
|
||||
from akkudoktoreos.server.dash.components import (
|
||||
ConfigButton,
|
||||
ConfigCard,
|
||||
JsonView,
|
||||
UpdateError,
|
||||
)
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
from akkudoktoreos.server.dash.markdown import Markdown
|
||||
from akkudoktoreos.server.dash.uihints import (
|
||||
UiHint,
|
||||
hint_for_indexed_field,
|
||||
resolve_form_factory,
|
||||
resolve_item_model,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_control(
|
||||
config_name: str,
|
||||
items_map: dict,
|
||||
item_model: Any,
|
||||
config_id: str,
|
||||
read_only: str,
|
||||
) -> Any:
|
||||
"""Build the "Add entry" control.
|
||||
|
||||
Always includes a text input for the new key name. When the item model
|
||||
can be fully defaulted, that's the only input needed — clicking "Add
|
||||
entry" reads the key and submits the map with the new key set to the
|
||||
model defaults. When required fields have no default (e.g.
|
||||
`consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), the
|
||||
control also renders inputs for those fields, all wrapped in a form so
|
||||
HTML `required` blocks submission until the key and every required
|
||||
field are filled in — otherwise clicking "Add entry" would persist an
|
||||
entry that fails the model's own validation.
|
||||
|
||||
If the typed key already exists the existing entry is overwritten — this
|
||||
is intentional and allows renaming-by-copy when combined with delete.
|
||||
|
||||
Args:
|
||||
config_name: Dotted config key name.
|
||||
items_map: The current map, used as the base for the JS merge.
|
||||
item_model: The Pydantic model class or instance for one map entry.
|
||||
config_id: CSS-safe version of ``config_name`` (dots replaced with
|
||||
hyphens), used to scope element ids and the key input's name.
|
||||
|
||||
Returns:
|
||||
A ``Form`` containing the key input, any required-field inputs, and
|
||||
the "Add entry" button.
|
||||
"""
|
||||
new_entry_defaults, required_missing = item_model_defaults(item_model)
|
||||
|
||||
id_prefix = f"{config_id}-new-entry"
|
||||
inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix)
|
||||
extra_js = f"{{ {', '.join(js_pairs)} }}" if js_pairs else "{}"
|
||||
|
||||
current_json = json.dumps(items_map)
|
||||
defaults_json = json.dumps(new_entry_defaults)
|
||||
|
||||
build_value_expr = f"""(() => {{
|
||||
const k = document.querySelector("[name='{config_id}_new_key']").value.trim();
|
||||
if (!k) return {json.dumps(json.dumps(items_map))};
|
||||
const defaults = {defaults_json};
|
||||
const extra = {extra_js};
|
||||
Object.assign(defaults, extra);
|
||||
if ('device_id' in defaults) defaults.device_id = k;
|
||||
const updated = Object.assign({{}}, {current_json}, {{ [k]: defaults }});
|
||||
return JSON.stringify(updated);
|
||||
}})()"""
|
||||
|
||||
return Form(
|
||||
Grid(
|
||||
Input(
|
||||
placeholder="Entry name / key",
|
||||
name=f"{config_id}_new_key",
|
||||
id=f"{config_id}-new-key",
|
||||
required=True,
|
||||
cls="border rounded px-3 py-2 text-sm",
|
||||
),
|
||||
*inputs,
|
||||
cols=2,
|
||||
cls="gap-2",
|
||||
),
|
||||
ConfigButton(
|
||||
UkIcon("plus"),
|
||||
" Add entry",
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {build_value_expr} }}',
|
||||
cls="mt-2",
|
||||
),
|
||||
cls="space-y-2 mt-3",
|
||||
)
|
||||
|
||||
|
||||
def _delete_control(config_name: str, items_map: dict, key: str) -> Details:
|
||||
"""Build the two-click delete control for a single inner entry card header.
|
||||
|
||||
The first click opens a ``<details>`` panel revealing a red "Confirm
|
||||
delete" button. Clicking outside collapses it. The second click submits
|
||||
an ``hx_put`` with the map minus the given key.
|
||||
|
||||
Args:
|
||||
config_name: Dotted config key name, e.g. ``"devices.batteries"``.
|
||||
items_map: The current full map of entries.
|
||||
key: The string key of the entry to delete.
|
||||
|
||||
Returns:
|
||||
A ``Details`` component implementing the two-click confirm pattern.
|
||||
"""
|
||||
remaining = {k: v for k, v in items_map.items() if k != key}
|
||||
remaining_json = json.dumps(json.dumps(remaining))
|
||||
return Details(
|
||||
Summary(
|
||||
UkIcon("trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"),
|
||||
cls="list-none",
|
||||
),
|
||||
Div(
|
||||
ConfigButton(
|
||||
UkIcon("trash-2"),
|
||||
" Confirm delete",
|
||||
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-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
),
|
||||
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
|
||||
),
|
||||
cls="relative",
|
||||
)
|
||||
|
||||
|
||||
def _inner_card(
|
||||
config_name: str,
|
||||
item_path: str,
|
||||
key: str,
|
||||
item_value: str,
|
||||
is_empty: bool,
|
||||
read_only: str,
|
||||
item_rows: list,
|
||||
item_update_open: bool,
|
||||
delete_control: Optional[Details],
|
||||
) -> Card:
|
||||
"""Render a single collapsible inner card for one map entry.
|
||||
|
||||
Args:
|
||||
config_name: Dotted config key of the parent map field.
|
||||
item_path: Dotted path prefix for this entry type, e.g.
|
||||
``"devices.batteries"``.
|
||||
key: The string key identifying this entry in the map.
|
||||
item_value: JSON-encoded current value of this entry.
|
||||
is_empty: ``True`` when the entry dict is falsy (empty or ``None``).
|
||||
read_only: ``"rw"`` or ``"ro"`` inherited from the parent field.
|
||||
item_rows: Pre-built list of ``ConfigCard`` children for this entry.
|
||||
item_update_open: Whether this card should start expanded.
|
||||
delete_control: The two-click delete ``Details`` widget, or ``None``
|
||||
for read-only fields.
|
||||
|
||||
Returns:
|
||||
A ``Card`` component for this map entry.
|
||||
"""
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
H4(
|
||||
f"{item_path}.{key}",
|
||||
cls="text-muted-foreground" if is_empty else "",
|
||||
),
|
||||
delete_control,
|
||||
),
|
||||
DivRAligned(
|
||||
P(
|
||||
"empty" if is_empty else read_only,
|
||||
cls="text-xs text-muted-foreground" if is_empty else "",
|
||||
),
|
||||
),
|
||||
),
|
||||
JsonView(json.loads(item_value)),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
*item_rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=item_update_open,
|
||||
),
|
||||
cls=f"w-full {'opacity-60' if is_empty else ''}",
|
||||
)
|
||||
|
||||
|
||||
def _outer_card(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
read_only: str,
|
||||
value: str,
|
||||
default: str,
|
||||
description: str,
|
||||
scope: Optional[list[str]],
|
||||
num_entries: int,
|
||||
items_update_value: str,
|
||||
items_update_error: Optional[str],
|
||||
items_update_open: bool,
|
||||
rows: list,
|
||||
add_control: Optional[Grid],
|
||||
) -> Card:
|
||||
"""Render the outer collapsible card for the whole map field.
|
||||
|
||||
Args:
|
||||
config_name: Dotted config key name.
|
||||
config_type: Human-readable type string from config details.
|
||||
read_only: ``"rw"`` or ``"ro"``.
|
||||
value: JSON-encoded current map value.
|
||||
default: JSON-encoded default value.
|
||||
description: Field description text.
|
||||
num_entries: Current number of entries, shown as a badge.
|
||||
items_update_value: Value to pre-fill the fallback text input.
|
||||
items_update_error: Error string from the last failed update, or
|
||||
``None``.
|
||||
items_update_open: Whether the outer card starts expanded.
|
||||
rows: Pre-built list of inner ``Card`` components.
|
||||
add_control: The "Add entry" ``Grid`` widget, or ``None`` for
|
||||
read-only fields.
|
||||
|
||||
Returns:
|
||||
The outer ``Card`` component.
|
||||
"""
|
||||
return Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
Grid(
|
||||
DivLAligned(
|
||||
UkIcon(icon="play"),
|
||||
P(config_name),
|
||||
P(
|
||||
f"{num_entries} entr{'ies' if num_entries != 1 else 'y'}",
|
||||
cls="ml-2 text-xs text-muted-foreground",
|
||||
),
|
||||
),
|
||||
DivRAligned(P(read_only)),
|
||||
),
|
||||
JsonView(json.loads(value)),
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
# Add entry control below summary
|
||||
add_control,
|
||||
Grid(
|
||||
Div(
|
||||
DivHStacked(*[Kbd(s) for s in scope]) if scope else None,
|
||||
Markdown(description),
|
||||
),
|
||||
P(config_type),
|
||||
),
|
||||
# Default value row
|
||||
Grid(
|
||||
DivRAligned(P("default")),
|
||||
P(default),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Raw JSON fallback update form
|
||||
Grid(
|
||||
DivRAligned(P("update")),
|
||||
Grid(
|
||||
Form(
|
||||
Input(value="update", type="hidden", id="action"),
|
||||
Input(value=config_name, type="hidden", id="key"),
|
||||
Input(value=items_update_value, type="text", id="value"),
|
||||
hx_put=request_url_for("/eosdash/configuration"),
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
),
|
||||
)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
# Last update error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
UpdateError(items_update_error),
|
||||
)
|
||||
if items_update_error
|
||||
else None,
|
||||
# Per-entry inner cards
|
||||
*rows,
|
||||
cls="space-y-4 gap-4",
|
||||
open=items_update_open,
|
||||
),
|
||||
cls="w-full",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ConfigMapCard(
|
||||
config: dict,
|
||||
hint: UiHint,
|
||||
config_details: dict[str, dict],
|
||||
config_update_latest: dict[str, dict],
|
||||
create_config_details: Callable,
|
||||
) -> Card:
|
||||
"""Creates a styled configuration card for a map of Pydantic sub-model entries.
|
||||
|
||||
Renders a collapsible outer card representing the map field as a whole,
|
||||
containing one collapsible inner card per map entry keyed by a string
|
||||
name. Each inner card expands into individual ``ConfigCard`` rows for
|
||||
every field of the entry's Pydantic sub-model.
|
||||
|
||||
The map contents are driven entirely by user interaction. An "Add entry"
|
||||
control at the bottom of the outer card accepts a key name and appends a
|
||||
new entry pre-filled with the sub-model's Pydantic field defaults. Each
|
||||
inner card header carries a trash icon that arms on first click (showing
|
||||
a red "Confirm delete" button via a ``<details>`` toggle) and deletes on
|
||||
the second click, with no modal required.
|
||||
|
||||
Per-entry field forms are resolved via ``hint_for_indexed_field`` using
|
||||
the parent hint's ``item_path``, so per-field UI customisation
|
||||
(dropdowns, selects, etc.) is driven entirely by ``UI_HINTS`` entries —
|
||||
no hard-coded field-name checks are needed here.
|
||||
|
||||
The outer card always includes a plain-text fallback update form for the
|
||||
whole map value so that recovery from a validation error is always
|
||||
possible.
|
||||
|
||||
Args:
|
||||
config: A single entry from the ``config_details`` dict produced by
|
||||
``create_config_details()``. Must contain the keys ``"name"``,
|
||||
``"type"``, ``"read-only"``, ``"value"``, ``"default"``,
|
||||
``"description"``, ``"deprecated"``, and ``"scope"``.
|
||||
hint: The ``UiHint`` for this field. Must have
|
||||
``form == "map_items"`` and valid ``item_model`` (resolved via
|
||||
``resolve_item_model``) and ``item_path`` values.
|
||||
config_details: The full config detail dict for the current page
|
||||
render, used to look up per-entry field update state.
|
||||
config_update_latest: The module-level dict that tracks the most
|
||||
recent update attempt for each config key, with sub-keys
|
||||
``"error"``, ``"value"``, and ``"open"``.
|
||||
create_config_details: The ``create_config_details`` callable from
|
||||
``configuration.py``, injected to avoid a circular import.
|
||||
Signature: ``(model, values, values_prefix) -> dict[str, dict]``.
|
||||
|
||||
Returns:
|
||||
Card: A fully rendered outer ``Card`` component containing the map
|
||||
summary with entry count, description, default value row, a raw-JSON
|
||||
fallback update form, an optional error row, one collapsible inner
|
||||
``Card`` per existing entry each with a two-click delete control, and
|
||||
an "Add entry" control at the bottom.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``update_error``, ``update_value``, or ``update_open``
|
||||
retrieved from ``config_update_latest`` are not of the expected
|
||||
types (``str | None``, ``str | None``, ``bool | None``
|
||||
respectively). This should never trigger in normal operation but
|
||||
is checked explicitly to satisfy static analysis.
|
||||
|
||||
Example:
|
||||
Typical call from inside the ``Configuration()`` render loop::
|
||||
|
||||
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
|
||||
|
||||
hint = UI_HINTS.get(config["name"])
|
||||
if hint and hint.form == "map_items" and not config["deprecated"]:
|
||||
rows.append(
|
||||
ConfigMapCard(
|
||||
config=config,
|
||||
hint=hint,
|
||||
config_details=config_details,
|
||||
config_update_latest=config_update_latest,
|
||||
create_config_details=create_config_details,
|
||||
)
|
||||
)
|
||||
"""
|
||||
config_name = config["name"]
|
||||
config_type = config["type"]
|
||||
read_only = config["read-only"]
|
||||
value = config["value"]
|
||||
default = config["default"]
|
||||
description = config["description"]
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
item_model = resolve_item_model(hint)
|
||||
item_path = hint.item_path # e.g. "devices.batteries"
|
||||
if item_path is None:
|
||||
raise ValueError(f"Hint needs item_path to be mapped. Got {hint}")
|
||||
path_parts = item_path.split(".") # e.g. ["devices", "batteries"]
|
||||
|
||||
items_map = json.loads(value) or {}
|
||||
num_entries = len(items_map)
|
||||
|
||||
# Outer card update state — resolved once before the inner loop
|
||||
items_update_error = config_update_latest.get(config_name, {}).get("error")
|
||||
items_update_value = config_update_latest.get(config_name, {}).get("value") or value
|
||||
items_update_open = config_update_latest.get(config_name, {}).get("open") or False
|
||||
|
||||
# Add entry control (key input + button) shown at the bottom of the card.
|
||||
# One-click append when the item model is fully defaulted, otherwise an inline form that
|
||||
# collects required fields before the PUT fires (see _add_control / _item_model_defaults
|
||||
# docstrings).
|
||||
add_control = _add_control(
|
||||
config_name=config_name,
|
||||
items_map=items_map,
|
||||
item_model=item_model,
|
||||
config_id=config_id,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
# Build inner cards — one per map key, sorted for stable ordering
|
||||
rows = []
|
||||
for key in sorted(items_map.keys()):
|
||||
entry = items_map[key]
|
||||
|
||||
# Synthetic wrapper: e.g. {"devices": {"batteries": {"bat1": {...}}}}
|
||||
wrapped = {key: entry}
|
||||
for part in reversed(path_parts):
|
||||
wrapped = {part: wrapped}
|
||||
|
||||
item_config = create_config_details(
|
||||
item_model,
|
||||
wrapped,
|
||||
values_prefix=path_parts + [key],
|
||||
)
|
||||
|
||||
item_rows = []
|
||||
item_update_open = False
|
||||
item_value = json.dumps(entry) if entry is not None else json.dumps(None)
|
||||
is_empty = not entry
|
||||
|
||||
for field_key in sorted(item_config.keys()):
|
||||
sub = item_config[field_key]
|
||||
update_error = config_update_latest.get(sub["name"], {}).get("error")
|
||||
update_value = config_update_latest.get(sub["name"], {}).get("value")
|
||||
update_open = config_update_latest.get(sub["name"], {}).get("open")
|
||||
if update_open:
|
||||
items_update_open = True # bubble up to outer card
|
||||
item_update_open = True
|
||||
# Make mypy happy — should never trigger
|
||||
if (
|
||||
not isinstance(update_error, (str, type(None)))
|
||||
or not isinstance(update_value, (str, type(None)))
|
||||
or not isinstance(update_open, (bool, type(None)))
|
||||
):
|
||||
error_msg = "update_error or update_value or update_open of wrong type."
|
||||
logger.error(error_msg)
|
||||
raise TypeError(error_msg)
|
||||
sub_hint = hint_for_indexed_field(sub["name"], item_path)
|
||||
update_form_factory = (
|
||||
resolve_form_factory(sub_hint, config_details) if sub_hint else None
|
||||
)
|
||||
item_rows.append(
|
||||
ConfigCard(
|
||||
sub["name"],
|
||||
sub["type"],
|
||||
sub["read-only"],
|
||||
sub["value"],
|
||||
sub["default"],
|
||||
sub["description"],
|
||||
sub["deprecated"],
|
||||
sub["scope"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
update_form_factory,
|
||||
)
|
||||
)
|
||||
|
||||
rows.append(
|
||||
_inner_card(
|
||||
config_name=config_name,
|
||||
item_path=item_path,
|
||||
key=key,
|
||||
item_value=item_value,
|
||||
is_empty=is_empty,
|
||||
read_only=read_only,
|
||||
item_rows=item_rows,
|
||||
item_update_open=item_update_open,
|
||||
delete_control=_delete_control(config_name, items_map, key)
|
||||
if read_only == "rw"
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
return _outer_card(
|
||||
config_name=config_name,
|
||||
config_type=config_type,
|
||||
read_only=read_only,
|
||||
value=value,
|
||||
default=default,
|
||||
description=description,
|
||||
scope=config.get("scope"),
|
||||
num_entries=num_entries,
|
||||
items_update_value=items_update_value,
|
||||
items_update_error=items_update_error,
|
||||
items_update_open=items_update_open,
|
||||
rows=rows,
|
||||
add_control=add_control,
|
||||
)
|
||||
Reference in New Issue
Block a user