diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index dac73b9e..209d656e 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -238,9 +238,9 @@ Returns: --- -## PUT /v1/config/reset +## POST /v1/config/update -**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_reset_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_reset_put) +**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_update_post_v1_config_update_post), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_update_post_v1_config_update_post) Fastapi Config Update Post @@ -257,6 +257,37 @@ Returns: --- +## PUT /v1/config/value + +**Links**: [local](http://localhost:8503/docs#/default/fastapi_config_value_put_v1_config_value_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_config_value_put_v1_config_value_put) + +Fastapi Config Value Put + +``` +Set the configuration option in the settings. + +Args: + key (str): configuration key + value (Any): configuration value + +Returns: + configuration (ConfigEOS): The current configuration after the write. +``` + +**Parameters**: + +- `key` (query, required): configuration key + +- `value` (query, required): configuration value + +**Responses**: + +- **200**: Successful Response + +- **422**: Validation Error + +--- + ## PUT /v1/measurement/data **Links**: [local](http://localhost:8503/docs#/default/fastapi_measurement_data_put_v1_measurement_data_put), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_measurement_data_put_v1_measurement_data_put) diff --git a/openapi.json b/openapi.json index bc419af2..3df0c99e 100644 --- a/openapi.json +++ b/openapi.json @@ -106,246 +106,6 @@ "title": "BaseBatteryParameters", "type": "object" }, - "GeneralSettings-Input": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - } - }, - "title": "GeneralSettings", - "type": "object" - }, - "GeneralSettings-Output": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "config_file_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration file.", - "readOnly": true, - "title": "Config File Path" - }, - "config_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS configuration directory.", - "readOnly": true, - "title": "Config Folder Path" - }, - "data_cache_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_cache_path based on data_folder_path.", - "readOnly": true, - "title": "Data Cache Path" - }, - "data_cache_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "cache", - "description": "Sub-path for the EOS cache data directory.", - "title": "Data Cache Subpath" - }, - "data_folder_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Path to EOS data directory.", - "examples": [ - null, - "/home/eos/data" - ], - "title": "Data Folder Path" - }, - "data_output_path": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute data_output_path based on data_folder_path.", - "readOnly": true, - "title": "Data Output Path" - }, - "data_output_subpath": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": "output", - "description": "Sub-path for the EOS output data directory.", - "title": "Data Output Subpath" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - }, - "timezone": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute timezone based on latitude and longitude.", - "readOnly": true, - "title": "Timezone" - } - }, - "required": [ - "timezone", - "data_output_path", - "data_cache_path", - "config_folder_path", - "config_file_path" - ], - "title": "GeneralSettings", - "type": "object" - }, "ConfigEOS": { "additionalProperties": false, "description": "Singleton configuration handler for the EOS application.\n\nConfigEOS extends `SettingsEOS` with support for default configuration paths and automatic\ninitialization.\n\n`ConfigEOS` ensures that only one instance of the class is created throughout the application,\nallowing consistent access to EOS configuration settings. This singleton instance loads\nconfiguration data from a predefined set of directories or creates a default configuration if\nnone is found.\n\nInitialization Process:\n - Upon instantiation, the singleton instance attempts to load a configuration file in this order:\n 1. The directory specified by the `EOS_CONFIG_DIR` environment variable\n 2. The directory specified by the `EOS_DIR` environment variable.\n 3. A platform specific default directory for EOS.\n 4. The current working directory.\n - The first available configuration file found in these directories is loaded.\n - If no configuration file is found, a default configuration file is created in the platform\n specific default directory, and default settings are loaded into it.\n\nAttributes from the loaded configuration are accessible directly as instance attributes of\n`ConfigEOS`, providing a centralized, shared configuration object for EOS.\n\nSingleton Behavior:\n - This class uses the `SingletonMixin` to ensure that all requests for `ConfigEOS` return\n the same instance, which contains the most up-to-date configuration. Modifying the configuration\n in one part of the application reflects across all references to this class.\n\nAttributes:\n config_folder_path (Optional[Path]): Path to the configuration directory.\n config_file_path (Optional[Path]): Path to the configuration file.\n\nRaises:\n FileNotFoundError: If no configuration file is found, and creating a default configuration fails.\n\nExample:\n To initialize and access configuration attributes (only one instance is created):\n ```python\n config_eos = ConfigEOS() # Always returns the same instance\n print(config_eos.prediction.hours) # Access a setting from the loaded configuration\n ```", @@ -743,138 +503,378 @@ "title": "Discharge Array", "type": "array" }, - "discharging_efficiency": { - "description": "The discharge efficiency as a float..", - "title": "Discharging Efficiency", - "type": "number" + "discharging_efficiency": { + "description": "The discharge efficiency as a float..", + "title": "Discharging Efficiency", + "type": "number" + }, + "hours": { + "description": "Number of hours in the simulation.", + "examples": [ + 24 + ], + "exclusiveMinimum": 0.0, + "title": "Hours", + "type": "integer" + }, + "initial_soc_percentage": { + "description": "State of charge at the start of the simulation in percentage.", + "title": "Initial Soc Percentage", + "type": "integer" + }, + "max_charge_power_w": { + "description": "Maximum charging power in watts.", + "title": "Max Charge Power W", + "type": "integer" + }, + "soc_wh": { + "description": "State of charge of the battery in watt-hours at the start of the simulation.", + "title": "Soc Wh", + "type": "number" + } + }, + "required": [ + "device_id", + "hours", + "charge_array", + "discharge_array", + "discharging_efficiency", + "capacity_wh", + "charging_efficiency", + "max_charge_power_w", + "soc_wh", + "initial_soc_percentage" + ], + "title": "ElectricVehicleResult", + "type": "object" + }, + "EnergieManagementSystemParameters": { + "additionalProperties": false, + "properties": { + "einspeiseverguetung_euro_pro_wh": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "number" + } + ], + "description": "A float or array of floats representing the feed-in compensation in euros per watt-hour.", + "title": "Einspeiseverguetung Euro Pro Wh" + }, + "gesamtlast": { + "description": "An array of floats representing the total load (consumption) in watts for different time intervals.", + "items": { + "type": "number" + }, + "title": "Gesamtlast", + "type": "array" + }, + "preis_euro_pro_wh_akku": { + "description": "A float representing the cost of battery energy per watt-hour.", + "title": "Preis Euro Pro Wh Akku", + "type": "number" + }, + "pv_prognose_wh": { + "description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals.", + "items": { + "type": "number" + }, + "title": "Pv Prognose Wh", + "type": "array" + }, + "strompreis_euro_pro_wh": { + "description": "An array of floats representing the electricity price in euros per watt-hour for different time intervals.", + "items": { + "type": "number" + }, + "title": "Strompreis Euro Pro Wh", + "type": "array" + } + }, + "required": [ + "pv_prognose_wh", + "strompreis_euro_pro_wh", + "einspeiseverguetung_euro_pro_wh", + "preis_euro_pro_wh_akku", + "gesamtlast" + ], + "title": "EnergieManagementSystemParameters", + "type": "object" + }, + "ForecastResponse": { + "properties": { + "pvpower": { + "items": { + "type": "number" + }, + "title": "Pvpower", + "type": "array" + }, + "temperature": { + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "title": "Temperature", + "type": "array" + } + }, + "required": [ + "temperature", + "pvpower" + ], + "title": "ForecastResponse", + "type": "object" + }, + "GeneralSettings-Input": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" + }, + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", + "examples": [ + null, + "/home/eos/data" + ], + "title": "Data Folder Path" + }, + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + } + }, + "title": "GeneralSettings", + "type": "object" + }, + "GeneralSettings-Output": { + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "properties": { + "config_file_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration file.", + "readOnly": true, + "title": "Config File Path" + }, + "config_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS configuration directory.", + "readOnly": true, + "title": "Config Folder Path" + }, + "data_cache_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute data_cache_path based on data_folder_path.", + "readOnly": true, + "title": "Data Cache Path" + }, + "data_cache_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "cache", + "description": "Sub-path for the EOS cache data directory.", + "title": "Data Cache Subpath" }, - "hours": { - "description": "Number of hours in the simulation.", + "data_folder_path": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Path to EOS data directory.", "examples": [ - 24 + null, + "/home/eos/data" ], - "exclusiveMinimum": 0.0, - "title": "Hours", - "type": "integer" - }, - "initial_soc_percentage": { - "description": "State of charge at the start of the simulation in percentage.", - "title": "Initial Soc Percentage", - "type": "integer" - }, - "max_charge_power_w": { - "description": "Maximum charging power in watts.", - "title": "Max Charge Power W", - "type": "integer" + "title": "Data Folder Path" }, - "soc_wh": { - "description": "State of charge of the battery in watt-hours at the start of the simulation.", - "title": "Soc Wh", - "type": "number" - } - }, - "required": [ - "device_id", - "hours", - "charge_array", - "discharge_array", - "discharging_efficiency", - "capacity_wh", - "charging_efficiency", - "max_charge_power_w", - "soc_wh", - "initial_soc_percentage" - ], - "title": "ElectricVehicleResult", - "type": "object" - }, - "EnergieManagementSystemParameters": { - "additionalProperties": false, - "properties": { - "einspeiseverguetung_euro_pro_wh": { + "data_output_path": { "anyOf": [ { - "items": { - "type": "number" - }, - "type": "array" + "format": "path", + "type": "string" }, { - "type": "number" + "type": "null" } ], - "description": "A float or array of floats representing the feed-in compensation in euros per watt-hour.", - "title": "Einspeiseverguetung Euro Pro Wh" - }, - "gesamtlast": { - "description": "An array of floats representing the total load (consumption) in watts for different time intervals.", - "items": { - "type": "number" - }, - "title": "Gesamtlast", - "type": "array" + "description": "Compute data_output_path based on data_folder_path.", + "readOnly": true, + "title": "Data Output Path" }, - "preis_euro_pro_wh_akku": { - "description": "A float representing the cost of battery energy per watt-hour.", - "title": "Preis Euro Pro Wh Akku", - "type": "number" + "data_output_subpath": { + "anyOf": [ + { + "format": "path", + "type": "string" + }, + { + "type": "null" + } + ], + "default": "output", + "description": "Sub-path for the EOS output data directory.", + "title": "Data Output Subpath" }, - "pv_prognose_wh": { - "description": "An array of floats representing the forecasted photovoltaic output in watts for different time intervals.", - "items": { - "type": "number" - }, - "title": "Pv Prognose Wh", - "type": "array" + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" }, - "strompreis_euro_pro_wh": { - "description": "An array of floats representing the electricity price in euros per watt-hour for different time intervals.", - "items": { - "type": "number" - }, - "title": "Strompreis Euro Pro Wh", - "type": "array" - } - }, - "required": [ - "pv_prognose_wh", - "strompreis_euro_pro_wh", - "einspeiseverguetung_euro_pro_wh", - "preis_euro_pro_wh_akku", - "gesamtlast" - ], - "title": "EnergieManagementSystemParameters", - "type": "object" - }, - "ForecastResponse": { - "properties": { - "pvpower": { - "items": { - "type": "number" - }, - "title": "Pvpower", - "type": "array" + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" }, - "temperature": { - "items": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "title": "Temperature", - "type": "array" + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute timezone based on latitude and longitude.", + "readOnly": true, + "title": "Timezone" } }, "required": [ - "temperature", - "pvpower" + "timezone", + "data_output_path", + "data_cache_path", + "config_folder_path", + "config_file_path" ], - "title": "ForecastResponse", + "title": "GeneralSettings", "type": "object" }, "GesamtlastRequest": { @@ -3144,10 +3144,10 @@ ] } }, - "/v1/config/reset": { - "put": { + "/v1/config/update": { + "post": { "description": "Reset the configuration to the EOS configuration file.\n\nReturns:\n configuration (ConfigEOS): The current configuration after update.", - "operationId": "fastapi_config_update_post_v1_config_reset_put", + "operationId": "fastapi_config_update_post_v1_config_update_post", "responses": { "200": { "content": { @@ -3160,10 +3160,59 @@ "description": "Successful Response" } }, - "summary": "Fastapi Config Update Post", - "tags": [ - "config" - ] + "summary": "Fastapi Config Update Post" + } + }, + "/v1/config/value": { + "put": { + "description": "Set the configuration option in the settings.\n\nArgs:\n key (str): configuration key\n value (Any): configuration value\n\nReturns:\n configuration (ConfigEOS): The current configuration after the write.", + "operationId": "fastapi_config_value_put_v1_config_value_put", + "parameters": [ + { + "description": "configuration key", + "in": "query", + "name": "key", + "required": true, + "schema": { + "description": "configuration key", + "title": "Key", + "type": "string" + } + }, + { + "description": "configuration value", + "in": "query", + "name": "value", + "required": true, + "schema": { + "description": "configuration value", + "title": "Value" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigEOS" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Fastapi Config Value Put" } }, "/v1/measurement/data": { diff --git a/requirements.txt b/requirements.txt index 8038da71..f3eac91d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,9 @@ numpydantic==1.6.4 matplotlib==3.10.0 fastapi[standard]==0.115.6 python-fasthtml==0.12.0 +MonsterUI==0.0.29 +mistletoe==1.4.0 +lxml==5.3.0 uvicorn==0.34.0 scikit-learn==1.6.1 timezonefinder==6.5.7 diff --git a/src/akkudoktoreos/server/dash/__init__.py b/src/akkudoktoreos/server/dash/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py new file mode 100644 index 00000000..105dd8f1 --- /dev/null +++ b/src/akkudoktoreos/server/dash/components.py @@ -0,0 +1,167 @@ +from typing import Any, Optional, Union + +from fasthtml.common import FT, H1, Div, Li, P +from monsterui.foundations import stringify +from monsterui.franken import ( + Button, + ButtonT, + Card, + Container, + ContainerT, + TabContainer, + render_md, +) + +scrollbar_viewport_styles = ( + "scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;" +) + +scrollbar_cls = "flex touch-none select-none transition-colors p-[1px]" + + +def ScrollArea( + *c: Any, cls: Optional[Union[str, tuple]] = None, orientation: str = "vertical", **kwargs: Any +) -> Div: + """Creates a styled scroll area. + + Args: + orientation (str): The orientation of the scroll area. Defaults to vertical. + """ + new_cls = "relative overflow-hidden" + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + + content = Div( + Div(*c, style="min-width:100%;display:table;"), + style=f"overflow: {'hidden scroll' if orientation == 'vertical' else 'scroll'}; {scrollbar_viewport_styles}", + cls="w-full h-full rounded-[inherit]", + data_ref="viewport", + ) + + scrollbar = Div( + Div(cls="bg-border rounded-full hidden relative flex-1", data_ref="thumb"), + cls=f"{scrollbar_cls} flex-col h-2.5 w-full border-t border-t-transparent" + if orientation == "horizontal" + else f"{scrollbar_cls} w-2.5 h-full border-l border-l-transparent", + data_ref="scrollbar", + style=f"position: absolute;{'right:0; top:0;' if orientation == 'vertical' else 'bottom:0; left:0;'}", + ) + + return Div( + content, + scrollbar, + role="region", + tabindex="0", + data_orientation=orientation, + data_ref_scrollarea=True, + aria_label="Scrollable content", + **kwargs, + ) + + +def Markdown(md: str) -> FT: + return render_md(md) + + +def Header(title: Optional[str]) -> Div: + """Creates a styled header with a title. + + Args: + title (Optional[str]): The title text for the header. + + Returns: + Div: A styled `Div` element containing the header. + """ + if title is None: + return Div("", cls="header") + return Div(H1(title, cls="text-2xl font-bold mb-4"), cls="header") + + +def Footer(info: str) -> Card: + """Creates a styled footer with the provided information. + + Args: + info (str): Footer information text. + + Returns: + Card: A styled `Card` element containing the footer. + """ + return Card(Container(P(info, cls="text-sm font-medium"), cls="footer")) + + +def DashboardTrigger(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any) -> Button: + """Creates a styled button for the dashboard trigger. + + Args: + *c: Positional arguments to pass to the button. + cls (Optional[str]): Additional CSS classes for styling. Defaults to None. + **kwargs: Additional keyword arguments for the button. + + Returns: + Button: A styled `Button` component. + """ + new_cls = f"{ButtonT.primary}" + if cls: + new_cls += f" {stringify(cls)}" + kwargs["cls"] = new_cls + return Button(*c, submit=False, **kwargs) + + +def DashboardTabs(dashboard_items: dict[str, str]) -> Card: + """Creates a dashboard tab with dynamic dashboard items. + + Args: + dashboard_items (dict[str, str]): A dictionary of dashboard items where keys are item names + and values are paths for navigation. + + Returns: + Card: A styled `Card` component containing the dashboard tabs. + """ + dash_items = [ + Li( + DashboardTrigger( + menu, + hx_get=f"{path}", + hx_target="#page-content", + hx_swap="innerHTML", + ), + ) + for menu, path in dashboard_items.items() + ] + return Card(TabContainer(*dash_items), alt=True) + + +def Content(content: Any) -> Card: + """Creates a content section within a styled card. + + Args: + content (Any): The content to display. + + Returns: + Card: A styled `Card` element containing the content. + """ + return Card(Container(content, id="page-content")) + + +def Page( + title: Optional[str], dashboard_items: dict[str, str], content: Any, footer_info: str +) -> Div: + """Generates a full-page layout with a header, dashboard items, content, and footer. + + Args: + title (Optional[str]): The page title. + dashboard_items (dict[str, str]): A dictionary of dashboard items. + content (Any): The main content for the page. + footer_info (str): Footer information text. + + Returns: + Div: A `Div` element representing the entire page layout. + """ + return Container( + Header(title), + DashboardTabs(dashboard_items), + Content(content), + Footer(footer_info), + cls=("w-screen p-4 space-y-4", ContainerT.xl), + ) diff --git a/src/akkudoktoreos/server/dash/configuration.py b/src/akkudoktoreos/server/dash/configuration.py new file mode 100644 index 00000000..1da95660 --- /dev/null +++ b/src/akkudoktoreos/server/dash/configuration.py @@ -0,0 +1,243 @@ +import json +from functools import reduce +from http import HTTPStatus +from typing import Any, Dict, List, Optional, TypeVar, Union + +import requests +from monsterui.franken import Table, Tbody, Td, Th, Thead, Tr +from pydantic.fields import ComputedFieldInfo, FieldInfo +from pydantic_core import PydanticUndefined +from requests.exceptions import RequestException + +from akkudoktoreos.config.config import get_config +from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import PydanticBaseModel +from akkudoktoreos.server.dash.components import ScrollArea + +logger = get_logger(__name__) +config_eos = get_config() + +T = TypeVar("T") + + +def get_nested_value( + dictionary: Dict[str, Any], keys: List[str], default: Optional[T] = None +) -> Union[Any, T]: + """Retrieve a nested value from a dictionary using a list of keys. + + Args: + dictionary (Dict[str, Any]): The nested dictionary to search. + keys (List[str]): A list of keys representing the path to the desired value. + default (Optional[T]): A value to return if the path is not found. + + Returns: + Union[Any, T]: The value at the specified nested path, or the default value if not found. + + Raises: + TypeError: If the dictionary is not of type `dict` or keys is not a `list`. + """ + # Validate input type + if not isinstance(dictionary, dict): + raise TypeError("First argument must be a dictionary") + + # Validate keys input + if not isinstance(keys, list): + raise TypeError("Keys must be provided as a list") + + # Empty key list returns the entire dictionary + if not keys: + return dictionary + + try: + # Use reduce for a functional approach + return reduce(lambda d, key: d[key], keys, dictionary) + except (KeyError, TypeError): + return default + + +def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any: + """Retrieve the default value of a field. + + Args: + field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic. + regular_field (bool): Indicates if the field is a regular field. + + Returns: + Any: The default value of the field or "N/A" if not a regular field. + """ + default_value = "" + if regular_field: + if (val := field_info.default) is not PydanticUndefined: + default_value = val + else: + default_value = "N/A" + return default_value + + +def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]: + """Resolve nested types within a field and return their structure. + + Args: + field_type (Any): The type of the field to resolve. + parent_types (List[str]): A list of parent type names. + + Returns: + List[tuple[Any, List[str]]]: A list of tuples containing resolved types and their parent hierarchy. + """ + resolved_types: list[tuple[Any, list[str]]] = [] + + origin = getattr(field_type, "__origin__", field_type) + if origin is Union: + for arg in getattr(field_type, "__args__", []): + if arg is not type(None): + resolved_types.extend(resolve_nested_types(arg, parent_types)) + else: + resolved_types.append((field_type, parent_types)) + + return resolved_types + + +def configuration(values: dict) -> list[dict]: + """Generate configuration details based on provided values and model metadata. + + Args: + values (dict): A dictionary containing the current configuration values. + + Returns: + List[dict]: A sorted list of configuration details, each represented as a dictionary. + """ + configs = [] + inner_types: set[type[PydanticBaseModel]] = set() + + for field_name, field_info in list(config_eos.model_fields.items()) + list( + config_eos.model_computed_fields.items() + ): + + def extract_nested_models( + subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str] + ) -> None: + regular_field = isinstance(subfield_info, FieldInfo) + subtype = subfield_info.annotation if regular_field else subfield_info.return_type + + if subtype in inner_types: + return + + nested_types = resolve_nested_types(subtype, []) + found_basic = False + for nested_type, nested_parent_types in nested_types: + if not isinstance(nested_type, type) or not issubclass( + nested_type, PydanticBaseModel + ): + if found_basic: + continue + + config = {} + config["name"] = ".".join(parent_types) + config["value"] = str(get_nested_value(values, parent_types, "")) + config["default"] = str(get_default_value(subfield_info, regular_field)) + config["description"] = ( + subfield_info.description if subfield_info.description else "" + ) + if isinstance(subfield_info, ComputedFieldInfo): + config["read-only"] = "ro" + type_description = str(subfield_info.return_type) + else: + config["read-only"] = "rw" + type_description = str(subfield_info.annotation) + config["type"] = ( + type_description.replace("typing.", "") + .replace("pathlib.", "") + .replace("[", "[ ") + .replace("NoneType", "None") + ) + configs.append(config) + found_basic = True + else: + new_parent_types = parent_types + nested_parent_types + inner_types.add(nested_type) + for nested_field_name, nested_field_info in list( + nested_type.model_fields.items() + ) + list(nested_type.model_computed_fields.items()): + extract_nested_models( + nested_field_info, + new_parent_types + [nested_field_name], + ) + + extract_nested_models(field_info, [field_name]) + return sorted(configs, key=lambda x: x["name"]) + + +def get_configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> list[dict]: + """Fetch and process configuration data from the specified EOS server. + + Args: + eos_host (Optional[str]): The hostname of the server. + eos_port (Optional[Union[str, int]]): The port of the server. + + Returns: + List[dict]: A list of processed configuration entries. + """ + config_eos = get_config() + if eos_host is None: + eos_host = config_eos.server.host + if eos_port is None: + eos_port = config_eos.server.port + + result = requests.Response() + try: + result = requests.get(f"http://{eos_host}:{eos_port}/v1/config") + except RequestException as e: + result.status_code = HTTPStatus.SERVICE_UNAVAILABLE + warning_msg = f"{e}" + logger.warning(warning_msg) + + config_values = {} + if result.status_code == HTTPStatus.OK: + config_values = json.loads(result.content) + + return configuration(config_values) + + +def Configuration(eos_host: Optional[str], eos_port: Optional[Union[str, int]]) -> Table: + """Create a visual representation of the configuration. + + Args: + eos_host (Optional[str]): The hostname of the EOS server. + eos_port (Optional[Union[str, int]]): The port of the EOS server. + + Returns: + Table: A `monsterui.franken.Table` component displaying configuration details. + """ + flds = "Name", "Type", "RO/RW", "Value", "Default", "Description" + rows = [ + Tr( + Td( + config["name"], + cls="max-w-64 text-wrap break-all", + ), + Td( + config["type"], + cls="max-w-48 text-wrap break-all", + ), + Td( + config["read-only"], + cls="max-w-24 text-wrap break-all", + ), + Td( + config["value"], + cls="max-w-md text-wrap break-all", + ), + Td(config["default"], cls="max-w-48 text-wrap break-all"), + Td( + config["description"], + cls="max-w-prose text-wrap", + ), + cls="even:bg-lime-100", + ) + for config in get_configuration(eos_host, eos_port) + ] + head = Thead(*map(Th, flds), cls="bg-lime-400 text-left") + return ScrollArea( + Table(head, Tbody(*rows), cls="w-full"), + cls="h-[75vh] w-full rounded-md", + ) diff --git a/src/akkudoktoreos/server/dash/demo.py b/src/akkudoktoreos/server/dash/demo.py new file mode 100644 index 00000000..8fd02e84 --- /dev/null +++ b/src/akkudoktoreos/server/dash/demo.py @@ -0,0 +1,8 @@ +from fasthtml.common import P + + +def Demo() -> str: + return P( + "Hello, I am the Demo!", + cls="text-center", + ) diff --git a/src/akkudoktoreos/server/dash/hello.py b/src/akkudoktoreos/server/dash/hello.py new file mode 100644 index 00000000..08f4126f --- /dev/null +++ b/src/akkudoktoreos/server/dash/hello.py @@ -0,0 +1,23 @@ +from fasthtml.common import Div + +from akkudoktoreos.server.dash.components import Markdown, ScrollArea + +hello_md = """# Akkudoktor EOSdash + +The dashboard for Akkudoktor EOS. + +EOS provides a comprehensive solution for simulating and optimizing an energy system based +on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), +load management (consumer requirements), heat pumps, electric vehicles, and consideration of +electricity price data, this system enables forecasting and optimization of energy flow and costs +over a specified period. + +Documentation can be found at [Akkudoktor-EOS](https://akkudoktor-eos.readthedocs.io/en/latest/). +""" + + +def Hello() -> Div: + return ScrollArea( + Markdown(hello_md), + cls="h-[75vh] w-full rounded-md", + ) diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 73fb2d6e..eb15490b 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -34,6 +34,7 @@ from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.prediction import PredictionCommonSettings, get_prediction from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings +from akkudoktoreos.server.rest.error import create_error_page from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration logger = get_logger(__name__) @@ -45,98 +46,6 @@ # Command line arguments args = None -ERROR_PAGE_TEMPLATE = """ - - - - - - Energy Optimization System (EOS) Error - - - -
-

STATUS_CODE

-

ERROR_TITLE

-

ERROR_MESSAGE

-
ERROR_DETAILS
- Back to Home -
- - -""" - - -def create_error_page( - status_code: str, error_title: str, error_message: str, error_details: str -) -> str: - """Create an error page by replacing placeholders in the template.""" - return ( - ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code) - .replace("ERROR_TITLE", error_title) - .replace("ERROR_MESSAGE", error_message) - .replace("ERROR_DETAILS", error_details) - ) - # ---------------------- # EOSdash server startup @@ -230,14 +139,37 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: root_path=str(Path(__file__).parent), ) -server_dir = Path(__file__).parent.resolve() - class PdfResponse(FileResponse): media_type = "application/pdf" -@app.put("/v1/config/reset", tags=["config"]) +@app.put("/v1/config/value") +def fastapi_config_value_put( + key: Annotated[str, Query(description="configuration key")], + value: Annotated[Any, Query(description="configuration value")], +) -> ConfigEOS: + """Set the configuration option in the settings. + + Args: + key (str): configuration key + value (Any): configuration value + + Returns: + configuration (ConfigEOS): The current configuration after the write. + """ + if key not in config_eos.config_keys: + raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.") + if key in config_eos.config_keys_read_only: + raise HTTPException(status_code=404, detail=f"Key '{key}' is read only.") + try: + setattr(config_eos, key, value) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}") + return config_eos + + +@app.post("/v1/config/update") def fastapi_config_update_post() -> ConfigEOS: """Reset the configuration to the EOS configuration file. diff --git a/src/akkudoktoreos/server/eosdash.py b/src/akkudoktoreos/server/eosdash.py index de332bb0..64323ba3 100644 --- a/src/akkudoktoreos/server/eosdash.py +++ b/src/akkudoktoreos/server/eosdash.py @@ -1,124 +1,87 @@ import argparse import os import sys -from functools import reduce -from typing import Any, Union +from typing import Optional import uvicorn -from fasthtml.common import H1, Table, Td, Th, Thead, Titled, Tr, fast_app -from pydantic.fields import ComputedFieldInfo, FieldInfo -from pydantic_core import PydanticUndefined +from monsterui.core import FastHTML, Theme from akkudoktoreos.config.config import get_config from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import PydanticBaseModel +from akkudoktoreos.server.dash.components import Page -logger = get_logger(__name__) +# Pages +from akkudoktoreos.server.dash.configuration import Configuration +from akkudoktoreos.server.dash.demo import Demo +from akkudoktoreos.server.dash.hello import Hello +logger = get_logger(__name__) config_eos = get_config() # Command line arguments -args = None +args: Optional[argparse.Namespace] = None +# The EOSdash application +app: FastHTML = FastHTML( + title="EOSdash", + hdrs=Theme.green.headers(highlightjs=True), + secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"), +) -def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any: - default_value = "" - if regular_field: - if (val := field_info.default) is not PydanticUndefined: - default_value = val - else: - default_value = "N/A" - return default_value +@app.get("/") +def get_eosdash(): # type: ignore + """Serves the main EOSdash page. -def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]: - resolved_types: list[tuple[Any, list[str]]] = [] + Returns: + Page: The main dashboard page with navigation links and footer. + """ + return Page( + None, + { + "EOSdash": "/eosdash/hello", + "Config": "/eosdash/configuration", + "Demo": "/eosdash/demo", + }, + Hello(), + "Footer_Info", + ) - origin = getattr(field_type, "__origin__", field_type) - if origin is Union: - for arg in getattr(field_type, "__args__", []): - if arg is not type(None): - resolved_types.extend(resolve_nested_types(arg, parent_types)) - else: - resolved_types.append((field_type, parent_types)) - - return resolved_types - - -configs = [] -inner_types: set[type[PydanticBaseModel]] = set() -for field_name, field_info in list(config_eos.model_fields.items()) + list( - config_eos.model_computed_fields.items() -): - - def extract_nested_models( - subfield_info: Union[ComputedFieldInfo, FieldInfo], parent_types: list[str] - ) -> None: - regular_field = isinstance(subfield_info, FieldInfo) - subtype = subfield_info.annotation if regular_field else subfield_info.return_type - - if subtype in inner_types: - return - - nested_types = resolve_nested_types(subtype, []) - found_basic = False - for nested_type, nested_parent_types in nested_types: - if not isinstance(nested_type, type) or not issubclass(nested_type, PydanticBaseModel): - if found_basic: - continue - - config = {} - config["name"] = ".".join(parent_types) - try: - config["value"] = reduce(getattr, [config_eos] + parent_types) - except AttributeError: - # Parent value(s) are not set in current config - config["value"] = "" - config["default"] = get_default_value(subfield_info, regular_field) - config["description"] = ( - subfield_info.description if subfield_info.description else "" - ) - configs.append(config) - found_basic = True - else: - new_parent_types = parent_types + nested_parent_types - inner_types.add(nested_type) - for nested_field_name, nested_field_info in list( - nested_type.model_fields.items() - ) + list(nested_type.model_computed_fields.items()): - extract_nested_models( - nested_field_info, - new_parent_types + [nested_field_name], - ) - - extract_nested_models(field_info, [field_name]) -configs = sorted(configs, key=lambda x: x["name"]) - - -app, rt = fast_app( - secret_key=os.getenv("EOS_SERVER__EOSDASH_SESSKEY"), -) +@app.get("/eosdash/hello") +def get_eosdash_hello(): # type: ignore + """Serves the EOSdash Hello page. + + Returns: + Hello: The Hello page component. + """ + return Hello() + + +@app.get("/eosdash/configuration") +def get_eosdash_configuration(): # type: ignore + """Serves the EOSdash Configuration page. + + Returns: + Configuration: The Configuration page component. + """ + if args is None: + eos_host = None + eos_port = None + else: + eos_host = args.eos_host + eos_port = args.eos_port + return Configuration(eos_host, eos_port) -def config_table() -> Table: - rows = [ - Tr( - Td(config["name"]), - Td(config["value"]), - Td(config["default"]), - Td(config["description"]), - cls="even:bg-purple/5", - ) - for config in configs - ] - flds = "Name", "Value", "Default", "Description" - head = Thead(*map(Th, flds), cls="bg-purple/10") - return Table(head, *rows, cls="w-full") +@app.get("/eosdash/demo") +def get_eosdash_demo(): # type: ignore + """Serves the EOSdash Demo page. -@rt("/") -def get(): # type: ignore - return Titled("EOS Dashboard", H1("Configuration"), config_table()) + Returns: + Demo: The Demo page component. + """ + return Demo() def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: bool) -> None: @@ -131,16 +94,16 @@ def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: server to the specified host and port, an error message is logged and the application exits. - Parameters: - host (str): The hostname to bind the server to. - port (int): The port number to bind the server to. - log_level (str): The log level for the server. Options include "critical", "error", - "warning", "info", "debug", and "trace". - access_log (bool): Whether to enable or disable the access log. Set to True to enable. - reload (bool): Whether to enable or disable auto-reload. Set to True for development. + Args: + host (str): The hostname to bind the server to. + port (int): The port number to bind the server to. + log_level (str): The log level for the server. Options include "critical", "error", + "warning", "info", "debug", and "trace". + access_log (bool): Whether to enable or disable the access log. Set to True to enable. + reload (bool): Whether to enable or disable auto-reload. Set to True for development. Returns: - None + None """ # Make hostname Windows friendly if host == "0.0.0.0" and os.name == "nt": @@ -150,7 +113,7 @@ def run_eosdash(host: str, port: int, log_level: str, access_log: bool, reload: "akkudoktoreos.server.eosdash:app", host=host, port=port, - log_level=log_level.lower(), # Convert log_level to lowercase + log_level=log_level.lower(), access_log=access_log, reload=reload, ) @@ -164,7 +127,7 @@ def main() -> None: 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, + config module if arguments are not provided. After parsing the arguments, it starts the EOSdash server with the specified configurations. Command-line Arguments: @@ -178,7 +141,6 @@ def main() -> None: """ parser = argparse.ArgumentParser(description="Start EOSdash server.") - # Host and port arguments with defaults from config_eos parser.add_argument( "--host", type=str, @@ -191,8 +153,6 @@ def main() -> None: default=config_eos.server.eosdash_port, help="Port for the EOSdash server (default: value from config)", ) - - # EOS Host and port arguments with defaults from config_eos parser.add_argument( "--eos-host", type=str, @@ -205,8 +165,6 @@ def main() -> None: default=config_eos.server.port, help="Port for the EOS server (default: value from config)", ) - - # Optional arguments for log_level, access_log, and reload parser.add_argument( "--log_level", type=str, @@ -217,7 +175,7 @@ def main() -> None: "--access_log", type=bool, default=False, - help="Enable or disable access log. Options: True or False (default: True)", + help="Enable or disable access log. Options: True or False (default: False)", ) parser.add_argument( "--reload", @@ -226,6 +184,7 @@ def main() -> None: help="Enable or disable auto-reload. Useful for development. Options: True or False (default: False)", ) + global args args = parser.parse_args() try: diff --git a/src/akkudoktoreos/server/rest/error.py b/src/akkudoktoreos/server/rest/error.py new file mode 100644 index 00000000..dd8d9ee7 --- /dev/null +++ b/src/akkudoktoreos/server/rest/error.py @@ -0,0 +1,91 @@ +ERROR_PAGE_TEMPLATE = """ + + + + + + Energy Optimization System (EOS) Error + + + +
+

STATUS_CODE

+

ERROR_TITLE

+

ERROR_MESSAGE

+
ERROR_DETAILS
+ Back to Home +
+ + +""" + + +def create_error_page( + status_code: str, error_title: str, error_message: str, error_details: str +) -> str: + """Create an error page by replacing placeholders in the template.""" + return ( + ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code) + .replace("ERROR_TITLE", error_title) + .replace("ERROR_MESSAGE", error_message) + .replace("ERROR_DETAILS", error_details) + )