HA improvements: entity overflow fix, live % fetch on device page, searchable entity dropdown

This commit is contained in:
2026-04-14 01:17:53 -05:00
parent d7ba64a2f3
commit b6a3533fed
4 changed files with 184 additions and 6 deletions
+26 -2
View File
@@ -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 import create_engine, func
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
@@ -547,6 +547,12 @@ def create_app(config_object="config"):
# Devices — list # 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/") @app.route("/device/")
def device_list(): def device_list():
devices = db.query(Device).order_by(Device.name).all() devices = db.query(Device).order_by(Device.name).all()
@@ -614,10 +620,28 @@ def create_app(config_object="config"):
.filter_by(status="available") .filter_by(status="available")
.order_by(Battery.label).all()) .order_by(Battery.label).all())
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})
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, 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) ha_enabled=ha_client.enabled,
ha_live_pct=ha_live_pct)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Devices — edit # Devices — edit
+29 -2
View File
@@ -21,7 +21,7 @@ class HomeAssistantClient:
def enabled(self) -> bool: def enabled(self) -> bool:
return self._enabled 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. """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, Returns None if HA is not configured, the entity is not found,
@@ -31,10 +31,37 @@ class HomeAssistantClient:
return None return None
url = f"{self._base_url}/api/states/{entity_id}" url = f"{self._base_url}/api/states/{entity_id}"
try: try:
resp = requests.get(url, headers=self._headers, timeout=10) resp = requests.get(url, headers=self._headers, timeout=timeout)
resp.raise_for_status() resp.raise_for_status()
state = resp.json().get("state") state = resp.json().get("state")
return int(float(state)) return int(float(state))
except (requests.RequestException, ValueError, TypeError, KeyError) as exc: except (requests.RequestException, ValueError, TypeError, KeyError) as exc:
logger.warning("HA API error for %s: %s", entity_id, exc) logger.warning("HA API error for %s: %s", entity_id, exc)
return None 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 []
+52 -2
View File
@@ -31,8 +31,20 @@
{% if ha_enabled and device.ha_entity_id %} {% if ha_enabled and device.ha_entity_id %}
<tr> <tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Entity</td> <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> <td style="border:none;"><code style="word-break:break-all;">{{ device.ha_entity_id }}</code></td>
</tr> </tr>
{% if ha_live_pct is not none %}
<tr>
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Live %</td>
<td style="border:none;">
{% if ha_live_pct < 20 %}
<span class="badge badge-warning">⚠ {{ ha_live_pct }}%</span>
{% else %}
{{ ha_live_pct }}%
{% endif %}
</td>
</tr>
{% endif %}
{% endif %} {% endif %}
</table> </table>
</div> </div>
@@ -200,7 +212,10 @@ function addInstallRow() {
<label for="edit-ha-entity">Home Assistant Entity ID</label> <label for="edit-ha-entity">Home Assistant Entity ID</label>
<input type="text" id="edit-ha-entity" name="ha_entity_id" <input type="text" id="edit-ha-entity" name="ha_entity_id"
value="{{ device.ha_entity_id or '' }}" value="{{ device.ha_entity_id or '' }}"
placeholder="e.g. sensor.tv_remote_battery"> placeholder="e.g. sensor.tv_remote_battery"
list="ha-entities-list" autocomplete="off">
<datalist id="ha-entities-list"></datalist>
<small class="text-muted" id="ha-entities-status" style="display:block;margin-top:0.25rem;font-size:0.8rem;"></small>
</div> </div>
{% endif %} {% endif %}
<button class="btn btn-primary" type="submit">Save Changes</button> <button class="btn btn-primary" type="submit">Save Changes</button>
@@ -218,4 +233,39 @@ function addInstallRow() {
</div> </div>
<a class="text-muted" href="{{ url_for('device_list') }}">&larr; Back to Devices</a> <a class="text-muted" href="{{ url_for('device_list') }}">&larr; Back to Devices</a>
{% if ha_enabled %}
<script>
(function() {
var datalist = document.getElementById('ha-entities-list');
var status = document.getElementById('ha-entities-status');
if (!datalist) return;
function populate(entities) {
datalist.innerHTML = '';
entities.forEach(function(e) {
var opt = document.createElement('option');
opt.value = e.entity_id;
if (e.friendly_name && e.friendly_name !== e.entity_id) opt.label = e.friendly_name;
datalist.appendChild(opt);
});
if (status) status.textContent = entities.length ? entities.length + ' battery entities available' : '';
}
try {
var cached = sessionStorage.getItem('ha_battery_entities');
if (cached) { populate(JSON.parse(cached)); return; }
} catch(e) {}
if (status) status.textContent = 'Loading HA entities\u2026';
fetch('/ha/entities')
.then(function(r) { return r.json(); })
.then(function(entities) {
try { sessionStorage.setItem('ha_battery_entities', JSON.stringify(entities)); } catch(e) {}
populate(entities);
})
.catch(function() { if (status) status.textContent = ''; });
}());
</script>
{% endif %}
{% endblock %} {% endblock %}
+77
View File
@@ -470,3 +470,80 @@ def test_manual_edit_no_log_when_unchanged(ha_client_f):
resp = ha_client_f.get("/battery/1") resp = ha_client_f.get("/battery/1")
# "40%" appears once in the info section and once in history — not twice in history # "40%" appears once in the info section and once in history — not twice in history
assert resp.data.count(b"40%") == 2 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"