Add optional Home Assistant integration for battery percentage tracking
This commit is contained in:
@@ -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
|
||||
|
||||
This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL
|
||||
|
||||
@@ -21,6 +21,25 @@ def create_app(config_object="config"):
|
||||
def remove_session(exc=None):
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -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]
|
||||
return render_template("dashboard.html", batteries=batteries,
|
||||
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
|
||||
@@ -156,6 +176,7 @@ def create_app(config_object="config"):
|
||||
battery.charge_cycles = _int("charge_cycles")
|
||||
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
||||
battery.storage_location = f.get("storage_location", "").strip() or None
|
||||
battery.battery_percentage = _int("battery_percentage")
|
||||
|
||||
db.commit()
|
||||
flash("Details updated.", "success")
|
||||
@@ -229,6 +250,7 @@ def create_app(config_object="config"):
|
||||
notes = request.form.get("notes", "").strip() or None
|
||||
if increment:
|
||||
battery.charge_cycles = (battery.charge_cycles or 0) + 1
|
||||
battery.battery_percentage = 100
|
||||
db.add(ChargeLog(battery_id=battery_id, charged_date=date_val,
|
||||
increment_cycles=increment, notes=notes))
|
||||
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})
|
||||
return render_template("device_detail.html", device=device, brands=brands,
|
||||
available_batteries=available_batteries,
|
||||
device_types=device_types)
|
||||
device_types=device_types,
|
||||
ha_enabled=ha_client.enabled)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Devices — edit
|
||||
@@ -607,6 +630,7 @@ def create_app(config_object="config"):
|
||||
device.battery_slots = slots
|
||||
device.notes = notes
|
||||
device.device_type = device_type
|
||||
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
|
||||
db.commit()
|
||||
flash("Device updated.", "success")
|
||||
return redirect(url_for("device_detail", device_id=device_id))
|
||||
|
||||
@@ -6,3 +6,8 @@ SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
)
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
||||
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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -12,6 +12,7 @@ class Device(Base):
|
||||
battery_slots = Column(Integer, nullable=False, default=1)
|
||||
device_type = Column(String(50), 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")
|
||||
|
||||
@@ -47,6 +48,7 @@ class Battery(Base):
|
||||
charge_cycles = Column(Integer, nullable=True) # number of charge cycles
|
||||
purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased
|
||||
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")
|
||||
capacity_tests = relationship(
|
||||
|
||||
@@ -3,3 +3,4 @@ SQLAlchemy>=2.0,<3.0
|
||||
PyMySQL>=1.1,<2.0
|
||||
waitress>=3.0,<4.0
|
||||
pytest>=8.0,<9.0
|
||||
requests>=2.31,<3.0
|
||||
|
||||
@@ -36,6 +36,13 @@ HOST="${HOST:-127.0.0.1}"
|
||||
read -rp "Listen port [default: 5000]: " PORT
|
||||
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 "Generating service file → $SERVICE_FILE"
|
||||
|
||||
@@ -51,6 +58,9 @@ Type=simple
|
||||
WorkingDirectory=$APP_DIR
|
||||
ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app
|
||||
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
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
{{ meta_row("Charge Cycles", battery.charge_cycles) }}
|
||||
{{ meta_row("Purchase Date", battery.purchase_date) }}
|
||||
{{ 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 %}
|
||||
<tr>
|
||||
<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 '' }}">
|
||||
</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 class="form-group">
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired }}</div>
|
||||
<div class="text-muted">Retired</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 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="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;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>
|
||||
@@ -150,6 +160,7 @@
|
||||
<th class="col-storage" style="display:none;">Storage</th>
|
||||
<th class="col-purchase" style="display:none;">Purchase Date</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>Assigned To</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="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>
|
||||
{% 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">
|
||||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||||
</td>
|
||||
@@ -358,7 +378,7 @@ updateToolbar();
|
||||
|
||||
// Column picker
|
||||
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) {
|
||||
var col = cb.dataset.col;
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
<td style="border:none;">{{ device.notes }}</td>
|
||||
</tr>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
@@ -99,13 +105,22 @@ function addInstallRow() {
|
||||
<div class="table-wrap">
|
||||
<table class="responsive-table">
|
||||
<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>
|
||||
<tbody>
|
||||
{% for b in installed %}
|
||||
<tr>
|
||||
<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>
|
||||
{% 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="Actions">
|
||||
<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>
|
||||
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ def app():
|
||||
SECRET_KEY = "test-secret"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
TESTING = True
|
||||
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
|
||||
HOMEASSISTANT_API_KEY = None
|
||||
|
||||
from app import create_app
|
||||
flask_app = create_app(TestConfig)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user