Recreate the Plenum (smart-thermostat-with-vents) development environment from scratch: prerequisites, backend pip install, frontend npm install/build, .env setup, VS Code launch configs, Docker image build, docker-compose test stack anatomy, and local E2E/Playwright setup. Load when setting up a fresh clone, when an install/build/test command fails with missing deps or wrong versions, when Vite/dev-server ports or proxies misbehave, or when you need to know where a build artifact or the SQLi...
Install to Claude Code
npx -y skills add dhruvb14/smart-thermostat-with-vents --skill plenum-build-and-env --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Plenum Build And Env?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/dhruvb14-plenum-build-and-env)More formats (shields.io, HTML) on the badges page.
---
name: plenum-build-and-env
description: >-
Recreate the Plenum (smart-thermostat-with-vents) development environment
from scratch: prerequisites, backend pip install, frontend npm install/build,
.env setup, VS Code launch configs, Docker image build, docker-compose test
stack anatomy, and local E2E/Playwright setup. Load when setting up a fresh
clone, when an install/build/test command fails with missing deps or wrong
versions, when Vite/dev-server ports or proxies misbehave, or when you need
to know where a build artifact or the SQLite DB lives. Do NOT load for
actually running/operating the app (plenum-run-and-operate) or for CI
workflow internals (plenum-ci-and-release).
---
# Plenum build & environment — from bare machine to green tests
Everything needed to stand up a dev environment for this repo and the traps
that waste time. Commands re-verified 2026-09-01 against v0.35.0 unless
marked **stated-not-executed** or **UNVERIFIED**.
**When NOT to use this skill:**
- Starting/operating the app, data locations at runtime, backup/restore,
ingress/MCP ports → `plenum-run-and-operate`.
- CI workflow mechanics (container-ci build modes, golden auto-commit) →
`plenum-ci-and-release`.
- What gates a change must clear before merging → `plenum-change-control`.
- Test-writing patterns, coverage gates in depth → `plenum-validation-and-qa`.
**Jargon** (defined once): *editable install* — `pip install -e`, links the
package to the source tree so edits apply without reinstall; *golden* — a
committed reference screenshot PNG under `e2e/screenshots/` compared
pixel-by-pixel by the visual-regression suite; *ingress* — Home Assistant's
built-in reverse proxy that serves add-on UIs inside the HA frontend.
---
## 1. Prerequisites
| Tool | Version | Source of truth |
|---|---|---|
| Python | **>= 3.12** (hard requirement) | `smart_vent/pyproject.toml` `requires-python = ">=3.12"` |
| Node.js | **>= 22.22** effectively required — `react-router@8` (added in the React 19 migration) declares `engines.node: ">=22.22.0"` in its own `package.json`. `smart_vent/frontend/package.json` itself has no `engines` field and there's no `.npmrc` with `engine-strict`, so npm only *warns* on an older Node, it doesn't refuse — but use 22.22+ to match CI. | `.github/workflows/lint.yml`'s `frontend-lint` job (the only job that actually `npm ci`s + executes `smart_vent/frontend`) pins `node-version: "22"`. `container-ci.yml` still pins `"20"`, but its Node-20 jobs only install `e2e/` (Playwright), which never imports react-router — see the comment at `lint.yml` line ~144. |
| npm | ships with Node; lockfiles are `package-lock.json` (npm, not yarn/pnpm) | `smart_vent/frontend/package-lock.json`, `e2e/package-lock.json` |
| Docker + Compose v2 | needed only for the container build and E2E stack | `docker-compose.test.yml` |
Trap: `python3` on a stock machine may be 3.10/3.11. pip will refuse the
install (`requires-python >=3.12`). Use `python3.12 -m venv ...` explicitly.
---
## 2. Backend install
Two equivalent commands (both verified against `pyproject.toml`):
```bash
# From the repo root — editable install WITH dev/test extras (what you want):
python3.12 -m venv .venv
.venv/bin/pip install -e './smart_vent[dev]'
# What CI does (cwd = smart_vent/, non-editable):
pip install ".[dev]"
```
- `.venv/` and `*.egg-info/` are gitignored (root `.gitignore`), so an
in-repo venv does not dirty the tree. `.vscode/settings.json` expects the
interpreter at `${workspaceFolder}/.venv/bin/python`.
- The README's minimal path (`pip install -e ./smart_vent`, no extras) runs
the app but cannot run pytest/ruff/mypy — always add `[dev]` for
development.
- The VS Code task "backend: install (.venv)" (`.vscode/tasks.json`) runs
exactly the repo-root command above.
Runtime deps live in `[project] dependencies`; test/lint deps in
`[project.optional-dependencies] dev` (pytest, pytest-asyncio, pytest-cov,
ruff, mypy, types-python-dateutil, and — since the MQTT bridge (#519) —
`amqtt`, an in-process MQTT broker so `test_mqtt_real_broker.py` can drive a
real `aiomqtt` client without Docker; that test skips itself if it's absent).
`aioresponses` (cited in older docs) is **no longer a dev dep** — it was
dropped at some point after the #519 MQTT work landed. **New deps of either
kind go in `pyproject.toml`** — CI installs via `pip install ".[dev]"`, no
workflow edit needed. **PyYAML is NOT a dependency** — never `import yaml` in
code or tests (config.yaml parsing in tests is done without it).
Smoke test (re-verified 2026-09-01 — 31 tests pass in well under 1 s):
```bash
cd smart_vent
python -m pytest backend/tests/test_units.py backend/tests/test_addon_config.py \
backend/tests/test_temperature_field_parity.py -q --no-cov
```
- `--no-cov` matters for subsets: `pyproject.toml` sets
`addopts = "--cov=backend --cov-report=term-missing"` and
`fail_under = 96.7` (repo file is the source of truth — check it before
quoting a number), so a partial run without `--no-cov` fails the coverage
gate even when every test passes.
- Full suite: `python -m pytest backend/tests/ -v` from `smart_vent/`.
- History note: `tests/test_routes_helpers.py` (named in pre-2026-07-05
CLAUDE.md copies) no longer exists; the conversion-helper unit tests are
`backend/tests/test_units.py`, matching the helpers' move from
`routes.py` privates to `backend/units.py` (`to_f`, `delta_to_f`, `from_f`,
`from_f_delta`; `routes.py` imports them under the old `_to_f`-style
aliases). See `plenum-architecture-contract` for the conversion contract.
Lint/format/typecheck (run before committing Python; see
`plenum-change-control` for gating):
```bash
cd smart_vent
ruff check backend/ && ruff format backend/ # format --check is what CI runs
mypy backend/ --ignore-missing-imports
```
---
## 3. Frontend install & build
```bash
cd smart_vent/frontend
npm ci # reproducible, from package-lock.json (CI uses this)
# npm install # only when intentionally changing dependencies
npm run build # runs `tsc && vite build` → dist/ (dist/ is gitignored)
```
Verified script names from `smart_vent/frontend/package.json` — use these
exact names, several are commonly misguessed:
| Script | Command |
|---|---|
| `npm run dev` | `vite` (dev server) |
| `npm run build` | `tsc && vite build` |
| `npm run lint` / `lint:fix` | `eslint src/` |
| `npm run format` / `format:check` | `prettier --write src/` / `--check src/` |
| `npm test` / `test:run` | `vitest` (watch) / `vitest run` |
| `npm run test:coverage` | `vitest run --coverage` |
Smoke test (re-verified 2026-09-01: `npx vitest run src/contexts.test.ts` —
17 tests pass).
Coverage thresholds live in `vite.config.ts` `test.coverage.thresholds`
(four ratcheted values, recalibrated for Vitest 4's v8 remapping; as of
2026-09-01 these are lines 94.2 / functions 91.3 / branches 79.9 / statements
92.0 — repo file is authoritative, they get ratcheted upward periodically;
threshold table of record: `plenum-validation-and-qa` §6).
### Vite dev server: port and proxies
`vite.config.ts` sets **no `server.port`**, so the dev server uses Vite's
default **5173**, proxying `/api` → `http://localhost:8099` and `/ws` →
`ws://localhost:8099` (WebSocket) to the locally running backend. README and
`.vscode/launch.json` both say 5173. The "5174" in issue #25 comes from a
*draft* CONTRIBUTING.md inside the issue body, not from repo config —
5174 only ever appears because Vite auto-increments when 5173 is already
bound (inference). Trust `vite.config.ts`.
`VITE_APP_VERSION` is a build-time constant. The Docker build extracts it
from `config.yaml`'s `version:` and bakes it into the bundle; the value
`"CI"` flips the frontend into frozen/deterministic mode via
`frontend/src/ci.tsx` (see `plenum-ci-and-release` for why). For local dev
builds you normally leave it unset.
---
## 4. `.env` setup
`cp .env.sample .env` at the repo root, then edit. The sample's exact
contents (all four variables):
```bash
HA_URL=http://homeassistant.local:8123
HA_TOKEN=your_long_lived_token_here
DATA_DIR=./data
PORT=8099
```
- `HA_TOKEN`: HA profile → Long-Lived Access Tokens → Create Token.
- The backend also honours `TEMPERATURE_UNIT` (`F`/`C`/empty=auto-detect),
`MCP_PORT` (default 9099), `TZ`, `HA_USE_WSS`, `HA_SSL_VERIFY`, and (since
auth #373 shipped) `REQUIRE_AUTH` — full knob inventory belongs to
`plenum-config-and-flags`.
- **`REQUIRE_AUTH` defaults to off for this mode, unlike the add-on/Docker
default.** `backend/main.py`'s `_resolve_require_auth()` treats a
completely *unset* `REQUIRE_AUTH` (never set at all, as in plain local dev
or the pytest harness — neither runs `run.sh`) as `False`, so a bare
`python -m backend.main` from `.env.sample` logs you straight in with no
auth boundary. Set `REQUIRE_AUTH=true` in `.env` if you want to exercise
the login flow locally; see `docs/auth.md`.
- `.env` and `data/` are gitignored. `python-dotenv` is a runtime dep, and
the VS Code backend launch loads `.env` via `envFile`.
### DATA_DIR defaults differ by context — the volume-loss trap
| Context | Default | Where set |
|---|---|---|
| Bare `python -m backend.main` | `/data` | `backend/main.py` line 52 (`os.environ.get("DATA_DIR", "/data")`) |
| Local dev via `.env.sample` | `./data` | `.env.sample` |
| Add-on (`run.sh`) | `/config` | `run.sh` `export DATA_DIR="${DATA_DIR:-/config}"` — with a one-time `flair.db`/`app.db` copy-migration from legacy `/data`, because `/config` is the Samba-visible add-on config share |
| Compose test stack | `/data` on named volume `plenum-data` | `docker-compose.test.yml` |
The DB is `${DATA_DIR}/app.db` (SQLite, WAL sidecars `-wal`/`-shm`). Traps:
running the backend locally **without** setting `DATA_DIR` tries to write to
`/data` (usually fails or lands in an unexpected root dir); and
`docker compose ... down -v` **deletes the named volumes** — HA data and the
Plenum DB — so omit `-v` to keep state between E2E iterations. Runtime data
management/backup is `plenum-run-and-operate`'s territory.
---
## 5. VS Code integration
`.vscode/launch.json` configurations (enumerated, verified):
| Name | Type | What it does |
|---|---|---|
| Backend (Python) | debugpy | `python -m backend.main`, cwd `smart_vent/`, loads repo-root `.env`, sets `DEV_DOCS=1` (note: `DEV_DOCS` is still read nowhere in the codebase as of 2026-09-01/v0.35.0 — vestigial, harmless) |
| Backend tests (pytest) | debugpy | `pytest backend/tests/ -v` under the debugger, cwd `smart_vent/` |
| Frontend (Chrome) | chrome | opens `http://localhost:5173`, pre-launch task starts `npm run dev` |
| Frontend (Edge) | msedge | same, Edge |
| **Compound:** Full stack (Backend + Frontend) | — | Backend (Python) + Frontend (Chrome), `stopAll: true` |
`.vscode/tasks.json` tasks: `frontend: dev server` (background, dependsOn
install), `frontend: install` (`npm install`), `frontend: build`,
`backend: install (.venv)`, `backend: test`, `backend: ruff`.
`.vscode/settings.json`: pytest wired to `smart_vent/backend/tests`, Ruff as
Python formatter with organize-imports on save, Prettier for TS/JS/JSON,
ESLint working dir `smart_vent/frontend`. Recommended extensions in
`.vscode/extensions.json`: ms-python.python, ms-python.debugpy,
charliermarsh.ruff, dbaeumer.vscode-eslint, esbenp.prettier-vscode,
ms-vscode.vscode-typescript-next.
---
## 6. Docker image build (`smart_vent/Dockerfile`)
**Single-stage** build (no `ARG BUILD_FROM`, no multi-stage — despite what
older docs/habits from HA add-on templates suggest), `FROM
ghcr.io/home-assistant/base-python:latest` (multi-arch amd64+arm64 manifest;
includes bashio, s6-overlay, python3, pip, jq). Sequence:
1. `apk upgrade --no-cache` **plus explicit `apk add --no-cache --upgrade
"<pkg>>=<version>"` floors** (currently jq, libcrypto3, libssl3, libexpat,
curl, libcurl), then `apk add --no-cache nodejs npm sqlite`. The floors are
load-bearing twice over: they are part of the RUN command string, so editing
one invalidates the layer and forces apk to re-resolve against a fresh index
— a bare `apk upgrade` is re-served from a warm BuildKit cache and upgrades
nothing — and apk fails the build when a floor cannot be met, so a missing
patch is loud. `rm -f /usr/bin/tempio` follows: an unused Go binary owned by
no apk package, so `apk upgrade` can never patch it.
2. `pip3 install --no-cache-dir --upgrade "pip>=<version>"` (the `>=` floor is
the cache key, for the same reason as the apk floors above), then copy
`pyproject.toml` alone (with a stub `backend/__init__.py`) and
`pip3 install --no-cache-dir .` — layer-cached so dependency installs
survive source edits. **pip is then removed from the shipped image**
(`pip3 uninstall --yes pip`, after the last install): it is build-time only,
and Trivy reads pip's vendored manifest (`pip/_vendor/vendor.txt`) and
reports its pins as installed packages — those were the image's last three
findings, unreachable by any dependency bump because pip was already at the
newest release. `python3 -m ensurepip` restores it if a build step ever
needs it back.
3. Copy `backend/`, `config.yaml`, then `frontend/package.json` +
`frontend/package-lock.json` → `npm ci` → copy `frontend/` →
`VITE_APP_VERSION=$(grep '^version:' config.yaml ...) npm run build` →
`rm -rf node_modules` (build-time only; removed so esbuild's bundled
binary doesn't trip image CVE scans). An `ARG APPLE_TOUCH_ICON_VARIANT`
(default `stable`) swaps in a red-ribbon "BETA" PWA icon before the build
when set to `beta` — only `.github/workflows/beta.yml`'s beta-image build
step passes `beta`; a plain local `docker build` gets the stable icon.
4. Copy `run.sh` (entrypoint), `EXPOSE 8099`, `CMD ["/run.sh"]`.
Local build: `docker build -t plenum-e2e ./smart_vent` (stated-not-executed
in this environment — no Docker daemon; this is the compose `build:` context
and the tag compose defaults to). Version bump trap: the image bakes the
frontend version from `config.yaml`, so a version bump without a rebuild
shows the old version in the UI footer.
## 7. Compose test stack anatomy
`docker-compose.test.yml` (repo root) — three services, two named volumes
(`ha-data`, `plenum-data`):
- **ha-init** — alpine one-shot; copies
`e2e/fixtures/ha-config/configuration.yaml` into the HA volume before HA
starts.
- **homeassistant** — pinned `ghcr.io/home-assistant/home-assistant:2025.5.3`,
port 8123, healthcheck curls `/api/config` (401 counts as healthy),
`start_period: 90s` for slow CI pulls.
- **plenum** — `image: "${PLENUM_IMAGE:-plenum-e2e}"` with
`build: context: ./smart_vent` as fallback. **`PLENUM_IMAGE`** is how CI
injects the once-built `ghcr.io/<repo>:ci-<sha>` image so E2E legs don't
rebuild (#333); unset locally, compose uses/builds local `plenum-e2e`.
Env: `HA_URL=http://homeassistant:8123`, `HA_TOKEN` from the shell
(produced by `e2e/scripts/setup-ha.py`), `TEMPERATURE_UNIT=F`,
`DATA_DIR=/data`; port 8099; healthcheck `/api/healthz`. Also, since #373
shipped: `REQUIRE_AUTH: "false"` — the stack deliberately opts into legacy
open mode (a default `true` would 401 every request, including the
healthcheck), because the round-trip and visual-regression suites exercise
the app, not the auth boundary (that's covered by backend integration
tests instead). And, since #456: `PLENUM_CLOCK_OVERRIDE:
"2025-06-04T10:00:00-04:00"` + `TIMEZONE: "America/New_York"` pin the
backend's room-active-status clock to a fixed weekday instant so the
visual-regression goldens render deterministic schedule-status text — see
`plenum-ci-and-release` for the full story.
`docker-compose.test.celsius.yml` is a **layered override**, not standalone —
its entire body overrides one value, `TEMPERATURE_UNIT: "C"` on the plenum
service:
```bash
docker compose -f docker-compose.test.yml -f docker-compose.test.celsius.yml up
```
HA itself stays °F either way — the fixture pins
`unit_system: us_customary` (the `158°F` bug root cause: unpinned YAML HA
defaults to metric, so `target_temp: 70` was read as 70 °C).
## 8. Local E2E environment (`e2e/`)
- `e2e/package.json`: only two scripts — `npm test` (`playwright test`) and
`npm run test:update` (`playwright test --update-snapshots`). Sole dep:
`@playwright/test`.
- `playwright.config.ts`: `baseURL` = `PLENUM_URL` env or
`http://localhost:8099`; `workers: 1`; projects `chromium` + `mobile`
(iPhone 14 viewport but forced `browserName: "chromium"` — WebKit is not
installed in CI); `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` overrides the
browser binary; snapshot names encode the unit via `PLENUM_TEMP_UNIT`
(`dashboard-Fahrenheit-chromium.png` vs `-Celsius-`); global default
`maxDiffPixels: 100`.
- `e2e/global-setup.ts` seeds the addon **through the UI** (EntityPicker
needs a live HA behind `/api/ha/entities` — without Docker/HA it times
out).
- `e2e/scripts/setup-ha.py` creates the HA admin user + long-lived token on
the fresh fixture instance (`--ha-url ... --output /tmp/ha_token.txt`);
needs `pip install requests websocket-client` (deliberately NOT in
pyproject — they are fixture-tooling, not project deps).
- Fixture entities (2 climate, 4 cover, 4 sensor) are listed in
`e2e/README.md`, which is the full local-run runbook (Docker path and a
degraded no-Docker path). Docker-stack bring-up order: `up --wait
homeassistant` → `setup-ha.py` → `HA_TOKEN=... up -d plenum` → `cd e2e &&
npm ci && npx playwright install chromium` → `npm test` (or `test:update`
to regenerate goldens — review PNG diffs like code; see
`plenum-change-control`). Stated-not-executed here (no Docker daemon).
As of 2026-09-01 `e2e/README.md`'s no-Docker section uses `pip install
./smart_vent` (the package itself, no hand-rolled list) — the older
`aiohttp-apispec` caveat this skill used to carry no longer applies.
---
## 9. Known traps (checklist)
- [ ] Python < 3.12 → pip refuses the install. Use `python3.12` explicitly.
- [ ] **PyYAML is not a dependency.** Do not `import yaml` anywhere.
- [ ] New Python dep (runtime or test) → `pyproject.toml` only
(`[project]` deps or `dev` extra). CI picks it up automatically.
- [ ] Pytest subset without `--no-cov` → coverage gate (96.7%) fails a
passing subset because `addopts` always enables `--cov`.
- [ ] Frontend `npm ci`/`npm run build`/`vitest` on Node < 22.22 → not a hard
failure (no `engine-strict`), but `react-router@8` declares
`engines.node: ">=22.22.0"` and CI's `frontend-lint` job runs Node 22 —
match it locally to avoid a class of bug CI won't reproduce for you.
- [ ] `ruff format backend/` before committing Python — CI checks formatting
(`ruff format --check`) separately from linting (`ruff check`).
- [ ] Frontend script names: it is `npm run format:check` and
`npm run test:coverage` — not `format-check`/`prettier:check`/etc.
- [ ] `npm ci`, not `npm install`, for reproducible installs (CI and
Dockerfile both use `ci`).
- [ ] Vite dev server is 5173 (no port override in `vite.config.ts`); if you
see 5174, something else already holds 5173.
- [ ] `DATA_DIR` unset when running the backend bare → tries `/data`. Set it
(`.env` uses `./data`).
- [ ] `docker compose ... down -v` wipes the `plenum-data` and `ha-data`
volumes (DB gone). Omit `-v` to keep state.
- [ ] Gitignore already covers `.venv/`, `venv/`, `node_modules/`, `dist/`,
`data/`, `*.db*`, `.coverage`, `htmlcov/`, frontend `coverage/` — a
normal dev setup leaves `git status` clean.
- [ ] Where CLAUDE.md prose and repo files disagree (coverage numbers, helper
locations, test filenames), **the repo files win** — verify before
quoting CLAUDE.md figures.
---
## Provenance and maintenance
Facts re-verified 2026-09-01 against v0.35.0 stable (`smart_vent/config.yaml`
`version`). Executed in a live container here: Python 3.12 venv +
`pip install -e './smart_vent[dev]'`; pytest subset (`test_units.py`,
`test_addon_config.py`, `test_temperature_field_parity.py` — 31 passed);
`npm ci` in `smart_vent/frontend` (Node 22.22.2, 277 packages) and
`npx vitest run src/contexts.test.ts` (17 passed). NOT executed (no Docker
daemon): `docker build`, compose stack, Playwright runs — commands
transcribed from `docker-compose.test.yml` / `e2e/README.md`.
Originally verified 2026-07-04 against v0.22.1 (HEAD `c65d35d`): 30 backend
tests, 13 frontend tests, 280 frontend packages — superseded by the counts
above; the coverage gate was 93.9% then (now 96.7%), and the frontend
coverage thresholds have also moved (see §3).
Re-verification one-liners for volatile facts:
| Fact | Command |
|---|---|
| requires-python, dep lists, coverage `fail_under` | `grep -A2 fail_under smart_vent/pyproject.toml; grep requires-python smart_vent/pyproject.toml` |
| CI Node version(s) | `grep -rn node-version .github/workflows/` (lint.yml vs container-ci.yml can differ — see §1) |
| react-router's Node floor | `python3 -c "import json;print(json.load(open('smart_vent/frontend/package-lock.json'))['packages']['node_modules/react-router'].get('engines'))"` |
| dev deps (incl. amqtt/aioresponses) | `sed -n '/optional-dependencies/,/^\[/p' smart_vent/pyproject.toml` |
| Frontend script names & coverage thresholds | `cat smart_vent/frontend/package.json; grep -A6 thresholds smart_vent/frontend/vite.config.ts` |
| Vite port/proxy | `cat smart_vent/frontend/vite.config.ts` (no `server.port` ⇒ 5173) |
| `.env.sample` variables | `cat .env.sample` |
| Launch configs / tasks | `cat .vscode/launch.json .vscode/tasks.json` |
| DATA_DIR defaults | `grep -n DATA_DIR smart_vent/backend/main.py smart_vent/run.sh docker-compose.test.yml` |
| Dockerfile shape | `cat smart_vent/Dockerfile` (still single-stage? still `base-python:latest`?) |
| PLENUM_IMAGE plumbing | `grep -n PLENUM_IMAGE docker-compose.test.yml .github/workflows/container-ci.yml` |
| HA fixture unit pin & image tag | `grep -n unit_system e2e/fixtures/ha-config/configuration.yaml; grep -n home-assistant: docker-compose.test.yml` |
| Conversion helpers location | `grep -n "def " smart_vent/backend/units.py` |
| PyYAML still absent | `grep -i yaml smart_vent/pyproject.toml` (expect no PyYAML) |
Scanned 9/11/2026
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!