Validate battery percentage range on manual edit; clamp HA values to 0-100

This commit is contained in:
2026-06-10 12:31:11 -05:00
parent a9835fee1e
commit 17bad3f36b
4 changed files with 50 additions and 0 deletions
+4
View File
@@ -293,6 +293,9 @@ def create_app(config_object="config"):
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
battery.storage_location = f.get("storage_location", "").strip() or None battery.storage_location = f.get("storage_location", "").strip() or None
new_pct = _int("battery_percentage") new_pct = _int("battery_percentage")
if new_pct is not None and not (0 <= new_pct <= 100):
flash("Battery percentage must be between 0 and 100.", "error")
return redirect(url_for("battery_detail", battery_id=battery_id))
if new_pct != battery.battery_percentage: if new_pct != battery.battery_percentage:
battery.battery_percentage = new_pct battery.battery_percentage = new_pct
if new_pct is not None: if new_pct is not None:
@@ -803,6 +806,7 @@ def create_app(config_object="config"):
if ha_client.enabled and device.ha_entity_id: if ha_client.enabled and device.ha_entity_id:
ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1) ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1)
if ha_live_pct is not None: if ha_live_pct is not None:
ha_live_pct = max(0, min(100, ha_live_pct))
changed = False changed = False
for battery in device.installed_batteries(): for battery in device.installed_batteries():
if battery.battery_percentage != ha_live_pct: if battery.battery_percentage != ha_live_pct:
+1
View File
@@ -49,6 +49,7 @@ class HaPoller:
for device in devices: for device in devices:
pct = self._client.get_state(device.ha_entity_id) pct = self._client.get_state(device.ha_entity_id)
if pct is not None: if pct is not None:
pct = max(0, min(100, pct))
for battery in device.installed_batteries(): for battery in device.installed_batteries():
if battery.battery_percentage != pct: if battery.battery_percentage != pct:
battery.battery_percentage = pct battery.battery_percentage = pct
+20
View File
@@ -117,6 +117,26 @@ def test_edit_notes(seeded_client):
# Battery — assign (per-battery, kept for special cases) # Battery — assign (per-battery, kept for special cases)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_edit_details_percentage_out_of_range_rejected(seeded_client):
for bad in ("150", "-5"):
resp = seeded_client.post("/battery/1/edit-details",
data={"battery_percentage": bad},
follow_redirects=True)
assert b"between 0 and 100" in resp.data
data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] is None
assert data["pct_logs"] == []
def test_edit_details_percentage_valid(seeded_client):
seeded_client.post("/battery/1/edit-details", data={"battery_percentage": "100"})
data = _json.loads(seeded_client.get("/export/all.json").data)
bat = next(b for b in data["batteries"] if b["id"] == 1)
assert bat["battery_percentage"] == 100
assert any(l["battery_id"] == 1 and l["source"] == "manual" for l in data["pct_logs"])
def test_assign_battery(seeded_client): def test_assign_battery(seeded_client):
resp = seeded_client.post("/battery/1/assign", resp = seeded_client.post("/battery/1/assign",
data={"device_id": "1"}, data={"device_id": "1"},
+25
View File
@@ -213,6 +213,31 @@ def test_poll_updates_batteries_in_subcomponents(ha_app, ha_client_f):
s.close() s.close()
def test_poll_clamps_out_of_range_percentage(ha_app, ha_client_f):
"""HA sensors occasionally report out-of-range values; the poller clamps to 0-100."""
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1", "battery_size": "AA"})
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 E", "battery_slots": "1", "battery_size": "AA", "ha_entity_id": "sensor.dev_e"
})
from ha_client import HomeAssistantClient
from ha_poller import HaPoller
from models import Battery
mock_ha = MagicMock(spec=HomeAssistantClient)
mock_ha.enabled = True
mock_ha.get_state.return_value = 150
Session = _make_session_factory(ha_app)
HaPoller(mock_ha, Session, interval=300)._poll_once()
s = Session()
assert s.get(Battery, 1).battery_percentage == 100
s.close()
def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f): def test_poll_skips_uninstalled_batteries(ha_app, ha_client_f):
"""Batteries that are available (not installed) are not updated by the poller.""" """Batteries that are available (not installed) are not updated by the poller."""
ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"}) ha_client_f.post("/device/add", data={"name": "Dev B", "battery_slots": "1", "battery_size": "AA"})