HA improvements: entity overflow fix, live % fetch on device page, searchable entity dropdown
This commit is contained in:
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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') }}">← 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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user