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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 7 additions & 5 deletions .github/workflows/_docs-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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/
52 changes: 52 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ uv run pylint src/batcontrol # target score >= 9.0 (10 if ach
uv run autopep8 --in-place <file> # 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

```
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
43 changes: 40 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -53,12 +85,17 @@ 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
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"]
Loading
Loading