Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ jobs:
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
exit $exit_code

generated-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[fastapi]"

- name: Check api.js is up to date
run: |
python -m open_ess.scripts.generate_types
if ! git diff --exit-code open_ess/frontend/static/api.js; then
echo "## Generated Types Check Failed" >> $GITHUB_STEP_SUMMARY
echo "api.js is out of date. Run \`generate-types\` and commit." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "## Generated Types Check" >> $GITHUB_STEP_SUMMARY
echo "api.js is up to date." >> $GITHUB_STEP_SUMMARY

pytest:
runs-on: ubuntu-latest
strategy:
Expand Down
2 changes: 1 addition & 1 deletion open_ess/battery_system/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ class BatterySystemConfig(BaseModel):
control: Annotated[VictronConfig | MqttControl, Field(discriminator="type")]
metrics: MetricsConfig = MetricsConfig()

@computed_field # type: ignore[prop-decorator]
@property
@computed_field
def id(self) -> str:
if isinstance(self.control, VictronConfig):
return f"victron/vebus/{self.control.vebus_id}"
Expand Down
3 changes: 1 addition & 2 deletions open_ess/frontend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from .app import create_app
from .config import FrontendConfig
from .dependencies import close_dependencies, init_dependencies

__all__ = ["FrontendConfig", "init_dependencies", "create_app", "close_dependencies"]
__all__ = ["FrontendConfig", "create_app"]
28 changes: 23 additions & 5 deletions open_ess/frontend/app.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from open_ess.battery_system import BatterySystem
from open_ess.database import Database

from .routes import api_router, pages_router

if TYPE_CHECKING:
from open_ess.config import Config

STATIC_DIR = Path(__file__).parent / "static"


def create_app() -> FastAPI:
def create_app(
database: Database,
config: "Config",
battery_systems: list[BatterySystem],
) -> FastAPI:
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
_app.state.database = database.connect()
_app.state.price_config = config.prices
_app.state.battery_systems = battery_systems
yield
_app.state.database.close()

app = FastAPI(
title="OpenESS",
description="Open Energy Storage System dashboard",
lifespan=lifespan,
)

# Mount static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

# Include routers
app.include_router(pages_router)
app.include_router(api_router, prefix="/api")

Expand Down
22 changes: 10 additions & 12 deletions open_ess/frontend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import uvicorn

from open_ess.config import Config
from open_ess.database import Database
from open_ess.frontend.app import create_app
from open_ess.frontend.dependencies import close_dependencies
from open_ess.util import parse_args, setup_logging

setup_logging()
Expand All @@ -17,21 +17,19 @@ def main() -> None:
config = Config.from_file(args.config)
if not config.frontend.enable:
logger.info("Frontend is not enabled. Exiting...")
return

# TODO: init_dependencies(config.database, config.prices, [])
database = Database(config.database)

logger.info(f"Starting web server on http://{config.frontend.host}:{config.frontend.port}")

try:
app = create_app()
uvicorn.run(
app,
host=config.frontend.host, # type: ignore[arg-type]
port=config.frontend.port,
log_level="info",
)
finally:
close_dependencies()
app = create_app(database, config, battery_systems=[])
uvicorn.run(
app,
host=config.frontend.host,
port=config.frontend.port,
log_level="info",
)


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions open_ess/frontend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ class FrontendConfig(BaseModel):
"""The frontend is disabled by default but is enabled when the host is set."""

enable: bool = False
host: str | None = None
host: str = ""
port: int = 8519

@model_validator(mode="before")
@classmethod
def set_enable_default(cls, data: Any) -> Any:
if isinstance(data, dict) and "enable" not in data:
data["enable"] = data.get("host") is not None
data["enable"] = bool(data.get("host"))
return data
57 changes: 15 additions & 42 deletions open_ess/frontend/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,25 @@
from typing import TYPE_CHECKING

from open_ess.battery_system import BatterySystem, BatterySystemConfig
from open_ess.database import Database, DatabaseConnection
from open_ess.pricing import PriceConfig

if TYPE_CHECKING:
from open_ess.config import Config

_config: "Config | None" = None
_database: DatabaseConnection | None = None
_battery_systems: list[BatterySystem] | None = None
from typing import Annotated

from fastapi import Depends, Request

def init_dependencies(db: Database, config: "Config", battery_systems: list[BatterySystem]) -> None:
global _config, _database, _battery_systems
_config = config
_database = db.connect()
_battery_systems = battery_systems


def get_database() -> DatabaseConnection:
if _database is None:
raise RuntimeError("Database not initialized. Call init_dependencies() first.")
return _database
from open_ess.battery_system import BatterySystem
from open_ess.database import DatabaseConnection
from open_ess.pricing import PriceConfig


def get_price_config() -> PriceConfig:
if _config is None:
raise RuntimeError("Price config not initialized. Call init_dependencies() first.")
return _config.prices
def get_database(request: Request) -> DatabaseConnection:
return request.app.state.database # type: ignore[no-any-return]


def get_battery_configs() -> dict[str, BatterySystemConfig]:
if _config is None:
raise RuntimeError("Battery configs not initialized. Call init_dependencies() first.")
return {battery_config.id: battery_config for battery_config in _config.battery_systems}
def get_price_config(request: Request) -> PriceConfig:
return request.app.state.price_config # type: ignore[no-any-return]


def get_battery_systems() -> list[BatterySystem]:
if _battery_systems is None:
raise RuntimeError("Battery_systems not initialized. Call init_dependencies() first.")
return _battery_systems
def get_battery_systems(request: Request) -> list[BatterySystem]:
return request.app.state.battery_systems # type: ignore[no-any-return]


def close_dependencies() -> None:
global _config, _database, _battery_systems
_config = None
_battery_systems = None
if _database is not None:
_database.close()
_database = None
# Type aliases for cleaner route signatures
Database = Annotated[DatabaseConnection, Depends(get_database)]
PriceConfigDep = Annotated[PriceConfig, Depends(get_price_config)]
BatterySystemsDep = Annotated[list[BatterySystem], Depends(get_battery_systems)]
Loading