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"