diff --git a/app.py b/app.py
index 1f2d21d..16054c0 100644
--- a/app.py
+++ b/app.py
@@ -1,4 +1,4 @@
-from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory
+from flask import Flask, render_template, redirect, url_for, request, flash, abort, send_from_directory, jsonify
from sqlalchemy import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker
@@ -547,6 +547,12 @@ def create_app(config_object="config"):
# Devices — list
# ------------------------------------------------------------------ #
+ @app.route("/ha/entities")
+ def ha_entities():
+ if not ha_client.enabled:
+ return jsonify([])
+ return jsonify(ha_client.list_battery_entities())
+
@app.route("/device/")
def device_list():
devices = db.query(Device).order_by(Device.name).all()
@@ -614,10 +620,28 @@ def create_app(config_object="config"):
.filter_by(status="available")
.order_by(Battery.label).all())
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
+ ha_live_pct = None
+ if ha_client.enabled and device.ha_entity_id:
+ ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=4)
+ if ha_live_pct is not None:
+ changed = False
+ for battery in device.batteries:
+ if battery.status == "installed" and battery.battery_percentage != ha_live_pct:
+ battery.battery_percentage = ha_live_pct
+ db.add(BatteryPctLog(
+ battery_id=battery.id,
+ percentage=ha_live_pct,
+ recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
+ source="poll",
+ ))
+ changed = True
+ if changed:
+ db.commit()
return render_template("device_detail.html", device=device, brands=brands,
available_batteries=available_batteries,
device_types=device_types,
- ha_enabled=ha_client.enabled)
+ ha_enabled=ha_client.enabled,
+ ha_live_pct=ha_live_pct)
# ------------------------------------------------------------------ #
# Devices — edit
diff --git a/ha_client.py b/ha_client.py
index 0db568e..620ad53 100644
--- a/ha_client.py
+++ b/ha_client.py
@@ -21,7 +21,7 @@ class HomeAssistantClient:
def enabled(self) -> bool:
return self._enabled
- def get_state(self, entity_id: str) -> int | None:
+ def get_state(self, entity_id: str, timeout: int = 10) -> 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,
@@ -31,10 +31,37 @@ class HomeAssistantClient:
return None
url = f"{self._base_url}/api/states/{entity_id}"
try:
- resp = requests.get(url, headers=self._headers, timeout=10)
+ resp = requests.get(url, headers=self._headers, timeout=timeout)
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
+
+ def list_battery_entities(self) -> list[dict]:
+ """Return all HA entities that look like battery sensors.
+
+ Includes entities where device_class == 'battery' or 'battery' appears
+ in the entity_id. Returns [] when disabled or on any error.
+ """
+ if not self._enabled:
+ return []
+ url = f"{self._base_url}/api/states"
+ try:
+ resp = requests.get(url, headers=self._headers, timeout=15)
+ resp.raise_for_status()
+ result = []
+ for s in resp.json():
+ eid = s.get("entity_id", "")
+ attrs = s.get("attributes", {})
+ if (attrs.get("device_class") == "battery"
+ or "battery" in eid.lower()):
+ result.append({
+ "entity_id": eid,
+ "friendly_name": attrs.get("friendly_name", eid),
+ })
+ return sorted(result, key=lambda x: x["entity_id"])
+ except (requests.RequestException, ValueError, TypeError) as exc:
+ logger.warning("HA list_battery_entities error: %s", exc)
+ return []
diff --git a/templates/device_detail.html b/templates/device_detail.html
index 331bd48..c2293de 100644
--- a/templates/device_detail.html
+++ b/templates/device_detail.html
@@ -31,8 +31,20 @@
{% if ha_enabled and device.ha_entity_id %}
| HA Entity |
- {{ device.ha_entity_id }} |
+ {{ device.ha_entity_id }} |
+ {% if ha_live_pct is not none %}
+
+ | HA Live % |
+
+ {% if ha_live_pct < 20 %}
+ ⚠ {{ ha_live_pct }}%
+ {% else %}
+ {{ ha_live_pct }}%
+ {% endif %}
+ |
+
+ {% endif %}
{% endif %}
@@ -200,7 +212,10 @@ function addInstallRow() {
+ placeholder="e.g. sensor.tv_remote_battery"
+ list="ha-entities-list" autocomplete="off">
+
+
{% endif %}
@@ -218,4 +233,39 @@ function addInstallRow() {
← Back to Devices
+
+{% if ha_enabled %}
+
+{% endif %}
{% endblock %}
diff --git a/tests/test_ha_integration.py b/tests/test_ha_integration.py
index 34a6605..5a903c3 100644
--- a/tests/test_ha_integration.py
+++ b/tests/test_ha_integration.py
@@ -470,3 +470,80 @@ def test_manual_edit_no_log_when_unchanged(ha_client_f):
resp = ha_client_f.get("/battery/1")
# "40%" appears once in the info section and once in history — not twice in history
assert resp.data.count(b"40%") == 2
+
+
+# ---------------------------------------------------------------------------
+# Group 5 — live fetch on device page load + /ha/entities endpoint
+# ---------------------------------------------------------------------------
+
+def test_device_detail_shows_live_pct(ha_app, ha_client_f):
+ """Opening a device page fetches live % from HA and displays it."""
+ ha_client_f.post("/device/add", data={"name": "Dev Live", "battery_slots": "1"})
+ ha_client_f.post("/device/1/edit", data={
+ "name": "Dev Live", "battery_slots": "1", "ha_entity_id": "sensor.live_test"
+ })
+
+ mock_resp = MagicMock()
+ mock_resp.json.return_value = {"state": "78", "entity_id": "sensor.live_test"}
+ mock_resp.raise_for_status.return_value = None
+
+ with patch("ha_client.requests.get", return_value=mock_resp):
+ resp = ha_client_f.get("/device/1")
+
+ assert resp.status_code == 200
+ assert b"78%" in resp.data
+
+
+def test_device_detail_updates_battery_on_load(ha_app, ha_client_f):
+ """Battery percentage is updated (with pct_log) when device page is loaded and value changed."""
+ ha_client_f.post("/device/add", data={"name": "Dev Update", "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 Update", "battery_slots": "1", "ha_entity_id": "sensor.update_test"
+ })
+
+ mock_resp = MagicMock()
+ mock_resp.json.return_value = {"state": "63"}
+ mock_resp.raise_for_status.return_value = None
+
+ with patch("ha_client.requests.get", return_value=mock_resp):
+ ha_client_f.get("/device/1")
+
+ from sqlalchemy import create_engine
+ from sqlalchemy.orm import sessionmaker
+ from models import Battery, BatteryPctLog
+ engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
+ s = sessionmaker(bind=engine)()
+ b = s.get(Battery, 1)
+ assert b.battery_percentage == 63
+ logs = s.query(BatteryPctLog).filter_by(battery_id=1, source="poll").all()
+ assert len(logs) == 1
+ assert logs[0].percentage == 63
+ s.close()
+
+
+def test_ha_entities_endpoint(ha_app, ha_client_f):
+ """GET /ha/entities returns only battery-related entities from HA."""
+ mock_resp = MagicMock()
+ mock_resp.json.return_value = [
+ {"entity_id": "sensor.tv_battery", "attributes": {"device_class": "battery", "friendly_name": "TV Battery"}},
+ {"entity_id": "sensor.remote_battery", "attributes": {"friendly_name": "Remote"}}, # 'battery' in id
+ {"entity_id": "sensor.temperature", "attributes": {"device_class": "temperature", "friendly_name": "Temp"}},
+ {"entity_id": "light.living_room", "attributes": {}},
+ ]
+ mock_resp.raise_for_status.return_value = None
+
+ with patch("ha_client.requests.get", return_value=mock_resp):
+ resp = ha_client_f.get("/ha/entities")
+
+ assert resp.status_code == 200
+ data = resp.get_json()
+ entity_ids = [e["entity_id"] for e in data]
+ assert "sensor.tv_battery" in entity_ids
+ assert "sensor.remote_battery" in entity_ids
+ assert "sensor.temperature" not in entity_ids
+ assert "light.living_room" not in entity_ids
+ # friendly names present
+ tv = next(e for e in data if e["entity_id"] == "sensor.tv_battery")
+ assert tv["friendly_name"] == "TV Battery"