Add optional Home Assistant integration for battery percentage tracking

This commit is contained in:
2026-04-13 20:10:23 -05:00
parent 9d2b1d0d51
commit 8c06478bca
13 changed files with 607 additions and 5 deletions
+28
View File
@@ -1,3 +1,31 @@
# Schema Migrations
## Adding Home Assistant fields (ha_entity_id, battery_percentage)
These columns were added in the Home Assistant integration feature. Existing databases
need a manual migration — `create_all()` does not add columns to existing tables.
**Always snapshot first:**
```bash
cp batteries.db batteries.db.$(date +%Y-%m-%d).snapshot
```
**SQLite:**
```bash
sqlite3 batteries.db "ALTER TABLE device ADD COLUMN ha_entity_id VARCHAR(100);"
sqlite3 batteries.db "ALTER TABLE battery ADD COLUMN battery_percentage INTEGER;"
```
**MariaDB / MySQL:**
```sql
ALTER TABLE device ADD COLUMN ha_entity_id VARCHAR(100) NULL;
ALTER TABLE battery ADD COLUMN battery_percentage INT NULL;
```
Both columns are nullable with no default, which is valid on all supported databases.
---
# Migrating from SQLite to MariaDB # Migrating from SQLite to MariaDB
This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL
+26 -2
View File
@@ -21,6 +21,25 @@ def create_app(config_object="config"):
def remove_session(exc=None): def remove_session(exc=None):
db.remove() db.remove()
# ------------------------------------------------------------------ #
# Home Assistant integration (optional)
# ------------------------------------------------------------------ #
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
ha_client = HomeAssistantClient(
url=app.config.get("HOMEASSISTANT_URL"),
api_key=app.config.get("HOMEASSISTANT_API_KEY"),
)
if ha_client.enabled:
poller = HaPoller(
ha_client=ha_client,
session_factory=sessionmaker(bind=engine),
interval=app.config.get("HOMEASSISTANT_POLL_INTERVAL", 300),
)
poller.start()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Dashboard # Dashboard
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -42,7 +61,8 @@ def create_app(config_object="config"):
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots] devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
return render_template("dashboard.html", batteries=batteries, return render_template("dashboard.html", batteries=batteries,
storage_locations=storage_locations, devices=devices, storage_locations=storage_locations, devices=devices,
devices_with_slots=devices_with_slots) devices_with_slots=devices_with_slots,
ha_enabled=ha_client.enabled)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Battery — add # Battery — add
@@ -156,6 +176,7 @@ def create_app(config_object="config"):
battery.charge_cycles = _int("charge_cycles") battery.charge_cycles = _int("charge_cycles")
battery.purchase_date = f.get("purchase_date", "").strip() or None battery.purchase_date = f.get("purchase_date", "").strip() or None
battery.storage_location = f.get("storage_location", "").strip() or None battery.storage_location = f.get("storage_location", "").strip() or None
battery.battery_percentage = _int("battery_percentage")
db.commit() db.commit()
flash("Details updated.", "success") flash("Details updated.", "success")
@@ -229,6 +250,7 @@ def create_app(config_object="config"):
notes = request.form.get("notes", "").strip() or None notes = request.form.get("notes", "").strip() or None
if increment: if increment:
battery.charge_cycles = (battery.charge_cycles or 0) + 1 battery.charge_cycles = (battery.charge_cycles or 0) + 1
battery.battery_percentage = 100
db.add(ChargeLog(battery_id=battery_id, charged_date=date_val, db.add(ChargeLog(battery_id=battery_id, charged_date=date_val,
increment_cycles=increment, notes=notes)) increment_cycles=increment, notes=notes))
db.commit() db.commit()
@@ -572,7 +594,8 @@ def create_app(config_object="config"):
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type}) device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
return render_template("device_detail.html", device=device, brands=brands, return render_template("device_detail.html", device=device, brands=brands,
available_batteries=available_batteries, available_batteries=available_batteries,
device_types=device_types) device_types=device_types,
ha_enabled=ha_client.enabled)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — edit # Devices — edit
@@ -607,6 +630,7 @@ def create_app(config_object="config"):
device.battery_slots = slots device.battery_slots = slots
device.notes = notes device.notes = notes
device.device_type = device_type device.device_type = device_type
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
db.commit() db.commit()
flash("Device updated.", "success") flash("Device updated.", "success")
return redirect(url_for("device_detail", device_id=device_id)) return redirect(url_for("device_detail", device_id=device_id))
+5
View File
@@ -6,3 +6,8 @@ SQLALCHEMY_DATABASE_URI = os.environ.get(
) )
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod") SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
# Home Assistant integration (all optional — app works normally when absent)
HOMEASSISTANT_URL = os.environ.get("HOMEASSISTANT_URL")
HOMEASSISTANT_API_KEY = os.environ.get("HOMEASSISTANT_API_KEY")
HOMEASSISTANT_POLL_INTERVAL = int(os.environ.get("HOMEASSISTANT_POLL_INTERVAL", "300"))
+40
View File
@@ -0,0 +1,40 @@
import logging
import requests
logger = logging.getLogger(__name__)
class HomeAssistantClient:
"""Thin wrapper around the Home Assistant REST API.
Instantiating with url=None or api_key=None produces a disabled client;
all methods become no-ops that return None.
"""
def __init__(self, url: str | None, api_key: str | None):
self._enabled = bool(url and api_key)
self._base_url = (url or "").rstrip("/")
self._headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
@property
def enabled(self) -> bool:
return self._enabled
def get_state(self, entity_id: str) -> int | None:
"""Fetch the current state of a HA entity and return it as an integer.
Returns None if HA is not configured, the entity is not found,
the state is non-numeric, or any network/HTTP error occurs.
"""
if not self._enabled:
return None
url = f"{self._base_url}/api/states/{entity_id}"
try:
resp = requests.get(url, headers=self._headers, timeout=10)
resp.raise_for_status()
state = resp.json().get("state")
return int(float(state))
except (requests.RequestException, ValueError, TypeError, KeyError) as exc:
logger.warning("HA API error for %s: %s", entity_id, exc)
return None
+60
View File
@@ -0,0 +1,60 @@
import logging
import threading
logger = logging.getLogger(__name__)
class HaPoller:
"""Background daemon thread that periodically polls Home Assistant
for battery percentages and updates installed batteries in the DB.
Lifecycle:
poller = HaPoller(ha_client, session_factory, interval_seconds)
poller.start() # called once from create_app
poller.stop() # not normally needed; daemon thread exits with the process
"""
def __init__(self, ha_client, session_factory, interval: int):
self._client = ha_client
self._Session = session_factory
self._interval = interval
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._run, name="ha-poller", daemon=True
)
def start(self):
self._thread.start()
logger.info("HA poller started (interval=%ds)", self._interval)
def stop(self):
self._stop.set()
def _run(self):
# Wait first so startup DB activity settles before the first poll
while not self._stop.wait(self._interval):
self._poll_once()
def _poll_once(self):
from models import Battery, Device # local import avoids circular-import risk
session = self._Session()
try:
devices = (
session.query(Device)
.filter(Device.ha_entity_id.isnot(None))
.all()
)
for device in devices:
pct = self._client.get_state(device.ha_entity_id)
if pct is not None:
for battery in device.batteries:
if battery.status == "installed":
battery.battery_percentage = pct
session.commit()
logger.debug("HA poll complete (%d devices checked)", len(devices))
except Exception as exc:
session.rollback()
logger.warning("HA poll failed: %s", exc)
finally:
session.close()
+2
View File
@@ -12,6 +12,7 @@ class Device(Base):
battery_slots = Column(Integer, nullable=False, default=1) battery_slots = Column(Integer, nullable=False, default=1)
device_type = Column(String(50), nullable=True) device_type = Column(String(50), nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
batteries = relationship("Battery", back_populates="device") batteries = relationship("Battery", back_populates="device")
@@ -47,6 +48,7 @@ class Battery(Base):
charge_cycles = Column(Integer, nullable=True) # number of charge cycles charge_cycles = Column(Integer, nullable=True) # number of charge cycles
purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased
storage_location = Column(String(100), nullable=True) # where stored when not installed storage_location = Column(String(100), nullable=True) # where stored when not installed
battery_percentage = Column(Integer, nullable=True) # current charge %, set by HA poller or manually
device = relationship("Device", back_populates="batteries") device = relationship("Device", back_populates="batteries")
capacity_tests = relationship( capacity_tests = relationship(
+1
View File
@@ -3,3 +3,4 @@ SQLAlchemy>=2.0,<3.0
PyMySQL>=1.1,<2.0 PyMySQL>=1.1,<2.0
waitress>=3.0,<4.0 waitress>=3.0,<4.0
pytest>=8.0,<9.0 pytest>=8.0,<9.0
requests>=2.31,<3.0
+10
View File
@@ -36,6 +36,13 @@ HOST="${HOST:-127.0.0.1}"
read -rp "Listen port [default: 5000]: " PORT read -rp "Listen port [default: 5000]: " PORT
PORT="${PORT:-5000}" PORT="${PORT:-5000}"
echo
echo "Home Assistant integration (optional — press Enter to skip):"
read -rp " HOMEASSISTANT_URL (e.g. http://homeassistant.local:8123): " HA_URL
read -rp " HOMEASSISTANT_API_KEY (long-lived access token): " HA_KEY
read -rp " HOMEASSISTANT_POLL_INTERVAL seconds [default: 300]: " HA_INTERVAL
HA_INTERVAL="${HA_INTERVAL:-300}"
echo echo
echo "Generating service file → $SERVICE_FILE" echo "Generating service file → $SERVICE_FILE"
@@ -51,6 +58,9 @@ Type=simple
WorkingDirectory=$APP_DIR WorkingDirectory=$APP_DIR
ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app
Environment=PYTHONPATH=$APP_DIR Environment=PYTHONPATH=$APP_DIR
$([ -n "$HA_URL" ] && echo "Environment=HOMEASSISTANT_URL=$HA_URL")
$([ -n "$HA_KEY" ] && echo "Environment=HOMEASSISTANT_API_KEY=$HA_KEY")
$([ -n "$HA_URL" ] && echo "Environment=HOMEASSISTANT_POLL_INTERVAL=$HA_INTERVAL")
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
+19
View File
@@ -67,6 +67,18 @@
{{ meta_row("Charge Cycles", battery.charge_cycles) }} {{ meta_row("Charge Cycles", battery.charge_cycles) }}
{{ meta_row("Purchase Date", battery.purchase_date) }} {{ meta_row("Purchase Date", battery.purchase_date) }}
{{ meta_row("Storage", battery.storage_location) }} {{ meta_row("Storage", battery.storage_location) }}
{% if battery.battery_percentage is not none %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Battery %</td>
<td style="border:none;">
{% if battery.battery_percentage < 20 %}
<span class="badge badge-warning">⚠ {{ battery.battery_percentage }}% — consider replacing</span>
{% else %}
{{ battery.battery_percentage }}%
{% endif %}
</td>
</tr>
{% endif %}
{% if battery.notes %} {% if battery.notes %}
<tr> <tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td> <td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
@@ -261,6 +273,13 @@
value="{{ battery.purchase_date or '' }}"> value="{{ battery.purchase_date or '' }}">
</div> </div>
<div class="form-group">
<label for="battery_percentage">Battery % (optional)</label>
<input type="number" id="battery_percentage" name="battery_percentage" min="0" max="100"
value="{{ battery.battery_percentage if battery.battery_percentage is not none else '' }}"
placeholder="e.g. 85">
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
+22 -2
View File
@@ -26,6 +26,15 @@
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired }}</div> <div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired }}</div>
<div class="text-muted">Retired</div> <div class="text-muted">Retired</div>
</div> </div>
{% if ha_enabled %}
{% set low_pct = batteries | selectattr('battery_percentage', 'ne', none) | selectattr('battery_percentage', 'lt', 20) | list %}
{% if low_pct %}
<div class="card" style="flex:1;min-width:120px;text-align:center;border:2px solid #f59e0b;">
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ low_pct|length }}</div>
<div class="text-muted">Low Battery</div>
</div>
{% endif %}
{% endif %}
</div> </div>
<div class="card"> <div class="card">
@@ -38,7 +47,8 @@
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label> <label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label> <label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label> <label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label> <label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
{% if ha_enabled %}<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="ha-pct" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Battery %</label>{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -150,6 +160,7 @@
<th class="col-storage" style="display:none;">Storage</th> <th class="col-storage" style="display:none;">Storage</th>
<th class="col-purchase" style="display:none;">Purchase Date</th> <th class="col-purchase" style="display:none;">Purchase Date</th>
<th class="col-cycles" style="display:none;">Cycles</th> <th class="col-cycles" style="display:none;">Cycles</th>
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;">Bat %</th>{% endif %}
<th>Status</th> <th>Status</th>
<th>Assigned To</th> <th>Assigned To</th>
<th>Actions</th> <th>Actions</th>
@@ -172,6 +183,15 @@
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td> <td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td> <td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
<td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td> <td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
{% if ha_enabled %}
<td data-label="Bat %" class="col-ha-pct" style="display:none;">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 %}
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Status"> <td data-label="Status">
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span> <span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
</td> </td>
@@ -358,7 +378,7 @@ updateToolbar();
// Column picker // Column picker
var COL_KEY = 'battery_cols'; var COL_KEY = 'battery_cols';
var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles']; var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
function toggleCol(cb) { function toggleCol(cb) {
var col = cb.dataset.col; var col = cb.dataset.col;
+24 -1
View File
@@ -28,6 +28,12 @@
<td style="border:none;">{{ device.notes }}</td> <td style="border:none;">{{ device.notes }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if ha_enabled and device.ha_entity_id %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Entity</td>
<td style="border:none;"><code>{{ device.ha_entity_id }}</code></td>
</tr>
{% endif %}
</table> </table>
</div> </div>
@@ -99,13 +105,22 @@ function addInstallRow() {
<div class="table-wrap"> <div class="table-wrap">
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr> <tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Notes</th><th>Actions</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for b in installed %} {% for b in installed %}
<tr> <tr>
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td> <td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
<td data-label="Brand">{{ b.brand }}</td> <td data-label="Brand">{{ b.brand }}</td>
{% if ha_enabled %}
<td data-label="Bat %">
{% if b.battery_percentage is not none %}
{% if b.battery_percentage < 20 %}
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
{% else %}{{ b.battery_percentage }}%{% endif %}
{% else %}—{% endif %}
</td>
{% endif %}
<td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td> <td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td>
<td data-label="Actions"> <td data-label="Actions">
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}"> <form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
@@ -180,6 +195,14 @@ function addInstallRow() {
<label for="edit-notes">Notes</label> <label for="edit-notes">Notes</label>
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea> <textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
</div> </div>
{% if ha_enabled %}
<div class="form-group">
<label for="edit-ha-entity">Home Assistant Entity ID</label>
<input type="text" id="edit-ha-entity" name="ha_entity_id"
value="{{ device.ha_entity_id or '' }}"
placeholder="e.g. sensor.tv_remote_battery">
</div>
{% endif %}
<button class="btn btn-primary" type="submit">Save Changes</button> <button class="btn btn-primary" type="submit">Save Changes</button>
</form> </form>
</div> </div>
+2
View File
@@ -15,6 +15,8 @@ def app():
SECRET_KEY = "test-secret" SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True TESTING = True
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
HOMEASSISTANT_API_KEY = None
from app import create_app from app import create_app
flask_app = create_app(TestConfig) flask_app = create_app(TestConfig)
+368
View File
@@ -0,0 +1,368 @@
"""Acceptance tests for the optional Home Assistant integration.
Groups:
1. HA disabled — regression guard (uses default client/seeded_client fixtures)
2. HomeAssistantClient unit tests — mock requests.get, no Flask required
3. Poller integration — uses ha_app fixture, calls _poll_once() directly
4. Dashboard/device/battery UI — checks rendered HTML with HA enabled
"""
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# HA-enabled app fixture
# ---------------------------------------------------------------------------
@pytest.fixture()
def ha_app():
"""Flask app with Home Assistant enabled (HOMEASSISTANT_URL set).
The poller is started but its first fire is after 300 s — it never fires
during a test run.
"""
db_fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(db_fd)
class HaTestConfig:
SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
SECRET_KEY = "test-secret"
SQLALCHEMY_TRACK_MODIFICATIONS = False
TESTING = True
HOMEASSISTANT_URL = "http://ha.test:8123"
HOMEASSISTANT_API_KEY = "fake-token"
HOMEASSISTANT_POLL_INTERVAL = 300
from app import create_app
flask_app = create_app(HaTestConfig)
yield flask_app
os.unlink(db_path)
@pytest.fixture()
def ha_client_f(ha_app):
"""Test client bound to the HA-enabled app."""
with ha_app.test_client() as c:
yield c
# ---------------------------------------------------------------------------
# Group 1 — HA disabled (regression guard)
# ---------------------------------------------------------------------------
def test_ha_disabled_dashboard_no_ha_column(client):
resp = client.get("/")
assert resp.status_code == 200
assert b"ha-pct" not in resp.data
def test_ha_disabled_device_no_ha_field(client):
client.post("/device/add", data={"name": "Dev A", "battery_slots": "1"})
resp = client.get("/device/1")
assert resp.status_code == 200
assert b"ha_entity_id" not in resp.data
def test_ha_disabled_battery_detail_still_has_percentage_field(client):
"""battery_percentage edit field is always shown regardless of HA config."""
client.post("/battery/add", data={"brand": "X", "count": "1"})
resp = client.get("/battery/1")
assert b"battery_percentage" in resp.data
# ---------------------------------------------------------------------------
# Group 2 — HomeAssistantClient unit tests
# ---------------------------------------------------------------------------
def test_ha_client_disabled_when_no_url():
from ha_client import HomeAssistantClient
c = HomeAssistantClient(None, None)
assert c.enabled is False
assert c.get_state("sensor.foo") is None
def test_ha_client_disabled_when_no_key():
from ha_client import HomeAssistantClient
c = HomeAssistantClient("http://ha.local", None)
assert c.enabled is False
def test_ha_client_returns_integer_state():
from ha_client import HomeAssistantClient
mock_resp = MagicMock()
mock_resp.json.return_value = {"state": "85", "entity_id": "sensor.foo"}
mock_resp.raise_for_status.return_value = None
with patch("ha_client.requests.get", return_value=mock_resp) as mock_get:
c = HomeAssistantClient("http://ha.local:8123", "tok")
result = c.get_state("sensor.foo")
assert result == 85
mock_get.assert_called_once_with(
"http://ha.local:8123/api/states/sensor.foo",
headers={"Authorization": "Bearer tok"},
timeout=10,
)
def test_ha_client_handles_float_state():
from ha_client import HomeAssistantClient
mock_resp = MagicMock()
mock_resp.json.return_value = {"state": "72.6"}
mock_resp.raise_for_status.return_value = None
with patch("ha_client.requests.get", return_value=mock_resp):
c = HomeAssistantClient("http://ha.local", "tok")
assert c.get_state("sensor.x") == 72
def test_ha_client_returns_none_on_network_error():
from ha_client import HomeAssistantClient
import requests as req
with patch("ha_client.requests.get", side_effect=req.RequestException("timeout")):
c = HomeAssistantClient("http://ha.local", "tok")
assert c.get_state("sensor.x") is None
def test_ha_client_returns_none_on_non_numeric_state():
from ha_client import HomeAssistantClient
mock_resp = MagicMock()
mock_resp.json.return_value = {"state": "unavailable"}
mock_resp.raise_for_status.return_value = None
with patch("ha_client.requests.get", return_value=mock_resp):
c = HomeAssistantClient("http://ha.local", "tok")
assert c.get_state("sensor.x") is None
# ---------------------------------------------------------------------------
# Group 3 — Poller integration
# ---------------------------------------------------------------------------
def _make_session_factory(ha_app):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
return sessionmaker(bind=engine)
def test_poll_updates_installed_batteries(ha_app, ha_client_f):
"""Installed battery in a device with ha_entity_id gets battery_percentage updated."""
ha_client_f.post("/device/add", data={"name": "Dev A", "battery_slots": "2"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Assign battery to device
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
# Set ha_entity_id on device
ha_client_f.post("/device/1/edit", data={
"name": "Dev A", "battery_slots": "2", "ha_entity_id": "sensor.dev_a_battery"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = 42
Session = _make_session_factory(ha_app)
poller = HaPoller(mock_ha, Session, interval=300)
poller._poll_once()
s = Session()
b = s.get(Battery, 1)
assert b.battery_percentage == 42
s.close()
mock_ha.get_state.assert_called_once_with("sensor.dev_a_battery")
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
"""Batteries that are available (not installed) are not updated by the poller."""
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Set entity on device but do NOT install the battery
ha_client_f.post("/device/1/edit", data={
"name": "Dev B", "battery_slots": "1", "ha_entity_id": "sensor.dev_b_battery"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = 55
Session = _make_session_factory(ha_app)
poller = HaPoller(mock_ha, Session, interval=300)
poller._poll_once()
s = Session()
b = s.get(Battery, 1)
assert b.battery_percentage is None # uninstalled — not touched
s.close()
def test_poll_skips_devices_without_entity_id(ha_app, ha_client_f):
"""Devices with no ha_entity_id must never reach get_state."""
ha_client_f.post("/device/add", data={"name": "Dev C", "battery_slots": "1"})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
Session = _make_session_factory(ha_app)
poller = HaPoller(mock_ha, Session, interval=300)
poller._poll_once()
mock_ha.get_state.assert_not_called()
def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
"""When get_state returns None, no exception is raised and percentage stays None."""
ha_client_f.post("/device/add", data={"name": "Dev D", "battery_slots": "1"})
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev D", "battery_slots": "1", "ha_entity_id": "sensor.dev_d"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = None # simulate API failure
Session = _make_session_factory(ha_app)
poller = HaPoller(mock_ha, Session, interval=300)
poller._poll_once() # must not raise
s = Session()
b = s.get(Battery, 1)
assert b.battery_percentage is None
s.close()
# ---------------------------------------------------------------------------
# Group 4 — Dashboard / device / battery UI with HA enabled
# ---------------------------------------------------------------------------
def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
resp = ha_client_f.get("/")
assert resp.status_code == 200
assert b"ha-pct" in resp.data
def test_dashboard_shows_low_battery_warning(ha_app, ha_client_f):
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Set percentage directly in DB
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Battery
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
s = sessionmaker(bind=engine)()
b = s.get(Battery, 1)
b.battery_percentage = 15
s.commit()
s.close()
resp = ha_client_f.get("/")
assert b"badge-warning" in resp.data
assert b"15%" in resp.data
def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Battery
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
s = sessionmaker(bind=engine)()
b = s.get(Battery, 1)
b.battery_percentage = 85
s.commit()
s.close()
resp = ha_client_f.get("/")
assert b"85%" in resp.data
# badge-warning should NOT appear for this battery's percentage
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
assert b"col-ha-pct" in resp.data # column rendered
def test_device_detail_shows_ha_field(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1"})
resp = ha_client_f.get("/device/1")
assert resp.status_code == 200
assert b"ha_entity_id" in resp.data
def test_edit_device_ha_entity_id_saves(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev F", "battery_slots": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev F", "battery_slots": "1", "ha_entity_id": "sensor.my_remote"
})
resp = ha_client_f.get("/device/1")
assert b"sensor.my_remote" in resp.data
def test_edit_device_ha_entity_id_clear(ha_client_f):
ha_client_f.post("/device/add", data={"name": "Dev G", "battery_slots": "1"})
ha_client_f.post("/device/1/edit", data={
"name": "Dev G", "battery_slots": "1", "ha_entity_id": "sensor.foo"
})
# Now clear it
ha_client_f.post("/device/1/edit", data={
"name": "Dev G", "battery_slots": "1", "ha_entity_id": ""
})
resp = ha_client_f.get("/device/1")
assert b"sensor.foo" not in resp.data
def test_charge_log_resets_percentage_to_100(ha_app, ha_client_f):
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Set low percentage
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Battery
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
s = sessionmaker(bind=engine)()
b = s.get(Battery, 1)
b.battery_percentage = 30
s.commit()
s.close()
# Log a charge
ha_client_f.post("/battery/1/charge-log/add", data={"charged_date": "2026-04-13"})
s2 = sessionmaker(bind=engine)()
b2 = s2.get(Battery, 1)
assert b2.battery_percentage == 100
s2.close()
def test_manual_battery_percentage_edit(ha_client_f):
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
# Set via edit form
ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": "55"})
resp = ha_client_f.get("/battery/1")
assert b"55%" in resp.data
# Clear via edit form
ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": ""})
resp2 = ha_client_f.get("/battery/1")
# percentage display row should not appear when None
assert b"Battery %" not in resp2.data or b"55%" not in resp2.data