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.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
+29 -2
View File
@@ -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 []
+52 -2
View File
@@ -31,8 +31,20 @@
{% 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>
<td style="border:none;"><code style="word-break:break-all;">{{ device.ha_entity_id }}</code></td>
</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 %}
</table>
</div>
@@ -200,7 +212,10 @@ function addInstallRow() {
<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">
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>
{% endif %}
<button class="btn btn-primary" type="submit">Save Changes</button>
@@ -218,4 +233,39 @@ function addInstallRow() {
</div>
<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 %}
+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")
# "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"