From 2bca34503685836fd87e1b70a6d57d6f41376a61 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 16 Oct 2026 13:27:02 +0200 Subject: [PATCH 1/5] feat(api): read-only REST API next to the control loop A FastAPI application served by uvicorn in a daemon thread, enabled with api.enabled. Every route is GET; all of them read through one service layer (web/service.py), which owns field names, units and rounding. - The service never calls the core's get_* accessors: it reads values the loop has cached (SoC, capacities, SoC limits), so a request can never cause inverter traffic or reset the loop's cache flags. - Forecasts come from one frozen ForecastSnapshot the loop publishes per cycle, so a response never mixes two cycles. Slot 0 is the slot that contains the current time; an expired forecast answers 503. - online/stale also covers a loop that never completes a cycle and a forecast that is no longer refreshed. - Optional static bearer token (api.auth_token); /api/v1/health stays open. - Swagger UI and the OpenAPI schema only with api.docs_enabled (default off). - Forecast slot rendering is shared with the MQTT forecasts (interval_utils.iter_slots); a golden test pins the MQTT payloads. - The decision journal tracks the last mode change separately, so "mode since" does not move on charge rate changes. - A failing web server never takes battery control down: bind and lifespan errors are logged and the loop keeps running. Co-Authored-By: Claude Opus 5.5 --- config/batcontrol_config_dummy.yaml | 14 + pyproject.toml | 29 +- src/batcontrol/core.py | 108 ++++- src/batcontrol/decision_journal.py | 10 + src/batcontrol/interval_utils.py | 36 +- src/batcontrol/modes.py | 53 ++ src/batcontrol/mqtt_api.py | 27 +- src/batcontrol/web/__init__.py | 7 + src/batcontrol/web/app.py | 234 +++++++++ src/batcontrol/web/auth.py | 107 ++++ src/batcontrol/web/config.py | 193 ++++++++ src/batcontrol/web/models.py | 333 +++++++++++++ src/batcontrol/web/routes.py | 224 +++++++++ src/batcontrol/web/server.py | 116 +++++ src/batcontrol/web/service.py | 458 ++++++++++++++++++ src/batcontrol/web/snapshot.py | 118 +++++ tests/batcontrol/test_decision_journal.py | 10 + tests/batcontrol/test_mqtt_api.py | 27 +- tests/batcontrol/web/__init__.py | 0 tests/batcontrol/web/conftest.py | 160 ++++++ tests/batcontrol/web/test_app.py | 86 ++++ tests/batcontrol/web/test_auth.py | 144 ++++++ tests/batcontrol/web/test_config.py | 156 ++++++ tests/batcontrol/web/test_core_integration.py | 153 ++++++ tests/batcontrol/web/test_dashboard.py | 158 ++++++ tests/batcontrol/web/test_openapi.py | 69 +++ tests/batcontrol/web/test_readonly.py | 59 +++ tests/batcontrol/web/test_routes.py | 143 ++++++ tests/batcontrol/web/test_server.py | 134 +++++ tests/batcontrol/web/test_service.py | 286 +++++++++++ tests/batcontrol/web/test_snapshot.py | 66 +++ 31 files changed, 3679 insertions(+), 39 deletions(-) create mode 100644 src/batcontrol/modes.py create mode 100644 src/batcontrol/web/__init__.py create mode 100644 src/batcontrol/web/app.py create mode 100644 src/batcontrol/web/auth.py create mode 100644 src/batcontrol/web/config.py create mode 100644 src/batcontrol/web/models.py create mode 100644 src/batcontrol/web/routes.py create mode 100644 src/batcontrol/web/server.py create mode 100644 src/batcontrol/web/service.py create mode 100644 src/batcontrol/web/snapshot.py create mode 100644 tests/batcontrol/web/__init__.py create mode 100644 tests/batcontrol/web/conftest.py create mode 100644 tests/batcontrol/web/test_app.py create mode 100644 tests/batcontrol/web/test_auth.py create mode 100644 tests/batcontrol/web/test_config.py create mode 100644 tests/batcontrol/web/test_core_integration.py create mode 100644 tests/batcontrol/web/test_dashboard.py create mode 100644 tests/batcontrol/web/test_openapi.py create mode 100644 tests/batcontrol/web/test_readonly.py create mode 100644 tests/batcontrol/web/test_routes.py create mode 100644 tests/batcontrol/web/test_server.py create mode 100644 tests/batcontrol/web/test_service.py create mode 100644 tests/batcontrol/web/test_snapshot.py diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index fd0e412c..529ac023 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -169,6 +169,20 @@ mqtt: # External grid-charge lock (HEMS or grid operator, para. 14a EnWG). Absolute # topic, NOT below 'topic'. Payload 1/true blocks grid charging, 0/false restores it. +#-------------------------- +# Read-only HTTP API, dashboard and MCP server +# https://mastr.github.io/batcontrol/integrations/rest-api/ +#-------------------------- +api: + enabled: false # REST API, dashboard and MCP server. Default: false + host: 127.0.0.1 # bind address; 0.0.0.0 exposes it to the network. Default: 127.0.0.1 + port: 8080 # TCP port. Default: 8080 + dashboard_enabled: true # serve the web dashboard at /. Default: true + docs_enabled: false # serve Swagger UI at /docs and the schema at /openapi.json. Default: false + mcp_enabled: false # serve the MCP server at /mcp. Default: false + # auth_token: "" # optional bearer token for /api/v1; empty = no authentication + # mcp_auth_token: "" # optional separate bearer token for /mcp; empty = inherits auth_token + #-------------------------- # Forecast Solar # https://mastr.github.io/batcontrol/configuration/solar-forecast/ diff --git a/pyproject.toml b/pyproject.toml index ac84674d..300799a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,22 @@ dependencies = [ # added in websockets 13.0. "websockets>=13.0", "schedule>=1.2.0", - "pytz>=2024.2" + "pytz>=2024.2", + # Read-only web API, dashboard and MCP server (src/batcontrol/web). + # Regular dependencies on purpose: the Home Assistant stable add-on + # installs the plain release wheel, so an optional extra would silently + # ship an add-on without a dashboard. + "fastapi>=0.115", + # Plain uvicorn, not [standard]: uvloop/httptools/watchfiles are compiled + # extras that only matter for raw throughput and --reload, neither of + # which this uses. On a platform without a prebuilt wheel they would turn + # a pip install into a source build - the Dockerfile prefers piwheels + # precisely because armv7 users exist. + "uvicorn>=0.30", + # Upper bound on purpose: 2.x renamed FastMCP to MCPServer, and this code + # imports mcp.server.mcpserver directly. A 3.x may rename again, and the + # feature is off by default, so a silent break would surface late. + "mcp>=2.3,<3" ] # Config for the build system @@ -35,6 +50,14 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["./src"] +# The dashboard is built by npm into src/batcontrol/web/static and has to +# travel inside the wheel: the Home Assistant stable add-on installs nothing +# but that wheel. See docs/development/frontend-build.md. +[tool.setuptools.package-data] +# Includes the bundled fonts and their OFL license texts, which the build +# emits into static/assets - see THIRD-PARTY-LICENSES.md. +"batcontrol.web" = ["static/**/*"] + [tool.setuptools.dynamic] version = {attr = "batcontrol.__pkginfo__.__version__"} @@ -59,10 +82,14 @@ test = [ "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", "pytest-mock>=3.0.0", + # fastapi.testclient needs httpx + "httpx>=0.27", ] docs = [ "mkdocs-material>=9.5,<10", "mkdocs-llmstxt>=0.5.0,<1.0", + # Embeds the generated OpenAPI schema as Swagger UI in the API reference + "mkdocs-swagger-ui-tag>=0.8,<1.0", ] # Bump Version diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 0c55af70..a8d1e2ad 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -38,6 +38,17 @@ Decision, DecisionRecord, DecisionTrace, Outcome, Reason, ) from .decision_journal import DecisionJournal +from .web import config as web_config +from .web.snapshot import ForecastSnapshot +# Mode constants live in modes.py so the web API can import them without +# pulling in core.py; re-exported here for backwards compatibility. +from .modes import ( # noqa: F401 pylint: disable=unused-import + MODE_ALLOW_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE, + MODE_AVOID_DISCHARGING, + MODE_FORCE_CHARGING, + MODE_NAMES, +) from .dynamictariff import DynamicTariff as tariff_factory from .inverter import Inverter as inverter_factory @@ -62,18 +73,6 @@ MIN_FORECAST_HOURS = 1 # Minimum required forecast hours FORECAST_TOLERANCE = 3 # Acceptable tolerance for forecast hours -MODE_ALLOW_DISCHARGING = 10 -MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge -MODE_AVOID_DISCHARGING = 0 -MODE_FORCE_CHARGING = -1 - -MODE_NAMES = { - MODE_ALLOW_DISCHARGING: Outcome.ALLOW_DISCHARGING, - MODE_LIMIT_BATTERY_CHARGE_RATE: Outcome.LIMIT_BATTERY_CHARGE_RATE, - MODE_AVOID_DISCHARGING: Outcome.AVOID_DISCHARGING, - MODE_FORCE_CHARGING: Outcome.FORCE_CHARGE, -} - CONTROL_SOURCE_API = 'api' CONTROL_SOURCE_OPTIMIZER = 'optimizer' @@ -122,6 +121,7 @@ def __init__(self, configdict: dict): self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit) self._evcc_peak_shaving_disabled = False # Tracks last evcc-disable state for log messages self.last_prices = None + self.last_min_dynamic_price_difference = None self.last_consumption = None self.last_production = None self.last_net_consumption = None @@ -132,6 +132,11 @@ def __init__(self, configdict: dict): self.last_reserved_energy = -1 self.last_max_capacity = -1 self.last_stored_usable_energy = -1 + # Inverter SoC limits in percent, cached per cycle for the web API: + # an InverterGroup derives them from every member's capacity, which + # can mean inverter traffic, so the web thread must not read them. + self.last_min_soc = -1 + self.last_max_soc = -1 self.discharge_blocked = False self.discharge_limit = 0 @@ -147,6 +152,13 @@ def __init__(self, configdict: dict): self.fetched_stored_usable_energy = False self.last_run_time = 0 + # Interval between control cycles, exposed for the web API so it can + # report when the next evaluation is due. + self.evaluation_interval_seconds = TIME_BETWEEN_EVALUATIONS + # Immutable forecast snapshot of the last completed cycle, published + # as a single atomic attribute assignment for the web API to read. + # See web/snapshot.py for why this is not a lock. + self.forecast_snapshot = None self.last_logic_instance = None @@ -488,6 +500,21 @@ def __init__(self, configdict: dict): self.evcc_api.wait_ready() logger.info('evcc Connection ready') + # Read-only web API, dashboard and MCP server. Started last, so a + # failure here cannot stop the control loop from coming up - which + # has to include parsing its configuration: a typo in an optional + # read-only accessory must not stop the battery being controlled. + self.web_server = None + try: + self.api_config = web_config.from_config(config) + except ValueError as exc: + logger.error( + 'Invalid api configuration (%s). The API, dashboard and MCP' + ' server stay disabled; battery control continues.', exc) + self.api_config = web_config.ApiConfig() + if self.api_config.enabled: + self.__start_web_api() + # Initialize scheduler thread self.scheduler = SchedulerThread() logger.info('Scheduler thread initialized') @@ -521,6 +548,26 @@ def __init__(self, configdict: dict): except Exception as e: logger.error("Error during initial data fetch: %s", e) + def __start_web_api(self) -> None: + """ Bring up the read-only web API, tolerating any failure. + + The API is a reader. If it cannot start - missing dependency, port + already in use - batcontrol logs it and carries on controlling the + battery. + """ + try: + # Imported here so an installation with the API disabled never + # imports FastAPI at all. + from .web.server import WebServer # pylint: disable=import-outside-toplevel + self.web_server = WebServer(self, self.api_config) + if not self.web_server.start(): + self.web_server = None + except Exception as exc: # pylint: disable=broad-exception-caught + self.web_server = None + logger.error( + 'Web API could not be started (%s). Battery control continues' + ' without it.', exc, exc_info=True) + def refresh_all_providers(self) -> None: """ Force a refresh of all data providers (triggered by SIGHUP). @@ -555,6 +602,13 @@ def shutdown(self): if self.mqtt_api is not None: self.mqtt_api.shutdown() del self.mqtt_api + # getattr, like the scheduler guard above: __init__ can raise + # before web_server is assigned, and shutdown() is still called. + # An AttributeError here would replace the real startup error in + # the log with a misleading one. + if getattr(self, 'web_server', None) is not None: + self.web_server.shutdown() + self.web_server = None except Exception as exc: logger.exception("Error during Batcontrol shutdown: %s", exc) @@ -880,6 +934,8 @@ def _run_once(self): # for API self.set_reserved_energy(calc_output.reserved_energy) + self.last_min_dynamic_price_difference = \ + calc_output.min_dynamic_price_difference if self.mqtt_api is not None: self.mqtt_api.publish_min_dynamic_price_diff( calc_output.min_dynamic_price_difference) @@ -1120,6 +1176,15 @@ def __save_run_data( self.last_consumption = consumption self.last_net_consumption = net_consumption self.last_prices = prices + # One atomic assignment, so a web request never mixes two cycles. + self.forecast_snapshot = ForecastSnapshot.build( + run_time=self.last_run_time, + interval_minutes=self.time_resolution, + prices=prices, + consumption=consumption, + production=production, + net_consumption=net_consumption, + ) if self.mqtt_api is not None: self.mqtt_api.publish_production(production, self.last_run_time) self.mqtt_api.publish_consumption(consumption, self.last_run_time) @@ -1268,14 +1333,25 @@ def set_discharge_blocked(self, discharge_blocked) -> None: def refresh_static_values(self) -> None: """ Refresh static and some dynamic values for API. Collected data is stored, that it is not fetched again. + + The values are refreshed regardless of which APIs are enabled. + They used to be fetched inside the MQTT branch below, which left + last_SOC on its -1 sentinel whenever MQTT was disabled - the + value is cached core state, not an MQTT detail, and the REST API + and the dashboard read the same cache. """ + soc = self.get_SOC() + stored_energy = self.get_stored_energy() + always_allow_discharge_limit = self.get_always_allow_discharge_limit() + self.last_min_soc = getattr(self.inverter, 'min_soc', -1) + self.last_max_soc = getattr(self.inverter, 'max_soc', -1) + if self.mqtt_api is not None: - self.mqtt_api.publish_SOC(self.get_SOC()) - self.mqtt_api.publish_stored_energy_capacity( - self.get_stored_energy()) + self.mqtt_api.publish_SOC(soc) + self.mqtt_api.publish_stored_energy_capacity(stored_energy) # self.mqtt_api.publish_always_allow_discharge_limit( - self.get_always_allow_discharge_limit()) + always_allow_discharge_limit) self.mqtt_api.publish_max_charging_from_grid_limit( self.max_charging_from_grid_limit) if self.min_grid_charge_soc is not None: diff --git a/src/batcontrol/decision_journal.py b/src/batcontrol/decision_journal.py index 43c10ef5..b8e2fa17 100644 --- a/src/batcontrol/decision_journal.py +++ b/src/batcontrol/decision_journal.py @@ -103,6 +103,9 @@ def __init__(self, max_traces: int = DEFAULT_MAX_TRACES, # commit. Values are compared against the last event, not the last # cycle, so a slow drift in small steps is detected as well. self._last_change: Optional[StatusChangeEvent] = None + # Last KIND_MODE event: value changes move _last_change but must not + # move "mode since". + self._last_mode_change: Optional[StatusChangeEvent] = None # Last event of any kind (including refresh), with the time it was # created: the reference for the next refresh. self._last_event: Optional[StatusChangeEvent] = None @@ -189,6 +192,8 @@ def commit(self, trace: DecisionTrace, mode: int, control_source: str, ) if kind != KIND_REFRESH: self._last_change = event + if kind == KIND_MODE: + self._last_mode_change = event self._last_event = event self._last_event_time = now listeners = [listener for listener, wants_refresh @@ -214,6 +219,11 @@ def last_status_change(self) -> Optional[StatusChangeEvent]: with self._lock: return self._last_change + def last_mode_change(self) -> Optional[StatusChangeEvent]: + """The most recent mode change, ignoring value-only changes.""" + with self._lock: + return self._last_mode_change + def last_event(self) -> Optional[StatusChangeEvent]: """The most recent event including refresh events: the current status with the most recent explanation that was published.""" diff --git a/src/batcontrol/interval_utils.py b/src/batcontrol/interval_utils.py index cb079688..6f083def 100644 --- a/src/batcontrol/interval_utils.py +++ b/src/batcontrol/interval_utils.py @@ -6,11 +6,45 @@ """ import logging -from typing import Dict, Literal +from typing import Dict, Iterator, Literal, Optional, Sequence, Tuple logger = logging.getLogger(__name__) +def align_to_interval(timestamp: float, interval_minutes: int) -> float: + """Align an epoch timestamp down to the start of its interval. + + Forecast slot 0 always starts at the beginning of the interval that + contains the given timestamp, so every consumer (MQTT, REST API, MCP, + dashboard) derives the same slot boundaries from the same run time. + """ + interval_seconds = interval_minutes * 60 + return timestamp - (timestamp % interval_seconds) + + +def iter_slots(values: Sequence[float], timestamp: float, # pylint: disable=too-many-arguments + interval_minutes: int, *, start: int = 0, + stop: Optional[int] = None + ) -> Iterator[Tuple[int, float, float, float, float]]: + """Yield (index, time_start, time_end, value, power) per forecast slot. + + The one place that turns a forecast array into time slots, shared by the + MQTT forecast topics and the web API. Slot 0 starts at the interval that + contains `timestamp`; times are epoch seconds. `power` is the average + power over the slot for a Wh-per-slot value - equal to the value only at + 60 minutes, four times it at 15. Units and rounding stay with the caller. + Only values[start:stop] is rendered. + """ + interval_seconds = interval_minutes * 60 + factor = 60 / interval_minutes + base = align_to_interval(timestamp, interval_minutes) + stop = len(values) if stop is None else min(stop, len(values)) + for index in range(start, stop): + value = values[index] + yield (index, base + index * interval_seconds, + base + (index + 1) * interval_seconds, value, value * factor) + + def upsample_forecast( hourly_forecast: Dict[int, float], target_resolution: int = 15, diff --git a/src/batcontrol/modes.py b/src/batcontrol/modes.py new file mode 100644 index 00000000..a656a5cf --- /dev/null +++ b/src/batcontrol/modes.py @@ -0,0 +1,53 @@ +""" Inverter control modes + +The mode values are a stable, externally consumed contract: they appear in the +MQTT API, in the Decision sensor, in the REST API and in the dashboard. They +live in their own module so consumers can import them without pulling in +core.py (which imports every provider factory). + +core.py re-exports these names, so `from batcontrol.core import MODE_*` keeps +working. + +Two name sets exist on purpose: + MODE_NAMES - machine readable outcome codes, shared with the decision trace + MODE_LABELS - display strings for the REST API and the dashboard +""" +from .logic.decision_trace import Outcome + +MODE_ALLOW_DISCHARGING = 10 +MODE_LIMIT_BATTERY_CHARGE_RATE = 8 # Limit PV charge, allow discharge +MODE_AVOID_DISCHARGING = 0 +MODE_FORCE_CHARGING = -1 + +MODE_NAMES = { + MODE_ALLOW_DISCHARGING: Outcome.ALLOW_DISCHARGING, + MODE_LIMIT_BATTERY_CHARGE_RATE: Outcome.LIMIT_BATTERY_CHARGE_RATE, + MODE_AVOID_DISCHARGING: Outcome.AVOID_DISCHARGING, + MODE_FORCE_CHARGING: Outcome.FORCE_CHARGE, +} + +MODE_LABELS = { + MODE_ALLOW_DISCHARGING: 'ALLOW DISCHARGE', + MODE_LIMIT_BATTERY_CHARGE_RATE: 'LIMIT CHARGE RATE', + MODE_AVOID_DISCHARGING: 'AVOID DISCHARGE', + MODE_FORCE_CHARGING: 'FORCE CHARGE', +} + + + +def mode_code(mode) -> str: + """ Machine readable outcome code for a mode value. + + Unknown values are reported as-is instead of raising, so a new mode in a + newer batcontrol never breaks a consumer that only renders the value. + """ + if mode is None: + return 'unknown' + return MODE_NAMES.get(mode, f'mode_{mode}') + + +def mode_label(mode) -> str: + """ Display label for a mode value, as shown in the dashboard. """ + if mode is None: + return 'UNKNOWN' + return MODE_LABELS.get(mode, f'MODE {mode}') diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 900e3d34..6ce8b090 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -79,6 +79,8 @@ import paho.mqtt.client as mqtt import numpy as np +from .interval_utils import iter_slots + if TYPE_CHECKING: from .decision_journal import StatusChangeEvent @@ -310,15 +312,6 @@ def publish_production( production, timestamp, include_power=True)) ) - def _energy_to_power(self, energy_wh: np.ndarray) -> np.ndarray: - """ Convert Wh-per-interval energy values to average power in W. - - Numerically this equals the Wh value only when the interval is - 60 minutes; at 15-minute resolution the average power is 4x the - Wh-per-interval value. - """ - return energy_wh * (60 / self.interval_minutes) - def _create_forecast( self, forecast: np.ndarray, @@ -331,22 +324,16 @@ def _create_forecast( 'value' is always Wh per interval; set include_power=True to also add a power_w field (average power in W) for energy-based forecasts. """ - interval_seconds = self.interval_minutes * 60 - - # Align timestamp to the start of the current interval - now = timestamp - (timestamp % interval_seconds) - - power = self._energy_to_power(forecast) if include_power else None - data_list = [] - for i, value in enumerate(forecast): + for _, time_start, time_end, value, power in iter_slots( + forecast, timestamp, self.interval_minutes): entry = { - 'time_start': now + i * interval_seconds, + 'time_start': time_start, 'value': value, - 'time_end': now + (i + 1) * interval_seconds + 'time_end': time_end } if include_power: - entry['power_w'] = power[i] + entry['power_w'] = power data_list.append(entry) data = {'data': data_list} diff --git a/src/batcontrol/web/__init__.py b/src/batcontrol/web/__init__.py new file mode 100644 index 00000000..a4d02161 --- /dev/null +++ b/src/batcontrol/web/__init__.py @@ -0,0 +1,7 @@ +""" Read-only web API, dashboard and MCP server for batcontrol. + +The package is self contained: importing it does not pull in FastAPI unless +the API is actually enabled, so an installation that never switches it on +pays nothing for it. core.py only imports web.snapshot (no dependencies) and +constructs WebServer lazily. +""" diff --git a/src/batcontrol/web/app.py b/src/batcontrol/web/app.py new file mode 100644 index 00000000..81b213b3 --- /dev/null +++ b/src/batcontrol/web/app.py @@ -0,0 +1,234 @@ +""" FastAPI application factory + +Assembles the read-only API: routes, error mapping, OpenAPI metadata and - +added in later steps - the dashboard's static files and the MCP endpoint. +""" +import contextlib +import logging +import pathlib +from typing import Optional + +from fastapi import FastAPI, Request +from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from .. import __pkginfo__ +from .auth import auth_dependencies +from .config import API_PREFIX, MCP_PATH, ApiConfig +from .routes import ( + TAG_CONFIG, TAG_DECISION, TAG_FORECAST, TAG_META, TAG_STATUS, + error_envelope, public_router, router, +) +from .service import BatcontrolService, NoDataAvailable, NotFound + +logger = logging.getLogger(__name__) + +DESCRIPTION = """ +Read-only access to a running batcontrol instance: control state, battery, +price/solar/consumption forecasts and the decision trace that explains why +batcontrol chose the current inverter mode. + +**This API never changes anything.** Every endpoint is a GET. Runtime +overrides stay on the MQTT API. + +Conventions: + +* Timestamps are epoch **milliseconds** (the MQTT API uses seconds). +* Energy is **Wh per forecast slot**; energy series also carry `power_w`, the + average power over the slot. At 15-minute resolution those differ by a + factor of four. +* Prices are EUR/kWh with 4 decimals. +* Forecast slot 0 is the interval containing the current time. Slots that + have passed since the last control cycle are not returned. +""" + +TAGS_METADATA = [ + {'name': TAG_META, 'description': 'Version, health and runtime information.'}, + {'name': TAG_STATUS, 'description': 'What batcontrol is doing right now.'}, + {'name': TAG_FORECAST, 'description': 'Price, solar and consumption forecasts.'}, + {'name': TAG_DECISION, 'description': 'Why batcontrol decided what it did.'}, + {'name': TAG_CONFIG, 'description': 'Runtime parameters. Never credentials.'}, +] + + +STATIC_DIR = pathlib.Path(__file__).parent / 'static' + +# Shown instead of the dashboard when the static assets are absent. That is a +# normal state for a source install: the assets are produced by the npm build +# and travel inside the wheel, so a clone without Node has none. A readable +# page beats a stack trace or a bare 404. +MISSING_DASHBOARD_HTML = """ +batcontrol + + +

batcontrol

+

The API is running, but the dashboard assets are not installed.

+

They are built by npm and shipped inside the wheel. For a source checkout:

+

cd frontend && npm ci && npm run build

+

The REST API at api/v1/ is unaffected.

+ +""" + + +def _error(status_code: int, code: str, detail: str) -> JSONResponse: + return JSONResponse(status_code=status_code, + content=error_envelope(code, detail)) + + +def create_app(batcontrol, config: Optional[ApiConfig] = None) -> FastAPI: + """ Build the ASGI application for a live Batcontrol instance. """ + config = config or ApiConfig() + service = BatcontrolService(batcontrol) + + # The MCP app is a Starlette application with its own lifespan, which + # runs the MCP session manager. Mounting an ASGI app does NOT run the + # mounted app's lifespan, so it has to be entered explicitly below. + mcp_routes, mcp_app = ([], None) + if config.mcp_enabled: + mcp_routes, mcp_app = _build_mcp(service, config) + + @contextlib.asynccontextmanager + async def lifespan(_app: FastAPI): + async with contextlib.AsyncExitStack() as stack: + if mcp_app is not None: + try: + await stack.enter_async_context( + mcp_app.router.lifespan_context(mcp_app)) + except Exception as exc: # pylint: disable=broad-exception-caught + # Without its session manager /mcp cannot answer, but + # REST and the dashboard must still come up. + logger.error('MCP server could not be started (%s). The' + ' REST API and dashboard are unaffected.', + exc, exc_info=True) + yield + + app = FastAPI( + lifespan=lifespan, + title='batcontrol', + summary='Read-only API of a running batcontrol instance', + description=DESCRIPTION, + version=__pkginfo__.__version__, + openapi_tags=TAGS_METADATA, + # /docs is registered below so it can use the self-hosted Swagger UI + # assets. ReDoc is not offered: it would be a second documentation UI + # loading a second CDN bundle. Both /docs and the schema are only + # served with api.docs_enabled; app.openapi() works either way, which + # is what scripts/dump_openapi.py uses. + openapi_url='/openapi.json' if config.docs_enabled else None, + docs_url=None, + redoc_url=None, + ) + if config.docs_enabled: + _add_docs(app, config) + app.state.service = service + # No Host header check anywhere (maintainer decision): a reverse proxy + # forwards its own host name, and rejecting it would break a setup the + # docs describe. api.auth_token is the protection - see the security + # section of docs/integrations/rest-api.md. + + @app.exception_handler(NoDataAvailable) + async def _no_data(_request: Request, exc: NoDataAvailable) -> JSONResponse: + return _error(503, exc.code, str(exc)) + + @app.exception_handler(NotFound) + async def _not_found(_request: Request, exc: NotFound) -> JSONResponse: + return _error(404, 'not_found', str(exc)) + + # /health stays reachable without a token; everything else is behind the + # optional bearer dependency (empty list when no token is configured). + app.include_router(public_router, prefix=API_PREFIX) + app.include_router(router, prefix=API_PREFIX, + dependencies=auth_dependencies(config)) + + # Appended after the API routes so MCP can never shadow them. + for route in mcp_routes: + app.router.routes.append(route) + if mcp_routes: + logger.info('MCP server available at %s', MCP_PATH) + + # The dashboard is mounted last, at the root: routes are matched in + # registration order, so everything above keeps precedence over it. + if config.dashboard_enabled: + _mount_dashboard(app) + return app + + +def _add_docs(app: FastAPI, config: ApiConfig) -> None: + """ Serve Swagger UI at /docs, from bundled assets where possible. + + FastAPI's default points at cdn.jsdelivr.net, which leaves the API + documentation blank on a Home Assistant box without internet egress - the + same reason the dashboard's fonts are bundled. The npm build copies the + two files it needs into the static assets. + + The asset URLs are relative, so they resolve correctly at /, under a Home + Assistant ingress token path and under a reverse-proxy subpath alike. If + the assets are missing - a source checkout without a build - the CDN URLs + are used instead, so /docs still works for a developer with internet. + """ + swagger_dir = STATIC_DIR / 'swagger' + bundled = (swagger_dir / 'swagger-ui-bundle.js').is_file() + urls = {} + if bundled: + urls = { + 'swagger_js_url': 'swagger/swagger-ui-bundle.js', + 'swagger_css_url': 'swagger/swagger-ui.css', + # FastAPI's default favicon lives on fastapi.tiangolo.com; use + # the dashboard's own so /docs makes no external request at all. + 'swagger_favicon_url': 'favicon.svg', + } + if not config.dashboard_enabled: + # Otherwise the root dashboard mount serves these files; without + # it /docs would load a bundle that 404s and render blank. + app.mount('/swagger', StaticFiles(directory=swagger_dir), + name='swagger') + + @app.get('/docs', include_in_schema=False) + async def _swagger_ui() -> HTMLResponse: + return get_swagger_ui_html( + openapi_url='openapi.json', + title=f'{app.title} - API documentation', + **urls, + ) + + +def _mount_dashboard(app: FastAPI) -> None: + """ Serve the built dashboard at /, or a hint page when it is missing. """ + if (STATIC_DIR / 'index.html').is_file(): + # html=True serves index.html for the root. StaticFiles resolves and + # confines every path inside the directory, so it also handles the + # traversal attempts a public mount attracts. + app.mount('/', StaticFiles(directory=STATIC_DIR, html=True), + name='dashboard') + logger.info('Dashboard served from %s', STATIC_DIR) + return + + @app.get('/', include_in_schema=False, response_class=HTMLResponse) + async def _missing_dashboard() -> str: + return MISSING_DASHBOARD_HTML + + logger.warning( + 'Dashboard assets not found in %s. Serving a hint page instead;' + ' the REST API is unaffected. Build them with' + ' "cd frontend && npm ci && npm run build".', STATIC_DIR) + + +def _build_mcp(service: BatcontrolService, config: ApiConfig): + """ Build the MCP routes and lifespan app, or ([], None) on failure. + + A missing or broken MCP SDK must not prevent the REST API and the + dashboard from starting: it is an optional feature of an optional API. + """ + try: + # Imported lazily so the MCP SDK is only loaded when it is used. + from . import mcp as mcp_module # pylint: disable=import-outside-toplevel + server = mcp_module.build_server(service) + return mcp_module.http_routes( + server, path=MCP_PATH, host=config.host, + token=config.effective_mcp_token) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error('MCP server could not be started (%s). The REST API and' + ' dashboard are unaffected.', exc, exc_info=True) + return [], None diff --git a/src/batcontrol/web/auth.py b/src/batcontrol/web/auth.py new file mode 100644 index 00000000..f5e83aef --- /dev/null +++ b/src/batcontrol/web/auth.py @@ -0,0 +1,107 @@ +""" Optional bearer token authentication + +The API is read-only, but its payload still describes a household: battery +level, consumption pattern, when nobody is home. Default is bind to localhost +and no token. A deployment that binds to the network can set +``api.auth_token``. + +There is no half protected state: with an empty token the dependency is not +installed at all, so there is no code path that could accept a request for the +wrong reason. ``/api/v1/health`` always stays open, because container health +checks need it. + +The MCP endpoint is a raw ASGI route outside FastAPI's dependency system, so +it gets the ASGI wrapper ``bearer_guard`` instead - same comparison, its own +token. +""" +import logging +from typing import List, Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .config import ApiConfig, token_matches + +logger = logging.getLogger(__name__) + +BEARER_SCHEME = 'bearer' + +# auto_error=False so a missing header produces our own 401 with a +# WWW-Authenticate header rather than FastAPI's 403. +_bearer = HTTPBearer(auto_error=False, scheme_name='Bearer token', + description='Value of api.auth_token.') + + +def _unauthorized(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={'WWW-Authenticate': 'Bearer'}, + ) + + +def auth_dependencies(config: ApiConfig) -> List: + """ The dependency list to put on the protected routers. + + Empty when no token is configured. + """ + if not config.auth_enabled: + return [] + + def require_token( + credentials: Optional[HTTPAuthorizationCredentials] = + Depends(_bearer)) -> None: + """ Verify the bearer token in constant time. """ + if credentials is None: + raise _unauthorized('Missing bearer token.') + if credentials.scheme.lower() != BEARER_SCHEME: + raise _unauthorized('Expected an "Authorization: Bearer" header.') + # Constant-time, and byte-based so a non-ASCII token is rejected + # rather than raising; the token is never logged. + if not token_matches(credentials.credentials, config.auth_token): + raise _unauthorized('Invalid bearer token.') + + return [Depends(require_token)] + + +def bearer_guard(asgi_app, token: str): + """ Wrap an ASGI app so it requires `Authorization: Bearer `. + + The MCP endpoint is attached to the FastAPI router as a raw ASGI route, + so FastAPI's dependency-based auth does not apply to it. A small ASGI + wrapper keeps the check in one obvious place and lets MCP carry its own + token, independent of the REST API's. + + An empty token means the wrapper is not installed at all; see app.py. + """ + async def guarded(scope, receive, send): + header = b'' + for key, value in scope.get('headers') or (): + if key.lower() == b'authorization': + header = value + break + # Split on the raw bytes: a token is compared as bytes, so there is + # no decode step that could fail or normalise it into a match. + parts = header.split(None, 1) + presented = parts[1].strip() if len(parts) == 2 and \ + parts[0].lower() == b'bearer' else b'' + # Constant-time, and a wrong scheme costs the same as a wrong token; + # the token itself is never logged. + if not token_matches(presented, token): + await send({ + 'type': 'http.response.start', + 'status': 401, + 'headers': [ + (b'content-type', b'application/json'), + (b'www-authenticate', b'Bearer'), + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'{"error":"unauthorized",' + b'"detail":"Missing or invalid bearer token for /mcp."}', + }) + return + await asgi_app(scope, receive, send) + + return guarded diff --git a/src/batcontrol/web/config.py b/src/batcontrol/web/config.py new file mode 100644 index 00000000..242ec2ae --- /dev/null +++ b/src/batcontrol/web/config.py @@ -0,0 +1,193 @@ +""" Configuration of the read-only web API + +Parses the `api:` block of the batcontrol configuration. Home Assistant form +fields may deliver strings for booleans and integers, so every value goes +through an explicit conversion. +""" +import dataclasses +import logging +import secrets + +from ..value_utils import parse_bool_flag + +logger = logging.getLogger(__name__) + +DEFAULT_HOST = '127.0.0.1' +DEFAULT_PORT = 8080 + +# Path prefixes served by the application +API_PREFIX = '/api/v1' +MCP_PATH = '/mcp' + +# Bind addresses that mean "this machine only": no warning about an +# unauthenticated bind for these. +LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '::1', '[::1]') + + +def token_matches(presented, expected: str) -> bool: + """ Constant-time comparison of a presented bearer token. + + Compares **bytes**, not strings, on purpose: ``secrets.compare_digest`` + raises ``TypeError`` for a string containing non-ASCII characters, so a + request with ``Authorization: Bearer `` would come back as a 500 + with a traceback instead of a plain 401. It fails closed either way, but + an authentication check must reject, not crash. + + ``presented`` may be raw header bytes (the ASGI path) or the string + Starlette decoded from them, which is latin-1 and round-trips exactly. + """ + if not presented or not expected: + return False + if isinstance(presented, str): + presented = presented.encode('latin-1', 'replace') + return secrets.compare_digest(presented, expected.encode('utf-8')) + + +@dataclasses.dataclass(frozen=True) +class ApiConfig: # pylint: disable=too-many-instance-attributes + """ Validated `api:` configuration block. + + Two independent tokens, so the MCP token can be handed to an assistant + without giving away access to the REST API: + + auth_token - protects /api/v1/** (except /health) + mcp_auth_token - protects /mcp; falls back to auth_token when empty, + so securing the API never leaves MCP wide open + + An empty token means no authentication at all for that surface - there is + deliberately no half protected state. + """ + enabled: bool = False + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + dashboard_enabled: bool = True + # Swagger UI (/docs) and the OpenAPI schema (/openapi.json). Off by + # default: a running installation does not need its API described to + # every visitor, and the schema is published with the documentation. + docs_enabled: bool = False + mcp_enabled: bool = False + auth_token: str = '' + mcp_auth_token: str = '' + + @property + def auth_enabled(self) -> bool: + """ True when the REST API requires a bearer token. """ + return bool(self.auth_token) + + @property + def effective_mcp_token(self) -> str: + """ The token /mcp requires, inheriting auth_token when unset. """ + return self.mcp_auth_token or self.auth_token + + @property + def mcp_auth_enabled(self) -> bool: + """ True when /mcp requires a bearer token. """ + return bool(self.effective_mcp_token) + + @property + def mcp_token_is_separate(self) -> bool: + """ True when MCP uses its own token rather than the API's. """ + return bool(self.mcp_auth_token) and \ + self.mcp_auth_token != self.auth_token + + def describe(self) -> str: + """ One line summary for the startup log. Never includes a token. """ + if not self.mcp_enabled: + mcp = 'off' + elif not self.mcp_auth_enabled: + mcp = 'on (auth: off)' + elif self.mcp_token_is_separate: + mcp = 'on (auth: own token)' + else: + mcp = 'on (auth: shared with api)' + return ( + f'http://{f"[{self.host}]" if ":" in self.host else self.host}' + f':{self.port}' + f' (dashboard: {"on" if self.dashboard_enabled else "off"},' + f' docs: {"on" if self.docs_enabled else "off"},' + f' api auth: {"on" if self.auth_enabled else "off"},' + f' mcp: {mcp})' + ) + + +def _unset(value) -> bool: + """ True for a value that means "not configured". + + Home Assistant submits an empty string for an optional form field that + was left blank, and a commented-out YAML key arrives as None. Both mean + the same thing: use the default. Treating the empty string as a *value* + turned a blank field into a startup failure. + """ + return value is None or (isinstance(value, str) and not value.strip()) + + +def _as_bool(value, key: str, default: bool) -> bool: + if _unset(value): + return default + try: + return parse_bool_flag(value) + except ValueError as exc: + raise ValueError(f'api.{key} must be true or false, got {value!r}') from exc + + +def _as_port(value) -> int: + if _unset(value): + return DEFAULT_PORT + try: + port = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f'api.port must be a number, got {value!r}') from exc + if not 1 <= port <= 65535: + raise ValueError(f'api.port must be between 1 and 65535, got {port}') + return port + + +def _as_host(value) -> str: + if _unset(value): + return DEFAULT_HOST + host = str(value).strip() + # '[::1]' is URL notation; uvicorn needs the bare address to bind it. + if host.startswith('[') and host.endswith(']'): + host = host[1:-1] + return host + + +def from_config(configdict: dict) -> ApiConfig: + """ Build an ApiConfig from the batcontrol configuration dictionary. + + A missing `api:` block yields a disabled API, which is the default for + every existing installation. + """ + section = configdict.get('api') or {} + if not isinstance(section, dict): + raise ValueError( + f'api must be a configuration block, got {type(section).__name__}') + + config = ApiConfig( + enabled=_as_bool(section.get('enabled'), 'enabled', False), + host=_as_host(section.get('host')), + port=_as_port(section.get('port')), + dashboard_enabled=_as_bool( + section.get('dashboard_enabled'), 'dashboard_enabled', True), + docs_enabled=_as_bool( + section.get('docs_enabled'), 'docs_enabled', False), + mcp_enabled=_as_bool(section.get('mcp_enabled'), 'mcp_enabled', False), + auth_token=str(section.get('auth_token') or '').strip(), + mcp_auth_token=str(section.get('mcp_auth_token') or '').strip(), + ) + + if config.enabled and config.host not in LOOPBACK_HOSTS: + if not config.auth_enabled: + logger.warning( + 'API is bound to %s without an auth_token. Anyone on that' + ' network can read your battery, price and consumption data.' + ' Set api.auth_token or put a reverse proxy in front of it.', + config.host + ) + if config.mcp_enabled and not config.mcp_auth_enabled: + logger.warning( + 'MCP is bound to %s without a token. Set api.mcp_auth_token' + ' (or api.auth_token, which it inherits).', config.host + ) + + return config diff --git a/src/batcontrol/web/models.py b/src/batcontrol/web/models.py new file mode 100644 index 00000000..27286156 --- /dev/null +++ b/src/batcontrol/web/models.py @@ -0,0 +1,333 @@ +""" Response models for the read-only API + +The field descriptions in here are not decoration: they are what Swagger UI +renders, what `openapi.json` carries and what the MCP tool descriptions reuse. +Units belong in the description of every numeric field - a bare "energy" field +has caused enough confusion between Wh per interval and W already. + +Timestamps are epoch **milliseconds** throughout the API. The MQTT API uses +epoch seconds; the conversion happens in the service layer. +""" +from typing import Generic, Optional, TypeVar + +from pydantic import BaseModel, Field + +DataT = TypeVar('DataT') + + +class Envelope(BaseModel, Generic[DataT]): + """ Common response envelope. Every endpoint answers in this shape. """ + ok: bool = Field(True, description='False only in an error response.') + timestamp: int = Field( + ..., + description='Server time when the response was built, epoch ms.') + data: DataT = Field(..., description='The payload.') + + +class Health(BaseModel): + """ Liveness of the batcontrol process and its control loop. """ + status: str = Field(..., description='Always "ok" when reachable.') + version: str = Field(..., description='batcontrol version.') + has_data: bool = Field( + ..., + description='True once the first control cycle has produced forecasts.') + last_evaluation: Optional[int] = Field( + None, description='Start of the last control cycle, epoch ms.') + cycle_age_seconds: Optional[float] = Field( + None, description='Seconds since the last control cycle.') + evaluation_interval_seconds: int = Field( + ..., description='Nominal seconds between two control cycles.') + stale: bool = Field( + ..., + description='True when the data must not be presented as current:' + ' the control loop has missed several cycles, or its' + ' forecast has not been refreshed for as long (for' + ' example while the tariff provider fails), or no cycle' + ' has completed since startup.') + + +class Version(BaseModel): + """ Version and runtime identification. """ + version: str = Field(..., description='batcontrol version.') + git_sha: Optional[str] = Field( + None, description='Git commit of the build, if the image provides it.') + runtime_env: str = Field( + ..., + description='Where batcontrol runs: docker, home_assistant_addon-ng,' + ' or source.') + interval_minutes: int = Field( + ..., description='Forecast slot width in minutes: 15 or 60.') + timezone: str = Field(..., description='Configured local timezone.') + + +class Status(BaseModel): + """ What batcontrol is doing right now. """ + mode: Optional[int] = Field( + None, + description='Inverter mode: -1 force charge from grid, 0 avoid' + ' discharge, 8 limit PV charge rate, 10 allow discharge.' + ' None before the first decision.') + mode_code: str = Field( + ..., + description='Machine readable mode, same vocabulary as the decision' + ' trace (e.g. allow_discharging).') + mode_label: str = Field( + ..., description='Display label, e.g. "ALLOW DISCHARGE".') + control_source: Optional[str] = Field( + None, + description='Who set the current mode: optimizer, api or evcc.') + charge_rate: Optional[float] = Field( + None, description='Forced grid charge rate in W, 0 when not charging.') + limit_battery_charge_rate: int = Field( + ..., + description='Dynamic PV charge rate limit in W. -1 means no limit.') + api_override_active: bool = Field( + ..., description='True while an external override holds the mode.') + discharge_blocked: bool = Field( + ..., description='True while an external consumer (evcc) blocks' + ' discharging.') + grid_charge_locked: bool = Field( + ..., + description='True while grid charging is locked externally' + ' (HEMS or grid operator, section 14a EnWG).') + last_evaluation: Optional[int] = Field( + None, description='Start of the last control cycle, epoch ms.') + next_evaluation: Optional[int] = Field( + None, + description='When the next cycle is due, epoch ms. Poll against this' + ' instead of a fixed interval.') + evaluation_interval_seconds: int = Field( + ..., description='Nominal seconds between two control cycles.') + online: bool = Field( + ..., + description='False when the data is stale: the control loop or its' + ' forecast has not been refreshed for several cycles.' + ' Same rule as /health stale.') + mode_changed_at: Optional[int] = Field( + None, + description='When the mode last actually changed, epoch ms. Not the' + ' last refresh, so a UI can show "since HH:MM". Null' + ' until the first change after a restart.') + + +class SocLimits(BaseModel): + """ The inverter's own SoC window. """ + min_soc: Optional[float] = Field( + None, description='Lowest SoC the inverter will discharge to, percent.') + max_soc: Optional[float] = Field( + None, description='Highest SoC the inverter will charge to, percent.') + + +class Battery(BaseModel): + """ Battery state and the limits the dashboard's SOC grid needs. + + Every energy value is in Wh and may be null while the inverter has not + been read yet - null means unknown, never zero. + """ + soc: Optional[float] = Field( + None, description='State of charge in percent (0-100).') + max_energy_capacity: Optional[float] = Field( + None, description='Battery capacity in Wh up to max_soc, i.e. gross' + ' capacity * max_soc / 100 (not reduced by min_soc).') + stored_energy_capacity: Optional[float] = Field( + None, description='Energy stored in the battery in Wh.') + stored_usable_energy_capacity: Optional[float] = Field( + None, + description='Stored energy in Wh that may actually be discharged,' + ' i.e. above the inverter min_soc.') + reserved_energy_capacity: Optional[float] = Field( + None, + description='Energy in Wh held back for upcoming expensive hours.' + ' Can exceed the battery capacity; consumers should cap' + ' it before rendering.') + free_capacity: Optional[float] = Field( + None, description='Capacity in Wh still free for charging.') + always_allow_discharge_limit_percent: Optional[float] = Field( + None, + description='Above this SoC discharging is always allowed, percent.') + max_charging_from_grid_limit_percent: Optional[float] = Field( + None, + description='Grid charging stops at this SoC, percent.') + soc_limits: SocLimits = Field(..., description='Inverter SoC window.') + + +class CurrentPrice(BaseModel): + """ The price situation right now. """ + current: Optional[float] = Field( + None, + description='Price of the current slot in EUR/kWh. Taken from the' + ' price forecast, so it is always populated regardless of' + ' which decision path ran.') + current_ts: int = Field( + ..., description='Start of the current price slot, epoch ms.') + unit: str = Field(..., description='Always EUR/kWh.') + interval_minutes: int = Field( + ..., description='Slot width in minutes: 15 or 60.') + min_dynamic_price_difference: Optional[float] = Field( + None, + description='Price delta in EUR/kWh that a future slot has to beat' + ' before batcontrol charges from the grid for it.') + + +class Current(BaseModel): + """ Everything the dashboard header and price block need in one call. """ + version: str = Field(..., description='batcontrol version.') + price: CurrentPrice = Field(..., description='Current price situation.') + soc: Optional[float] = Field(None, description='State of charge, percent.') + soc_limits: SocLimits = Field(..., description='Inverter SoC window.') + + +class ForecastSlot(BaseModel): + """ One forecast interval. """ + slot: int = Field( + ..., + description='Index, 0 being the interval that contains the current' + ' time.') + time_start: int = Field(..., description='Slot start, epoch ms.') + time_end: int = Field(..., description='Slot end, epoch ms.') + value: float = Field( + ..., + description='The series value: EUR/kWh for prices, Wh per slot for' + ' energy series.') + power_w: Optional[float] = Field( + None, + description='Average power over the slot in W. Energy series only.' + ' At 15-minute resolution this is four times the Wh' + ' value, not the same number.') + + +class ForecastWindow(BaseModel): + """ Paging metadata shared by every forecast response. + + Slot 0 is the slot containing the current time, so all counts start + there - not at the start of the control cycle's forecast. + """ + run_time: Optional[int] = Field( + None, description='Control cycle this forecast belongs to, epoch ms.') + interval_minutes: int = Field(..., description='Slot width in minutes.') + total_count: int = Field( + ..., description='Number of slots from the current slot to the end of' + ' the forecast horizon.') + returned_count: int = Field( + ..., description='Number of slots in this response.') + offset: int = Field( + ..., description='Slot index this window starts at, 0 being the' + ' current slot.') + has_more: bool = Field( + ..., description='True when further slots exist beyond this window.') + next_offset: Optional[int] = Field( + None, + description='Offset to request next, or null when the horizon ends' + ' here.') + + +class ForecastSeries(ForecastWindow): + """ One forecast series, windowed by limit/offset. """ + unit: str = Field(..., description='EUR/kWh or Wh.') + slots: list[ForecastSlot] = Field(..., description='The slots, in order.') + + +class ForecastBundle(ForecastWindow): + """ All four series of one control cycle, for a single poll. """ + price: list[ForecastSlot] = Field( + ..., description='Electricity price per slot in EUR/kWh.') + consumption: list[ForecastSlot] = Field( + ..., description='Forecast household consumption in Wh per slot.') + production: list[ForecastSlot] = Field( + ..., description='Forecast solar production in Wh per slot.') + net_consumption: list[ForecastSlot] = Field( + ..., + description='Consumption minus production in Wh per slot. Negative' + ' means surplus solar energy.') + + +class Decision(BaseModel): + """ The latest decision and the trace that explains it. + + `trace`, `decisive` and `event` are passed through from the decision + journal. Their inner shape is documented in + docs/development/decision-trace.md and deliberately not pinned down here, + because the inputs of a record differ per reason code. + """ + decided_at: Optional[int] = Field( + None, description='When this decision was taken, epoch ms.') + trace: dict = Field( + ..., + description='The full trace: timestamp, decided_by and records. Its' + ' own timestamp is ISO 8601, because the trace is the' + ' same object the MQTT Decision sensor publishes.') + status_text: Optional[str] = Field( + None, + description='Resulting mode plus a plain language reason, the same' + ' text the Home Assistant Decision sensor shows.') + explanation: Optional[str] = Field( + None, description='Plain language "why" of the decisive step.') + decisive: Optional[dict] = Field( + None, description='The record that determined the outcome.') + event: Optional[dict] = Field( + None, + description='The last status *change*, including the mode transition.' + ' This is not the latest evaluation: it only moves when' + ' the mode or value changes, or every 15 minutes. Render' + ' the records from `trace`, not from here.') + event_at: Optional[int] = Field( + None, + description='When that status change happened, epoch ms. Compare it' + ' with decided_at to see how much older it is.') + + +class DecisionHistory(BaseModel): + """ A window into the in-memory decision journal. """ + count: int = Field(..., description='Number of traces returned.') + limit: int = Field(..., description='Requested maximum.') + traces: list[dict] = Field( + ..., description='Traces, oldest first.') + + +class BatteryControlConfig(BaseModel): + """ The parameters that drive the price decision. """ + type: str = Field(..., description='Logic type: default (or its alias next).') + min_price_difference: Optional[float] = Field( + None, description='Absolute price delta in EUR/kWh to justify charging.') + min_price_difference_rel: Optional[float] = Field( + None, description='Relative price delta as a factor (0.10 = 10 %).') + always_allow_discharge_limit: Optional[float] = Field( + None, description='SoC ratio (0-1) above which discharging is always allowed.') + max_charging_from_grid_limit: Optional[float] = Field( + None, description='SoC ratio (0-1) up to which grid charging is allowed.') + min_grid_charge_soc: Optional[float] = Field( + None, description='Optional SoC ratio (0-1) to preserve before expensive slots.') + preserve_min_grid_charge_soc: bool = Field( + ..., description='Whether min_grid_charge_soc counts as reserved energy.') + production_offset_percent: Optional[float] = Field( + None, description='Factor applied to the solar forecast (1.0 = unchanged).') + + +class PeakShavingConfigModel(BaseModel): + """ Peak shaving switches and thresholds. """ + enabled: bool = Field(..., description='Whether peak shaving runs at all.') + time_active: bool = Field(..., description='Target-time rule active.') + price_active: bool = Field(..., description='Cheap-price rule active.') + solar_cap_active: bool = Field(..., description='Feed-in limit rule active.') + allow_full_battery_after: int = Field( + ..., description='Hour (0-23) the battery should be full by.') + price_limit: Optional[float] = Field( + None, description='Cheap-slot threshold in EUR/kWh, -1 disables the rule.') + feed_in_limit_w: Optional[float] = Field( + None, description='Grid feed-in power limit in W, 0 = off.') + + +class Configuration(BaseModel): + """ Runtime parameters that explain batcontrol's behaviour. + + Built from an explicit allow-list of runtime values. No credential can + appear here: the endpoint does not read the configuration file at all. + """ + interval_minutes: int = Field(..., description='Forecast slot width, 15 or 60.') + timezone: str = Field(..., description='Configured local timezone.') + evaluation_interval_seconds: int = Field( + ..., description='Nominal seconds between two control cycles.') + battery_control: BatteryControlConfig = Field( + ..., description='Price decision parameters.') + peak_shaving: PeakShavingConfigModel = Field( + ..., description='Peak shaving configuration.') diff --git a/src/batcontrol/web/routes.py b/src/batcontrol/web/routes.py new file mode 100644 index 00000000..d6b18da2 --- /dev/null +++ b/src/batcontrol/web/routes.py @@ -0,0 +1,224 @@ +""" HTTP routes of the read-only API + +Every route in here is a GET. There is no POST, PUT, PATCH or DELETE, and +tests/batcontrol/web/test_readonly.py enforces that: the API reads batcontrol, +it never steers it. Control stays with the MQTT API. + +Two routers: + public_router - /health only, reachable without a token + router - everything else, behind the optional bearer token +""" +import logging +import time +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Path, Query, Request + +from .models import ( + Battery, Configuration, Current, Decision, DecisionHistory, + Envelope, ForecastBundle, ForecastSeries, Health, Status, Version, +) +from .service import HISTORY_MAX, WINDOW_MAX, BatcontrolService + +logger = logging.getLogger(__name__) + +TAG_META = 'meta' +TAG_STATUS = 'status' +TAG_FORECAST = 'forecast' +TAG_DECISION = 'decision' +TAG_CONFIG = 'config' + +# Default and maximum number of traces returned by the history endpoint. The +# journal keeps 200 by default; a full dump is large, so the default is small. +HISTORY_DEFAULT = 20 + +public_router = APIRouter() +router = APIRouter() + +# REST clients default to the whole horizon - the dashboard wants it - while +# the MCP tools default to 24 hours and page for more. +LIMIT_QUERY = Query( + None, ge=1, le=WINDOW_MAX, + description='Maximum number of slots to return. Omit for the whole' + ' horizon.') +OFFSET_QUERY = Query( + 0, ge=0, le=WINDOW_MAX, + description='Slot index to start at, for paging through the horizon.') + + +def now_ms() -> int: + """ Current server time in epoch milliseconds. """ + return int(time.time() * 1000) + + +def envelope(data: Any) -> Dict[str, Any]: + """ Wrap a payload in the common response envelope. """ + return {'ok': True, 'timestamp': now_ms(), 'data': data} + + +def error_envelope(code: str, detail: str) -> Dict[str, Any]: + """ The envelope of every non-2xx response. """ + return {'ok': False, 'timestamp': now_ms(), 'error': code, + 'detail': detail} + + +def service(request: Request) -> BatcontrolService: + """ The service layer instance attached to the application. """ + return request.app.state.service + + +@public_router.get( + '/health', + response_model=Envelope[Health], + tags=[TAG_META], + summary='Liveness of the control loop', + description='Answers even before the first control cycle has completed,' + ' so it is usable as a container health check. This is the' + ' only endpoint that stays reachable without a token when' + ' api.auth_token is configured.', +) +def get_health(request: Request) -> Dict[str, Any]: + """ Liveness and freshness of the control loop. """ + return envelope(service(request).health()) + + +@router.get( + '/version', + response_model=Envelope[Version], + tags=[TAG_META], + summary='Version and runtime identification', +) +def get_version(request: Request) -> Dict[str, Any]: + """ Version, build and runtime environment. """ + return envelope(service(request).version()) + + +@router.get( + '/status', + response_model=Envelope[Status], + tags=[TAG_STATUS], + summary='Current control state', + description='The inverter mode batcontrol has set, who set it, and when' + ' the next evaluation is due.', +) +def get_status(request: Request) -> Dict[str, Any]: + """ What batcontrol is doing right now. """ + return envelope(service(request).status()) + + +@router.get( + '/battery', + response_model=Envelope[Battery], + tags=[TAG_STATUS], + summary='Battery state and limits', + description='State of charge, capacities and the SoC limits. Values are' + ' the ones cached by the last control cycle - reading this' + ' endpoint never queries the inverter.', +) +def get_battery(request: Request) -> Dict[str, Any]: + """ Battery state and the limits needed to render it. """ + return envelope(service(request).battery()) + + +@router.get( + '/current', + response_model=Envelope[Current], + tags=[TAG_STATUS], + summary='Current price and SoC in one call', + description='Convenience endpoint for dashboards: the current price is' + ' always populated, independent of which decision path ran.' + ' Returns 503 until the first control cycle has completed.', +) +def get_current(request: Request) -> Dict[str, Any]: + """ Current price situation plus SoC and its limits. """ + return envelope(service(request).current()) + + +@router.get( + '/forecast', + response_model=Envelope[ForecastBundle], + tags=[TAG_FORECAST], + summary='All forecast series of the last cycle', + description='Price, consumption, production and net consumption in one' + ' response, so a dashboard needs a single request. All series' + ' share the same slot grid.', +) +def get_forecast( + request: Request, + limit: Optional[int] = LIMIT_QUERY, + offset: int = OFFSET_QUERY, +) -> Dict[str, Any]: + """ All four forecast series of the last control cycle. """ + return envelope(service(request).forecast_all(limit=limit, offset=offset)) + + +@router.get( + '/forecast/{series}', + response_model=Envelope[ForecastSeries], + tags=[TAG_FORECAST], + summary='One forecast series', + description='`price` is EUR/kWh per slot. `consumption`, `production` and' + ' `net_consumption` are Wh per slot and additionally carry' + ' `power_w`, the average power over the slot. Negative' + ' net_consumption means surplus solar energy.', +) +def get_forecast_series( + request: Request, + series: str = Path( + ..., + description='price, consumption, production or net_consumption', + examples=['price'], + ), + limit: Optional[int] = LIMIT_QUERY, + offset: int = OFFSET_QUERY, +) -> Dict[str, Any]: + """ A single forecast series. """ + return envelope( + service(request).forecast(series, limit=limit, offset=offset)) + + +@router.get( + '/decision', + response_model=Envelope[Decision], + tags=[TAG_DECISION], + summary='Why batcontrol chose the current mode', + description='The latest decision trace with its decisive step and a' + ' plain language explanation. The record shapes are' + ' documented in the decision trace developer docs.', +) +def get_decision(request: Request) -> Dict[str, Any]: + """ The latest decision trace. """ + return envelope(service(request).decision()) + + +@router.get( + '/decision/history', + response_model=Envelope[DecisionHistory], + tags=[TAG_DECISION], + summary='Recent decisions', + description='Traces from the in-memory journal, oldest first. The journal' + ' is not persisted: it starts empty after a restart.', +) +def get_decision_history( + request: Request, + limit: int = Query( + HISTORY_DEFAULT, ge=1, le=HISTORY_MAX, + description='Maximum number of traces to return.'), +) -> Dict[str, Any]: + """ The last decisions from the journal. """ + return envelope(service(request).decision_history(limit=limit)) + + +@router.get( + '/config', + response_model=Envelope[Configuration], + tags=[TAG_CONFIG], + summary='Runtime parameters', + description='The parameters that explain batcontrol\'s behaviour. This is' + ' an explicit allow-list of runtime values, not a dump of the' + ' configuration file: inverter passwords, tariff tokens and' + ' MQTT credentials are not reachable from here.', +) +def get_config(request: Request) -> Dict[str, Any]: + """ Runtime parameters, never credentials. """ + return envelope(service(request).configuration()) diff --git a/src/batcontrol/web/server.py b/src/batcontrol/web/server.py new file mode 100644 index 00000000..b19fd4d7 --- /dev/null +++ b/src/batcontrol/web/server.py @@ -0,0 +1,116 @@ +""" Hosting the read-only web API + +The server runs uvicorn in a daemon thread next to the control loop, the same +pattern MqttApi uses. The control loop stays the owner of the process: if the +HTTP server fails to start, batcontrol logs the failure and keeps controlling +the battery. +""" +import asyncio +import logging +import threading +import time +from typing import Optional + +import uvicorn + +from .app import create_app +from .config import ApiConfig + +logger = logging.getLogger(__name__) + +# Time granted to the event loop to shut down before the process moves on. +SHUTDOWN_TIMEOUT = 5.0 +# Open streams (an MCP client's GET stream never ends on its own) are cut +# after this long, so shutdown fits into SHUTDOWN_TIMEOUT and the lifespan +# shutdown still runs. +GRACEFUL_SHUTDOWN_TIMEOUT = 2 +# How long start() waits for the socket to be bound before giving up. +STARTUP_TIMEOUT = 10.0 + + +class WebServer: + """ uvicorn in a daemon thread, serving the read-only API. + + The constructor only prepares; nothing listens until start() is called. + """ + + def __init__(self, batcontrol, config: ApiConfig): + self._config = config + self._thread: Optional[threading.Thread] = None + self._server = None + self._app = create_app(batcontrol, config) + + @property + def app(self): + """ The ASGI application, exposed for tests. """ + return self._app + + def start(self) -> bool: + """ Start serving in a background thread. + + Waits until uvicorn has bound the socket and run the lifespan + startup. Returns False (after logging why) when it did not, e.g. on + an occupied port. + """ + if self._thread is not None: + logger.warning('Web API already started') + return True + + # log_config=None keeps uvicorn from replacing batcontrol's logging + # setup; its loggers then propagate into the batcontrol log file. + uvicorn_config = uvicorn.Config( + self._app, + host=self._config.host, + port=self._config.port, + log_config=None, + access_log=False, + # The control loop owns the process lifetime. + lifespan='on', + timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT, + ) + self._server = uvicorn.Server(uvicorn_config) + # uvicorn installs signal handlers only on the main thread; it detects + # the thread itself, but be explicit about not touching them. + self._server.install_signal_handlers = lambda: None + + self._thread = threading.Thread( + target=self._serve, + name='batcontrol-web', + daemon=True, + ) + self._thread.start() + deadline = time.monotonic() + STARTUP_TIMEOUT + while not self._server.started and self._thread.is_alive() \ + and time.monotonic() < deadline: + time.sleep(0.02) + if not self._server.started: + logger.error( + 'Web API could not be started on %s (see the uvicorn error' + ' above). Battery control continues without it.', + self._config.describe()) + self.shutdown() + return False + logger.info('Web API listening on %s', self._config.describe()) + return True + + def _serve(self) -> None: + try: + asyncio.run(self._server.serve()) + except BaseException as exc: # pylint: disable=broad-exception-caught + # uvicorn calls sys.exit(1) on a bind or lifespan failure, which + # is a SystemExit, not an Exception. A failing web API must never + # take down battery control, so catch that as well. + logger.error('Web API stopped: %r', exc, + exc_info=not isinstance(exc, SystemExit)) + + def shutdown(self) -> None: + """ Ask the server to stop and wait briefly for the thread. """ + if self._server is not None: + self._server.should_exit = True + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=SHUTDOWN_TIMEOUT) + if self._thread.is_alive(): + logger.warning( + 'Web API thread did not stop within %.0f s', SHUTDOWN_TIMEOUT) + self._thread = None + self._server = None diff --git a/src/batcontrol/web/service.py b/src/batcontrol/web/service.py new file mode 100644 index 00000000..af039451 --- /dev/null +++ b/src/batcontrol/web/service.py @@ -0,0 +1,458 @@ +""" Read-only service layer + +Every REST route and every MCP tool reads through this module. That keeps one +source of truth for field names, units and rounding, and it keeps business +logic out of both transports. + +Two rules hold for everything in here: + +1. **Read-only.** Nothing in this module changes batcontrol state. +2. **No inverter traffic.** The service reads the values the control loop has + already cached (``last_SOC``, ``last_max_capacity``, ...), never the + ``get_*`` accessors on the core, because those fetch from the inverter on a + cache miss and would mutate the loop's cache flags from a web thread. An + HTTP request must never cause Modbus or HTTP traffic towards the inverter. +""" +import logging +import os +import time +from typing import Any, Dict, Optional + +from .. import __pkginfo__ +from ..modes import mode_code, mode_label +from .snapshot import ENERGY_DIGITS, PRICE_DIGITS, ForecastSnapshot + +logger = logging.getLogger(__name__) + +# A cycle older than this many evaluation intervals counts as stale: the +# control loop has missed several runs and the data should not be presented as +# current. +STALE_AFTER_INTERVALS = 3 + +# Default forecast window, in hours. Expressed in hours rather than slots +# because a slot is 15 or 60 minutes depending on the configuration: a fixed +# slot count would mean 48 hours at one resolution and 12 at the other. +DEFAULT_WINDOW_HOURS = 24 + +# Upper bound for a forecast window or offset: one week at 15-minute +# resolution. The real horizon is at most about two days, so this is a +# validation guard for both transports, not a usable window. +WINDOW_MAX = 672 +# The journal keeps 200 traces by default; no transport may ask for more. +HISTORY_MAX = 200 + + +class NoDataAvailable(Exception): + """ Raised while the first control cycle has not completed yet. """ + code = 'no_data_yet' + + +class ForecastExpired(NoDataAvailable): + """ Raised when every slot of the last published forecast has passed. """ + code = 'forecast_expired' + + +class NotFound(Exception): + """ Raised when a requested resource does not exist (yet). """ + + +def _dt_ms(moment) -> Optional[int]: + """ Convert a datetime to epoch milliseconds. + + The decision trace carries a datetime (serialised as ISO 8601 for the + MQTT contract). The API states timestamps in epoch milliseconds + throughout, so the conversion happens here rather than leaving two time + formats in one response for consumers to sort out. + """ + if moment is None: + return None + try: + return int(moment.timestamp() * 1000) + except (AttributeError, TypeError, ValueError, OSError): + return None + + +def _ms(seconds: Optional[float]) -> Optional[int]: + """ Convert epoch seconds to epoch milliseconds. """ + if seconds is None or seconds <= 0: + return None + return int(seconds * 1000) + + +def _round(value, digits: int = ENERGY_DIGITS) -> Optional[float]: + """ Round a value, mapping None and non-numeric input to None. """ + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return round(number, digits) + + +def _cached(value, digits: int = ENERGY_DIGITS) -> Optional[float]: + """ Round a cached core value, honouring batcontrol's -1 sentinel. + + The core initialises its cached capacities and the SoC to -1 and only + replaces them once the inverter has been read. Reporting that as a + negative energy would be a lie, so it becomes None (unknown). + """ + rounded = _round(value, digits) + if rounded is None or rounded == -1: + return None + return rounded + + +class BatcontrolService: + """ Transport independent read access to a live Batcontrol instance. """ + + def __init__(self, batcontrol): + self._bc = batcontrol + # Reference for "stale" while no cycle has completed yet: without it + # a loop that fails on every cycle from startup would report online + # forever. + self._started = time.time() + + # -- snapshot --------------------------------------------------------- + + @property + def snapshot(self) -> Optional[ForecastSnapshot]: + """ The forecast snapshot of the last completed cycle, if any. """ + return getattr(self._bc, 'forecast_snapshot', None) + + def require_snapshot(self) -> ForecastSnapshot: + """ Snapshot or NoDataAvailable, for endpoints that need forecasts. """ + snapshot = self.snapshot + if snapshot is None or not snapshot.has_data(): + raise NoDataAvailable( + 'No completed control cycle yet. The first forecast is' + ' available after the first evaluation.' + ) + return snapshot + + @staticmethod + def _origin(snapshot: ForecastSnapshot) -> int: + """ Snapshot index of the current slot; raises once all are past. """ + origin = snapshot.current_index(time.time()) + if origin >= len(snapshot.prices): + raise ForecastExpired( + 'The last forecast has expired: no control cycle has' + ' completed for the whole forecast horizon.' + ) + return origin + + def _is_stale(self) -> bool: + """ True when the data must not be presented as current. + + Measured against the older of the last cycle and the last forecast + snapshot: a cycle that aborts on a forecast error still advances + last_run_time, but publishes no snapshot. Until a snapshot exists the + service start time is the reference - also when cycles run but their + forecasts fail from startup on. + """ + last_run = getattr(self._bc, 'last_run_time', 0) or 0 + snapshot = self.snapshot + if last_run <= 0 or snapshot is None or not snapshot.has_data(): + reference = self._started + else: + reference = min(last_run, snapshot.run_time) + limit = self._evaluation_interval() * STALE_AFTER_INTERVALS + return time.time() - reference > limit + + # -- meta ------------------------------------------------------------- + + def version(self) -> Dict[str, Any]: + """ Version and runtime identification. """ + return { + 'version': __pkginfo__.__version__, + 'git_sha': os.environ.get('BATCONTROL_GIT_SHA') or None, + 'runtime_env': os.environ.get('BATCONTROL_RUNTIME_ENV') or 'source', + 'interval_minutes': self._bc.time_resolution, + 'timezone': str(self._bc.timezone), + } + + def health(self) -> Dict[str, Any]: + """ Liveness information. Intentionally answers before the first cycle. + + This is the endpoint a container health check uses, so it must never + fail just because no forecast exists yet. + """ + last_run = getattr(self._bc, 'last_run_time', 0) or 0 + age = None if last_run <= 0 else max(0.0, time.time() - last_run) + snapshot = self.snapshot + return { + 'status': 'ok', + 'version': __pkginfo__.__version__, + 'has_data': snapshot is not None and snapshot.has_data(), + 'last_evaluation': _ms(last_run), + 'cycle_age_seconds': None if age is None else round(age, 1), + 'evaluation_interval_seconds': self._evaluation_interval(), + 'stale': self._is_stale(), + } + + def window_slots(self, hours: float = DEFAULT_WINDOW_HOURS) -> int: + """ Number of forecast slots covering `hours` at this resolution. + + 24 h is 24 slots at 60-minute and 96 slots at 15-minute resolution. + """ + interval = int(self._bc.time_resolution) or 60 + return max(1, int(hours * 60 / interval)) + + def _evaluation_interval(self) -> int: + return int(getattr(self._bc, 'evaluation_interval_seconds', 180)) + + # -- status and battery ---------------------------------------------- + + def status(self) -> Dict[str, Any]: + """ Current control state: what batcontrol is doing right now. """ + last_run = getattr(self._bc, 'last_run_time', 0) or 0 + interval = self._evaluation_interval() + mode = self._bc.last_mode + return { + 'mode': mode, + 'mode_code': mode_code(mode), + 'mode_label': mode_label(mode), + 'control_source': self._bc.last_control_source, + 'charge_rate': _round(self._bc.last_charge_rate, 0), + 'limit_battery_charge_rate': getattr( + self._bc, '_limit_battery_charge_rate', -1), + 'api_override_active': bool(self._bc.api_overwrite), + 'discharge_blocked': bool(self._bc.discharge_blocked), + 'grid_charge_locked': bool( + getattr(self._bc, '_grid_charge_locked', False)), + 'last_evaluation': _ms(last_run), + 'next_evaluation': _ms(last_run + interval) if last_run else None, + 'evaluation_interval_seconds': interval, + 'online': not self._is_stale(), + # When the mode last actually changed - not the last refresh, so + # a UI can show "since HH:MM" rather than "a moment ago" on every + # cycle. None until the first change after a restart. + 'mode_changed_at': _dt_ms(self._mode_changed_at()), + } + + def _mode_changed_at(self): + """ Timestamp of the last mode change, if one is recorded. """ + change = self._bc.decision_journal.last_mode_change() + if change is None or change.trace is None: + return None + return change.trace.timestamp + + def battery(self) -> Dict[str, Any]: + """ Battery state and the limits the dashboard's SOC grid needs. """ + soc = self._bc.last_SOC + return { + 'soc': None if soc is None or soc < 0 else round(float(soc), 2), + 'max_energy_capacity': _cached(self._bc.last_max_capacity), + 'stored_energy_capacity': _cached(self._bc.last_stored_energy), + 'stored_usable_energy_capacity': _cached( + self._bc.last_stored_usable_energy), + 'reserved_energy_capacity': _cached(self._bc.last_reserved_energy), + 'free_capacity': _cached(self._bc.last_free_capacity), + 'always_allow_discharge_limit_percent': _round( + self._bc.general_logic.get_always_allow_discharge_limit() * 100, 2), + 'max_charging_from_grid_limit_percent': _round( + self._bc.max_charging_from_grid_limit * 100, 2), + # Cached by the core per cycle, -1 until the first read. + 'soc_limits': { + 'min_soc': _cached(self._bc.last_min_soc, 2), + 'max_soc': _cached(self._bc.last_max_soc, 2), + }, + } + + # -- current ---------------------------------------------------------- + + def current(self) -> Dict[str, Any]: + """ Everything the dashboard header and PRICE block need at once. + + Deliberately complete and independent of which decision path ran: + the current price is taken from the price forecast's current slot, + not from a decision input that some paths never populate. + """ + snapshot = self.require_snapshot() + origin = self._origin(snapshot) + battery = self.battery() + return { + 'version': __pkginfo__.__version__, + 'price': { + 'current': _round(snapshot.prices[origin], PRICE_DIGITS), + 'current_ts': snapshot.slot_start_ms(origin), + 'unit': 'EUR/kWh', + 'interval_minutes': snapshot.interval_minutes, + # A single scalar the logic computes after the snapshot is + # published; it does not need the snapshot's cycle guarantee. + 'min_dynamic_price_difference': _round( + self._bc.last_min_dynamic_price_difference, PRICE_DIGITS), + }, + 'soc': battery['soc'], + 'soc_limits': battery['soc_limits'], + } + + # -- forecasts -------------------------------------------------------- + + # Series name -> (snapshot attribute, unit, digits, include_power) + SERIES = { + 'price': ('prices', 'EUR/kWh', PRICE_DIGITS, False), + 'consumption': ('consumption', 'Wh', ENERGY_DIGITS, True), + 'production': ('production', 'Wh', ENERGY_DIGITS, True), + 'net_consumption': ('net_consumption', 'Wh', ENERGY_DIGITS, True), + } + + def forecast(self, name: str, *, limit: Optional[int] = None, + offset: int = 0) -> Dict[str, Any]: + """ One forecast series, optionally windowed. + + Raises NotFound for an unknown series name. + """ + spec = self.SERIES.get(name) + if spec is None: + raise NotFound( + f'Unknown forecast series {name!r}. Available:' + f' {", ".join(sorted(self.SERIES))}.' + ) + snapshot = self.require_snapshot() + return self._series(snapshot, self._origin(snapshot), spec, + limit=limit, offset=offset) + + def forecast_all(self, *, limit: Optional[int] = None, + offset: int = 0) -> Dict[str, Any]: + """ All four series in one response, so the dashboard polls once. """ + # One snapshot read for all four series: a cycle publishing between + # two reads would otherwise mix two cycles in one response. + snapshot = self.require_snapshot() + origin = self._origin(snapshot) + series = { + name: self._series(snapshot, origin, spec, limit=limit, + offset=offset) + for name, spec in self.SERIES.items() + } + price = series['price'] + return { + 'run_time': _ms(snapshot.run_time), + 'interval_minutes': snapshot.interval_minutes, + 'total_count': price['total_count'], + 'returned_count': price['returned_count'], + 'offset': price['offset'], + 'has_more': price['has_more'], + 'next_offset': price['next_offset'], + **{name: payload['slots'] for name, payload in series.items()}, + } + + @staticmethod + def _series(snapshot: ForecastSnapshot, origin: int, spec, *, + limit: Optional[int] = None, + offset: int = 0) -> Dict[str, Any]: + """ Render a series, windowed by offset/limit from the current slot. + + Slot 0 is the slot containing now (`origin` in the snapshot), so a + snapshot that outlived its first slot never presents a past price as + current. The window carries its own metadata (total_count, has_more, + next_offset) so a consumer that cannot hold the full horizon - an LLM + in particular - can page through it instead of being handed several + hundred slots. + """ + attribute, unit, digits, include_power = spec + values = getattr(snapshot, attribute) + total = max(0, len(values) - origin) + offset = max(0, int(offset)) + start = origin + offset + stop = None if limit is None else start + max(0, int(limit)) + window = snapshot.slots(values, start=start, stop=stop, origin=origin, + digits=digits, include_power=include_power) + next_offset = offset + len(window) + return { + 'run_time': _ms(snapshot.run_time), + 'interval_minutes': snapshot.interval_minutes, + 'unit': unit, + 'total_count': total, + 'returned_count': len(window), + 'offset': offset, + 'has_more': next_offset < total, + 'next_offset': next_offset if next_offset < total else None, + 'slots': window, + } + + # -- decisions -------------------------------------------------------- + + def decision(self) -> Dict[str, Any]: + """ The latest decision trace: what batcontrol decided and why. """ + journal = self._bc.decision_journal + trace = journal.latest() + if trace is None: + raise NotFound('No decision recorded yet.') + event = journal.last_event() + # Two different ages on purpose, and they must not be confused: + # `trace` is the latest evaluation, refreshed every control cycle, + # while `event` is the last status *change* and only moves on a mode + # or value change (or the journal's 15-minute refresh). A consumer + # that renders the records from `event` shows a "why" that can be a + # quarter of an hour older than the mode it sits next to. + payload = { + 'decided_at': _dt_ms(trace.timestamp), + 'trace': trace.to_dict(), + 'status_text': trace.status_text(), + 'explanation': trace.explanation(), + } + decisive = trace.decisive_record() + payload['decisive'] = None if decisive is None else decisive.to_dict() + payload['event'] = None if event is None else event.to_dict() + payload['event_at'] = ( + None if event is None or event.trace is None + else _dt_ms(event.trace.timestamp)) + return payload + + def decision_history(self, limit: int = 20) -> Dict[str, Any]: + """ The last traces from the in-memory journal, oldest first. """ + journal = self._bc.decision_journal + traces = journal.history(limit) + return { + 'count': len(traces), + 'limit': limit, + 'traces': [trace.to_dict() for trace in traces], + } + + # -- configuration ---------------------------------------------------- + + def configuration(self) -> Dict[str, Any]: + """ The runtime parameters that explain batcontrol's behaviour. + + Built from an explicit list of core attributes, never from the + configuration dictionary. The config holds inverter passwords, tariff + API tokens and MQTT credentials, so there is no code path here that + could reach them: adding a secret to the config cannot leak it through + this endpoint, because nothing in here reads the config at all. + """ + peak_shaving = self._bc.peak_shaving_config + logic_type = 'default' + batconfig = getattr(self._bc, 'batconfig', None) + if isinstance(batconfig, dict): + logic_type = str(batconfig.get('type', 'default')) + return { + 'interval_minutes': self._bc.time_resolution, + 'timezone': str(self._bc.timezone), + 'evaluation_interval_seconds': self._evaluation_interval(), + 'battery_control': { + 'type': logic_type, + 'min_price_difference': _round( + self._bc.min_price_difference, PRICE_DIGITS), + 'min_price_difference_rel': _round( + self._bc.min_price_difference_rel, 4), + 'always_allow_discharge_limit': _round( + self._bc.general_logic.get_always_allow_discharge_limit(), 4), + 'max_charging_from_grid_limit': _round( + self._bc.max_charging_from_grid_limit, 4), + 'min_grid_charge_soc': _round(self._bc.min_grid_charge_soc, 4), + 'preserve_min_grid_charge_soc': bool( + self._bc.preserve_min_grid_charge_soc), + 'production_offset_percent': _round( + self._bc.production_offset_percent, 4), + }, + 'peak_shaving': { + 'enabled': bool(peak_shaving.enabled), + 'time_active': bool(peak_shaving.time_active), + 'price_active': bool(peak_shaving.price_active), + 'solar_cap_active': bool(peak_shaving.solar_cap_active), + 'allow_full_battery_after': peak_shaving.allow_full_battery_after, + 'price_limit': _round(peak_shaving.price_limit, PRICE_DIGITS), + 'feed_in_limit_w': _round(peak_shaving.feed_in_limit_w, 0), + }, + } diff --git a/src/batcontrol/web/snapshot.py b/src/batcontrol/web/snapshot.py new file mode 100644 index 00000000..98a9eae1 --- /dev/null +++ b/src/batcontrol/web/snapshot.py @@ -0,0 +1,118 @@ +""" Immutable per-cycle snapshot of the forecast arrays + +The control loop writes its forecast arrays while the web thread reads them. +Instead of locking every read, the loop publishes one frozen snapshot per +control cycle and the reader picks it up with a single attribute read, which +is atomic. A request therefore either sees the previous cycle completely or +the new one completely - never prices from one cycle next to production from +the other. + +Numpy arrays are converted to tuples of floats on publish: that copies the +data once, at a point where the loop owns it anyway, and leaves the reader +with an object nothing can mutate underneath it. +""" +import dataclasses +import logging +from typing import Optional, Sequence, Tuple + +from ..interval_utils import align_to_interval, iter_slots + +logger = logging.getLogger(__name__) + +# Rounding per quantity. Prices need more digits than energy: 4 decimals keep +# sub-cent tariff differences, which are exactly what batcontrol decides on. +PRICE_DIGITS = 4 +ENERGY_DIGITS = 1 +POWER_DIGITS = 0 + + +def _as_tuple(values) -> Tuple[float, ...]: + """ Convert a numpy array, list or None into a tuple of floats. """ + if values is None: + return () + return tuple(float(value) for value in values) + + +@dataclasses.dataclass(frozen=True) +class ForecastSnapshot: + """ The forecast state of one completed control cycle. + + run_time is in epoch seconds, as used inside batcontrol. The API converts + to epoch milliseconds at the edge. + """ + run_time: float + interval_minutes: int + prices: Tuple[float, ...] = () + consumption: Tuple[float, ...] = () + production: Tuple[float, ...] = () + net_consumption: Tuple[float, ...] = () + + @classmethod + def build(cls, *, run_time: float, # pylint: disable=too-many-arguments + interval_minutes: int, + prices=None, consumption=None, production=None, + net_consumption=None) -> 'ForecastSnapshot': + """ Build a snapshot from the loop's numpy arrays. """ + return cls( + run_time=float(run_time), + interval_minutes=int(interval_minutes), + prices=_as_tuple(prices), + consumption=_as_tuple(consumption), + production=_as_tuple(production), + net_consumption=_as_tuple(net_consumption), + ) + + @property + def base_time(self) -> float: + """ Start of slot 0, in epoch seconds. """ + return align_to_interval(self.run_time, self.interval_minutes) + + @property + def interval_seconds(self) -> int: + """ Slot width in seconds. """ + return self.interval_minutes * 60 + + def slot_start_ms(self, index: int) -> int: + """ Start of a slot in epoch milliseconds. """ + return int((self.base_time + index * self.interval_seconds) * 1000) + + def has_data(self) -> bool: + """ True when at least the price forecast is populated. """ + return bool(self.prices) + + def current_index(self, now: float) -> int: + """ Index of the slot that contains `now` (epoch seconds). + + Normally 0. It grows when cycles keep failing before a new snapshot + is published (a tariff provider outage, for example): slot 0 then + lies in the past and must not be presented as the current price. + """ + return max(0, int((now - self.base_time) // self.interval_seconds)) + + def slots(self, values: Sequence[float], *, # pylint: disable=too-many-arguments + start: int = 0, stop: Optional[int] = None, origin: int = 0, + digits: int = ENERGY_DIGITS, + include_power: bool = False) -> list: + """ Render values[start:stop] as a list of slot dictionaries. + + Every slot carries its index relative to `origin` (the current slot) + and its start/end in epoch milliseconds, from the same slot generator + the MQTT API uses (interval_utils.iter_slots). Only the requested + window is rendered. Energy series also get the average power over the + slot, because 'Wh per interval' and 'W' differ by a factor of four at + 15-minute resolution and that has tripped up consumers before. + """ + result = [] + for index, start_s, end_s, value, power in iter_slots( + values, self.run_time, self.interval_minutes, + start=start, stop=stop): + entry = { + 'slot': index - origin, + 'time_start': int(start_s * 1000), + 'time_end': int(end_s * 1000), + 'value': round(float(value), digits), + } + if include_power: + entry['power_w'] = round(float(power), POWER_DIGITS) + result.append(entry) + return result diff --git a/tests/batcontrol/test_decision_journal.py b/tests/batcontrol/test_decision_journal.py index 4733783d..5a1fc38e 100644 --- a/tests/batcontrol/test_decision_journal.py +++ b/tests/batcontrol/test_decision_journal.py @@ -365,6 +365,16 @@ def test_refresh_keeps_the_value_reference(self): assert [e.kind for e in status] == [KIND_MODE, KIND_VALUE] assert status[-1].previous_value == 1000 + def test_value_change_does_not_move_the_mode_change(self): + """Regression: "mode since" must not reset on a charge rate change.""" + journal, _clock, _status, _refresh = self._journal() + journal.commit(_trace(), -1, 'optimizer', 1000) + mode_change = journal.last_mode_change() + journal.commit(_trace(), -1, 'optimizer', 2000) + + assert journal.last_status_change().kind == KIND_VALUE + assert journal.last_mode_change() is mode_change + def test_last_event_includes_refresh(self): journal, clock, _status, _refresh = self._journal() journal.commit(_trace(), 10, 'optimizer') diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py index 04d837f5..1c508cf8 100644 --- a/tests/batcontrol/test_mqtt_api.py +++ b/tests/batcontrol/test_mqtt_api.py @@ -335,7 +335,6 @@ def _make_forecast_publish_stub(interval_minutes: int): api.interval_minutes = interval_minutes api.client = MagicMock() api.client.is_connected.return_value = True - api._energy_to_power = MqttApi._energy_to_power.__get__(api, MqttApi) api._create_forecast = MqttApi._create_forecast.__get__(api, MqttApi) api.publish_production = MqttApi.publish_production.__get__(api, MqttApi) api.publish_consumption = MqttApi.publish_consumption.__get__(api, MqttApi) @@ -792,6 +791,32 @@ def test_bool_inputs_are_rejected(self): bc.mqtt_api.publish_peak_shaving_price_limit.assert_not_called() +# Byte-for-byte MQTT forecast payloads, recorded before the slot rendering was +# shared with the web snapshot (interval_utils.iter_slots). Home Assistant +# sensors parse these, so the refactoring must not change a single character: +# key order, float timestamps and unrounded values included. +_GOLDEN_FORECASTS = [ + (15, 'batcontrol/FCST/production', + '{"data": [{"time_start": 1792083600.0, "value": 0.0, "time_end": 1792084500.0, "power_w": 0.0}, {"time_start": 1792084500.0, "value": 123.456, "time_end": 1792085400.0, "power_w": 493.824}, {"time_start": 1792085400.0, "value": 1500.25, "time_end": 1792086300.0, "power_w": 6001.0}]}'), + (15, 'batcontrol/FCST/prices', + '{"data": [{"time_start": 1792083600.0, "value": 0.30812345, "time_end": 1792084500.0}, {"time_start": 1792084500.0, "value": 0.25, "time_end": 1792085400.0}, {"time_start": 1792085400.0, "value": -0.01, "time_end": 1792086300.0}]}'), + (60, 'batcontrol/FCST/production', + '{"data": [{"time_start": 1792083600.0, "value": 0.0, "time_end": 1792087200.0, "power_w": 0.0}, {"time_start": 1792087200.0, "value": 123.456, "time_end": 1792090800.0, "power_w": 123.456}, {"time_start": 1792090800.0, "value": 1500.25, "time_end": 1792094400.0, "power_w": 1500.25}]}'), + (60, 'batcontrol/FCST/prices', + '{"data": [{"time_start": 1792083600.0, "value": 0.30812345, "time_end": 1792087200.0}, {"time_start": 1792087200.0, "value": 0.25, "time_end": 1792090800.0}, {"time_start": 1792090800.0, "value": -0.01, "time_end": 1792094400.0}]}'), +] + + +@pytest.mark.parametrize('interval, topic, payload', _GOLDEN_FORECASTS) +def test_forecast_payload_is_unchanged(interval, topic, payload): + api = _make_forecast_publish_stub(interval) + timestamp = 1792083725.7 # deliberately not aligned to the slot grid + api.publish_production(np.array([0.0, 123.456, 1500.25]), timestamp) + api.publish_prices(np.array([0.30812345, 0.25, -0.01]), timestamp) + published = {c.args[0]: c.args[1] for c in api.client.publish.call_args_list} + assert published[topic] == payload + + def _make_solar_publish_stub(): """Stub for solar surplus / solar_active publish tests.""" api = MagicMock(spec=MqttApi) diff --git a/tests/batcontrol/web/__init__.py b/tests/batcontrol/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/batcontrol/web/conftest.py b/tests/batcontrol/web/conftest.py new file mode 100644 index 00000000..df8ecf66 --- /dev/null +++ b/tests/batcontrol/web/conftest.py @@ -0,0 +1,160 @@ +"""Fixtures for the read-only web API tests. + +The tests run against a stand-in for the Batcontrol core instead of a real +one: building a real core needs an inverter, a tariff provider and two +forecast providers, while the service layer only ever reads a handful of +cached attributes. The stand-in documents exactly which attributes the API +depends on - if the service starts reading something else, these tests fail +with an AttributeError instead of silently talking to an inverter. +""" +import datetime +import time + +import numpy as np +import pytest + +from batcontrol.decision_journal import DecisionJournal +from batcontrol.logic.decision_trace import ( + Decision, DecisionRecord, DecisionTrace, Outcome, Reason, +) +from batcontrol.modes import MODE_ALLOW_DISCHARGING, MODE_NAMES +from batcontrol.web.snapshot import ForecastSnapshot + +def recent_run_time() -> float: + """A fresh run time, as the control loop would record it. + + Deliberately relative to now: the API derives "online" from the age of + the last cycle, so a hardcoded timestamp would make every test go stale + the moment it is written. The snapshot aligns this to its slot grid + itself, exactly like the real loop does. + """ + return time.time() + + +class FakeInverter: + """Only the SoC limits are read by the API.""" + + def __init__(self, min_soc=7.0, max_soc=100.0): + self.min_soc = min_soc + self.max_soc = max_soc + + +class FakeGeneralLogic: + def __init__(self, always_allow_discharge_limit=0.9): + self._limit = always_allow_discharge_limit + + def get_always_allow_discharge_limit(self): + return self._limit + + +class FakePeakShavingConfig: + enabled = False + time_active = True + price_active = True + solar_cap_active = False + allow_full_battery_after = 14 + price_limit = 0.05 + feed_in_limit_w = 0.0 + + +class FakeBatcontrol: # pylint: disable=too-many-instance-attributes + """Stand-in exposing exactly the attributes the service layer reads.""" + + def __init__(self, *, with_data=True, run_time=None): + run_time = recent_run_time() if run_time is None else run_time + self.time_resolution = 60 + self.timezone = 'Europe/Berlin' + self.evaluation_interval_seconds = 180 + self.last_run_time = run_time if with_data else 0 + + self.last_mode = MODE_ALLOW_DISCHARGING + self.last_control_source = 'optimizer' + self.last_charge_rate = 0 + self._limit_battery_charge_rate = -1 + self.api_overwrite = False + self.discharge_blocked = False + self._grid_charge_locked = False + + self.last_SOC = 87.4 + self.last_max_capacity = 11000.0 + self.last_stored_energy = 9614.0 + self.last_stored_usable_energy = 8844.0 + self.last_reserved_energy = 1500.0 + self.last_free_capacity = 1386.0 + + self.min_price_difference = 0.05 + self.min_price_difference_rel = 0.10 + self.max_charging_from_grid_limit = 0.89 + self.min_grid_charge_soc = None + self.preserve_min_grid_charge_soc = False + self.production_offset_percent = 1.0 + self.batconfig = {'type': 'default'} + + self.inverter = FakeInverter() + self.last_min_soc = self.inverter.min_soc + self.last_max_soc = self.inverter.max_soc + self.general_logic = FakeGeneralLogic() + self.peak_shaving_config = FakePeakShavingConfig() + self.decision_journal = DecisionJournal() + + self.last_min_dynamic_price_difference = 0.0612 if with_data else None + self.forecast_snapshot = None + if with_data: + self.forecast_snapshot = ForecastSnapshot.build( + run_time=run_time, + interval_minutes=self.time_resolution, + prices=np.array([0.308, 0.254, 0.201, 0.350]), + consumption=np.array([450.0, 420.0, 400.0, 500.0]), + production=np.array([0.0, 120.0, 900.0, 1500.0]), + net_consumption=np.array([450.0, 300.0, -500.0, -1000.0]), + ) + + def record_decision(self, mode=MODE_ALLOW_DISCHARGING): + """Put one committed trace into the journal. + + Mirrors what core.__commit_decision does: a decisive step plus the + closing MODE record, because status_text() needs that record. + """ + # With a timestamp, like core.__new_trace() - without one the API + # reports decided_at: null, which is not a state the real loop + # produces. + trace = DecisionTrace(timestamp=datetime.datetime.now()) + trace.step(Decision.DISCHARGE, Outcome.ALLOWED, + Reason.ALWAYS_ALLOW_DISCHARGE_LIMIT, decisive=True, + stored_energy=9614.0, reserved_energy=1500.0) + decided_by = trace.decisive_record() + inputs = dict(decided_by.inputs) + inputs.update(mode=mode, control_source='optimizer', + decided_by=decided_by.decision, value=None) + trace.add(DecisionRecord( + decision=Decision.MODE, + outcome=MODE_NAMES.get(mode, str(mode)), + reason=decided_by.reason, + inputs=inputs, + )) + self.decision_journal.commit(trace, mode, 'optimizer', None) + return trace + + +@pytest.fixture(name='fake_bc') +def fixture_fake_bc(): + """A core stand-in with one completed control cycle.""" + return FakeBatcontrol() + + +@pytest.fixture(name='empty_bc') +def fixture_empty_bc(): + """A core stand-in before the first control cycle.""" + return FakeBatcontrol(with_data=False) + + +@pytest.fixture(name='client') +def fixture_client(fake_bc): + """TestClient against the real application.""" + from fastapi.testclient import TestClient + + from batcontrol.web.app import create_app + from batcontrol.web.config import ApiConfig + # docs_enabled: several tests read the contract from /openapi.json. + return TestClient(create_app(fake_bc, ApiConfig(docs_enabled=True)), + base_url='http://127.0.0.1') diff --git a/tests/batcontrol/web/test_app.py b/tests/batcontrol/web/test_app.py new file mode 100644 index 00000000..6963810e --- /dev/null +++ b/tests/batcontrol/web/test_app.py @@ -0,0 +1,86 @@ +"""Application level tests: envelope, error mapping and OpenAPI metadata.""" +from fastapi.testclient import TestClient + +from batcontrol.web.app import create_app +from batcontrol.web.config import ApiConfig + + +def test_health_is_wrapped_in_the_envelope(client): + response = client.get('/api/v1/health') + assert response.status_code == 200 + body = response.json() + assert body['ok'] is True + assert isinstance(body['timestamp'], int) + assert body['data']['status'] == 'ok' + + +def test_version_endpoint(client): + body = client.get('/api/v1/version').json() + assert body['data']['interval_minutes'] == 60 + assert body['data']['runtime_env'] + + +def test_health_works_without_a_completed_cycle(empty_bc): + from fastapi.testclient import TestClient + + from batcontrol.web.app import create_app + with TestClient(create_app(empty_bc), base_url='http://127.0.0.1') as client: + body = client.get('/api/v1/health').json() + assert body['data']['has_data'] is False + + +def test_unknown_path_is_a_clean_404(client): + assert client.get('/api/v1/nope').status_code == 404 + + +def test_openapi_metadata_is_informative(client): + schema = client.get('/openapi.json').json() + assert schema['info']['title'] == 'batcontrol' + assert 'never changes anything' in schema['info']['description'] + # Units must be documented on the fields, not just in prose. + health = schema['components']['schemas']['Health'] + assert 'description' in health['properties']['evaluation_interval_seconds'] + + +def test_swagger_ui_is_served(client): + response = client.get('/docs') + assert response.status_code == 200 + assert 'swagger' in response.text.lower() + + +def test_a_reverse_proxy_host_header_is_accepted(fake_bc): + """Maintainer decision: REST does not validate the Host header, so a + reverse proxy forwarding its own host name works on a loopback bind.""" + with TestClient(create_app(fake_bc), base_url='http://127.0.0.1') as client: + response = client.get('/api/v1/battery', + headers={'Host': 'batcontrol.example.org'}) + assert response.status_code == 200 + + +def test_docs_and_schema_are_off_by_default(fake_bc): + """api.docs_enabled defaults to false: no Swagger UI, no schema.""" + with TestClient(create_app(fake_bc, ApiConfig(dashboard_enabled=False)), + base_url='http://127.0.0.1') as client: + assert client.get('/docs').status_code == 404 + assert client.get('/openapi.json').status_code == 404 + assert client.get('/api/v1/health').status_code == 200 + + +def test_docs_stay_off_behind_the_dashboard_mount(fake_bc, tmp_path, monkeypatch): + """With the dashboard mounted at / (the default), StaticFiles answers the + two paths - and must not find anything there either.""" + from batcontrol.web import app as app_module # pylint: disable=import-outside-toplevel + (tmp_path / 'index.html').write_text('') + (tmp_path / 'swagger').mkdir() + (tmp_path / 'swagger' / 'swagger-ui-bundle.js').write_text('//') + monkeypatch.setattr(app_module, 'STATIC_DIR', tmp_path) + with TestClient(create_app(fake_bc), base_url='http://127.0.0.1') as client: + assert client.get('/').status_code == 200 + assert client.get('/docs').status_code == 404 + assert client.get('/openapi.json').status_code == 404 + + +def test_the_schema_can_still_be_generated_when_docs_are_off(fake_bc): + """scripts/dump_openapi.py relies on app.openapi(), not on the route.""" + schema = create_app(fake_bc).openapi() + assert '/api/v1/status' in schema['paths'] diff --git a/tests/batcontrol/web/test_auth.py b/tests/batcontrol/web/test_auth.py new file mode 100644 index 00000000..b119a555 --- /dev/null +++ b/tests/batcontrol/web/test_auth.py @@ -0,0 +1,144 @@ +"""Tests for the optional bearer token. + +The token is opt-in. With it configured, every endpoint but /health requires +it; without it, nothing is protected and no dependency is installed at all. +""" +import pytest +from fastapi.testclient import TestClient + +from batcontrol.web.app import create_app +from batcontrol.web.config import ApiConfig + +TOKEN = 'a-very-secret-token' + +PROTECTED = [ + '/api/v1/version', '/api/v1/status', '/api/v1/battery', + '/api/v1/current', '/api/v1/forecast', '/api/v1/forecast/price', + '/api/v1/decision', '/api/v1/decision/history', '/api/v1/config', +] + + +@pytest.fixture(name='secured') +def fixture_secured(fake_bc): + # docs_enabled, so the token check below also covers the schema. + config = ApiConfig(enabled=True, auth_token=TOKEN, docs_enabled=True) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + yield client + + +def test_health_stays_open(secured): + """Container health checks must not need a token.""" + assert secured.get('/api/v1/health').status_code == 200 + + +@pytest.mark.parametrize('path', PROTECTED) +def test_missing_token_is_401(secured, path): + response = secured.get(path) + assert response.status_code == 401 + assert response.headers['www-authenticate'] == 'Bearer' + + +@pytest.mark.parametrize('header', [ + 'Bearer wrong-token', + 'Bearer ', + 'Basic a-very-secret-token', + 'a-very-secret-token', +]) +def test_wrong_credentials_are_401(secured, header): + response = secured.get('/api/v1/status', + headers={'Authorization': header}) + assert response.status_code == 401 + + +def test_correct_token_is_accepted(secured): + response = secured.get('/api/v1/status', + headers={'Authorization': f'Bearer {TOKEN}'}) + assert response.status_code == 200 + + +def test_scheme_is_case_insensitive(secured): + response = secured.get('/api/v1/status', + headers={'Authorization': f'bearer {TOKEN}'}) + assert response.status_code == 200 + + +def test_without_a_token_nothing_is_protected(client): + for path in PROTECTED: + assert client.get(path).status_code in (200, 404), path + + +def test_no_dependency_is_installed_without_a_token(): + """Not just "allows everything" - the check must not exist at all.""" + from batcontrol.web.auth import auth_dependencies + assert auth_dependencies(ApiConfig()) == [] + assert auth_dependencies(ApiConfig(auth_token='x')) + + +def test_the_token_never_appears_in_a_response(secured): + for path in ['/api/v1/health', '/openapi.json']: + assert TOKEN not in secured.get(path).text + + +def test_the_token_is_never_logged(fake_bc, caplog): + with caplog.at_level('DEBUG'): + config = ApiConfig(enabled=True, auth_token=TOKEN) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + client.get('/api/v1/status') + client.get('/api/v1/status', + headers={'Authorization': f'Bearer {TOKEN}'}) + assert TOKEN not in caplog.text + + +# -- non-ASCII tokens ------------------------------------------------------- + +# Sent as raw bytes on purpose: a well-behaved client refuses to put a +# non-ASCII string into a header, so this is what actually reaches the server +# from curl or a hand-rolled socket - the case the old code crashed on. +NON_ASCII = ( + b'Bearer \xc3\xbc', # "u" with umlaut, UTF-8 + b'Bearer gr\xc3\xbcn-schl\xc3\xbcssel', # a plausible German token + b'Bearer \xe2\x9c\x93', # check mark + b'Bearer \xf0\x9f\x94\x92', # lock emoji + b'Bearer \xff\xfe\x00', # not valid UTF-8 at all +) + + +@pytest.mark.parametrize('header', NON_ASCII) +def test_a_non_ascii_token_is_rejected_not_crashed(secured, header): + """Regression: secrets.compare_digest raises on non-ASCII strings. + + Comparing the presented token as a str turned a bearer token with an + umlaut in it into a 500 with a traceback instead of a plain 401. It failed + closed either way, but an authentication check must reject, not crash. + """ + response = secured.get('/api/v1/status', headers={'Authorization': header}) + assert response.status_code == 401 + assert response.headers['www-authenticate'] == 'Bearer' + + +def test_a_non_ascii_token_can_be_configured_and_used(fake_bc): + """And if someone does configure one, it has to actually work.""" + token = 'gr\u00fcn-schl\u00fcssel-\u2713' + config = ApiConfig(enabled=True, auth_token=token) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + assert client.get('/api/v1/status').status_code == 401 + assert client.get('/api/v1/status', headers={ + 'Authorization': b'Bearer ' + token.encode('utf-8'), + }).status_code == 200 + + +def test_token_matching_is_byte_exact(): + """No decode step may normalise a wrong token into a match.""" + from batcontrol.web.config import token_matches + + assert token_matches('secret', 'secret') is True + assert token_matches(b'secret', 'secret') is True + assert token_matches('secret ', 'secret') is False + assert token_matches('Secret', 'secret') is False + assert token_matches('', 'secret') is False + assert token_matches(None, 'secret') is False + assert token_matches('secret', '') is False + # a non-ASCII value on either side returns a verdict instead of raising + assert token_matches(b'\xc3\xbc', 'secret') is False + assert token_matches('secret', '\u00fc') is False + assert token_matches('\u00fc'.encode('utf-8'), '\u00fc') is True diff --git a/tests/batcontrol/web/test_config.py b/tests/batcontrol/web/test_config.py new file mode 100644 index 00000000..037edad1 --- /dev/null +++ b/tests/batcontrol/web/test_config.py @@ -0,0 +1,156 @@ +"""Tests for parsing the api: configuration block.""" +import pytest + +from batcontrol.web import config as web_config + + +def test_missing_block_disables_the_api(): + """Every existing installation has no api: block and must stay unchanged.""" + parsed = web_config.from_config({}) + assert parsed.enabled is False + assert parsed.host == '127.0.0.1' + assert parsed.port == 8080 + assert parsed.auth_enabled is False + + +def test_defaults_of_an_enabled_block(): + parsed = web_config.from_config({'api': {'enabled': True}}) + assert parsed.enabled is True + assert parsed.dashboard_enabled is True + assert parsed.mcp_enabled is False + + +def test_home_assistant_string_values_are_accepted(): + """HA form fields deliver strings for booleans and numbers.""" + parsed = web_config.from_config({'api': { + 'enabled': 'true', + 'port': '8099', + 'dashboard_enabled': 'false', + 'mcp_enabled': 'TRUE', + }}) + assert parsed.enabled is True + assert parsed.port == 8099 + assert parsed.dashboard_enabled is False + assert parsed.mcp_enabled is True + + +@pytest.mark.parametrize('port', [0, -1, 65536, 'http', [8080]]) +def test_invalid_port_is_rejected(port): + with pytest.raises(ValueError): + web_config.from_config({'api': {'port': port}}) + + +def test_explicit_null_port_falls_back_to_the_default(): + """A commented-out HA option arrives as None, not as a missing key.""" + assert web_config.from_config({'api': {'port': None}}).port == 8080 + + +def test_invalid_boolean_is_rejected(): + with pytest.raises(ValueError) as excinfo: + web_config.from_config({'api': {'enabled': 'maybe'}}) + assert 'api.enabled' in str(excinfo.value) + + +def test_an_empty_host_falls_back_to_the_default(): + """Changed deliberately: an empty string means "not configured". + + This used to raise. A Home Assistant form that submits a blank optional + field produces exactly that, and the resulting ValueError aborted + batcontrol at startup. + """ + assert web_config.from_config({'api': {'host': ' '}}).host == '127.0.0.1' + + +def test_api_must_be_a_block(): + with pytest.raises(ValueError): + web_config.from_config({'api': 'yes'}) + + +def test_token_is_trimmed_and_enables_auth(): + parsed = web_config.from_config({'api': {'auth_token': ' s3cret '}}) + assert parsed.auth_token == 's3cret' + assert parsed.auth_enabled is True + + +def test_empty_token_means_no_auth(): + parsed = web_config.from_config({'api': {'auth_token': ' '}}) + assert parsed.auth_token == '' + assert parsed.auth_enabled is False + + +def test_describe_never_leaks_the_token(): + parsed = web_config.from_config({'api': { + 'enabled': True, 'auth_token': 'super-secret-value'}}) + described = parsed.describe() + assert 'super-secret-value' not in described + assert 'auth: on' in described + + +def test_public_bind_without_token_warns(caplog): + with caplog.at_level('WARNING'): + web_config.from_config({'api': {'enabled': True, 'host': '0.0.0.0'}}) + assert 'auth_token' in caplog.text + + +def test_public_bind_with_token_does_not_warn(caplog): + with caplog.at_level('WARNING'): + web_config.from_config({'api': { + 'enabled': True, 'host': '0.0.0.0', 'auth_token': 'x'}}) + assert 'auth_token' not in caplog.text + + +@pytest.mark.parametrize('host', ['127.0.0.1', 'localhost', '::1', '[::1]']) +def test_no_public_bind_warning_for_a_loopback_address(host, caplog): + with caplog.at_level('WARNING'): + web_config.from_config({'api': {'enabled': True, 'host': host}}) + assert 'auth_token' not in caplog.text + + +@pytest.mark.parametrize('value, expected', [ + (None, False), (True, True), ('true', True), (False, False), +]) +def test_docs_enabled(value, expected): + block = {'enabled': True} + if value is not None: + block['docs_enabled'] = value + assert web_config.from_config({'api': block}).docs_enabled is expected + + +def test_a_bracketed_ipv6_host_is_bindable(): + """Regression: '[::1]' passed validation but uvicorn cannot bind it.""" + config = web_config.from_config({'api': {'enabled': True, 'host': '[::1]'}}) + assert config.host == '::1' + assert config.describe().startswith('http://[::1]:') + + +# -- values that mean "not configured" -------------------------------------- + +@pytest.mark.parametrize('block', [ + {'enabled': False, 'port': ''}, + {'enabled': False, 'host': ''}, + {'enabled': False, 'host': ' '}, + {'enabled': False, 'mcp_enabled': ''}, + {'enabled': False, 'dashboard_enabled': ''}, + {'enabled': '', 'port': 8080}, + {'enabled': 'true', 'host': '', 'port': '', 'auth_token': ''}, +]) +def test_an_empty_value_means_unset_not_invalid(block): + """Home Assistant submits '' for an optional field left blank. + + Treating that as a value made a blank field in the add-on form abort + batcontrol at startup - even with the API disabled, so the battery would + have stopped being controlled because of a switched-off read-only + accessory. + """ + parsed = web_config.from_config({'api': block}) + assert parsed.host == '127.0.0.1' + assert parsed.port == 8080 + + +@pytest.mark.parametrize('block', [ + {'port': 'eighty'}, {'enabled': 'maybe'}, {'port': 99999}, {'port': 0}, +]) +def test_a_wrong_value_is_still_an_error(block): + """Empty is not a value; wrong still is.""" + with pytest.raises(ValueError): + web_config.from_config({'api': block}) diff --git a/tests/batcontrol/web/test_core_integration.py b/tests/batcontrol/web/test_core_integration.py new file mode 100644 index 00000000..73b6355d --- /dev/null +++ b/tests/batcontrol/web/test_core_integration.py @@ -0,0 +1,153 @@ +"""The contract between the control loop and the read-only API. + +The service layer only reads values the loop has already cached, so these +tests pin the loop's side of that deal. They use a real Batcontrol instance +with mocked providers, because the point is what core.py actually does. +""" +import pytest + +from batcontrol.core import Batcontrol +from batcontrol.web.service import BatcontrolService +from batcontrol.web.snapshot import ForecastSnapshot + + +@pytest.fixture(name='config') +def fixture_config(): + """Minimal config with every API and MQTT switch off.""" + return { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100, + }, + 'utility': {'type': 'tibber', 'apikey': 'test_token'}, + 'pvinstallations': [], + 'consumption_forecast': {'type': 'simple', 'value': 500}, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05, + }, + 'mqtt': {'enabled': False}, + } + + +@pytest.fixture(name='inverter') +def fixture_inverter(mocker): + """Patch the provider factories and return the mocked inverter.""" + mock_inverter = mocker.MagicMock() + mock_inverter.get_SOC.return_value = 63.5 + mock_inverter.get_max_capacity.return_value = 10000 + mock_inverter.get_stored_energy.return_value = 6350 + mock_inverter.get_stored_usable_energy.return_value = 5350 + mock_inverter.get_free_capacity.return_value = 3650 + mock_inverter.min_soc = 10 + mock_inverter.max_soc = 95 + for name in ('tariff_factory.create_tarif_provider', + 'solar_factory.create_solar_provider', + 'consumption_factory.create_consumption'): + mocker.patch(f'batcontrol.core.{name}', autospec=True, + return_value=mocker.MagicMock()) + mocker.patch('batcontrol.core.inverter_factory.create_inverter', + autospec=True, return_value=mock_inverter) + return mock_inverter + + +def test_api_is_disabled_without_an_api_block(config, inverter): + """An existing installation must see no behaviour change.""" + del inverter + batcontrol = Batcontrol(config) + try: + assert batcontrol.api_config.enabled is False + assert batcontrol.web_server is None + finally: + batcontrol.shutdown() + + +def test_refresh_populates_the_soc_cache_without_mqtt(config, inverter): + """Regression: the SoC was only fetched inside the MQTT branch. + + With MQTT disabled last_SOC stayed on its -1 sentinel forever, so the + REST API and the dashboard reported soc: null on a perfectly healthy + system. The cached values are core state, not an MQTT detail. + """ + del inverter + batcontrol = Batcontrol(config) + try: + assert batcontrol.mqtt_api is None + assert batcontrol.last_SOC == -1 # sentinel before the first refresh + + batcontrol.refresh_static_values() + + assert batcontrol.last_SOC == 63.5 + assert batcontrol.last_stored_energy == 6350 + battery = BatcontrolService(batcontrol).battery() + assert battery['soc'] == 63.5 + # cached by the core, never read from the inverter by the web thread + assert battery['soc_limits'] == {'min_soc': 10.0, 'max_soc': 95.0} + finally: + batcontrol.shutdown() + + +def test_forecast_snapshot_is_published_by_a_cycle(config, inverter): + """__save_run_data has to hand the web layer a complete snapshot.""" + import numpy as np # pylint: disable=import-outside-toplevel + + del inverter + batcontrol = Batcontrol(config) + try: + assert batcontrol.forecast_snapshot is None + batcontrol.last_run_time = 1792083600.0 + # pylint: disable=protected-access + batcontrol._Batcontrol__save_run_data( + np.array([0.0, 500.0]), np.array([400.0, 450.0]), + np.array([400.0, -50.0]), np.array([0.30, 0.25])) + + snapshot = batcontrol.forecast_snapshot + assert isinstance(snapshot, ForecastSnapshot) + assert snapshot.prices == (0.30, 0.25) + assert snapshot.interval_minutes == 60 + finally: + batcontrol.shutdown() + + +def test_snapshot_does_not_alias_the_loops_arrays(config, inverter): + """A reader must not see the next cycle's numbers in the old snapshot.""" + import numpy as np # pylint: disable=import-outside-toplevel + + del inverter + batcontrol = Batcontrol(config) + try: + prices = np.array([0.30, 0.25]) + batcontrol.last_run_time = 1792083600.0 + # pylint: disable=protected-access + batcontrol._Batcontrol__save_run_data( + np.zeros(2), np.zeros(2), np.zeros(2), prices) + prices[0] = 9.99 + assert batcontrol.forecast_snapshot.prices == (0.30, 0.25) + finally: + batcontrol.shutdown() + + +def test_a_broken_api_config_does_not_stop_battery_control(config, inverter, caplog): + """An unusable api: block must not take the control loop down with it. + + The API is a read-only accessory. core.py's own comment promises that a + failure there cannot stop the control loop from coming up; that has to + include parsing its configuration. + """ + del inverter + config['api'] = {'enabled': True, 'port': 'eighty'} + with caplog.at_level('ERROR'): + batcontrol = Batcontrol(config) + try: + assert 'Invalid api configuration' in caplog.text + assert batcontrol.api_config.enabled is False + assert batcontrol.web_server is None + # and the parts that actually control the battery are up + assert batcontrol.inverter is not None + assert batcontrol.general_logic is not None + finally: + batcontrol.shutdown() diff --git a/tests/batcontrol/web/test_dashboard.py b/tests/batcontrol/web/test_dashboard.py new file mode 100644 index 00000000..f492b77c --- /dev/null +++ b/tests/batcontrol/web/test_dashboard.py @@ -0,0 +1,158 @@ +"""Serving the dashboard assets. + +Both branches are forced explicitly, because whether src/batcontrol/web/static +exists depends on whether someone ran the npm build - true on a developer's +machine, false in CI. A test that silently tested only one of them would be +worse than no test. +""" +import pytest +from fastapi.testclient import TestClient + +from batcontrol.web import app as app_module +from batcontrol.web.app import create_app +from batcontrol.web.config import ApiConfig + + +@pytest.fixture(name='built') +def fixture_built(tmp_path, monkeypatch): + """A directory that looks like a finished dashboard build.""" + static = tmp_path / 'static' + (static / 'assets').mkdir(parents=True) + (static / 'index.html').write_text('batcontrol') + (static / 'assets' / 'index-abc123.js').write_text('console.log(1)') + (static / 'licenses').mkdir() + (static / 'licenses' / 'LICENSE-OFL-VT323.txt').write_text('OFL') + (static / 'swagger').mkdir() + (static / 'swagger' / 'swagger-ui-bundle.js').write_text('//swagger') + (static / 'swagger' / 'swagger-ui.css').write_text('/*swagger*/') + (static / 'favicon.svg').write_text('') + monkeypatch.setattr(app_module, 'STATIC_DIR', static) + return static + + +@pytest.fixture(name='unbuilt') +def fixture_unbuilt(tmp_path, monkeypatch): + """A checkout where npm never ran.""" + monkeypatch.setattr(app_module, 'STATIC_DIR', tmp_path / 'nothing-here') + + +def client_for(fake_bc, **options): + options.setdefault('docs_enabled', True) + return TestClient(create_app(fake_bc, ApiConfig(enabled=True, **options)), + base_url='http://127.0.0.1') + + +def test_index_is_served_at_the_root(fake_bc, built): + del built + with client_for(fake_bc) as client: + response = client.get('/') + assert response.status_code == 200 + assert 'batcontrol' in response.text + + +def test_hashed_assets_are_served(fake_bc, built): + del built + with client_for(fake_bc) as client: + assert client.get('/assets/index-abc123.js').status_code == 200 + + +def test_font_licenses_are_reachable(fake_bc, built): + """The OFL requires the license text to travel with the fonts.""" + del built + with client_for(fake_bc) as client: + assert client.get('/licenses/LICENSE-OFL-VT323.txt').status_code == 200 + + +def test_the_api_keeps_precedence_over_the_dashboard(fake_bc, built): + """The dashboard is mounted at /, so this is not self-evident.""" + del built + with client_for(fake_bc) as client: + assert client.get('/api/v1/health').json()['ok'] is True + assert client.get('/openapi.json').status_code == 200 + assert client.get('/docs').status_code == 200 + + +def test_path_traversal_is_refused(fake_bc, built): + del built + with client_for(fake_bc) as client: + response = client.get('/../../pyproject.toml') + assert response.status_code in (400, 404) + + +def test_a_missing_build_serves_a_hint_not_a_stack_trace(fake_bc, unbuilt, caplog): + del unbuilt + with caplog.at_level('WARNING'): + with client_for(fake_bc) as client: + response = client.get('/') + assert response.status_code == 200 + assert 'npm' in response.text + assert 'npm' in caplog.text + + +def test_the_api_works_without_a_build(fake_bc, unbuilt): + del unbuilt + with client_for(fake_bc) as client: + assert client.get('/api/v1/health').status_code == 200 + + +def test_the_dashboard_can_be_switched_off(fake_bc, built): + del built + with client_for(fake_bc, dashboard_enabled=False) as client: + assert client.get('/').status_code == 404 + assert client.get('/api/v1/health').status_code == 200 + + +def test_docs_work_without_the_dashboard(fake_bc, built): + """Regression: the Swagger bundle was only served by the dashboard + mount, so /docs rendered blank with dashboard_enabled: false.""" + del built + with client_for(fake_bc, dashboard_enabled=False) as client: + assert client.get('/swagger/swagger-ui-bundle.js').status_code == 200 + assert client.get('/swagger/swagger-ui.css').status_code == 200 + assert client.get('/').status_code == 404 + + +def test_docs_use_the_bundled_swagger_ui(fake_bc, built): + """FastAPI points /docs at a CDN by default. + + On a Home Assistant box without internet egress that leaves the API + documentation blank, which is the same reason the fonts are bundled. + """ + del built + with client_for(fake_bc) as client: + page = client.get('/docs').text + assert 'swagger/swagger-ui-bundle.js' in page + assert 'swagger/swagger-ui.css' in page + + +def test_docs_make_no_external_request(fake_bc, built): + del built + with client_for(fake_bc) as client: + page = client.get('/docs').text + for host in ('cdn.jsdelivr.net', 'unpkg.com', 'fastapi.tiangolo.com'): + assert host not in page, f'/docs still loads from {host}' + + +def test_docs_asset_urls_are_relative_for_ingress(fake_bc, built): + """Under ingress the page lives at /api/hassio_ingress//docs.""" + del built + with client_for(fake_bc) as client: + page = client.get('/docs').text + assert 'src="swagger/' in page + assert 'src="/swagger/' not in page + + +def test_docs_fall_back_to_the_cdn_without_a_build(fake_bc, unbuilt): + """A source checkout without npm still gets working docs - with internet.""" + del unbuilt + with client_for(fake_bc) as client: + response = client.get('/docs') + assert response.status_code == 200 + assert 'swagger-ui' in response.text + + +def test_redoc_is_not_served(fake_bc, built): + """One documentation UI that works offline beats two that need a CDN.""" + del built + with client_for(fake_bc) as client: + assert client.get('/redoc').status_code in (404, 405) diff --git a/tests/batcontrol/web/test_openapi.py b/tests/batcontrol/web/test_openapi.py new file mode 100644 index 00000000..b5a7cb84 --- /dev/null +++ b/tests/batcontrol/web/test_openapi.py @@ -0,0 +1,69 @@ +"""The OpenAPI contract. + +The schema is what external consumers generate clients from, so a change to +it is a change to a published contract. This test spells out the expected +surface: adding an endpoint means adding it here deliberately, and removing +or renaming one cannot slip through unnoticed. +""" +EXPECTED_PATHS = { + '/api/v1/health', + '/api/v1/version', + '/api/v1/status', + '/api/v1/battery', + '/api/v1/current', + '/api/v1/forecast', + '/api/v1/forecast/{series}', + '/api/v1/decision', + '/api/v1/decision/history', + '/api/v1/config', +} + +EXPECTED_TAGS = {'meta', 'status', 'forecast', 'decision', 'config'} + + +def schema(client): + response = client.get('/openapi.json') + assert response.status_code == 200 + return response.json() + + +def test_paths_match_the_documented_contract(client): + assert set(schema(client)['paths']) == EXPECTED_PATHS + + +def test_every_endpoint_is_tagged(client): + for path, operations in schema(client)['paths'].items(): + for method, operation in operations.items(): + assert operation.get('tags'), f'{method} {path} has no tag' + assert set(operation['tags']) <= EXPECTED_TAGS + + +def test_every_endpoint_has_a_summary(client): + for path, operations in schema(client)['paths'].items(): + for method, operation in operations.items(): + assert operation.get('summary'), f'{method} {path} has no summary' + + +def test_every_response_is_enveloped(client): + """ok/timestamp/data is part of the contract, not a coincidence.""" + components = schema(client)['components']['schemas'] + envelopes = [name for name in components if name.startswith('Envelope_')] + assert envelopes + for name in envelopes: + assert set(components[name]['properties']) == { + 'ok', 'timestamp', 'data'} + + +def test_units_are_documented_on_numeric_fields(client): + """A bare numeric field without a unit has confused consumers before.""" + components = schema(client)['components']['schemas'] + for model in ('ForecastSlot', 'Battery', 'CurrentPrice'): + for field, spec in components[model]['properties'].items(): + assert spec.get('description'), f'{model}.{field} is undocumented' + + +def test_forecast_slot_documents_the_power_pitfall(client): + """Wh per slot vs average W differ by 4x at 15-minute resolution.""" + power = schema(client)['components']['schemas']['ForecastSlot'][ + 'properties']['power_w'] + assert '15-minute' in power['description'] diff --git a/tests/batcontrol/web/test_readonly.py b/tests/batcontrol/web/test_readonly.py new file mode 100644 index 00000000..2a53795f --- /dev/null +++ b/tests/batcontrol/web/test_readonly.py @@ -0,0 +1,59 @@ +"""The read-only invariant of the API. + +These tests are the guard rail for a deliberate design decision: the HTTP API +reads batcontrol, it never steers it. Runtime overrides stay on the MQTT API. +If a future change adds a mutating route, this file fails - that is intended, +do not relax it without the maintainer's decision. + +The checks go through the OpenAPI schema and real requests rather than +FastAPI's route objects, so they keep working across FastAPI releases that +reorganise internals. +""" +import pytest + +READ_METHODS = {'GET', 'HEAD', 'OPTIONS'} +WRITE_METHODS = ('POST', 'PUT', 'PATCH', 'DELETE') + + +def documented_paths(client): + """Every path the API advertises, with its methods.""" + return client.get('/openapi.json').json()['paths'] + + +def test_openapi_declares_no_write_operations(client): + write_ops = { + f'{method.upper()} {path}' + for path, operations in documented_paths(client).items() + for method in operations + if method.upper() not in READ_METHODS + } + assert not write_ops, f'OpenAPI advertises write operations: {write_ops}' + + +def test_the_api_actually_has_endpoints(client): + """Guards against the test above passing on an empty application.""" + paths = documented_paths(client) + assert len(paths) >= 2, f'suspiciously few endpoints: {sorted(paths)}' + + +@pytest.mark.parametrize('method', WRITE_METHODS) +def test_every_endpoint_rejects_write_methods(client, method): + for path in documented_paths(client): + response = client.request(method, path) + assert response.status_code == 405, ( + f'{method} {path} returned {response.status_code},' + ' the API must not accept write methods' + ) + + +def test_service_layer_exposes_no_setters(): + """The shared service layer is the only data path; keep it read-only.""" + from batcontrol.web.service import BatcontrolService + + mutators = [ + name for name in dir(BatcontrolService) + if not name.startswith('_') + and name.split('_')[0] in ('set', 'put', 'update', 'apply', 'clear', + 'write', 'delete', 'force') + ] + assert not mutators, f'service layer gained mutating methods: {mutators}' diff --git a/tests/batcontrol/web/test_routes.py b/tests/batcontrol/web/test_routes.py new file mode 100644 index 00000000..b04fb914 --- /dev/null +++ b/tests/batcontrol/web/test_routes.py @@ -0,0 +1,143 @@ +"""Endpoint tests of the read-only API.""" +import pytest +from fastapi.testclient import TestClient + +from batcontrol.web.app import create_app + + +def data(response): + """The payload of a successful envelope, with the envelope checked.""" + assert response.status_code == 200, response.text + body = response.json() + assert body['ok'] is True + assert isinstance(body['timestamp'], int) + return body['data'] + + +def test_status(client): + payload = data(client.get('/api/v1/status')) + assert payload['mode'] == 10 + assert payload['mode_label'] == 'ALLOW DISCHARGE' + assert payload['online'] is True + assert payload['next_evaluation'] > payload['last_evaluation'] + + +def test_battery(client): + payload = data(client.get('/api/v1/battery')) + assert payload['soc'] == 87.4 + assert payload['soc_limits']['min_soc'] == 7.0 + assert payload['reserved_energy_capacity'] == 1500.0 + + +def test_current(client): + payload = data(client.get('/api/v1/current')) + assert payload['price']['current'] == 0.308 + assert payload['price']['unit'] == 'EUR/kWh' + assert payload['soc'] == 87.4 + + +def test_forecast_bundle_shares_one_slot_grid(client): + payload = data(client.get('/api/v1/forecast')) + grids = { + series: [slot['time_start'] for slot in payload[series]] + for series in ('price', 'consumption', 'production', 'net_consumption') + } + assert len({tuple(grid) for grid in grids.values()}) == 1 + + +@pytest.mark.parametrize('series,unit', [ + ('price', 'EUR/kWh'), + ('consumption', 'Wh'), + ('production', 'Wh'), + ('net_consumption', 'Wh'), +]) +def test_single_forecast_series(client, series, unit): + payload = data(client.get(f'/api/v1/forecast/{series}')) + assert payload['unit'] == unit + assert payload['total_count'] == 4 + assert payload['slots'][0]['slot'] == 0 + + +def test_unknown_forecast_series_is_a_404_with_a_hint(client): + response = client.get('/api/v1/forecast/weather') + assert response.status_code == 404 + body = response.json() + assert body['error'] == 'not_found' + assert 'net_consumption' in body['detail'] + + +def test_forecast_before_the_first_cycle_is_503(empty_bc): + with TestClient(create_app(empty_bc), base_url='http://127.0.0.1') as client: + response = client.get('/api/v1/forecast') + assert response.status_code == 503 + body = response.json() + assert body['ok'] is False + assert body['error'] == 'no_data_yet' + assert 'first evaluation' in body['detail'] + + +def test_decision_without_a_trace_is_404(client): + response = client.get('/api/v1/decision') + assert response.status_code == 404 + assert response.json()['error'] == 'not_found' + + +def test_decision(client, fake_bc): + fake_bc.record_decision() + payload = data(client.get('/api/v1/decision')) + assert payload['decisive']['reason'] == 'ALWAYS_ALLOW_DISCHARGE_LIMIT' + assert payload['status_text'] + assert payload['trace']['records'] + + +def test_decision_history_respects_the_limit(client, fake_bc): + for _ in range(4): + fake_bc.record_decision() + payload = data(client.get('/api/v1/decision/history?limit=2')) + assert payload['count'] == 2 + + +@pytest.mark.parametrize('limit', [0, -1, 201, 'many']) +def test_decision_history_rejects_an_invalid_limit(client, limit): + assert client.get(f'/api/v1/decision/history?limit={limit}').status_code == 422 + + +def test_config_exposes_runtime_parameters(client): + payload = data(client.get('/api/v1/config')) + assert payload['battery_control']['min_price_difference'] == 0.05 + assert payload['peak_shaving']['enabled'] is False + + +def test_config_contains_no_credentials(client, fake_bc): + """A credential must not be reachable through the config endpoint. + + The stand-in is given a config dict full of secrets on purpose: the + endpoint builds its answer from core attributes, so none of this can + surface. If someone changes the service to read the config dict, the + secrets would appear here and this test fails. + """ + fake_bc.config = { + 'inverter': {'user': 'customer', 'password': 'inverter-pw'}, + 'utility': {'api_token': 'tibber-token'}, + 'mqtt': {'username': 'mqtt-user', 'password': 'mqtt-pw'}, + } + body = client.get('/api/v1/config').text + for secret in ('inverter-pw', 'tibber-token', 'mqtt-user', 'mqtt-pw', + 'customer'): + assert secret not in body + + +def test_no_response_contains_a_secret_looking_key(client, fake_bc): + """Sweep every endpoint for credential-shaped keys.""" + fake_bc.record_decision() + forbidden = ('password', 'token', 'secret', 'apikey', 'api_key', + 'username') + paths = [ + '/api/v1/health', '/api/v1/version', '/api/v1/status', + '/api/v1/battery', '/api/v1/current', '/api/v1/forecast', + '/api/v1/decision', '/api/v1/decision/history', '/api/v1/config', + ] + for path in paths: + body = client.get(path).text.lower() + for needle in forbidden: + assert needle not in body, f'{path} mentions {needle!r}' diff --git a/tests/batcontrol/web/test_server.py b/tests/batcontrol/web/test_server.py new file mode 100644 index 00000000..f4bd86b1 --- /dev/null +++ b/tests/batcontrol/web/test_server.py @@ -0,0 +1,134 @@ +"""The uvicorn thread that hosts the API. + +This is the one part of the web layer a route test cannot reach: thread +start-up, shutdown, and the promise that a failing web server never takes the +control loop with it. It is therefore exercised against a real socket rather +than a mock. +""" +import socket +import threading +import time + +import pytest + +from batcontrol.web.config import ApiConfig +from batcontrol.web.server import WebServer + +# Generous: a cold uvicorn start on a loaded CI runner is not instant. +STARTUP_TIMEOUT = 20.0 + + +def free_port() -> int: + """An ephemeral port the OS just confirmed is free.""" + with socket.socket() as probe: + probe.bind(('127.0.0.1', 0)) + return probe.getsockname()[1] + + +def wait_for(predicate, timeout=STARTUP_TIMEOUT): + """Poll until predicate() is truthy, or give up.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return False + + +@pytest.fixture(name='running') +def fixture_running(fake_bc): + """A WebServer actually listening on a free port.""" + config = ApiConfig(enabled=True, host='127.0.0.1', port=free_port()) + server = WebServer(fake_bc, config) + server.start() + try: + yield server, config + finally: + server.shutdown() + + +def test_it_serves_requests(running): + import httpx # pylint: disable=import-outside-toplevel + + _server, config = running + url = f'http://{config.host}:{config.port}/api/v1/health' + + def reachable(): + try: + return httpx.get(url, timeout=1.0).status_code == 200 + except httpx.HTTPError: + return False + + assert wait_for(reachable), 'the server never started listening' + body = httpx.get(url, timeout=5.0).json() + assert body['data']['status'] == 'ok' + + +def test_it_runs_as_a_daemon_thread(running): + """A hung web thread must never keep the process alive.""" + server, _config = running + thread = next(t for t in threading.enumerate() if t.name == 'batcontrol-web') + assert thread.daemon is True + assert server.app is not None + + +def test_shutdown_stops_the_thread(fake_bc): + config = ApiConfig(enabled=True, host='127.0.0.1', port=free_port()) + server = WebServer(fake_bc, config) + server.start() + assert wait_for(lambda: any( + t.name == 'batcontrol-web' for t in threading.enumerate())) + + server.shutdown() + + assert wait_for(lambda: not any( + t.name == 'batcontrol-web' for t in threading.enumerate())) + + +def test_shutdown_without_start_is_a_no_op(fake_bc): + WebServer(fake_bc, ApiConfig()).shutdown() + + +def test_starting_twice_is_refused_not_duplicated(fake_bc, caplog): + config = ApiConfig(enabled=True, host='127.0.0.1', port=free_port()) + server = WebServer(fake_bc, config) + server.start() + try: + with caplog.at_level('WARNING'): + assert server.start() is True + assert 'already started' in caplog.text + threads = [t for t in threading.enumerate() if t.name == 'batcontrol-web'] + assert len(threads) == 1 + finally: + server.shutdown() + + +def test_an_occupied_port_is_reported_not_hidden(fake_bc, caplog): + """Regression: uvicorn sys.exit()s on a bind error. SystemExit slipped + past the thread's except clause, the thread died silently and + "listening" had already been logged.""" + with socket.socket() as blocker: + blocker.bind(('127.0.0.1', 0)) + blocker.listen() + port = blocker.getsockname()[1] + server = WebServer(fake_bc, ApiConfig(enabled=True, port=port)) + with caplog.at_level('INFO'): + assert server.start() is False + assert 'could not be started' in caplog.text + assert 'Web API listening' not in caplog.text + assert not [t for t in threading.enumerate() if t.name == 'batcontrol-web'] + + + +def test_the_startup_log_never_contains_the_token(fake_bc, caplog): + config = ApiConfig(enabled=True, host='127.0.0.1', port=free_port(), + auth_token='super-secret', mcp_auth_token='also-secret') + server = WebServer(fake_bc, config) + with caplog.at_level('INFO'): + server.start() + try: + assert 'super-secret' not in caplog.text + assert 'also-secret' not in caplog.text + assert 'Web API listening' in caplog.text + finally: + server.shutdown() diff --git a/tests/batcontrol/web/test_service.py b/tests/batcontrol/web/test_service.py new file mode 100644 index 00000000..423f5250 --- /dev/null +++ b/tests/batcontrol/web/test_service.py @@ -0,0 +1,286 @@ +"""Tests for the read-only service layer.""" +import dataclasses +import datetime +import time + +import pytest + +from batcontrol.decision_journal import DecisionJournal + +from batcontrol.web.service import ( + BatcontrolService, NoDataAvailable, NotFound, +) + + +def test_health_answers_before_the_first_cycle(empty_bc): + """A container health check must work during startup.""" + health = BatcontrolService(empty_bc).health() + assert health['status'] == 'ok' + assert health['has_data'] is False + assert health['last_evaluation'] is None + assert health['cycle_age_seconds'] is None + assert health['stale'] is False + + +def test_a_loop_that_never_completes_a_cycle_goes_stale(empty_bc): + """Regression: with last_run_time 0 the API reported online forever.""" + service = BatcontrolService(empty_bc) + service._started -= 3600 # pylint: disable=protected-access + assert service.health()['stale'] is True + assert service.status()['online'] is False + + +def test_forecasts_failing_from_startup_make_the_api_stale(empty_bc): + """Regression: cycles run (last_run_time advances) but every forecast + fails, so no snapshot is ever published.""" + empty_bc.last_run_time = time.time() - 10 + service = BatcontrolService(empty_bc) + assert service.status()['online'] is True # just started + service._started -= 3600 # pylint: disable=protected-access + assert service.status()['online'] is False + + +def test_failing_forecasts_make_the_api_stale(fake_bc): + """Regression: a cycle that aborts on a forecast error advances + last_run_time but publishes no snapshot, so the old snapshot decides.""" + fake_bc.forecast_snapshot = dataclasses.replace( + fake_bc.forecast_snapshot, run_time=time.time() - 3600) + assert BatcontrolService(fake_bc).status()['online'] is False + + +def test_current_follows_the_clock_when_the_snapshot_ages(fake_bc): + """Regression: an old snapshot served its slot 0 as the current price.""" + fake_bc.forecast_snapshot = dataclasses.replace( + fake_bc.forecast_snapshot, run_time=time.time() - 2 * 3600) + service = BatcontrolService(fake_bc) + assert service.current()['price']['current'] == 0.201 + forecast = service.forecast('price') + assert forecast['total_count'] == 2 + assert [s['slot'] for s in forecast['slots']] == [0, 1] + assert forecast['slots'][0]['value'] == 0.201 + + +def test_an_expired_forecast_is_no_data(fake_bc): + fake_bc.forecast_snapshot = dataclasses.replace( + fake_bc.forecast_snapshot, run_time=time.time() - 10 * 3600) + with pytest.raises(NoDataAvailable) as excinfo: + BatcontrolService(fake_bc).current() + assert excinfo.value.code == 'forecast_expired' + + +def test_forecast_all_reads_the_snapshot_once(fake_bc, monkeypatch): + """Regression: four reads could mix two cycles in one response.""" + service = BatcontrolService(fake_bc) + reads = [] + original = BatcontrolService.require_snapshot + + def counting(self): + reads.append(1) + return original(self) + monkeypatch.setattr(BatcontrolService, 'require_snapshot', counting) + service.forecast_all() + assert len(reads) == 1 + + +def test_mode_since_ignores_value_changes(fake_bc): + """Regression: a charge rate change reset "mode since".""" + first = fake_bc.record_decision() + later = fake_bc.record_decision() + later.timestamp = first.timestamp + datetime.timedelta(minutes=5) + journal = fake_bc.decision_journal = DecisionJournal() + journal.commit(first, fake_bc.last_mode, 'optimizer', 1000) + journal.commit(later, fake_bc.last_mode, 'optimizer', 5000) + assert journal.last_status_change().trace is later + since = BatcontrolService(fake_bc).status()['mode_changed_at'] + assert since == int(first.timestamp.timestamp() * 1000) + + +def test_health_reports_a_stalled_loop(fake_bc): + fake_bc.last_run_time = 1.0 # far in the past + health = BatcontrolService(fake_bc).health() + assert health['stale'] is True + + +def test_version_exposes_the_interval(fake_bc): + version = BatcontrolService(fake_bc).version() + assert version['interval_minutes'] == 60 + assert version['timezone'] == 'Europe/Berlin' + assert version['version'] + + +def test_status_labels_the_mode(fake_bc): + status = BatcontrolService(fake_bc).status() + assert status['mode'] == 10 + assert status['mode_code'] == 'allow_discharging' + assert status['mode_label'] == 'ALLOW DISCHARGE' + assert status['control_source'] == 'optimizer' + + +def test_status_derives_the_next_evaluation(fake_bc): + status = BatcontrolService(fake_bc).status() + assert status['next_evaluation'] - status['last_evaluation'] == 180 * 1000 + + +def test_status_without_a_cycle_has_no_timestamps(empty_bc): + status = BatcontrolService(empty_bc).status() + assert status['last_evaluation'] is None + assert status['next_evaluation'] is None + + +def test_battery_reports_capacities_and_limits(fake_bc): + battery = BatcontrolService(fake_bc).battery() + assert battery['soc'] == 87.4 + assert battery['max_energy_capacity'] == 11000.0 + assert battery['always_allow_discharge_limit_percent'] == 90.0 + assert battery['soc_limits'] == {'min_soc': 7.0, 'max_soc': 100.0} + + +def test_unread_inverter_values_are_unknown_not_negative(fake_bc): + """The core initialises its caches to -1 until the inverter was read.""" + fake_bc.last_SOC = -1 + fake_bc.last_max_capacity = -1 + fake_bc.last_min_soc = -1 + battery = BatcontrolService(fake_bc).battery() + assert battery['soc'] is None + assert battery['max_energy_capacity'] is None + assert battery['soc_limits']['min_soc'] is None + + +def test_current_takes_the_price_from_slot_zero(fake_bc): + """Independent of which decision path ran - that was the prototype's bug.""" + current = BatcontrolService(fake_bc).current() + assert current['price']['current'] == 0.308 + assert current['price']['unit'] == 'EUR/kWh' + assert current['price']['interval_minutes'] == 60 + assert current['price']['min_dynamic_price_difference'] == 0.0612 + assert current['soc_limits']['min_soc'] == 7.0 + + +def test_current_without_data_raises(empty_bc): + with pytest.raises(NoDataAvailable): + BatcontrolService(empty_bc).current() + + +@pytest.mark.parametrize('name', [ + 'price', 'consumption', 'production', 'net_consumption', None, +]) +def test_forecasts_require_a_completed_cycle(empty_bc, name): + service = BatcontrolService(empty_bc) + with pytest.raises(NoDataAvailable): + if name is None: + service.forecast_all() + else: + service.forecast(name) + + +def test_price_forecast_has_no_power_field(fake_bc): + forecast = BatcontrolService(fake_bc).forecast('price') + assert forecast['unit'] == 'EUR/kWh' + assert forecast['total_count'] == 4 + assert 'power_w' not in forecast['slots'][0] + + +@pytest.mark.parametrize('name', [ + 'consumption', 'production', 'net_consumption', +]) +def test_energy_forecasts_carry_power(fake_bc, name): + forecast = BatcontrolService(fake_bc).forecast(name) + assert forecast['unit'] == 'Wh' + assert 'power_w' in forecast['slots'][0] + + +def test_net_consumption_keeps_the_surplus_sign(fake_bc): + """Negative net consumption means surplus solar energy.""" + slots = BatcontrolService(fake_bc).forecast('net_consumption')['slots'] + assert slots[0]['value'] == 450.0 + assert slots[2]['value'] == -500.0 + + +def test_forecast_all_returns_every_series(fake_bc): + forecast = BatcontrolService(fake_bc).forecast_all() + for key in ('price', 'consumption', 'production', 'net_consumption'): + assert len(forecast[key]) == 4 + assert forecast['interval_minutes'] == 60 + + +def test_decision_requires_a_recorded_trace(fake_bc): + with pytest.raises(NotFound): + BatcontrolService(fake_bc).decision() + + +def test_decision_exposes_trace_and_decisive_record(fake_bc): + fake_bc.record_decision() + decision = BatcontrolService(fake_bc).decision() + assert decision['decisive']['reason'] == 'ALWAYS_ALLOW_DISCHARGE_LIMIT' + assert decision['trace']['records'] + assert decision['status_text'] + assert decision['event']['mode'] == 10 + + +def test_decision_history_is_limited(fake_bc): + for _ in range(5): + fake_bc.record_decision() + history = BatcontrolService(fake_bc).decision_history(limit=3) + assert history['count'] == 3 + assert history['limit'] == 3 + + +def test_configuration_exposes_runtime_parameters(fake_bc): + config = BatcontrolService(fake_bc).configuration() + assert config['battery_control']['min_price_difference'] == 0.05 + assert config['battery_control']['type'] == 'default' + assert config['peak_shaving']['enabled'] is False + assert config['interval_minutes'] == 60 + + +def test_configuration_never_reads_the_config_dict(fake_bc): + """The whitelist is built from core attributes, so secrets cannot leak. + + The stand-in has no `config` attribute at all: if the service ever starts + reading the raw configuration dictionary, this test fails immediately. + """ + assert not hasattr(fake_bc, 'config') + assert BatcontrolService(fake_bc).configuration() + + +def test_service_never_calls_inverter_accessors(fake_bc): + """A request must not trigger Modbus or HTTP traffic to the inverter.""" + def explode(*_args, **_kwargs): + raise AssertionError('the API must not read the inverter') + + for name in ('get_SOC', 'get_max_capacity', 'get_stored_energy', + 'get_stored_usable_energy', 'get_free_capacity'): + setattr(fake_bc, name, explode) + + class _Untouchable: + """An InverterGroup derives min_soc/max_soc from every member's + capacity, which can mean inverter traffic.""" + def __getattr__(self, name): + raise AssertionError(f'the API must not read inverter.{name}') + fake_bc.inverter = _Untouchable() + + service = BatcontrolService(fake_bc) + fake_bc.record_decision() + service.health() + service.status() + service.battery() + service.current() + service.forecast_all() + service.decision() + service.configuration() + + +def test_decision_separates_the_latest_trace_from_the_last_change(fake_bc): + """The two have different ages and must not be presented as one. + + `trace` is refreshed every control cycle; `event` only moves on a mode or + value change, or the journal's 15-minute refresh. Rendering the event's + records next to the current mode shows a "why" that can be a quarter of + an hour older than what it sits beside. + """ + fake_bc.record_decision() + decision = BatcontrolService(fake_bc).decision() + assert decision['decided_at'] is not None + assert decision['event_at'] is not None + # both ages are exposed, so a consumer can tell them apart + assert 'records' in decision['trace'] diff --git a/tests/batcontrol/web/test_snapshot.py b/tests/batcontrol/web/test_snapshot.py new file mode 100644 index 00000000..fbcaed2a --- /dev/null +++ b/tests/batcontrol/web/test_snapshot.py @@ -0,0 +1,66 @@ +"""Tests for the immutable per-cycle forecast snapshot.""" +import dataclasses + +import numpy as np +import pytest + +from batcontrol.interval_utils import align_to_interval +from batcontrol.web.snapshot import ForecastSnapshot + + +def test_build_copies_numpy_arrays_into_tuples(): + """The reader must not be able to see a mutated array.""" + prices = np.array([0.30, 0.25]) + snapshot = ForecastSnapshot.build( + run_time=1000.0, interval_minutes=60, prices=prices) + prices[0] = 99.0 + assert snapshot.prices == (0.30, 0.25) + + +def test_snapshot_is_frozen(): + snapshot = ForecastSnapshot.build(run_time=1000.0, interval_minutes=60) + with pytest.raises(dataclasses.FrozenInstanceError): + snapshot.run_time = 2000.0 + + +def test_missing_series_become_empty_tuples(): + snapshot = ForecastSnapshot.build(run_time=1000.0, interval_minutes=15) + assert snapshot.prices == () + assert snapshot.consumption == () + assert snapshot.has_data() is False + + +def test_slot_zero_starts_at_the_interval_boundary(): + """Slot 0 is the interval containing the run time, not the run time.""" + run_time = 1792065332.0 # somewhere inside an hour + snapshot = ForecastSnapshot.build( + run_time=run_time, interval_minutes=60, prices=[0.3]) + assert snapshot.base_time == align_to_interval(run_time, 60) + assert snapshot.base_time <= run_time + assert snapshot.slot_start_ms(0) == int(snapshot.base_time * 1000) + assert snapshot.slot_start_ms(1) - snapshot.slot_start_ms(0) == 3600 * 1000 + + +@pytest.mark.parametrize('interval,factor', [(60, 1), (15, 4)]) +def test_power_conversion_follows_the_interval(interval, factor): + """Wh per interval and average W differ by 4x at 15-minute resolution.""" + snapshot = ForecastSnapshot.build( + run_time=0.0, interval_minutes=interval, consumption=[250.0]) + slot = snapshot.slots(snapshot.consumption, include_power=True)[0] + assert slot['value'] == 250.0 + assert slot['power_w'] == 250.0 * factor + + +def test_slots_carry_index_and_bounds(): + snapshot = ForecastSnapshot.build( + run_time=0.0, interval_minutes=60, prices=[0.1, 0.2, 0.3]) + slots = snapshot.slots(snapshot.prices, digits=4) + assert [slot['slot'] for slot in slots] == [0, 1, 2] + assert slots[0]['time_end'] == slots[1]['time_start'] + assert 'power_w' not in slots[0] + + +def test_price_rounding_keeps_four_digits(): + snapshot = ForecastSnapshot.build( + run_time=0.0, interval_minutes=60, prices=[0.30812345]) + assert snapshot.slots(snapshot.prices, digits=4)[0]['value'] == 0.3081 From da27cb3adf52a3abfe1df6d8411e74b01f5a40a1 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 16 Oct 2026 13:27:02 +0200 Subject: [PATCH 2/5] feat(mcp): read-only MCP server at /mcp Streamable HTTP transport on the same port (api.mcp_enabled). The tools are thin wrappers around the same service layer as REST, annotated read-only, with output schemas and paged forecast series (24 hours by default). /mcp has its own bearer token (api.mcp_auth_token), inheriting api.auth_token when unset. A failing MCP start-up is logged and leaves REST and the dashboard running. No Host header validation, so reverse proxies work; the token is the protection. Co-Authored-By: Claude Opus 5.5 --- src/batcontrol/web/mcp.py | 367 +++++++++++++++++++++++++++++++ tests/batcontrol/web/test_mcp.py | 321 +++++++++++++++++++++++++++ 2 files changed, 688 insertions(+) create mode 100644 src/batcontrol/web/mcp.py create mode 100644 tests/batcontrol/web/test_mcp.py diff --git a/src/batcontrol/web/mcp.py b/src/batcontrol/web/mcp.py new file mode 100644 index 00000000..28139b4c --- /dev/null +++ b/src/batcontrol/web/mcp.py @@ -0,0 +1,367 @@ +""" Read-only MCP server + +Exposes batcontrol to MCP clients (Claude Desktop, Claude Code, a voice +assistant) as query tools. Every tool is a thin wrapper around the shared +service layer - no logic lives here. + +**Read-only.** There is deliberately no tool that sets a mode, a charge rate +or a parameter. An assistant can explain what batcontrol is doing and why; it +cannot steer the battery. Runtime overrides stay on the MQTT API, where the +broker authenticates them. + +Conventions follow the MCP server best practices: + - names are `batcontrol__` (snake_case, service prefixed, + action first), so they do not collide with another server's tools + - tool docstrings are the LLM-facing documentation and carry the unit, the + sign convention, the time base and what the tool does *not* know + - every tool returns a typed envelope, so clients get an output schema and + structured content rather than a prose blob + - long series are paged with limit/offset and report total_count, + has_more and next_offset + - failures are reported inside the result, never as a protocol error, with + a next step for the caller and no internal detail + +Transport is HTTP only, served at /mcp by the same application as the REST +API (see app.py). There is deliberately no stdio transport: with stdio the +client spawns the server process, and a freshly spawned batcontrol either +opens a second connection to the inverter - two controllers on one battery - +or runs without a control loop, in which case every tool can only answer "no +data yet". The useful MCP server is the one attached to the batcontrol that is +already running, and that one is reachable over HTTP. + +Requires the `mcp` SDK (>= 2.3, where FastMCP was renamed to MCPServer). +""" +import logging +from typing import Annotated, Any, Dict, Generic, Optional, TypeVar + +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import ToolAnnotations +from pydantic import BaseModel, Field + +from .. import __pkginfo__ +from .auth import bearer_guard +from .models import ( + Battery, Configuration, Current, Decision, DecisionHistory, + ForecastSeries, Status, Version, +) +from .service import ( + HISTORY_MAX, WINDOW_MAX, BatcontrolService, NoDataAvailable, NotFound, +) + +logger = logging.getLogger(__name__) + + +SERVER_NAME = 'batcontrol' + +# The forecast tools default to service.window_slots() - 24 hours, resolved +# to a slot count from the configured resolution (24 slots hourly, 96 +# quarter-hourly). A model rarely needs more than the next day up front and +# pays for every token it is given - and because the series are paged, it can +# ask for the rest whenever it actually needs it. +DEFAULT_HISTORY = 10 + +INSTRUCTIONS = """ +batcontrol charges a home battery when electricity is cheap and preserves it +for expensive hours, using dynamic tariff prices, a solar forecast and a +consumption forecast. + +These tools are read-only: they report state, forecasts and the reasoning +behind the current inverter mode. Nothing here can change the battery's +behaviour; say so if the user asks for a change. + +Conventions that apply to every tool: +- Timestamps are epoch milliseconds. +- Energy is Wh per forecast slot; energy series also carry power_w, the + average power over the slot. At 15-minute resolution those differ by a + factor of four - do not mix them up. +- Prices are EUR/kWh. +- Forecast slot 0 is the interval containing the current time. +- Series are paged: check has_more and request next_offset for the rest. +- A tool answers available=false with a reason when batcontrol has not + completed a control cycle yet. Report that instead of inventing numbers. +""".strip() + +DataT = TypeVar('DataT') + +# Parameter annotations, so the generated input schema carries the +# descriptions a model needs instead of a bare integer. +SlotLimit = Annotated[Optional[int], Field( + ge=1, le=WINDOW_MAX, + description='Maximum number of forecast slots to return. Omit for the next' + ' 24 hours, which is 24 slots at 60-minute and 96 slots at' + ' 15-minute resolution. One slot is interval_minutes long.', + examples=[24, 96])] +SlotOffset = Annotated[int, Field( + ge=0, le=WINDOW_MAX, + description='Slot index to start at. Pass the next_offset of the previous' + ' response to page through the horizon.', + examples=[0, 24])] +HistoryLimit = Annotated[int, Field( + ge=1, le=HISTORY_MAX, + description='Maximum number of decision traces to return, newest last.', + examples=[10, 50])] + + +class ToolResult(BaseModel, Generic[DataT]): + """ Uniform tool envelope, so every answer has an output schema. """ + available: bool = Field( + ..., + description='False when the data does not exist yet or could not be' + ' read. Then data is null and reason explains why.') + reason: Optional[str] = Field( + None, description='Why the data is unavailable, including what to do' + ' about it.') + data: Optional[DataT] = Field(None, description='The payload.') + + +def _ok(model, payload: Dict[str, Any]): + return ToolResult[model](available=True, data=model(**payload)) + + +def _unavailable(model, reason: str): + return ToolResult[model](available=False, reason=reason) + + +def _call(model, func, *args, **kwargs): + """ Run a service call and wrap it in the envelope. + + Errors are reported inside the result, as the MCP guidance requires, with + a next step for the caller. An unexpected exception is logged in full but + reported generically: an MCP answer is not the place to leak internals. + """ + try: + return _ok(model, func(*args, **kwargs)) + except NoDataAvailable as exc: + return _unavailable( + model, + f'{exc} Retry after the next control cycle; batcontrol evaluates' + ' every few minutes.') + except NotFound as exc: + return _unavailable(model, str(exc)) + except Exception: # pylint: disable=broad-exception-caught + logger.error('MCP tool %s failed', getattr(func, '__name__', func), + exc_info=True) + return _unavailable( + model, + 'batcontrol could not answer this request. Check the batcontrol' + ' log for the cause.') + + +def build_server(service: BatcontrolService): # pylint: disable=too-many-locals + """ Create the MCP server and register the read-only tools. """ + server = MCPServer( + name=SERVER_NAME, + title='batcontrol', + instructions=INSTRUCTIONS, + version=__pkginfo__.__version__, + ) + + # Hints, not guarantees - but these tools genuinely only read this + # instance, so open_world_hint is false. + read_only = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, + ) + + def tool(func): + return server.tool(annotations=read_only)(func) + + def _forecast(name: str, limit: Optional[int], offset: int): + # None means "24 hours": the slot count depends on the configured + # interval, so it cannot be a literal default in the input schema. + # The range itself is enforced by the schema. + return _call(ForecastSeries, service.forecast, name, + limit=service.window_slots() if limit is None else limit, + offset=offset) + + @tool + def batcontrol_get_status() -> ToolResult[Status]: + """Get the current inverter mode and control state of batcontrol. + + Returns the active mode (-1 force charge from grid, 0 avoid discharge, + 8 limit PV charge rate, 10 allow discharge) with a label, who set it + (optimizer, api or evcc), the forced grid charge rate in W, whether an + external override or a grid-charge lock is active, and when the next + evaluation is due. `online` is false when the control loop has missed + several cycles, which means every value here is stale. + """ + return _call(Status, service.status) + + @tool + def batcontrol_get_battery() -> ToolResult[Battery]: + """Get state of charge, capacities and SoC limits of the battery. + + All energies are Wh. `stored_usable_energy_capacity` is what may + actually be discharged (above the inverter's min_soc); + `reserved_energy_capacity` is what batcontrol holds back for upcoming + expensive hours and can exceed the battery capacity. A null value + means not read yet, never zero. The values are the ones cached by the + last control cycle: this tool does not query the inverter. + """ + return _call(Battery, service.battery) + + @tool + def batcontrol_get_current() -> ToolResult[Current]: + """Get the current electricity price together with the state of charge. + + The one call to make for "what is the price right now and how full is + the battery". The price is the current forecast slot in EUR/kWh and is + always populated, independent of which decision path ran. + """ + return _call(Current, service.current) + + @tool + def batcontrol_get_price_forecast( + limit: SlotLimit = None, + offset: SlotOffset = 0) -> ToolResult[ForecastSeries]: + """Get the electricity price forecast in EUR/kWh per slot. + + Slot 0 is the current price. Use this for "is it cheap right now", + "when is the cheapest window" and "what is the spread today". The + horizon depends on the tariff provider, typically to the end of + tomorrow once the next day's prices are published. + + Defaults to the next 24 hours. Returns at most `limit` slots from + `offset`; check has_more and next_offset to page through the rest. + """ + return _forecast('price', limit, offset) + + @tool + def batcontrol_get_solar_forecast( + limit: SlotLimit = None, + offset: SlotOffset = 0) -> ToolResult[ForecastSeries]: + """Get the expected solar production in Wh per slot. + + `power_w` is the average power over the slot. This is a forecast from + the configured solar provider, not a measurement: it says nothing + about what the panels produced in the past or produce this second. + + Defaults to the next 24 hours. Returns at most `limit` slots from + `offset`; check has_more and next_offset to page through the rest. + """ + return _forecast('production', limit, offset) + + @tool + def batcontrol_get_consumption_forecast( + limit: SlotLimit = None, + offset: SlotOffset = 0) -> ToolResult[ForecastSeries]: + """Get the expected household consumption in Wh per slot. + + Derived from a load profile scaled to the configured yearly + consumption, so it is a statistical expectation, not a measurement, + and it does not know about a specific appliance being switched on. + + Defaults to the next 24 hours. Returns at most `limit` slots from + `offset`; check has_more and next_offset to page through the rest. + """ + return _forecast('consumption', limit, offset) + + @tool + def batcontrol_get_net_consumption_forecast( + limit: SlotLimit = None, + offset: SlotOffset = 0) -> ToolResult[ForecastSeries]: + """Get consumption minus solar production in Wh per slot. + + Positive means energy has to come from the battery or the grid. + Negative means surplus solar energy that can charge the battery or be + fed into the grid. This is the series batcontrol's decisions are based + on, so it is the one to look at when explaining a decision. + + Defaults to the next 24 hours. Returns at most `limit` slots from + `offset`; check has_more and next_offset to page through the rest. + """ + return _forecast('net_consumption', limit, offset) + + @tool + def batcontrol_get_decision() -> ToolResult[Decision]: + """Get the reason why batcontrol chose the current inverter mode. + + Returns the decision trace of the last control cycle: every rule that + was evaluated, which one was decisive, and a plain language + explanation. `status_text` is the one-line summary the Home Assistant + Decision sensor shows. This is the tool for "why is the battery not + discharging right now". + """ + return _call(Decision, service.decision) + + @tool + def batcontrol_list_decisions( + limit: HistoryLimit = DEFAULT_HISTORY + ) -> ToolResult[DecisionHistory]: + """List recent decisions from the in-memory journal, oldest first. + + Use this for "what has it been doing over the last hours" or to spot a + mode flapping between two states. The journal is not persisted: it + starts empty after a restart and holds a few hundred traces at most, + so it is not a long-term history. + + Returns at most `limit` traces. + """ + return _call(DecisionHistory, service.decision_history, + limit=limit) + + @tool + def batcontrol_get_config() -> ToolResult[Configuration]: + """Get the runtime parameters that explain batcontrol's behaviour. + + Price thresholds, SoC limits and the peak shaving settings - the + numbers a decision has to be read against. This is an allow-list of + behavioural parameters: it holds no credentials, no inverter address + and no tariff API token, and it cannot be used to change anything. + """ + return _call(Configuration, service.configuration) + + @tool + def batcontrol_get_version() -> ToolResult[Version]: + """Get version, build and runtime environment of this instance. + + Includes the forecast interval (15 or 60 minutes), which determines + the slot width of every forecast, and the configured timezone that + local times should be rendered in. + """ + return _call(Version, service.version) + + return server + + +def tool_names(server) -> list: + """ Names of the registered tools, for tests and diagnostics. """ + # list_tools() on the tool manager is the documented inspection path; the + # manager itself is private, hence the disable. + return sorted( + definition.name + for definition in server._tool_manager.list_tools() # pylint: disable=protected-access + ) + + +def http_routes(server, path: str = '/mcp', host: str = '127.0.0.1', + token: str = ''): + """ The MCP transport as (routes, lifespan_app) for a FastAPI application. + + The SDK hands out a complete Starlette app whose lifespan runs the + session manager. Its routes are attached to the FastAPI router directly + rather than mounting the app, for one practical reason: a mounted app with + an inner "/" route answers /mcp with a 307 redirect to /mcp/, and an + exact route avoids making every client follow that. + + The returned app is still needed - the caller has to enter its lifespan, + because attaching routes does not start the session manager. + """ + app = server.streamable_http_app( + streamable_http_path=path, + host=host, + # No Host header validation (maintainer decision): it breaks every + # reverse proxy that forwards its own host name. The SDK switches it + # on by itself for a loopback host, so it is turned off explicitly. + # api.mcp_auth_token is what protects /mcp. + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False), + ) + routes = list(app.routes) + if token: + for route in routes: + route.app = bearer_guard(route.app, token) + return routes, app diff --git a/tests/batcontrol/web/test_mcp.py b/tests/batcontrol/web/test_mcp.py new file mode 100644 index 00000000..b612324b --- /dev/null +++ b/tests/batcontrol/web/test_mcp.py @@ -0,0 +1,321 @@ +"""Tests for the read-only MCP server.""" +import asyncio + +import pytest +from fastapi.testclient import TestClient + +from batcontrol.web import mcp as mcp_module +from batcontrol.web.app import create_app +from batcontrol.web.config import ApiConfig +from batcontrol.web.service import BatcontrolService + +EXPECTED_TOOLS = { + 'batcontrol_get_status', + 'batcontrol_get_battery', + 'batcontrol_get_current', + 'batcontrol_get_price_forecast', + 'batcontrol_get_solar_forecast', + 'batcontrol_get_consumption_forecast', + 'batcontrol_get_net_consumption_forecast', + 'batcontrol_get_decision', + 'batcontrol_list_decisions', + 'batcontrol_get_config', + 'batcontrol_get_version', +} + +INIT = { + 'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', + 'params': {'protocolVersion': '2025-06-18', 'capabilities': {}, + 'clientInfo': {'name': 'test', 'version': '1'}}, +} +MCP_HEADERS = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Host': '127.0.0.1:8080', +} + + +@pytest.fixture(name='server') +def fixture_server(fake_bc): + return mcp_module.build_server(BatcontrolService(fake_bc)) + + +def definitions(server): + return server._tool_manager.list_tools() # pylint: disable=protected-access + + +def call(server, name, **arguments): + """Call a tool and return the parsed structured result.""" + result = asyncio.run(server.call_tool(name, arguments)) + return result.structured_content + + +# -- tool surface ----------------------------------------------------------- + +def test_the_tool_set_is_exactly_the_read_tools(server): + assert set(mcp_module.tool_names(server)) == EXPECTED_TOOLS + + +def test_no_tool_can_change_anything(server): + """The read-only guarantee, enforced on the MCP side as well.""" + forbidden = ('set', 'clear', 'force', 'write', 'update', 'apply', + 'override', 'charge', 'start', 'stop', 'delete') + for name in mcp_module.tool_names(server): + action = name.replace('batcontrol_', '').split('_')[0] + assert action in ('get', 'list'), f'{name} is not a query tool' + assert not any(word == action for word in forbidden) + + +def test_names_follow_the_service_prefix_convention(server): + for name in mcp_module.tool_names(server): + assert name.startswith('batcontrol_') + assert name == name.lower() + + +def test_every_tool_is_annotated_read_only(server): + for definition in definitions(server): + annotations = definition.annotations + assert annotations is not None, definition.name + assert annotations.read_only_hint is True + assert annotations.destructive_hint is False + assert annotations.open_world_hint is False + + +def test_every_tool_has_a_description_and_an_output_schema(server): + for definition in definitions(server): + assert definition.description, definition.name + # Units belong in the description - a model that guesses Wh vs W + # answers confidently and wrongly. + assert definition.output_schema, definition.name + + +def test_paged_tools_document_their_parameters(server): + paged = [d for d in definitions(server) if 'forecast' in d.name] + assert paged + for definition in paged: + for field in ('limit', 'offset'): + spec = definition.parameters['properties'][field] + assert spec.get('description'), f'{definition.name}.{field}' + + +def test_instructions_state_the_conventions(server): + for needle in ('epoch millisecond', 'EUR/kWh', 'read-only', 'has_more'): + assert needle.lower() in server.instructions.lower() + + +# -- payloads --------------------------------------------------------------- + +def test_status_tool_returns_structured_data(server): + result = call(server, 'batcontrol_get_status') + assert result['available'] is True + assert result['data']['mode_label'] == 'ALLOW DISCHARGE' + + +def test_forecast_defaults_to_24_hours_of_slots(fake_bc): + """A fixed slot count would mean 12 h at 15-minute resolution.""" + fake_bc.time_resolution = 15 + service = BatcontrolService(fake_bc) + assert service.window_slots() == 96 + fake_bc.time_resolution = 60 + assert service.window_slots() == 24 + + +def test_forecast_respects_limit_and_reports_paging(server): + result = call(server, 'batcontrol_get_price_forecast', limit=2) + data = result['data'] + assert data['returned_count'] == 2 + assert data['total_count'] == 4 + assert data['has_more'] is True + assert data['next_offset'] == 2 + + +def test_forecast_offset_continues_the_window(server): + first = call(server, 'batcontrol_get_price_forecast', limit=2)['data'] + rest = call(server, 'batcontrol_get_price_forecast', + limit=2, offset=first['next_offset'])['data'] + assert rest['slots'][0]['slot'] == 2 + assert rest['has_more'] is False + assert rest['next_offset'] is None + + +def test_out_of_range_limit_is_rejected_with_a_readable_error(server): + """The input schema enforces the range, so the model can correct itself.""" + from mcp.server.mcpserver.exceptions import ToolError + + with pytest.raises(ToolError) as excinfo: + call(server, 'batcontrol_get_price_forecast', limit=10_000_000) + assert 'less than or equal to' in str(excinfo.value) + + +def test_omitted_limit_falls_back_to_the_default_window(server): + """None means "the next 24 hours", not "everything".""" + result = call(server, 'batcontrol_get_price_forecast') + assert result['available'] is True + # the fixture horizon is shorter than 24 h, so everything fits + assert result['data']['returned_count'] == 4 + + +def test_net_consumption_keeps_the_surplus_sign(server): + slots = call(server, 'batcontrol_get_net_consumption_forecast')['data']['slots'] + assert slots[2]['value'] == -500.0 + + +def test_decision_history_is_paged(server, fake_bc): + for _ in range(5): + fake_bc.record_decision() + assert call(server, 'batcontrol_list_decisions', + limit=2)['data']['count'] == 2 + + +def test_config_tool_exposes_no_credentials(server, fake_bc): + fake_bc.config = {'inverter': {'password': 'inverter-pw'}} + assert 'inverter-pw' not in str(call(server, 'batcontrol_get_config')) + + +# -- degradation ------------------------------------------------------------ + +def test_missing_data_is_reported_with_a_next_step(empty_bc): + server = mcp_module.build_server(BatcontrolService(empty_bc)) + result = call(server, 'batcontrol_get_price_forecast') + assert result['available'] is False + assert result['data'] is None + assert 'control cycle' in result['reason'] + + +def test_missing_decision_is_reported_not_raised(server): + result = call(server, 'batcontrol_get_decision') + assert result['available'] is False + assert 'No decision recorded yet' in result['reason'] + + +def test_internal_errors_do_not_leak_details(fake_bc): + def explode(): + raise RuntimeError('mqtt://user:hunter2@broker internal detail') + + service = BatcontrolService(fake_bc) + service.status = explode + server = mcp_module.build_server(service) + result = call(server, 'batcontrol_get_status') + assert result['available'] is False + assert 'hunter2' not in result['reason'] + assert 'batcontrol log' in result['reason'] + + +# -- HTTP transport --------------------------------------------------------- + +def test_mcp_is_reachable_without_a_redirect(fake_bc): + config = ApiConfig(enabled=True, mcp_enabled=True) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + response = client.post('/mcp', json=INIT, headers=MCP_HEADERS, + follow_redirects=False) + assert response.status_code == 200 + + +def test_a_reverse_proxy_host_header_is_accepted_on_mcp(fake_bc): + """Maintainer decision: no Host validation, also not the one the SDK + switches on by itself for a loopback bind.""" + config = ApiConfig(enabled=True, mcp_enabled=True) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + response = client.post( + '/mcp', json=INIT, + headers={**MCP_HEADERS, 'Host': 'batcontrol.example.org'}) + assert response.status_code == 200 + + +def test_mcp_is_absent_when_disabled(client): + schema = client.get('/openapi.json').json() + assert '/mcp' not in schema['paths'] + # With the dashboard mounted at /, an unknown path is answered by + # StaticFiles: 404 when it is not mounted, 405 when it is (it serves GET + # only). Either way there is no MCP endpoint. + assert client.post('/mcp', json=INIT, + headers=MCP_HEADERS).status_code in (404, 405) + + +# -- the two tokens --------------------------------------------------------- + +def test_mcp_token_is_independent_of_the_api_token(fake_bc): + config = ApiConfig(enabled=True, mcp_enabled=True, + auth_token='api-token', mcp_auth_token='mcp-token') + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + assert client.post('/mcp', json=INIT, + headers=MCP_HEADERS).status_code == 401 + assert client.post('/mcp', json=INIT, headers={ + **MCP_HEADERS, + 'Authorization': 'Bearer api-token'}).status_code == 401 + assert client.post('/mcp', json=INIT, headers={ + **MCP_HEADERS, + 'Authorization': 'Bearer mcp-token'}).status_code == 200 + # and the MCP token must not open the REST API + assert client.get('/api/v1/status', headers={ + 'Authorization': 'Bearer mcp-token'}).status_code == 401 + + +def test_mcp_inherits_the_api_token_when_unset(fake_bc): + """Securing the API must never leave MCP wide open.""" + config = ApiConfig(enabled=True, mcp_enabled=True, auth_token='shared') + assert config.effective_mcp_token == 'shared' + assert config.mcp_token_is_separate is False + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + assert client.post('/mcp', json=INIT, + headers=MCP_HEADERS).status_code == 401 + assert client.post('/mcp', json=INIT, headers={ + **MCP_HEADERS, + 'Authorization': 'Bearer shared'}).status_code == 200 + + +def test_no_token_leaves_mcp_open(fake_bc): + config = ApiConfig(enabled=True, mcp_enabled=True) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + assert client.post('/mcp', json=INIT, + headers=MCP_HEADERS).status_code == 200 + + +def test_mcp_rejects_a_non_ascii_token_without_crashing(fake_bc): + """Regression, mirroring the REST side. + + /mcp has its own ASGI bearer guard, so the fix has to hold in two places. + The header is raw bytes because that is what reaches the server from a + client that does not restrict itself to ASCII. + """ + config = ApiConfig(enabled=True, mcp_enabled=True, mcp_auth_token='secret') + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + for header in (b'Bearer \xc3\xbc', b'Bearer \xff\xfe\x00', + b'\xc3\xbc', b'Bearer'): + response = client.post('/mcp', json=INIT, headers={ + **MCP_HEADERS, 'Authorization': header}) + assert response.status_code == 401, header + assert response.headers['www-authenticate'] == 'Bearer' + + +def test_mcp_accepts_a_non_ascii_token_that_was_configured(fake_bc): + token = 'gr\u00fcn-schl\u00fcssel' + config = ApiConfig(enabled=True, mcp_enabled=True, mcp_auth_token=token) + with TestClient(create_app(fake_bc, config), base_url='http://127.0.0.1') as client: + assert client.post('/mcp', json=INIT, headers={ + **MCP_HEADERS, + 'Authorization': b'Bearer ' + token.encode('utf-8'), + }).status_code == 200 + + +def test_a_failing_mcp_lifespan_does_not_take_rest_down(fake_bc, monkeypatch, caplog): + """MCP is optional: if its session manager cannot start, REST and the + dashboard must still come up.""" + import contextlib # pylint: disable=import-outside-toplevel + from types import SimpleNamespace # pylint: disable=import-outside-toplevel + + from batcontrol.web import app as app_module # pylint: disable=import-outside-toplevel + + @contextlib.asynccontextmanager + async def exploding(_app): + raise RuntimeError('session manager broke') + yield # pylint: disable=unreachable + + broken = SimpleNamespace(router=SimpleNamespace(lifespan_context=exploding)) + monkeypatch.setattr(app_module, '_build_mcp', lambda *_a: ([], broken)) + config = ApiConfig(enabled=True, mcp_enabled=True) + with caplog.at_level('ERROR'): + with TestClient(create_app(fake_bc, config), + base_url='http://127.0.0.1') as client: + assert client.get('/api/v1/health').status_code == 200 + assert 'session manager broke' in caplog.text From e84fecf8efd6f4348ba665d6e8c5dd6a1f89f586 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 16 Oct 2026 13:27:02 +0200 Subject: [PATCH 3/5] feat(dashboard): Vue 3 dashboard served at / Terminal-style dashboard (decision, SoC grid, price) built by npm into src/batcontrol/web/static and shipped as package data. Fonts and Swagger UI are bundled; the build fails on any external asset reference, because batcontrol runs without internet egress and behind HA ingress (relative base path). Frozen API fixtures (stored as patches over default.json) drive both "npm run dev:fixtures" and the unit tests. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 21 + THIRD-PARTY-LICENSES.md | 71 + frontend/.env.example | 2 + frontend/.npmrc | 5 + frontend/fixtures/README.md | 39 + frontend/fixtures/battery-full.json | 15 + frontend/fixtures/battery-low.json | 19 + frontend/fixtures/default.json | 389 ++ frontend/fixtures/load.js | 45 + frontend/fixtures/no-data.json | 72 + frontend/fixtures/offline.json | 10 + frontend/fixtures/reserved-over-capacity.json | 12 + frontend/fixtures/unauthorized.json | 44 + frontend/index.html | 20 + frontend/package-lock.json | 3445 +++++++++++++++++ frontend/package.json | 36 + frontend/public/favicon.svg | 9 + .../licenses/LICENSE-OFL-IBMPlexMono.txt | 93 + .../public/licenses/LICENSE-OFL-VT323.txt | 93 + frontend/public/licenses/README.txt | 12 + frontend/scripts/check-offline.mjs | 89 + frontend/src/App.vue | 189 + frontend/src/components/DecisionPanel.vue | 76 + frontend/src/components/DecisionTrace.test.js | 79 + frontend/src/components/DecisionTrace.vue | 154 + frontend/src/components/PricePanel.test.js | 61 + frontend/src/components/PricePanel.vue | 106 + frontend/src/components/SocGrid.vue | 160 + frontend/src/lib/api.js | 212 + frontend/src/lib/api.test.js | 213 + frontend/src/lib/format.js | 19 + frontend/src/lib/soc.js | 58 + frontend/src/lib/soc.test.js | 153 + frontend/src/main.js | 5 + frontend/src/style.css | 122 + frontend/vite.config.js | 93 + frontend/vite.fixtures.js | 117 + scripts/verify_wheel_dashboard.py | 74 + 38 files changed, 6432 insertions(+) create mode 100644 THIRD-PARTY-LICENSES.md create mode 100644 frontend/.env.example create mode 100644 frontend/.npmrc create mode 100644 frontend/fixtures/README.md create mode 100644 frontend/fixtures/battery-full.json create mode 100644 frontend/fixtures/battery-low.json create mode 100644 frontend/fixtures/default.json create mode 100644 frontend/fixtures/load.js create mode 100644 frontend/fixtures/no-data.json create mode 100644 frontend/fixtures/offline.json create mode 100644 frontend/fixtures/reserved-over-capacity.json create mode 100644 frontend/fixtures/unauthorized.json create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/licenses/LICENSE-OFL-IBMPlexMono.txt create mode 100644 frontend/public/licenses/LICENSE-OFL-VT323.txt create mode 100644 frontend/public/licenses/README.txt create mode 100755 frontend/scripts/check-offline.mjs create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/DecisionPanel.vue create mode 100644 frontend/src/components/DecisionTrace.test.js create mode 100644 frontend/src/components/DecisionTrace.vue create mode 100644 frontend/src/components/PricePanel.test.js create mode 100644 frontend/src/components/PricePanel.vue create mode 100644 frontend/src/components/SocGrid.vue create mode 100644 frontend/src/lib/api.js create mode 100644 frontend/src/lib/api.test.js create mode 100644 frontend/src/lib/format.js create mode 100644 frontend/src/lib/soc.js create mode 100644 frontend/src/lib/soc.test.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js create mode 100644 frontend/vite.fixtures.js create mode 100755 scripts/verify_wheel_dashboard.py diff --git a/.gitignore b/.gitignore index e5fef58a..fe50da81 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,15 @@ __pycache__/ # C extensions *.so +# Generated by scripts/dump_openapi.py during the docs build +docs/assets/openapi.json + +# Frontend build (dashboard) +node_modules/ +frontend/dist/ +src/batcontrol/web/static/ +.env + # Distribution / packaging .Python build/ @@ -193,3 +202,15 @@ site/ # Generated from scripts/data/peak_shaving_example_day.{csv,yaml} by # scripts/generate_peak_shaving_csv.py during the documentation build. docs/assets/data/peak_shaving/ + +# Dashboard sources that the broad rules above would swallow. +# "*.json" (top of this file) and the unanchored "lib" (meant for the venv, +# but it matches any directory of that name at any depth) would otherwise +# keep the frontend's manifest, lockfile, fixtures and library out of the +# repository - a fresh clone would then fail at "npm ci" and at the +# Dockerfile's COPY, while a working tree that still has the files builds +# fine and hides the problem. +!frontend/package.json +!frontend/package-lock.json +!frontend/fixtures/*.json +!frontend/src/lib/ diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md new file mode 100644 index 00000000..1e03614e --- /dev/null +++ b/THIRD-PARTY-LICENSES.md @@ -0,0 +1,71 @@ +# Third-Party Licenses + +batcontrol itself is MIT licensed (see [`LICENSE`](LICENSE)). The dashboard ships third-party +assets *inside* the distributed artifacts - the wheel, the Docker image and the Home Assistant +add-on - so their licenses travel with them. + +This file covers everything that ends up in a build output. Runtime Python dependencies are +installed by pip from PyPI and are not redistributed by this project; their licenses are +declared in their own packages. + +## Fonts + +Both fonts are bundled rather than loaded from a CDN: batcontrol runs in homes without internet +egress and behind Home Assistant ingress, where an external font request simply fails. They are +imported from `frontend/src/style.css` and emitted into `src/batcontrol/web/static/assets/`. + +| Font | Version | License | Copyright | +|---|---|---|---| +| IBM Plex Mono (via `@fontsource/ibm-plex-mono`) | 5.3.0 | SIL Open Font License 1.1 | Copyright 2017 IBM Corp. | +| VT323 (via `@fontsource/vt323`) | 5.3.0 | SIL Open Font License 1.1 | Copyright 2011, The VT323 Project Authors | + +The OFL permits bundling and redistribution, including inside a commercial or differently +licensed product, provided the license text and copyright notice travel with the font files. +The verbatim license texts therefore live in `frontend/public/licenses/`, which the build copies +unchanged into the dashboard's static assets. They end up in the wheel, the Docker image and the +add-on, and are reachable from a running dashboard at `licenses/`: + +- `licenses/LICENSE-OFL-IBMPlexMono.txt` +- `licenses/LICENSE-OFL-VT323.txt` + +`public/` is the mechanism that matters here: Vite only emits assets something imports, so a +license file placed under `src/` would silently never reach the build output - which is how this +was wrong once already. + +Only the `latin` subsets in the weights actually used (400 and 600 for IBM Plex Mono, 400 for +VT323) are imported. + +## JavaScript + +| Package | Version | License | Where | +|---|---|---|---| +| Vue | 3.5.44 | MIT | bundled into the dashboard | +| Swagger UI (`swagger-ui-dist`) | 5.33.3 | Apache-2.0 | copied verbatim, served at `/docs` | + +Vue is the only runtime dependency compiled into the dashboard bundle. The rest of the build +toolchain (Vite, `@vitejs/plugin-vue`, Vitest, ESLint) runs at build time and ships nothing. + +Swagger UI is a special case: FastAPI points `/docs` at a CDN by default, which leaves the API +documentation blank on a Home Assistant box without internet egress. Two files +(`swagger-ui-bundle.js`, `swagger-ui.css`) are therefore copied into the package and served +from it. Apache-2.0 requires the license text and the NOTICE to be redistributed with them, and +the bundle carries its own third-party notices, so all three ship alongside: + +- `licenses/LICENSE-Apache-2.0-SwaggerUI.txt` +- `licenses/NOTICE-SwaggerUI.txt` +- `licenses/LICENSE-SwaggerUI-bundled-dependencies.txt` + +## Keeping this current + +A new bundled asset means a new row here. The check is deliberately manual, because "does this +end up in the artifact" is a judgement a tool gets wrong in both directions: `npm ls` lists the +build toolchain too, and a font imported from CSS is invisible to a dependency scan. + +When adding a dependency that lands in the bundle: + +1. Add it to the matching table with its version and license. +2. If the license requires the text to be redistributed (OFL, Apache-2.0 with NOTICE, ...), + commit the verbatim text under `frontend/public/licenses/` and verify it appears in + `src/batcontrol/web/static/licenses/` after a build. Under `src/` it would not be emitted. +3. Prefer a bundled asset over a CDN reference - `npm run build` fails on an external asset + reference (`frontend/scripts/check-offline.mjs`). diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..c494cbc2 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# A running batcontrol with api.enabled: true, proxied by the Vite dev server +VITE_BACKEND_URL=http://127.0.0.1:8080 diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 00000000..51b3b01d --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1,5 @@ +# Exact versions, so a future "npm install " cannot reintroduce a range. +# The lockfile already pins every transitive package by sha512 integrity hash +# and "npm ci" verifies each one; this keeps the direct dependencies in +# package.json from drifting away from it. +save-exact=true diff --git a/frontend/fixtures/README.md b/frontend/fixtures/README.md new file mode 100644 index 00000000..6cba8c05 --- /dev/null +++ b/frontend/fixtures/README.md @@ -0,0 +1,39 @@ +# Dashboard fixtures + +Frozen API responses for `npm run dev:fixtures`, so the dashboard can be developed and reviewed +without a running batcontrol - and, more importantly, in states a live system will not produce +on request. + +All of them were captured from a real instance and then mutated, so their shape is the API's. +**Only the fields the scenario is about are changed**: the decision trace in `battery-low.json`, +for example, still describes the captured decision and will not match the mutated battery. That +is deliberate - these exercise the rendering, not the control logic. + +| Scenario | What it is for | +|---|---| +| `default` | The ordinary case. Also the baseline the others are derived from. | +| `battery-full` | SoC exactly 100 % with max_soc 100: every cell lit, none blinking, no free capacity. | +| `battery-low` | SoC 3 % in FORCE CHARGE: the amber mode, a charge rate in the header, lock dots below MIN_SOC. | +| `reserved-over-capacity` | A reservation larger than the battery - it has to be capped at `always_allow_discharge` before the hatching is drawn. | +| `no-data` | Before the first control cycle: 503 on `current` and the forecast, 404 on the decision. | +| `offline` | A control loop that has missed several evaluations. | +| `unauthorized` | 401 everywhere, for the token prompt. | + +The derived scenarios store only their differences: `"$extends": "default"` plus the changed +fields, deep-merged by `fixtures/load.js` (objects merge, arrays and scalars replace). A contract +change therefore only has to be made in `default.json`. + +Timestamps are rebased onto the current time per request, so relative displays stay meaningful +however old the file is. Forecast slots are shifted by whole slots, so slot boundaries stay on +the interval - otherwise an hourly forecast renders as "MIN 24h @ 13:04" and sends you looking +for a bug that is not there. + +## Updating them + +```bash +python -m batcontrol --config # dummy inverter is fine +# then capture the envelopes from /api/v1/{status,battery,current,version,forecast/price,decision} +``` + +`src/lib/api.test.js` runs `normalize()` against these files, so a change to the API contract +shows up as a failing test rather than as a dashboard that quietly renders nothing. diff --git a/frontend/fixtures/battery-full.json b/frontend/fixtures/battery-full.json new file mode 100644 index 00000000..a49f3599 --- /dev/null +++ b/frontend/fixtures/battery-full.json @@ -0,0 +1,15 @@ +{ + "$extends": "default", + "battery": { + "data": { + "soc": 100.0, + "max_energy_capacity": 10000.0, + "stored_energy_capacity": 10000.0, + "stored_usable_energy_capacity": 9000.0, + "free_capacity": 0.0, + "soc_limits": { + "max_soc": 100.0 + } + } + } +} diff --git a/frontend/fixtures/battery-low.json b/frontend/fixtures/battery-low.json new file mode 100644 index 00000000..31295eff --- /dev/null +++ b/frontend/fixtures/battery-low.json @@ -0,0 +1,19 @@ +{ + "$extends": "default", + "status": { + "data": { + "mode": -1, + "mode_code": "force_charge", + "mode_label": "FORCE CHARGE", + "charge_rate": 4200.0 + } + }, + "battery": { + "data": { + "soc": 3.0, + "stored_energy_capacity": 300.0, + "stored_usable_energy_capacity": 0.0, + "reserved_energy_capacity": 2500.0 + } + } +} diff --git a/frontend/fixtures/default.json b/frontend/fixtures/default.json new file mode 100644 index 00000000..7aea915c --- /dev/null +++ b/frontend/fixtures/default.json @@ -0,0 +1,389 @@ +{ + "status": { + "ok": true, + "timestamp": 1792086425053, + "data": { + "mode": 10, + "mode_code": "allow_discharging", + "mode_label": "ALLOW DISCHARGE", + "control_source": "optimizer", + "charge_rate": 0.0, + "limit_battery_charge_rate": -1, + "api_override_active": false, + "discharge_blocked": false, + "grid_charge_locked": false, + "last_evaluation": 1792086422544, + "next_evaluation": 1792086602544, + "evaluation_interval_seconds": 180, + "online": true, + "mode_changed_at": 1792086422604 + } + }, + "battery": { + "ok": true, + "timestamp": 1792086425055, + "data": { + "soc": 65.0, + "max_energy_capacity": 9500.0, + "stored_energy_capacity": 6500.0, + "stored_usable_energy_capacity": 5500.0, + "reserved_energy_capacity": 0.0, + "free_capacity": 3000.0, + "always_allow_discharge_limit_percent": 90.0, + "max_charging_from_grid_limit_percent": 89.0, + "soc_limits": { + "min_soc": 10.0, + "max_soc": 95.0 + } + } + }, + "current": { + "ok": true, + "timestamp": 1792086425057, + "data": { + "version": "0.10.0dev", + "price": { + "current": 0.3383, + "current_ts": 1792083600000, + "unit": "EUR/kWh", + "interval_minutes": 60, + "min_dynamic_price_difference": 0.05 + }, + "soc": 65.0, + "soc_limits": { + "min_soc": 10.0, + "max_soc": 95.0 + } + } + }, + "version": { + "ok": true, + "timestamp": 1792086425059, + "data": { + "version": "0.10.0dev", + "git_sha": null, + "runtime_env": "source", + "interval_minutes": 60, + "timezone": "Europe/Berlin" + } + }, + "forecast/price": { + "ok": true, + "timestamp": 1792086425060, + "data": { + "run_time": 1792086422544, + "interval_minutes": 60, + "unit": "EUR/kWh", + "total_count": 24, + "returned_count": 24, + "offset": 0, + "has_more": false, + "next_offset": null, + "slots": [ + { + "slot": 0, + "time_start": 1792083600000, + "time_end": 1792087200000, + "value": 0.3383, + "power_w": null + }, + { + "slot": 1, + "time_start": 1792087200000, + "time_end": 1792090800000, + "value": 0.3017, + "power_w": null + }, + { + "slot": 2, + "time_start": 1792090800000, + "time_end": 1792094400000, + "value": 0.2732, + "power_w": null + }, + { + "slot": 3, + "time_start": 1792094400000, + "time_end": 1792098000000, + "value": 0.264, + "power_w": null + }, + { + "slot": 4, + "time_start": 1792098000000, + "time_end": 1792101600000, + "value": 0.2515, + "power_w": null + }, + { + "slot": 5, + "time_start": 1792101600000, + "time_end": 1792105200000, + "value": 0.2588, + "power_w": null + }, + { + "slot": 6, + "time_start": 1792105200000, + "time_end": 1792108800000, + "value": 0.2492, + "power_w": null + }, + { + "slot": 7, + "time_start": 1792108800000, + "time_end": 1792112400000, + "value": 0.2515, + "power_w": null + }, + { + "slot": 8, + "time_start": 1792112400000, + "time_end": 1792116000000, + "value": 0.254, + "power_w": null + }, + { + "slot": 9, + "time_start": 1792116000000, + "time_end": 1792119600000, + "value": 0.2537, + "power_w": null + }, + { + "slot": 10, + "time_start": 1792119600000, + "time_end": 1792123200000, + "value": 0.2519, + "power_w": null + }, + { + "slot": 11, + "time_start": 1792123200000, + "time_end": 1792126800000, + "value": 0.3003, + "power_w": null + }, + { + "slot": 12, + "time_start": 1792126800000, + "time_end": 1792130400000, + "value": 0.3253, + "power_w": null + }, + { + "slot": 13, + "time_start": 1792130400000, + "time_end": 1792134000000, + "value": 0.3421, + "power_w": null + }, + { + "slot": 14, + "time_start": 1792134000000, + "time_end": 1792137600000, + "value": 0.3259, + "power_w": null + }, + { + "slot": 15, + "time_start": 1792137600000, + "time_end": 1792141200000, + "value": 0.2914, + "power_w": null + }, + { + "slot": 16, + "time_start": 1792141200000, + "time_end": 1792144800000, + "value": 0.2629, + "power_w": null + }, + { + "slot": 17, + "time_start": 1792144800000, + "time_end": 1792148400000, + "value": 0.2263, + "power_w": null + }, + { + "slot": 18, + "time_start": 1792148400000, + "time_end": 1792152000000, + "value": 0.2029, + "power_w": null + }, + { + "slot": 19, + "time_start": 1792152000000, + "time_end": 1792155600000, + "value": 0.2132, + "power_w": null + }, + { + "slot": 20, + "time_start": 1792155600000, + "time_end": 1792159200000, + "value": 0.2348, + "power_w": null + }, + { + "slot": 21, + "time_start": 1792159200000, + "time_end": 1792162800000, + "value": 0.2664, + "power_w": null + }, + { + "slot": 22, + "time_start": 1792162800000, + "time_end": 1792166400000, + "value": 0.3154, + "power_w": null + }, + { + "slot": 23, + "time_start": 1792166400000, + "time_end": 1792170000000, + "value": 0.3299, + "power_w": null + } + ] + } + }, + "decision": { + "ok": true, + "timestamp": 1792086425062, + "data": { + "decided_at": 1792086422604, + "trace": { + "timestamp": "2026-10-15T19:47:02.604569+02:00", + "decided_by": { + "decision": "discharge", + "outcome": "allowed", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE" + }, + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "records": [ + { + "decision": "discharge", + "outcome": "allowed", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE", + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": true, + "inputs": { + "current_price": 0.3383, + "min_dynamic_price_difference": 0.05, + "stored_usable_energy": 5500.0, + "reserved_energy": 0, + "evaluation_slots": 2, + "higher_price_slots": [], + "cheaper_price_slot": 2, + "cheaper_price": 0.2732, + "interval_minutes": 60 + } + }, + { + "decision": "mode", + "outcome": "allow_discharging", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE", + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": false, + "inputs": { + "current_price": 0.3383, + "min_dynamic_price_difference": 0.05, + "stored_usable_energy": 5500.0, + "reserved_energy": 0, + "evaluation_slots": 2, + "higher_price_slots": [], + "cheaper_price_slot": 2, + "cheaper_price": 0.2732, + "interval_minutes": 60, + "mode": 10, + "control_source": "optimizer", + "decided_by": "discharge", + "value": null + } + } + ] + }, + "status_text": "Discharge Allowed - usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "explanation": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": { + "decision": "discharge", + "outcome": "allowed", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE", + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": true, + "inputs": { + "current_price": 0.3383, + "min_dynamic_price_difference": 0.05, + "stored_usable_energy": 5500.0, + "reserved_energy": 0, + "evaluation_slots": 2, + "higher_price_slots": [], + "cheaper_price_slot": 2, + "cheaper_price": 0.2732, + "interval_minutes": 60 + } + }, + "event": { + "timestamp": "2026-10-15T19:47:02.604569+02:00", + "decided_by": { + "decision": "discharge", + "outcome": "allowed", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE" + }, + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "records": [ + { + "decision": "discharge", + "outcome": "allowed", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE", + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": true, + "inputs": { + "current_price": 0.3383, + "min_dynamic_price_difference": 0.05, + "stored_usable_energy": 5500.0, + "reserved_energy": 0, + "evaluation_slots": 2, + "higher_price_slots": [], + "cheaper_price_slot": 2, + "cheaper_price": 0.2732, + "interval_minutes": 60 + } + }, + { + "decision": "mode", + "outcome": "allow_discharging", + "reason": "USABLE_ENERGY_EXCEEDS_RESERVE", + "why": "usable energy (5500 Wh) exceeds the 0 Wh reserved for upcoming more expensive hours", + "decisive": false, + "inputs": { + "current_price": 0.3383, + "min_dynamic_price_difference": 0.05, + "stored_usable_energy": 5500.0, + "reserved_energy": 0, + "evaluation_slots": 2, + "higher_price_slots": [], + "cheaper_price_slot": 2, + "cheaper_price": 0.2732, + "interval_minutes": 60, + "mode": 10, + "control_source": "optimizer", + "decided_by": "discharge", + "value": null + } + } + ], + "kind": "mode", + "previous_mode": null, + "mode": 10, + "control_source": "optimizer", + "value": null, + "previous_value": null + } + } + } +} diff --git a/frontend/fixtures/load.js b/frontend/fixtures/load.js new file mode 100644 index 00000000..a8b1bb13 --- /dev/null +++ b/frontend/fixtures/load.js @@ -0,0 +1,45 @@ +/** + * Fixture loader shared by the dev server (vite.fixtures.js) and the unit + * tests (src/lib/api.test.js). + * + * A scenario only stores what makes it a scenario: `"$extends": "default"` + * plus the fields that differ, deep-merged over the base. Objects merge key by + * key; arrays and scalars replace. A contract change then has to be made in + * default.json only. + */ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// import.meta.dirname: vitest's jsdom environment rewrites import.meta.url to +// an http URL, so it cannot be used to find files there. +const DIR = import.meta.dirname ?? fileURLToPath(new URL('./', import.meta.url)) + +const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v) + +function merge(base, patch) { + const out = { ...base } + for (const [key, value] of Object.entries(patch)) { + out[key] = isObject(value) && isObject(base[key]) ? merge(base[key], value) : value + } + return out +} + +function read(name) { + try { + return readFileSync(join(DIR, `${name}.json`), 'utf8') + } catch { + const available = readdirSync(DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + throw new Error( + `No such fixture. Available: ${available.join(', ')}` + ' (set VITE_FIXTURE=)', + ) + } +} + +/** The fully resolved fixture. Read on every call: edit, reload, done. */ +export function loadFixture(name) { + const { $extends: parent, ...patch } = JSON.parse(read(name)) + return parent ? merge(loadFixture(parent), patch) : patch +} diff --git a/frontend/fixtures/no-data.json b/frontend/fixtures/no-data.json new file mode 100644 index 00000000..fcfd8e54 --- /dev/null +++ b/frontend/fixtures/no-data.json @@ -0,0 +1,72 @@ +{ + "status": { + "ok": true, + "timestamp": 1792086425053, + "data": { + "mode": 10, + "mode_code": "allow_discharging", + "mode_label": "ALLOW DISCHARGE", + "control_source": "optimizer", + "charge_rate": 0.0, + "limit_battery_charge_rate": -1, + "api_override_active": false, + "discharge_blocked": false, + "grid_charge_locked": false, + "last_evaluation": 1792086422544, + "next_evaluation": 1792086602544, + "evaluation_interval_seconds": 180, + "online": true, + "mode_changed_at": 1792086422604 + } + }, + "battery": { + "ok": true, + "timestamp": 1792086425055, + "data": { + "soc": 65.0, + "max_energy_capacity": 9500.0, + "stored_energy_capacity": 6500.0, + "stored_usable_energy_capacity": 5500.0, + "reserved_energy_capacity": 0.0, + "free_capacity": 3000.0, + "always_allow_discharge_limit_percent": 90.0, + "max_charging_from_grid_limit_percent": 89.0, + "soc_limits": { + "min_soc": 10.0, + "max_soc": 95.0 + } + } + }, + "current": { + "ok": false, + "timestamp": 1792086425053, + "error": "no_data_yet", + "detail": "No completed control cycle yet. The first forecast is available after the first evaluation.", + "_status": 503 + }, + "version": { + "ok": true, + "timestamp": 1792086425059, + "data": { + "version": "0.10.0dev", + "git_sha": null, + "runtime_env": "source", + "interval_minutes": 60, + "timezone": "Europe/Berlin" + } + }, + "forecast/price": { + "ok": false, + "timestamp": 1792086425053, + "error": "no_data_yet", + "detail": "No completed control cycle yet. The first forecast is available after the first evaluation.", + "_status": 503 + }, + "decision": { + "ok": false, + "timestamp": 1792086425053, + "error": "not_found", + "detail": "No decision recorded yet.", + "_status": 404 + } +} diff --git a/frontend/fixtures/offline.json b/frontend/fixtures/offline.json new file mode 100644 index 00000000..6ac520af --- /dev/null +++ b/frontend/fixtures/offline.json @@ -0,0 +1,10 @@ +{ + "$extends": "default", + "status": { + "data": { + "last_evaluation": 1792083722544, + "next_evaluation": 1792083902544, + "online": false + } + } +} diff --git a/frontend/fixtures/reserved-over-capacity.json b/frontend/fixtures/reserved-over-capacity.json new file mode 100644 index 00000000..b77954bb --- /dev/null +++ b/frontend/fixtures/reserved-over-capacity.json @@ -0,0 +1,12 @@ +{ + "$extends": "default", + "battery": { + "data": { + "soc": 42.0, + "stored_energy_capacity": 4200.0, + "stored_usable_energy_capacity": 3200.0, + "free_capacity": 5300.0, + "reserved_energy_capacity": 17100.0 + } + } +} diff --git a/frontend/fixtures/unauthorized.json b/frontend/fixtures/unauthorized.json new file mode 100644 index 00000000..b9f00684 --- /dev/null +++ b/frontend/fixtures/unauthorized.json @@ -0,0 +1,44 @@ +{ + "status": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + }, + "battery": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + }, + "current": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + }, + "version": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + }, + "forecast/price": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + }, + "decision": { + "ok": false, + "timestamp": 0, + "error": "unauthorized", + "detail": "Missing or invalid bearer token.", + "_status": 401 + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..df3efc05 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + batcontrol + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..3d3c6aab --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3445 @@ +{ + "name": "batcontrol-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "batcontrol-dashboard", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/vt323": "5.3.0", + "vue": "3.5.44" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.10", + "@vue/test-utils": "2.5.1", + "eslint": "9.39.5", + "eslint-plugin-vue": "10.11.1", + "jsdom": "26.1.0", + "swagger-ui-dist": "5.33.3", + "vite": "8.3.4", + "vitest": "4.1.11" + }, + "engines": { + "node": ">=22.12" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/vt323": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/vt323/-/vt323-5.3.0.tgz", + "integrity": "sha512-3w33Rg/0+R1587HQfz4t7Q7e0GGeCqr3wmzIwqdGInUnrGEwMA5eX39GP4Z9wpTQnwfhVOhYuxuRwRUeIcicbw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.11.1.tgz", + "integrity": "sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.33.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.3.tgz", + "integrity": "sha512-7xJKxAjf+/ajlW/V/dMfbiY+NC5BWtbvzzdI/AsX5UzMtOMudmFz/tjuKN0jN7QVe5drKKnx01CdYEJvTFvQlw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.13.tgz", + "integrity": "sha512-02KCxSmKDhIITjKyJ4QT5csP6L0WoRborLqY/34a40+S9uvMvdNgMV0yVjO3p2Gkz3ZoPmFz/0JsGHacwJYTxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..d90d2b8f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "batcontrol-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Read-only web dashboard for batcontrol, built into the Python package", + "license": "MIT", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "dev": "vite --host", + "build": "vite build && node scripts/check-offline.mjs", + "check:offline": "node scripts/check-offline.mjs", + "preview": "vite preview --host", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src", + "dev:fixtures": "VITE_FIXTURE=${VITE_FIXTURE:-default} vite --host" + }, + "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/vt323": "5.3.0", + "vue": "3.5.44" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.10", + "@vue/test-utils": "2.5.1", + "eslint": "9.39.5", + "eslint-plugin-vue": "10.11.1", + "jsdom": "26.1.0", + "swagger-ui-dist": "5.33.3", + "vite": "8.3.4", + "vitest": "4.1.11" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..3c69d09f --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/licenses/LICENSE-OFL-IBMPlexMono.txt b/frontend/public/licenses/LICENSE-OFL-IBMPlexMono.txt new file mode 100644 index 00000000..83eac827 --- /dev/null +++ b/frontend/public/licenses/LICENSE-OFL-IBMPlexMono.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/public/licenses/LICENSE-OFL-VT323.txt b/frontend/public/licenses/LICENSE-OFL-VT323.txt new file mode 100644 index 00000000..bcb092f7 --- /dev/null +++ b/frontend/public/licenses/LICENSE-OFL-VT323.txt @@ -0,0 +1,93 @@ +Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/public/licenses/README.txt b/frontend/public/licenses/README.txt new file mode 100644 index 00000000..7aba8f12 --- /dev/null +++ b/frontend/public/licenses/README.txt @@ -0,0 +1,12 @@ +Licenses of the fonts bundled with the batcontrol dashboard. + +IBM Plex Mono - SIL Open Font License 1.1 - Copyright 2017 IBM Corp. +VT323 - SIL Open Font License 1.1 - Copyright 2011, The VT323 Project Authors + +The OFL allows the fonts to be bundled and redistributed, provided this +license text and the copyright notice travel with the font files. These files +are copied verbatim into the dashboard's static assets by the build, so they +ship inside the wheel, the Docker image and the Home Assistant add-on. + +See THIRD-PARTY-LICENSES.md in the batcontrol repository for the full list of +bundled third-party assets. diff --git a/frontend/scripts/check-offline.mjs b/frontend/scripts/check-offline.mjs new file mode 100755 index 00000000..07f25f65 --- /dev/null +++ b/frontend/scripts/check-offline.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Fails when the built dashboard would load anything from the network. + * + * batcontrol runs in homes without internet egress and behind Home Assistant + * ingress. An external font, stylesheet or script does not degrade there - it + * simply never arrives. This check is the guard rail for that: it inspects + * the actual build output, not the sources, so a dependency that pulls in a + * CDN URL is caught too. + * + * It only looks at references the browser would *fetch*. Link targets a user + * clicks, XML namespace identifiers and URLs inside error messages are fine + * and are listed as allowed prefixes below. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, extname } from 'node:path' +import { fileURLToPath, URL } from 'node:url' + +const ROOT = fileURLToPath(new URL('../../src/batcontrol/web/static', import.meta.url)) + +// URLs that are never fetched by the browser. +const ALLOWED = [ + 'https://github.com/MaStr/batcontrol', // release link in the header, user clicks it + 'https://vuejs.org/error-reference/', // text of a Vue runtime error message + 'http://www.w3.org/', // XML/SVG/MathML namespace identifiers +] + +// Patterns that mean "the browser will fetch this". +const FETCHING = [ + { re: /url\(\s*['"]?(https?:)?\/\/[^)'"]+/gi, what: 'CSS url() to a remote host' }, + { re: /@import\s+(url\()?['"](https?:)?\/\//gi, what: 'CSS @import from a remote host' }, + { re: /]+href=["'](https?:)?\/\/[^"']+/gi, what: ' to a remote host' }, + { re: /]+src=["'](https?:)?\/\/[^"']+/gi, what: ' + + + + diff --git a/frontend/src/components/DecisionPanel.vue b/frontend/src/components/DecisionPanel.vue new file mode 100644 index 00000000..bb0d06a9 --- /dev/null +++ b/frontend/src/components/DecisionPanel.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/components/DecisionTrace.test.js b/frontend/src/components/DecisionTrace.test.js new file mode 100644 index 00000000..83a77e33 --- /dev/null +++ b/frontend/src/components/DecisionTrace.test.js @@ -0,0 +1,79 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import DecisionTrace from './DecisionTrace.vue' +import defaultFixture from '../../fixtures/default.json' + +/** The trace the dashboard passes in: the status-change event, which spreads + * the trace and adds the mode transition. */ +const trace = defaultFixture.decision.data.event ?? defaultFixture.decision.data.trace + +function render(payload = trace) { + return mount(DecisionTrace, { props: { trace: payload } }) +} + +describe('DecisionTrace', () => { + it('renders the header of the real API payload', () => { + const text = render().text() + expect(text).toContain('timestamp') + expect(text).toContain('control_source') + expect(text).toContain('mode') + }) + + it('renders one step per record', () => { + const steps = render().findAll('.steps > li') + expect(steps.length).toBe(trace.records.length) + expect(steps.length).toBeGreaterThan(0) + }) + + it('marks the decisive step', () => { + const wrapper = render() + const decisive = wrapper.findAll('.steps > li.decisive') + expect(decisive.length).toBe(trace.records.filter((r) => r.decisive).length) + expect(decisive[0].text()).toContain('DECISIVE') + }) + + it('translates a mode number into its label', () => { + expect(render().text()).toMatch(/ALLOW DISCHARGE|AVOID DISCHARGE|FORCE CHARGE|LIMIT CHARGE RATE/) + }) + + it('formats prices, energies and powers with their unit', () => { + const text = render().text() + const inputs = trace.records.flatMap((r) => Object.keys(r.inputs || {})) + if (inputs.some((k) => /price/.test(k))) expect(text).toMatch(/\d+\.\d{3} €/) + if (inputs.some((k) => /energy|_wh$|demand/.test(k))) expect(text).toMatch(/\d+ Wh/) + }) + + it('collapses a long slot list behind a toggle instead of printing it', async () => { + const withSlots = { + ...trace, + records: [ + { + decision: 'grid_recharge', + outcome: 'no_charge', + reason: 'NO_CHEAPER_SLOT', + decisive: true, + inputs: { + interval_minutes: 60, + higher_price_slots: Array.from({ length: 42 }, (_, i) => i), + }, + }, + ], + } + const wrapper = render(withSlots) + const toggle = wrapper.find('.toggle') + expect(toggle.text()).toContain('42 SLOTS') + expect(wrapper.find('.slots').exists()).toBe(false) + + await toggle.trigger('click') + const items = wrapper.findAll('.slots li') + expect(items.length).toBe(42) + // each line carries a from-to time + expect(items[0].text()).toMatch(/\d{2}:\d{2}.\d{2}:\d{2}/) + }) + + it('survives a record without inputs', () => { + const bare = { ...trace, records: [{ decision: 'mode', outcome: 'allow_discharging', reason: 'UNSPECIFIED' }] } + expect(() => render(bare)).not.toThrow() + }) +}) diff --git a/frontend/src/components/DecisionTrace.vue b/frontend/src/components/DecisionTrace.vue new file mode 100644 index 00000000..a04f5858 --- /dev/null +++ b/frontend/src/components/DecisionTrace.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/components/PricePanel.test.js b/frontend/src/components/PricePanel.test.js new file mode 100644 index 00000000..09d772e3 --- /dev/null +++ b/frontend/src/components/PricePanel.test.js @@ -0,0 +1,61 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import PricePanel from './PricePanel.vue' + +const BARS = '▁▂▃▄▅▆▇█' + +/** A day of 15-minute slots with a single deep dip inside one hour. */ +function slots({ intervalMinutes = 15, hours = 24 } = {}) { + const perHour = 60 / intervalMinutes + const start = Date.UTC(2026, 0, 1) + return Array.from({ length: hours * perHour }, (_, i) => ({ + start: start + i * intervalMinutes * 60_000, + // one quarter-hour far below everything else + price: i === 5 ? 0.05 : 0.30 + (i % 8) * 0.001, + })) +} + +function spark(wrapper) { + return wrapper.find('.spark').text() +} + +describe('PricePanel sparkline', () => { + it('uses the full glyph range', () => { + // Regression: the bars were scaled against the per-slot MIN/MAX while + // the bars themselves are hourly means. At 15-minute resolution the + // means sit strictly inside those extremes, so the lowest and highest + // glyphs were never reached and the curve understated the spread + // printed right beside it. + const wrapper = mount(PricePanel, { + props: { p: { current: 0.3, intervalMinutes: 15, minDynamicDiff: 0.05, slots: slots() } }, + }) + const drawn = spark(wrapper) + expect(drawn).toContain(BARS[0]) + expect(drawn).toContain(BARS[BARS.length - 1]) + }) + + it('draws one bar per hour at either resolution', () => { + for (const intervalMinutes of [15, 60]) { + const wrapper = mount(PricePanel, { + props: { p: { current: 0.3, intervalMinutes, minDynamicDiff: 0.05, + slots: slots({ intervalMinutes }) } }, + }) + expect(spark(wrapper)).toHaveLength(24) + } + }) + + it('reports min and max from the slots, not from the hourly means', () => { + const wrapper = mount(PricePanel, { + props: { p: { current: 0.3, intervalMinutes: 15, minDynamicDiff: 0.05, slots: slots() } }, + }) + expect(wrapper.text()).toContain('0.050') + }) + + it('says so instead of drawing nothing when there is no forecast', () => { + const wrapper = mount(PricePanel, { + props: { p: { current: null, intervalMinutes: 60, minDynamicDiff: null, slots: [] } }, + }) + expect(wrapper.text()).toContain('no price forecast yet') + }) +}) diff --git a/frontend/src/components/PricePanel.vue b/frontend/src/components/PricePanel.vue new file mode 100644 index 00000000..b5030e12 --- /dev/null +++ b/frontend/src/components/PricePanel.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/SocGrid.vue b/frontend/src/components/SocGrid.vue new file mode 100644 index 00000000..1963de64 --- /dev/null +++ b/frontend/src/components/SocGrid.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 00000000..acdb7d04 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,212 @@ +/** + * The only place that knows what the batcontrol API looks like. + * + * Components read the view model this module returns, never a raw response. + * That was true of the prototype against the Node-RED bridge and it stays + * true here: the payloads are typed now, so there is no string parsing left, + * but the mapping still happens in exactly one file. + */ + +/** + * Base URL of the API, derived from the document rather than configured. + * + * The same bundle is served at /, under a Home Assistant ingress token path + * (/api/hassio_ingress//) and under a reverse-proxy subpath. Deriving + * the base from document.baseURI - which the relative -less index.html + * resolves to the directory the page was served from - makes all three work + * without a rebuild. In dev, Vite proxies /api to a real batcontrol. + */ +export const API_BASE = new URL('api/v1/', document.baseURI).href + +export const releaseUrl = (version) => + `https://github.com/MaStr/batcontrol/releases/tag/${version}` + +/** Raised when the API answers 401, so the caller can ask for a token. */ +export class UnauthorizedError extends Error { + constructor(message = 'Unauthorized') { + super(message) + this.name = 'UnauthorizedError' + } +} + +/** Raised while batcontrol has not completed its first control cycle. */ +export class NoDataError extends Error { + constructor(detail) { + super(detail || 'No data yet') + this.name = 'NoDataError' + } +} + +const TOKEN_KEY = 'batcontrol.token' + +/** + * The bearer token, if one was entered. Kept in sessionStorage: it should + * survive a reload but not outlive the browser session, and it must never be + * written anywhere the dashboard could leak it. + */ +export function getToken() { + try { + return sessionStorage.getItem(TOKEN_KEY) || '' + } catch { + // Private mode or blocked storage - work without persistence. + return '' + } +} + +export function setToken(token) { + try { + if (token) sessionStorage.setItem(TOKEN_KEY, token) + else sessionStorage.removeItem(TOKEN_KEY) + } catch { + /* ignore - the token then only lives for this page view */ + } +} + +async function get(path) { + const token = getToken() + const res = await fetch(API_BASE + path, { + cache: 'no-store', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (res.status === 401) throw new UnauthorizedError() + if (res.status === 503 || res.status === 404) { + const body = await res.json().catch(() => ({})) + throw new NoDataError(body.detail) + } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const json = await res.json() + if (!json.ok) throw new Error('batcontrol reported ok=false') + return json.data +} + +/** Same shape as a successful get(), but a missing resource becomes null. */ +async function getOptional(path) { + try { + return await get(path) + } catch (e) { + if (e instanceof NoDataError) return null + throw e + } +} + +export const MODES = { + '-1': 'FORCE CHARGE', + '0': 'AVOID DISCHARGE', + '8': 'LIMIT CHARGE RATE', + '10': 'ALLOW DISCHARGE', +} + +/** Price forecast slots as [{ start, price }] for the UI. */ +function priceSlots(forecast) { + if (!forecast) return [] + return forecast.slots.map((slot) => ({ start: slot.time_start, price: slot.value })) +} + +/** + * Build the view model from the API responses. + * + * Exported separately from fetchAll so it can be unit tested against the + * fixtures in frontend/fixtures/ - the edge cases that matter here (SoC at + * 0 % and 100 %, a reservation larger than the battery, no cycle yet) are + * hard to produce on a live system. + */ +export function normalize({ status, battery, current, price, decision, version }) { + // Left null when the inverter has not been read yet: the grid draws an + // unknown limit differently from a limit of zero. + const minSoc = battery.soc_limits?.min_soc ?? null + const maxSoc = battery.soc_limits?.max_soc ?? null + // max_energy_capacity is already reduced by max_soc (the inverter's + // get_max_capacity()), but every formula below works on percent of the + // gross capacity - scale it back up, or max_soc is applied twice. + const maxCapacity = battery.max_energy_capacity ?? 0 + const capacity = maxSoc > 0 ? (maxCapacity * 100) / maxSoc : maxCapacity + const minSocOr0 = minSoc ?? 0 + const maxSocOr100 = maxSoc ?? 100 + const allowDischargeSoc = battery.always_allow_discharge_limit_percent + const reserved = battery.reserved_energy_capacity ?? 0 + + // Nothing is held back above always_allow_discharge, so that is the upper + // bound for a reservation; the API reports the raw demand, which can exceed + // the battery. + const reserveTopSoc = Math.min(maxSocOr100, allowDischargeSoc ?? maxSocOr100) + const reservedCapped = Math.min( + reserved, + (capacity * Math.max(0, reserveTopSoc - minSocOr0)) / 100, + ) + + const mode = String(status.mode ?? '') + + return { + online: status.online, + version: version?.version ?? current?.version ?? null, + lastEvaluation: status.last_evaluation, + nextEvaluation: status.next_evaluation, + controlSource: status.control_source, + chargeRate: status.charge_rate, + intervalMinutes: version?.interval_minutes ?? current?.price?.interval_minutes ?? 60, + + decision: decision?.status_text || decision?.explanation || null, + decisionTs: decision?.decided_at ?? null, + reason: decision?.trace?.decided_by?.reason ?? null, + // The event carries the mode transition (kind, previous_mode, value), + // the trace carries the records of the latest evaluation. The trace wins + // on every key they share, because the event can be up to 15 minutes + // older - rendering its records next to the current mode would show a + // "why" that no longer explains what is on screen. + trace: decision ? { ...(decision.event ?? {}), ...decision.trace } : null, + + mode, + modeLabel: status.mode_label || MODES[mode] || `MODE ${mode}`, + modeTs: status.mode_changed_at, + + soc: battery.soc, + minSoc, + maxSoc, + allowDischargeSoc, + capacity, + stored: battery.stored_energy_capacity, + storedUsable: battery.stored_usable_energy_capacity, + usableCapacity: (capacity * (maxSocOr100 - minSocOr0)) / 100, + reserved, + reservedCapped, + + price: { + current: current?.price?.current ?? null, + intervalMinutes: current?.price?.interval_minutes ?? 60, + minDynamicDiff: current?.price?.min_dynamic_price_difference ?? null, + slots: priceSlots(price), + }, + } +} + +/** + * Fetch everything the dashboard needs. + * + * One request per resource, in parallel: the endpoints are cheap reads of + * values the control loop has already cached, and separate resources keep the + * dashboard from re-fetching a 24 h forecast to learn the current mode. + * + * status and battery are required - without them there is nothing to draw. + * current, price and decision are optional, because they are unavailable in + * legitimate states: before the first control cycle, or with an empty + * decision journal after a restart. + */ +export async function fetchAll() { + // fetch() cannot put anything outside Latin-1 into a header and throws a + // TypeError, which would read as "unreachable" and never show the token + // prompt again - the dashboard would be locked out for the session. + if (/[^\x00-\xff]/.test(getToken())) { + setToken('') + throw new UnauthorizedError( + 'The token contains characters a browser cannot send in a header.') + } + const [status, battery, version, current, price, decision] = await Promise.all([ + get('status'), + get('battery'), + getOptional('version'), + getOptional('current'), + getOptional('forecast/price'), + getOptional('decision'), + ]) + return normalize({ status, battery, current, price, decision, version }) +} diff --git a/frontend/src/lib/api.test.js b/frontend/src/lib/api.test.js new file mode 100644 index 00000000..889c9942 --- /dev/null +++ b/frontend/src/lib/api.test.js @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchAll, getToken, normalize, setToken, UnauthorizedError } from './api.js' + +import { loadFixture } from '../../fixtures/load.js' + +const defaultFixture = loadFixture('default') +const fullFixture = loadFixture('battery-full') +const lowFixture = loadFixture('battery-low') +const overFixture = loadFixture('reserved-over-capacity') +const noDataFixture = loadFixture('no-data') +const offlineFixture = loadFixture('offline') + +/** Feed a fixture through normalize() the way fetchAll() would. */ +function view(fixture) { + const payload = (key) => (fixture[key]?.ok ? fixture[key].data : null) + return normalize({ + status: payload('status'), + battery: payload('battery'), + current: payload('current'), + price: payload('forecast/price'), + decision: payload('decision'), + version: payload('version'), + }) +} + +describe('normalize', () => { + it('maps the default scenario', () => { + const v = view(defaultFixture) + expect(v.online).toBe(true) + expect(typeof v.soc).toBe('number') + expect(v.modeLabel).toBeTruthy() + expect(v.version).toMatch(/\d+\.\d+/) + }) + + it('keeps numbers as numbers', () => { + const v = view(defaultFixture) + for (const key of ['soc', 'capacity', 'stored', 'storedUsable', 'reserved']) { + expect(typeof v[key]).toBe('number') + } + expect(typeof v.price.current).toBe('number') + }) + + it('takes the SoC limits from the API instead of assuming them', () => { + // The prototype hardcoded MIN_SOC 7 / MAX_SOC 100 because the Node-RED + // bridge did not expose them. They are per-inverter values. + const v = view(defaultFixture) + expect(v.minSoc).toBe(defaultFixture.battery.data.soc_limits.min_soc) + expect(v.maxSoc).toBe(defaultFixture.battery.data.soc_limits.max_soc) + }) + + it('treats max_energy_capacity as already reduced by max_soc', () => { + // Regression: the API value is max_soc/100 * gross. Treating it as the + // gross capacity applied max_soc twice (8.08 instead of 8.5 kWh usable). + const v = view(defaultFixture) + const { max_soc: maxSoc, min_soc: minSoc } = defaultFixture.battery.data.soc_limits + const gross = (defaultFixture.battery.data.max_energy_capacity * 100) / maxSoc + expect(maxSoc).toBeLessThan(100) + expect(v.capacity).toBeCloseTo(gross, 6) + expect(v.usableCapacity).toBeCloseTo((gross * (maxSoc - minSoc)) / 100, 6) + // stored_energy_capacity is soc * gross, so the two must agree + expect(v.stored).toBeCloseTo((v.soc * v.capacity) / 100, 6) + }) + + it.each(['default', 'battery-full', 'battery-low', 'reserved-over-capacity', 'offline'])( + 'fixture %s is consistent with the inverter math', + (name) => { + // stored = soc * gross, usable = stored above min_soc, and the API's + // max_energy_capacity = gross * max_soc. A fixture that breaks this + // tests the rendering against numbers no inverter produces. + const v = view(loadFixture(name)) + expect(v.stored).toBeCloseTo((v.soc * v.capacity) / 100, 6) + expect(v.storedUsable).toBeCloseTo( + Math.max(0, v.stored - (v.minSoc * v.capacity) / 100), 6) + }, + ) + + it('derives usable capacity from the real limits', () => { + const v = view(defaultFixture) + expect(v.usableCapacity).toBeCloseTo((v.capacity * (v.maxSoc - v.minSoc)) / 100, 6) + }) + + it('exposes the price forecast from the API, not a mock', () => { + const v = view(defaultFixture) + expect(v.price.slots.length).toBeGreaterThan(0) + expect(v.price.slots[0]).toHaveProperty('start') + expect(v.price.slots[0]).toHaveProperty('price') + // slot 0 is the current price + expect(v.price.slots[0].price).toBeCloseTo(v.price.current, 6) + }) + + it('reports the decision with its timestamp as epoch ms', () => { + const v = view(defaultFixture) + expect(v.decision).toBeTruthy() + expect(typeof v.decisionTs).toBe('number') + expect(v.decisionTs).toBeGreaterThan(1e12) + expect(v.reason).toBeTruthy() + }) + + it('passes the trace through for the trace view', () => { + const v = view(defaultFixture) + expect(Array.isArray(v.trace.records)).toBe(true) + }) + + describe('a full battery', () => { + it('has no free capacity and no reservation', () => { + const v = view(fullFixture) + expect(v.soc).toBe(100) + expect(v.reservedCapped).toBe(0) + }) + }) + + describe('a nearly empty battery in force charge', () => { + it('reports the charge rate and the amber mode', () => { + const v = view(lowFixture) + expect(v.soc).toBe(3) + expect(v.mode).toBe('-1') + expect(v.modeLabel).toBe('FORCE CHARGE') + expect(v.chargeRate).toBeGreaterThan(0) + }) + }) + + describe('a reservation larger than the battery', () => { + it('caps it at always_allow_discharge', () => { + const v = view(overFixture) + expect(v.reserved).toBeGreaterThan(v.capacity) + expect(v.reservedCapped).toBeLessThan(v.reserved) + const ceiling = (v.capacity * (Math.min(v.maxSoc, v.allowDischargeSoc) - v.minSoc)) / 100 + expect(v.reservedCapped).toBeCloseTo(ceiling, 6) + }) + }) + + describe('before the first control cycle', () => { + it('renders status and battery and leaves the rest empty', () => { + const v = view(noDataFixture) + expect(v.modeLabel).toBeTruthy() + expect(v.price.current).toBeNull() + expect(v.price.slots).toEqual([]) + expect(v.decision).toBeNull() + expect(v.trace).toBeNull() + }) + + it('does not invent a price', () => { + // The prototype kept the last seen price and showed it as + // "CURRENT - LAST KNOWN"; with a real forecast endpoint there is + // nothing to guess. + expect(view(noDataFixture).price.current).toBeNull() + }) + }) + + describe('a stalled control loop', () => { + it('is reported as offline', () => { + expect(view(offlineFixture).online).toBe(false) + }) + }) + + it('survives a missing mode', () => { + const v = normalize({ + status: { mode: null, mode_label: '', online: true }, + battery: { soc_limits: {} }, + }) + expect(v.modeLabel).toMatch(/MODE|UNKNOWN/) + expect(v.soc).toBeUndefined() + }) +}) + +describe('the decision trace', () => { + const base = defaultFixture.decision.data + + it('renders the records of the latest evaluation, not of the older event', () => { + // The event is the last status *change* and can be up to 15 minutes old. + const stale = { + ...base, + event: { ...base.event, records: [{ decision: 'stale', outcome: 'old', reason: 'OLD' }], + timestamp: '2020-01-01T00:00:00' }, + } + const v = normalize({ + status: defaultFixture.status.data, + battery: defaultFixture.battery.data, + decision: stale, + }) + expect(v.trace.records).toEqual(base.trace.records) + expect(v.trace.records.some((r) => r.reason === 'OLD')).toBe(false) + expect(v.trace.timestamp).toBe(base.trace.timestamp) + }) + + it('still carries the mode transition, which only the event knows', () => { + const v = normalize({ + status: defaultFixture.status.data, + battery: defaultFixture.battery.data, + decision: base, + }) + expect(v.trace.kind).toBe(base.event.kind) + expect(v.trace).toHaveProperty('previous_mode') + }) +}) + +describe('the stored token', () => { + afterEach(() => { + vi.unstubAllGlobals() + setToken('') + }) + + it('is dropped, not sent, when a header cannot carry it', async () => { + // Regression: fetch() threw a TypeError for a non-Latin-1 token, which + // was shown as "unreachable" and never brought the token prompt back. + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + setToken('geheim\u20ac') + await expect(fetchAll()).rejects.toBeInstanceOf(UnauthorizedError) + expect(fetch).not.toHaveBeenCalled() + expect(getToken()).toBe('') + }) +}) diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js new file mode 100644 index 00000000..a8495765 --- /dev/null +++ b/frontend/src/lib/format.js @@ -0,0 +1,19 @@ +export const kwh = (wh) => (wh == null ? '—' : `${(wh / 1000).toFixed(2)} kWh`) + +export const eur = (v, digits = 3) => (v == null ? '—' : `${v.toFixed(digits)} €`) + +export const pct = (v, digits = 1) => (v == null ? '—' : `${v.toFixed(digits)} %`) + +export const clock = (ts) => + ts ? new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '—' + +export function ago(ts, now) { + if (!ts) return '' + const s = Math.max(0, Math.round((now - ts) / 1000)) + if (s < 60) return `${s} s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m} min ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h} h ${m % 60} min ago` + return `${Math.floor(h / 24)} d ${h % 24} h ago` +} diff --git a/frontend/src/lib/soc.js b/frontend/src/lib/soc.js new file mode 100644 index 00000000..6de20d65 --- /dev/null +++ b/frontend/src/lib/soc.js @@ -0,0 +1,58 @@ +/** + * State of the 100 cells in the SOC grid. + * + * Pure on purpose: this is the one piece of the dashboard with real logic in + * it - five combinable states, two of them with off-by-one traps - and it is + * worth testing against the table in docs/development/dashboard-spec.md + * rather than against a rendered component. + */ + +/** Rows for the grid, top row first. Cell 0 is bottom-left. */ +export function socRows(state) { + const cells = socCells(state) + return Array.from({ length: 10 }, (_, i) => { + const row = 9 - i + return { + start: row * 10, + cells: cells.slice(row * 10, row * 10 + 10), + } + }) +} + +/** + * The 100 cells, index 0..99, each standing for one percent. + * + * Cell p covers the interval (p-1, p]. + */ +export function socCells({ + soc, + minSoc = null, + maxSoc = null, + allowDischargeSoc = null, + capacity = 0, + reservedCapped = 0, +}) { + // The cell holding the SoC blinks: 87.0 % -> cell 87, 87.1 % -> cell 88. + // At 100 % there is no cell 100, so nothing blinks and everything is lit - + // intentional, because balancing takes the step from 99 to 100 % almost + // immediately anyway. + const current = soc == null ? -1 : Math.ceil(soc) + // An unknown limit is not a limit of zero: before the inverter has been + // read, min_soc is null, and treating that as 0 marked cell 0 as + // "locked (<= MIN)" when no limit is known at all. + const floor = minSoc ?? 0 + const reservedTop = floor + (capacity ? (reservedCapped / capacity) * 100 : 0) + + return Array.from({ length: 100 }, (_, p) => ({ + p, + on: p < current, + partial: p === current, + locked: (minSoc != null && p <= minSoc) || + (maxSoc != null && maxSoc < 100 && p > maxSoc), + // Hatched as soon as the reservation reaches into the cell, including the + // blinking one. Never above always_allow_discharge: nothing is held back + // there. + hatched: p > floor && p - 1 < reservedTop, + discharge: allowDischargeSoc != null && p > allowDischargeSoc, + })) +} diff --git a/frontend/src/lib/soc.test.js b/frontend/src/lib/soc.test.js new file mode 100644 index 00000000..c948f4d9 --- /dev/null +++ b/frontend/src/lib/soc.test.js @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { socCells, socRows } from './soc.js' + +const base = { + soc: 50, + minSoc: 10, + maxSoc: 95, + allowDischargeSoc: 90, + capacity: 10000, + reservedCapped: 0, +} + +const cell = (state, p) => socCells(state)[p] + +describe('socCells', () => { + it('has one cell per percent, 0..99', () => { + const cells = socCells(base) + expect(cells).toHaveLength(100) + expect(cells[0].p).toBe(0) + expect(cells[99].p).toBe(99) + }) + + // The spec is explicit: 87.0 % -> cell 87 blinks, 87.1 % -> cell 88. + it.each([ + [87.0, 87], + [87.1, 88], + [87.9, 88], + [0.5, 1], + ])('at %s %% the blinking cell is %i', (soc, expected) => { + const cells = socCells({ ...base, soc }) + expect(cells.filter((c) => c.partial).map((c) => c.p)).toEqual([expected]) + }) + + it('lights every cell and blinks none at 100 %', () => { + const cells = socCells({ ...base, soc: 100, maxSoc: 100 }) + expect(cells.every((c) => c.on)).toBe(true) + expect(cells.some((c) => c.partial)).toBe(false) + }) + + it('lights every cell from 99.1 % upwards, by design', () => { + const cells = socCells({ ...base, soc: 99.1, maxSoc: 100 }) + expect(cells.every((c) => c.on)).toBe(true) + }) + + it('lights nothing at 0 %', () => { + const cells = socCells({ ...base, soc: 0 }) + expect(cells.some((c) => c.on)).toBe(false) + expect(cells.filter((c) => c.partial).map((c) => c.p)).toEqual([0]) + }) + + it('blinks nothing when the SoC is unknown', () => { + const cells = socCells({ ...base, soc: null }) + expect(cells.some((c) => c.partial)).toBe(false) + expect(cells.some((c) => c.on)).toBe(false) + }) + + describe('locked', () => { + it('locks everything up to and including MIN_SOC', () => { + expect(cell(base, 10).locked).toBe(true) + expect(cell(base, 11).locked).toBe(false) + }) + + it('locks above MAX_SOC only when MAX_SOC is below 100', () => { + expect(cell(base, 96).locked).toBe(true) + expect(cell({ ...base, maxSoc: 100 }, 96).locked).toBe(false) + }) + + it('can be locked and lit at the same time', () => { + const c = cell({ ...base, soc: 50 }, 5) + expect([c.on, c.locked]).toEqual([true, true]) + }) + }) + + describe('reserved', () => { + // 2000 Wh of 10000 Wh is 20 %, starting at MIN_SOC 10 -> up to 30 %. + const reserved = { ...base, reservedCapped: 2000 } + + it('hatches every cell the reservation reaches into', () => { + expect(cell(reserved, 11).hatched).toBe(true) + expect(cell(reserved, 30).hatched).toBe(true) + expect(cell(reserved, 31).hatched).toBe(false) + }) + + it('never hatches at or below MIN_SOC', () => { + expect(cell(reserved, 10).hatched).toBe(false) + expect(cell(reserved, 0).hatched).toBe(false) + }) + + it('hatches nothing without a reservation', () => { + expect(socCells(base).some((c) => c.hatched)).toBe(false) + }) + + it('hatches the blinking cell too when the reservation reaches it', () => { + const c = cell({ ...reserved, soc: 25 }, 25) + expect([c.partial, c.hatched]).toEqual([true, true]) + }) + }) + + describe('always_discharge', () => { + it('marks everything above the limit', () => { + expect(cell(base, 90).discharge).toBe(false) + expect(cell(base, 91).discharge).toBe(true) + }) + + it('marks nothing when the limit is unknown', () => { + expect(socCells({ ...base, allowDischargeSoc: null }) + .some((c) => c.discharge)).toBe(false) + }) + }) +}) + +describe('socRows', () => { + it('puts cell 0 bottom left and cell 99 top right', () => { + const rows = socRows(base) + expect(rows).toHaveLength(10) + expect(rows[0].cells[0].p).toBe(90) // top row starts at 90 + expect(rows[0].cells[9].p).toBe(99) // 99 is top right + expect(rows[9].cells[0].p).toBe(0) // 0 is bottom left + expect(rows[9].start).toBe(0) + }) + + it('labels each row with its decade', () => { + expect(socRows(base).map((r) => r.start)).toEqual([90, 80, 70, 60, 50, 40, 30, 20, 10, 0]) + }) +}) + +describe('unknown limits', () => { + // Before the inverter has been read the API reports soc_limits as null. + const unknown = { ...base, minSoc: null, maxSoc: null } + + it('locks nothing when no limit is known', () => { + // An unknown limit is not a limit of zero: cell 0 used to render as + // "locked (<= MIN)" on a backend that reports no min_soc. + expect(socCells(unknown).some((c) => c.locked)).toBe(false) + }) + + it('still lights and blinks the right cells', () => { + const cells = socCells({ ...unknown, soc: 50 }) + expect(cells.filter((c) => c.partial).map((c) => c.p)).toEqual([50]) + expect(cells[49].on).toBe(true) + }) + + it('treats a known minSoc of 0 as a real limit', () => { + expect(cell({ ...base, minSoc: 0 }, 0).locked).toBe(true) + }) + + it('hatches from the floor when the limit is unknown', () => { + const cells = socCells({ ...unknown, reservedCapped: 1000 }) // 10 % of 10000 + expect(cells[1].hatched).toBe(true) + expect(cells[10].hatched).toBe(true) + expect(cells[11].hatched).toBe(false) + }) +}) diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 00000000..fe5bae39 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 00000000..6f9df812 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,122 @@ +/* + * Fonts are bundled with the application, never fetched from a CDN: + * batcontrol runs in homes without internet egress and behind Home Assistant + * ingress, where an external font request fails and leaves the UI in a + * fallback face. Only the latin subsets actually used are imported. + * + * IBM Plex Mono (c) 2017 IBM Corp. and VT323 (c) 2011 The VT323 Project + * Authors are licensed under the SIL Open Font License 1.1. The license texts + * live in public/licenses/ so the build copies them verbatim into the static + * assets - the OFL requires them to travel with the font files, and an + * unimported .txt under src/ would silently not be emitted at all. + */ +@import '@fontsource/ibm-plex-mono/latin-400.css'; +@import '@fontsource/ibm-plex-mono/latin-600.css'; +@import '@fontsource/vt323/latin-400.css'; + +:root { + --bg: #030a05; + --panel: #06140a; + --fg: #33ff66; + --fg-dim: #1f9e43; + --fg-faint: #0f4d22; + --fg-glow: rgba(51, 255, 102, 0.55); + --amber: #ffcc33; + --red: #ff5544; + + /* defrag grid cells */ + --cell-on: #39ff6e; + --cell-on-hi: #b6ffc9; + --cell-on-lo: #129638; + --cell-off: #0a2412; + --cell-off-hi: #17432a; + --cell-off-lo: #020804; + + /* Explicit local fallbacks, so a font file that fails to load degrades to + another monospace face instead of breaking the layout. */ + --font: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, 'Cascadia Mono', Consolas, monospace; + --font-big: 'VT323', 'IBM Plex Mono', ui-monospace, monospace; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; } + +body { + background: var(--bg); + color: var(--fg); + font-family: var(--font); + font-size: 14px; + line-height: 1.45; + text-shadow: 0 0 6px var(--fg-glow); + min-height: 100vh; +} + +/* CRT scanlines + vignette */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background: + repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.22) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at center, transparent 60%, rgba(0, 0, 0, 0.55) 100%); + z-index: 10; +} + +.panel { + border: 1px solid var(--fg-dim); + background: var(--panel); + padding: 14px 16px 16px; + position: relative; + box-shadow: 0 0 18px rgba(51, 255, 102, 0.08) inset; +} + +.panel > .panel-title { + position: absolute; + top: -0.8em; + left: 12px; + padding: 0 6px; + background: var(--bg); + font-weight: 600; + letter-spacing: 0.12em; +} + +.dim { color: var(--fg-dim); text-shadow: none; } +.big { font-family: var(--font-big); line-height: 1; } + +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 14px; +} +.kv dt { color: var(--fg-dim); text-shadow: none; } +.kv dd { margin: 0; text-align: right; } + +/* terminal-style >TOGGLE< button, inverted when hovered/active */ +.toggle { + font: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.1em; + color: var(--fg); + text-shadow: inherit; + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; +} +.toggle:hover, +.toggle:focus-visible, +.toggle.active { + background: var(--fg); + color: var(--bg); + text-shadow: none; + outline: none; +} + +@keyframes blink { 50% { opacity: 0.25; } } + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 00000000..13d0a17f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,93 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' + +import { fixtureApi } from './vite.fixtures.js' + +/** + * Copy the two Swagger UI files batcontrol serves at /docs into the build. + * + * FastAPI points /docs at cdn.jsdelivr.net by default, which means the API + * documentation is blank on a Home Assistant box without internet egress - + * the same reason the dashboard's fonts are bundled. Only the bundle and the + * stylesheet are copied; the rest of swagger-ui-dist (11 MB) is not needed. + * + * Swagger UI is Apache-2.0 and ships a NOTICE, so both travel with it into + * licenses/ - redistributing the files without them would not be allowed. + */ +function swaggerAssets() { + const assets = ['swagger-ui-bundle.js', 'swagger-ui.css'] + const licenses = { + LICENSE: 'LICENSE-Apache-2.0-SwaggerUI.txt', + NOTICE: 'NOTICE-SwaggerUI.txt', + 'swagger-ui-bundle.js.LICENSE.txt': 'LICENSE-SwaggerUI-bundled-dependencies.txt', + } + const from = (file) => + fileURLToPath(new URL(`node_modules/swagger-ui-dist/${file}`, import.meta.url)) + + return { + name: 'batcontrol-swagger-assets', + apply: 'build', + async writeBundle(options) { + const { copyFile, mkdir } = await import('node:fs/promises') + const out = `file://${options.dir}/` + const swagger = new URL('swagger/', out) + const licenseDir = new URL('licenses/', out) + await mkdir(swagger, { recursive: true }) + await mkdir(licenseDir, { recursive: true }) + for (const file of assets) { + await copyFile(from(file), fileURLToPath(new URL(file, swagger))) + } + for (const [source, name] of Object.entries(licenses)) { + await copyFile(from(source), fileURLToPath(new URL(name, licenseDir))) + } + }, + } +} + +// The dashboard is served by batcontrol itself, from inside the Python +// package, and it has to work under three different base paths: +// / Docker, direct port access +// /api/hassio_ingress// Home Assistant ingress +// /dashboard/ (or similar) a reverse proxy subpath +// Hence base: './' - every asset is referenced relatively, so the bundle +// does not care where it is mounted. The API base URL is derived from +// document.baseURI at runtime (see src/lib/api.js). +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + // With VITE_FIXTURE set, /api/v1/* is answered from fixtures/.json + // instead of being proxied - see vite.fixtures.js for why. + const fixture = env.VITE_FIXTURE + return { + base: './', + plugins: [vue(), swaggerAssets(), ...(fixture ? [fixtureApi(fixture)] : [])], + build: { + // Straight into the package, so `uv build --wheel` picks the assets up + // as package data. The stable Home Assistant add-on installs nothing + // but that wheel, so assets missing here means a dashboard-less add-on. + outDir: fileURLToPath(new URL('../src/batcontrol/web/static', import.meta.url)), + emptyOutDir: true, + // No sourcemaps: they would triple the size of the wheel for a UI that + // is debugged against the dev server anyway. + sourcemap: false, + assetsInlineLimit: 0, + }, + server: { + // Same-origin proxy to a running batcontrol, so the browser sees no + // CORS or private-network prompt during development. Skipped when + // fixtures answer instead. + proxy: fixture + ? undefined + : { + '/api': { + target: env.VITE_BACKEND_URL || 'http://127.0.0.1:8080', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.js'], + }, + } +}) diff --git a/frontend/vite.fixtures.js b/frontend/vite.fixtures.js new file mode 100644 index 00000000..c958217b --- /dev/null +++ b/frontend/vite.fixtures.js @@ -0,0 +1,117 @@ +/** + * Dev-server plugin that answers /api/v1/* from a fixture file instead of + * proxying to a running batcontrol. + * + * Why this exists: the states worth looking at hardest are the ones a live + * system will not produce on request - a battery at exactly 100 %, a battery + * at 3 %, a reservation larger than the battery, the 503 before the first + * control cycle, a stalled control loop, a wrong token. Waiting for those on + * real hardware is not a workflow. + * + * The fixtures are captured from a real instance, so their shape is the + * API's, not an invention. Timestamps are rebased onto the current time at + * request time, so relative displays ("5 min ago", the poll countdown) stay + * meaningful however old the file is. + * + * npm run dev -> proxy to a real batcontrol + * npm run dev:fixtures -> serve fixtures/default.json + * VITE_FIXTURE=no-data npm run dev:fixtures + */ +import { loadFixture } from './fixtures/load.js' + +const PREFIX = '/api/v1/' + +/** Fields that carry an epoch-ms timestamp and get rebased. */ +const TIME_FIELDS = [ + 'timestamp', + 'last_evaluation', + 'next_evaluation', + 'mode_changed_at', + 'decided_at', + 'current_ts', + 'run_time', + 'time_start', + 'time_end', +] + +/** + * Shift every timestamp in the payload by the same offset. + * + * Forecast slots get an offset rounded down to a whole slot, so slot + * boundaries stay on the interval the way the real API produces them - + * otherwise a fixture renders "MIN 24h @ 13:04" for hourly prices and sends + * you looking for a bug that is not there. + */ +function rebase(value, offset) { + if (Array.isArray(value)) return value.map((v) => rebase(v, offset)) + if (value && typeof value === 'object') { + const out = {} + for (const [key, inner] of Object.entries(value)) { + out[key] = + TIME_FIELDS.includes(key) && typeof inner === 'number' && inner > 0 + ? inner + offset + : rebase(inner, offset) + } + return out + } + return value +} + +export function fixtureApi(name = 'default') { + return { + name: 'batcontrol-fixture-api', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith(PREFIX)) return next() + const path = req.url.slice(PREFIX.length).split('?')[0] + + let fixture + try { + fixture = loadFixture(name) + } catch (e) { + res.statusCode = 500 + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ ok: false, error: 'fixture', detail: e.message })) + return + } + + const body = fixture[path] + if (!body) { + res.statusCode = 404 + res.setHeader('content-type', 'application/json') + res.end( + JSON.stringify({ + ok: false, + error: 'not_found', + detail: `Fixture "${name}" has no entry for ${path}.`, + }), + ) + return + } + + // Anchor: the fixture's own status timestamp becomes "now". + const anchor = fixture.status?.timestamp ?? body.timestamp ?? Date.now() + let offset = Date.now() - anchor + const intervalMinutes = body.data?.interval_minutes ?? body.data?.price?.interval_minutes + if (intervalMinutes) { + const slot = intervalMinutes * 60_000 + offset = Math.floor(offset / slot) * slot + } + const shifted = rebase(body, offset) + + // _status carries the HTTP code for the error fixtures (503/404/401). + const status = shifted._status ?? 200 + delete shifted._status + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.setHeader('cache-control', 'no-store') + if (status === 401) res.setHeader('www-authenticate', 'Bearer') + res.end(JSON.stringify(shifted)) + }) + + server.config.logger.info( + ` \x1b[32m➜\x1b[0m fixtures: serving ${PREFIX}* from fixtures/${name}.json`, + ) + }, + } +} diff --git a/scripts/verify_wheel_dashboard.py b/scripts/verify_wheel_dashboard.py new file mode 100755 index 00000000..da578276 --- /dev/null +++ b/scripts/verify_wheel_dashboard.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python +"""Verify that a built wheel actually contains the dashboard. + +This guards the one failure mode of the whole dashboard feature that is +otherwise silent: the Home Assistant stable add-on installs nothing but the +released wheel, so a wheel built without the npm step produces an add-on with +no dashboard - no error, no warning, just a hint page where the UI should be. + + python scripts/verify_wheel_dashboard.py dist/batcontrol-*.whl +""" +import sys +import zipfile +from pathlib import Path + +# Everything the dashboard needs to actually render. +REQUIRED = ( + 'batcontrol/web/static/index.html', +) +# Patterns at least one file has to match. +REQUIRED_GLOBS = ( + 'batcontrol/web/static/assets/*.js', + 'batcontrol/web/static/assets/*.css', + 'batcontrol/web/static/assets/*.woff2', + 'batcontrol/web/static/licenses/LICENSE-OFL-*.txt', + # Swagger UI is served from the package, not from a CDN, so /docs works + # without internet egress. Apache-2.0 with a NOTICE: both ship with it. + 'batcontrol/web/static/swagger/swagger-ui-bundle.js', + 'batcontrol/web/static/swagger/swagger-ui.css', + 'batcontrol/web/static/licenses/*SwaggerUI*', +) + + +def verify(wheel: Path) -> list: + """Return a list of problems; empty means the wheel is fine.""" + problems = [] + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + for required in REQUIRED: + if required not in names: + problems.append(f'missing: {required}') + for pattern in REQUIRED_GLOBS: + if not any(zipfile.Path(archive, name).match(pattern) + for name in names): + problems.append(f'nothing matches: {pattern}') + return problems + + +def main(argv) -> int: + """Check every wheel given on the command line.""" + if len(argv) < 2: + print(__doc__) + return 2 + + failed = False + for argument in argv[1:]: + wheel = Path(argument) + if not wheel.is_file(): + print(f'{wheel}: not a file') + failed = True + continue + problems = verify(wheel) + if problems: + failed = True + print(f'{wheel.name}: the dashboard is NOT in this wheel') + for problem in problems: + print(f' - {problem}') + print(' Build it first: cd frontend && npm ci && npm run build') + else: + print(f'{wheel.name}: dashboard present') + return 1 if failed else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) From ee73486ef5ead36320737215fff05a1c500f9771 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 16 Oct 2026 13:27:03 +0200 Subject: [PATCH 4/5] build: dashboard in the Docker image, release and docs pipelines - Dockerfile builds the dashboard in a separate node stage (BUILDPLATFORM pinned, so multi-arch builds run npm natively once) - the release workflow builds the dashboard and verify_wheel_dashboard.py fails the release if the wheel lacks it - frontend CI (build, tests, offline check), dependabot for npm - the docs build publishes the OpenAPI schema via dump_openapi.py Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 45 +++++++++++++++++++++++ .github/dependabot.yml | 21 +++++++++++ .github/workflows/_docs-build.yml | 12 ++++--- .github/workflows/frontend.yml | 51 ++++++++++++++++++++++++++ .github/workflows/prepare-release.yml | 22 ++++++++++++ Dockerfile | 43 ++++++++++++++++++++-- scripts/dump_openapi.py | 52 +++++++++++++++++++++++++++ 7 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/frontend.yml create mode 100755 scripts/dump_openapi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..327a3dda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Keep the build context small and make the build reproducible: anything the +# Dockerfile needs is copied explicitly. + +# Frontend: node_modules is hundreds of MB and the image builds its own. +frontend/node_modules +frontend/dist + +# A local dashboard build must not leak into the image - the frontend stage +# produces it, and a stale local copy would be shadowed at best. +src/batcontrol/web/static + +# Python build and test artefacts +.venv +venv +build +dist +*.egg-info +**/__pycache__ +*.py[cod] +.pytest_cache +.coverage +coverage.xml +.ruff_cache +.mypy_cache + +# Repository and tooling +.git +.github +.gitignore +.vscode +.idea +.claude + +# Runtime and throwaway data +logs/* +!logs/.keep +tmp +*.log +.env + +# Documentation and tests are not needed in the image +docs +site +tests +scripts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0df22166..1510f68e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,3 +17,24 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + # Dashboard dependencies. package.json pins exact versions and .npmrc sets + # save-exact, so every bump arrives here as a reviewable pull request rather + # than drifting in on the next npm install. + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + groups: + # Split by what a bump actually affects, not to reduce noise: + # + # production dependencies end up inside the wheel, the Docker image and + # the Home Assistant add-on, and two of them carry license obligations + # that THIRD-PARTY-LICENSES.md tracks (the OFL fonts and Swagger UI's + # Apache-2.0 NOTICE). Those bumps need the license file re-checked and + # the dashboard looked at. + dashboard-runtime: + dependency-type: "production" + # build tooling never ships; a bump only has to keep "npm run build", + # the offline asset check and the tests green. + dashboard-build-tooling: + dependency-type: "development" diff --git a/.github/workflows/_docs-build.yml b/.github/workflows/_docs-build.yml index 0dd5d6c3..484b1776 100644 --- a/.github/workflows/_docs-build.yml +++ b/.github/workflows/_docs-build.yml @@ -24,15 +24,17 @@ jobs: with: python-version: '3.12' - - name: Install documentation dependencies - run: pip install "mkdocs-material>=9.5,<10" "mkdocs-llmstxt>=0.5.0,<1.0" - - - name: Install batcontrol (for documentation data generation) - run: pip install -e . + - name: Install batcontrol and documentation dependencies + run: pip install -e ".[docs]" - name: Generate peak shaving scenario data run: python scripts/generate_peak_shaving_csv.py + # The published API reference is rendered from the schema the code + # produces, so it cannot drift from the implementation. + - name: Generate the OpenAPI schema + run: python scripts/dump_openapi.py + - name: Build documentation run: mkdocs build --strict diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..8918811f --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,51 @@ +name: Dashboard + +on: + workflow_dispatch: + push: + branches: [ main ] + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + pull_request: + branches: [ main ] + paths: + - 'frontend/**' + - '.github/workflows/frontend.yml' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Test + working-directory: frontend + run: npm test + + # Also runs scripts/check-offline.mjs: the build fails if the dashboard + # would load a font, stylesheet or script from the network. batcontrol + # runs in homes without internet egress and behind Home Assistant + # ingress, where an external asset never arrives. + - name: Build + working-directory: frontend + run: npm run build + + - name: Verify the dashboard reaches the wheel + run: | + pipx run uv build --wheel --out-dir dist + python scripts/verify_wheel_dashboard.py dist/*.whl diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index b9a63d97..d8bd7721 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -28,6 +28,22 @@ jobs: - name: Install bump-my-version run: uv pip install bump-my-version + # The dashboard ships inside the wheel as package data, and the Home + # Assistant stable add-on installs nothing but that wheel. Without this + # step the release would silently contain no dashboard. + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build dashboard + working-directory: frontend + run: | + npm ci + npm run build + - name: Setup git user run: | git config --global user.name "github-actions[bot]" @@ -61,6 +77,12 @@ jobs: echo "WHEEL_FILE=$(ls dist/*.whl)" >> $GITHUB_OUTPUT echo "TAR_FILE=$(ls dist/*.tar.gz)" >> $GITHUB_OUTPUT + # Fails the release rather than publishing a wheel that installs + # without a dashboard - a failure mode that is otherwise silent all the + # way into the add-on. + - name: Verify the dashboard is in the wheel + run: python scripts/verify_wheel_dashboard.py ${{ steps.build.outputs.WHEEL_FILE }} + - name: Create Draft Release uses: softprops/action-gh-release@v3 with: diff --git a/Dockerfile b/Dockerfile index 2f4b2be4..46eeea48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,31 @@ -# Stage 1: Build Stage +# Stage 1: Dashboard +# +# A dedicated Node image rather than installing nodejs into the Python stage: +# the toolchain stays out of the build stage that produces the wheel, and the +# Node version is pinned here instead of inheriting whatever the base image's +# package repository happens to ship. +# +# --platform=$BUILDPLATFORM pins this stage to the machine doing the build, so +# a multi-arch build runs npm once, natively, instead of once per target +# architecture under QEMU emulation. The output is static files, which are +# architecture independent. +FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend + +# Same directory layout as the repository, so vite's outDir +# (../src/batcontrol/web/static) resolves the same way it does locally. +WORKDIR /build/frontend + +# Dependencies first, so a source-only change reuses the npm layer. +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +# Also runs scripts/check-offline.mjs, which fails the build if the dashboard +# would load a font, stylesheet or script from the network: batcontrol runs in +# homes without internet egress and behind Home Assistant ingress. +RUN npm run build + +# Stage 2: Build Stage FROM python:3.14-alpine AS builder # Install uv for fast, reliable Python packaging @@ -12,10 +39,15 @@ COPY pyproject.toml LICENSE README.md ./ # Copy the rest of the source files COPY ./src ./src +# The built dashboard travels inside the wheel as package data. The Home +# Assistant stable add-on installs nothing but that wheel, so this copy is +# what keeps the add-on from shipping without a dashboard. +COPY --from=frontend /build/src/batcontrol/web/static ./src/batcontrol/web/static + # Build a wheel RUN uv build --wheel --out-dir /wheels -# Stage 2: Build the final image +# Stage 3: Build the final image FROM python:3.14-alpine ARG VERSION @@ -53,7 +85,7 @@ COPY config/load_profile_default.csv ./config/load_profile.csv COPY config/load_profile_default.csv ./default_load_profile.csv # Copy all the other necessary runtime files -COPY LICENSE entrypoint.sh ./ +COPY LICENSE THIRD-PARTY-LICENSES.md entrypoint.sh ./ COPY config ./config_template # Set the scripts as executable @@ -61,4 +93,9 @@ RUN chmod +x entrypoint.sh VOLUME ["/app/logs", "/app/config"] +# Read-only API, dashboard and MCP server. Only reachable when api.enabled is +# set in the configuration, and the bind address has to be 0.0.0.0 for a +# published port to work. +EXPOSE 8080 + CMD ["/bin/sh", "/app/entrypoint.sh"] diff --git a/scripts/dump_openapi.py b/scripts/dump_openapi.py new file mode 100755 index 00000000..03e56a44 --- /dev/null +++ b/scripts/dump_openapi.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +"""Write the OpenAPI schema of the read-only API to a file. + +Used by the documentation build so the published API reference is generated +from the code instead of being re-typed next to it - a hand-maintained copy +drifts the moment a field description changes. + +No batcontrol instance is needed: the schema comes from the route and model +definitions, and nothing is called while it is generated. The service layer +is handed a stand-in that would fail loudly if that ever stopped being true. + + python scripts/dump_openapi.py [output.json] +""" +import json +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'src')) + +from batcontrol.web.app import create_app # noqa: E402 pylint: disable=wrong-import-position + +DEFAULT_OUTPUT = pathlib.Path('docs/assets/openapi.json') + + +class _NotARunningBatcontrol: + """Stand-in for the core. Schema generation must not touch it.""" + + def __getattr__(self, name): + raise AssertionError( + f'Generating the OpenAPI schema read {name!r} from the batcontrol' + ' instance. The schema must be derivable without a running' + ' instance - keep route handlers lazy.' + ) + + +def main() -> int: + """Write the schema and report where it went.""" + output = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT + app = create_app(_NotARunningBatcontrol()) + schema = app.openapi() + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(schema, indent=2, sort_keys=True) + '\n', + encoding='utf-8') + + print(f'{output}: {len(schema["paths"])} paths,' + f' {len(schema["components"]["schemas"])} schemas') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From ab88c01b50f1bae4594b70892473e6333ba46863 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 16 Oct 2026 13:27:03 +0200 Subject: [PATCH 5/5] docs: REST API, MCP server and dashboard User docs for the REST API (conventions, error codes, security), the MCP server and the dashboard, the design specs under docs/development, the api: parameters in installation, and agent instructions in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 --- .github/copilot-instructions.md | 17 +++ CLAUDE.md | 52 ++++++++ README.md | 26 ++++ docs/assets/dashboard.png | Bin 0 -> 219096 bytes docs/development/dashboard-spec.md | 191 +++++++++++++++++++++++++++ docs/development/frontend-build.md | 95 +++++++++++++ docs/development/mcp-evaluation.md | 71 ++++++++++ docs/development/mcp-spec.md | 179 +++++++++++++++++++++++++ docs/features/dashboard.md | 87 ++++++++++++ docs/getting-started/installation.md | 40 ++++++ docs/index.md | 3 + docs/integrations/mcp-server.md | 102 ++++++++++++++ docs/integrations/rest-api.md | 134 +++++++++++++++++++ mkdocs.yml | 17 +++ 14 files changed, 1014 insertions(+) create mode 100644 docs/assets/dashboard.png create mode 100644 docs/development/dashboard-spec.md create mode 100644 docs/development/frontend-build.md create mode 100644 docs/development/mcp-evaluation.md create mode 100644 docs/development/mcp-spec.md create mode 100644 docs/features/dashboard.md create mode 100644 docs/integrations/mcp-server.md create mode 100644 docs/integrations/rest-api.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 16875820..097680a1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -32,6 +32,8 @@ This is a Python based repository providing an application for controlling batte - `config/`: Configuration files and templates - `scripts/`: Store tests to verfiy logic or stand-alone tests & helpers here. - `src/`: Batcontrol source code +- `src/batcontrol/web/`: Read-only HTTP API, dashboard and MCP server +- `frontend/`: Vue 3 dashboard sources, built by npm into `src/batcontrol/web/static/` - `tests/`: pytests tests for automatic testing - `tmp/`: Folder for test scripts, which will be never committed. - `docs/`: Documentation for technical documentation. @@ -52,3 +54,18 @@ This is a Python based repository providing an application for controlling batte `logic/next.py`), must stay traceable: add or extend a `DecisionRecord` with a plain-language explanation (`logic/decision_trace.py`, `logic/decision_records.py`). This feeds the MQTT "Decision" sensor and the in-memory decision journal — see `docs/development/decision-trace.md`. +10. The web layer (`src/batcontrol/web/`) is **read-only**: every route is a `GET` and a test + fails if a mutating one appears. Runtime overrides stay on the MQTT API. REST and MCP both + read through `web/service.py`, which reads the values the control loop has cached — never + the `get_*` accessors, which would talk to the inverter from a web thread. +11. Units and time bases are part of the contract: the API uses **epoch milliseconds** (MQTT + uses seconds), `value` is **Wh per forecast slot** and `power_w` is **W** (a factor of four + apart at 15-minute resolution). Put the unit in every numeric field's description. +12. `/api/v1/config` is an allow-list built from core attributes and must never read the + configuration dictionary — that is what keeps credentials unreachable by construction. +13. Nothing in the dashboard may load from the network: batcontrol runs in homes without + internet egress and behind Home Assistant ingress. Fonts and Swagger UI are bundled, and + `npm run build` fails on an external asset reference. +14. The dashboard ships inside the wheel as package data. A source checkout without Node has no + assets and serves a hint page — that is expected. The release workflow builds them and + `scripts/verify_wheel_dashboard.py` verifies the result. diff --git a/CLAUDE.md b/CLAUDE.md index 47c350b0..398dd448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,18 @@ uv run pylint src/batcontrol # target score >= 9.0 (10 if ach uv run autopep8 --in-place # PEP8 formatting ``` +Dashboard (`frontend/`, only needed when touching the web UI): + +```bash +cd frontend && npm ci && npm run build # -> src/batcontrol/web/static (package data) +npm test # vitest +npm run dev # against a running batcontrol on :8080 +npm run dev:fixtures # against frozen fixtures, no backend needed +VITE_FIXTURE=battery-full npm run dev:fixtures +python scripts/dump_openapi.py # docs/assets/openapi.json for the docs build +python scripts/verify_wheel_dashboard.py dist/*.whl +``` + ## Module Map ``` @@ -30,6 +42,15 @@ src/batcontrol/ scheduler.py # Main loop mqtt_api.py # State publishing + runtime config overrides evcc_api.py # evcc integration + modes.py # Inverter mode constants (core re-exports them) + web/ # Read-only HTTP API, dashboard and MCP server + snapshot.py # immutable per-cycle forecast snapshot + service.py # the single read path, shared by REST and MCP + models.py routes.py # Pydantic responses + GET-only routes + auth.py app.py # optional bearer token + application factory + server.py mcp.py # uvicorn thread + MCP tools + static/ # built dashboard (npm output, package data) +frontend/ # Vue 3 dashboard sources, built into web/static config/batcontrol_config_dummy.yaml # Every parameter, one-line comments; also the # first-run template copied by entrypoint.sh tests/ # pytest suite (mirrors package layout) @@ -59,6 +80,13 @@ tmp/ # Throwaway experiments — NEVER committed value change (listeners registered with `refresh=True` also on a reason change and every 15 min). The MQTT "Decision" sensor (`mqtt_api.py`) is the first listener. See `docs/development/decision-trace.md` and `docs/features/decision-sensor.md`. +- **Read-only web layer:** `web/` serves the REST API, the dashboard and the MCP server from one + port, all `GET`. Both transports read through `web/service.py` - one source of truth for field + names, units and rounding. The service reads only values the control loop has already cached + (`last_SOC`, `last_max_capacity`, ...), never the `get_*` accessors, because those fetch from + the inverter and would reset the loop's cache flags from a web thread. Forecasts come from one + frozen `ForecastSnapshot` the loop publishes per cycle, so a request can never mix two cycles. + Specs: `docs/development/mcp-spec.md`, `docs/development/dashboard-spec.md`. ## Change Checklist @@ -91,6 +119,12 @@ tmp/ # Throwaway experiments — NEVER committed `docs/development/decision-trace.md`. Skipping this makes the change invisible to the Decision sensor and to the journal's `add_listener()` endpoint — no error is raised, so this is easy to miss in review. +7. New REST endpoint or MCP tool -> it is only done when all of these exist: the Pydantic model + with the **unit in every numeric field's description**, the matching MCP tool (or a note why + not), a test, the path added to the contract list in + `tests/batcontrol/web/test_openapi.py`, the entry in `docs/integrations/rest-api.md`, and - + if the dashboard uses it - the mapping in `frontend/src/lib/api.js`. The API is read-only: + adding a non-GET route fails `tests/batcontrol/web/test_readonly.py`, which is the point. ## Releasing @@ -125,3 +159,21 @@ python -m batcontrol [--config PATH] [--one-shot] "Decision" sensor, decision journal listeners) — add new ones rather than renaming existing ones. A reason without an entry in `_REASON_EXPLANATIONS` still works (falls back to a readable version of the code itself) but should get one. +- **Timestamps: the REST API and MCP use epoch milliseconds, MQTT uses epoch seconds.** The + decision trace keeps its ISO timestamp because it is the same object MQTT publishes; the API + adds `decided_at`/`mode_changed_at` in ms next to it. +- **`value` is Wh per forecast slot, `power_w` is W.** At 15-minute resolution they differ by a + factor of four. Both carry the unit in their field description for that reason. +- `/api/v1/config` is an explicit allow-list built from core attributes and must never read + `self.config` — that is what keeps inverter passwords and tariff tokens unreachable by + construction, not by filtering. +- Dashboard assets are **built by npm and shipped as package data**. A source checkout without + Node has none and serves a hint page; that is normal, and the tests force both branches. The + release workflow builds them and `scripts/verify_wheel_dashboard.py` fails the release if they + are missing — the HA stable add-on installs nothing but that wheel. +- **Nothing in the web layer may load from the network.** Fonts and Swagger UI are bundled, + because batcontrol runs in homes without internet egress and behind HA ingress. + `npm run build` fails on an external asset reference. An asset only ships if something imports + it — files that must ship verbatim (license texts) belong in `frontend/public/`. +- Ingress and reverse proxies serve the dashboard under a path prefix, so Vite uses + `base: './'` and the API base URL comes from `document.baseURI`. Absolute asset paths break it. diff --git a/README.md b/README.md index c179c888..bf78774f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ Batcontrol controls your PV battery inverter based on dynamic electricity prices [Documentation](https://mastr.github.io/batcontrol/) — [Development Documentation](https://mastr.github.io/batcontrol-dev/) — [Home Assistant Add-on](https://github.com/MaStr/batcontrol_ha_addon) +## Dashboard + +With the optional read-only API enabled, batcontrol serves a dashboard showing what it is doing +and why — the current decision with its full trace, the state of charge, and the price forecast. +It is served by batcontrol itself, needs no additional service, and appears as a sidebar panel in +the Home Assistant add-on. + +[![batcontrol dashboard](https://mastr.github.io/batcontrol/assets/dashboard.png)](https://mastr.github.io/batcontrol/features/dashboard/) + +The same port serves a [REST API](https://mastr.github.io/batcontrol/integrations/rest-api/) +with its own OpenAPI documentation and, optionally, an +[MCP server](https://mastr.github.io/batcontrol/integrations/mcp-server/) so an AI assistant can +answer questions about the battery. All of it is read-only: control stays with the MQTT API. + ## Supported Systems **Inverters / batteries** @@ -46,6 +60,18 @@ docker run -d \ mastr950/batcontrol:latest ``` +To also reach the dashboard, publish its port and set `api.enabled: true` with +`api.host: 0.0.0.0` in the configuration: + +```sh +docker run -d \ + --name batcontrol \ + -p 8080:8080 \ + -v "$PWD/config:/app/config" \ + -v "$PWD/logs:/app/logs" \ + mastr950/batcontrol:latest +``` + On the first start, if no configuration file is found, batcontrol copies a sample config with a dummy inverter into `./config/batcontrol_config.yaml`. The dummy inverter simulates responses without touching real hardware, so you can explore the setup safely. Edit the file to configure your actual inverter and tariff provider before pointing batcontrol at real hardware. ## Configuration diff --git a/docs/assets/dashboard.png b/docs/assets/dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..086d5919f7e2765bca6759e0ab83b7facca0997e GIT binary patch literal 219096 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%0D+W=BakC|M}K}Q%!iRAv}(|F(nS(wd7?} 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îrLeKlI3@TJnE@{8ZxC{kWgi5y|d9#mL&Lh>y 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 DY(zj) literal 0 HcmV?d00001 diff --git a/docs/development/dashboard-spec.md b/docs/development/dashboard-spec.md new file mode 100644 index 00000000..5f02c84d --- /dev/null +++ b/docs/development/dashboard-spec.md @@ -0,0 +1,191 @@ +# Dashboard Specification + +This is the design contract for the batcontrol web dashboard: a single-page Vue 3 application +(Vite, SFC, `