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 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
@@ -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 []
|
||||||
|
|||||||
@@ -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') }}">← Back to Devices</a>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user