From 17bad3f36b52d797a5274192e25b6bd855d00fb7 Mon Sep 17 00:00:00 2001 From: Darek Date: Wed, 10 Jun 2026 12:31:11 -0500 Subject: [PATCH] Validate battery percentage range on manual edit; clamp HA values to 0-100 --- app.py | 4 ++++ ha_poller.py | 1 + tests/test_acceptance.py | 20 ++++++++++++++++++++ tests/test_ha_integration.py | 25 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/app.py b/app.py index d219c34..3bff4f5 100644 --- a/app.py +++ b/app.py @@ -293,6 +293,9 @@ def create_app(config_object="config"): battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None battery.storage_location = f.get("storage_location", "").strip() or None 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: battery.battery_percentage = new_pct 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: ha_live_pct = ha_client.get_state(device.ha_entity_id, timeout=1) if ha_live_pct is not None: + ha_live_pct = max(0, min(100, ha_live_pct)) changed = False for battery in device.installed_batteries(): if battery.battery_percentage != ha_live_pct: diff --git a/ha_poller.py b/ha_poller.py index 92792bd..abed3f9 100644 --- a/ha_poller.py +++ b/ha_poller.py @@ -49,6 +49,7 @@ class HaPoller: for device in devices: pct = self._client.get_state(device.ha_entity_id) if pct is not None: + pct = max(0, min(100, pct)) for battery in device.installed_batteries(): if battery.battery_percentage != pct: battery.battery_percentage = pct diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index b271544..6e10acb 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -117,6 +117,26 @@ def test_edit_notes(seeded_client): # 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): resp = seeded_client.post("/battery/1/assign", data={"device_id": "1"}, diff --git a/tests/test_ha_integration.py b/tests/test_ha_integration.py index 8f17bd8..690ab37 100644 --- a/tests/test_ha_integration.py +++ b/tests/test_ha_integration.py @@ -213,6 +213,31 @@ def test_poll_updates_batteries_in_subcomponents(ha_app, ha_client_f): 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): """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"})