81 lines
5.6 KiB
Markdown
81 lines
5.6 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Common Commands
|
|
|
|
```bash
|
|
# Activate the venv (required before all commands below)
|
|
source .venv/bin/activate
|
|
|
|
# Run all acceptance tests
|
|
pytest tests/ -v
|
|
|
|
# Run a single test
|
|
pytest tests/test_acceptance.py::test_assign_battery -v
|
|
|
|
# Seed the database (safe to skip if batteries.db already exists)
|
|
python seed.py
|
|
|
|
# Start the dev server
|
|
flask run
|
|
|
|
# Start production server (same as systemd service uses)
|
|
waitress-serve --host=127.0.0.1 --port=5000 app:app
|
|
|
|
# Install/reinstall systemd user service
|
|
bash sbin/install-service.sh
|
|
|
|
# Service management
|
|
systemctl --user start|stop|restart|status battery-tracker
|
|
journalctl --user -u battery-tracker -f
|
|
```
|
|
|
|
## Architecture
|
|
|
|
### Request lifecycle
|
|
`app.py` contains `create_app(config_object)` which builds the Flask app, creates the SQLAlchemy engine from `config.SQLALCHEMY_DATABASE_URI`, calls `Base.metadata.create_all(engine)`, and wires a `scoped_session` as the `db` local. All routes close over this `db` via `@app.teardown_appcontext`. There is no Flask-SQLAlchemy — the ORM session is raw SQLAlchemy so that `models.py` can be imported by `migrate_to_mariadb.py` without an app context.
|
|
|
|
### Database config
|
|
`config.py` reads `DATABASE_URL` from the environment, falling back to `sqlite:///batteries.db`. Swapping to MariaDB is entirely a matter of setting that env var — no code changes needed. All column types are restricted to `Integer`, `String`, `Text`, `Boolean`, `DateTime` for MariaDB compatibility. Status is stored as `String(20)` (not `Enum`) to avoid DDL differences between SQLite and MariaDB.
|
|
|
|
### Models
|
|
- `Battery`: label (unique), brand, status (`available`/`installed`/`retired`), device_id (FK nullable, `ondelete=SET NULL`), notes, size, chemistry, capacity_mah, tested_capacity_mah, tested_date, charge_cycles, purchase_date, storage_location, battery_percentage
|
|
- `Device`: name (unique), battery_slots, device_type, notes, ha_entity_id
|
|
- Helper methods: `Battery.is_available/installed/retired()`, `Device.installed_count()`, `Device.installed_brands()`, `Device.has_mixed_brands()`
|
|
|
|
### Business rules (enforced in routes, not DB constraints)
|
|
- Assigning a retired battery → hard block with flash error
|
|
- Assigning to a full device → hard block with flash error
|
|
- Mixed brands on same device → flash warning, assignment still proceeds
|
|
- Deleting a battery → requires GET confirmation page first, then POST
|
|
- Bulk install into device: capacity check before write; batteries already in target device are skipped; retired batteries are skipped
|
|
- Unretire: sets status back to `available`
|
|
- Unassign with `next` form field → redirects to that URL (must start with `/`); falls back to dashboard
|
|
- Logging a charge entry → sets `battery.battery_percentage = 100`
|
|
- Retired batteries are excluded from `needs_attention` and `low_pct` warnings — both use the `active` list (`status in ("available", "installed")`); the template-side `low_pct` filter and the in-table ⚠ badge also guard against retired status
|
|
|
|
### Dashboard filtering
|
|
The dashboard route builds an `active` list (`status in ("available", "installed")`) used for all warning logic. Client-side filtering uses `data-status` attributes on each table row and `applyFilters()` in JS. The default filter state is `"active"` (retired rows hidden on page load); the Reset button restores `"active"`, not an empty filter. Column visibility choices are stored in `localStorage`.
|
|
|
|
### Home Assistant integration (optional)
|
|
`ha_client.py` wraps the HA REST API (`GET /api/states/<entity_id>`). `ha_poller.py` runs a daemon thread started in `create_app` only when `HOMEASSISTANT_URL` and `HOMEASSISTANT_API_KEY` are set. The poller queries all `Device` rows with `ha_entity_id IS NOT NULL`, fetches the current percentage from HA, and writes it to `battery_percentage` on each installed battery in that device. The poller uses its own `sessionmaker` session (not the request-scoped `scoped_session`). When HA is not configured the app behaves exactly as before — all HA UI is gated on `ha_enabled` passed to templates.
|
|
|
|
### Adding new columns to existing DB
|
|
`create_all()` won't add columns to existing tables. Run via Python:
|
|
```python
|
|
import sqlite3
|
|
conn = sqlite3.connect('batteries.db')
|
|
conn.execute('ALTER TABLE <table> ADD COLUMN <col> <type>')
|
|
conn.commit(); conn.close()
|
|
```
|
|
Always snapshot the DB first: `cp batteries.db batteries.db.$(date +%Y-%m-%d).snapshot`
|
|
|
|
### Testing
|
|
Tests use a temporary file-based SQLite DB (via `tempfile.mkstemp`) created fresh per test — avoids SQLite in-memory per-connection isolation issues. The `seeded_client` fixture in `conftest.py` pre-populates via HTTP POST calls (not direct DB access), so tests exercise the full stack. Battery IDs in tests are positional (id=1 is always the first battery POSTed by the fixture).
|
|
|
|
`TestConfig` sets `HOMEASSISTANT_URL = None` to prevent the HA poller thread from starting during tests. HA integration tests live in `tests/test_ha_integration.py` and use a separate `ha_app` fixture with a fake HA URL; they call `poller._poll_once()` directly rather than waiting for the background thread to fire. HA API calls are mocked with `unittest.mock.patch("ha_client.requests.get")`.
|
|
|
|
### MariaDB migration
|
|
`migrate_to_mariadb.py` opens two SQLAlchemy sessions simultaneously (SQLite source, MariaDB destination), migrates Devices first (FK dependency), then Batteries with explicit `id=` values to preserve FK links, then resets `AUTO_INCREMENT` via `text("ALTER TABLE ...")`. Takes `MARIADB_URL` from env or CLI arg. See `MIGRATION.md` for the full procedure.
|