From d7ba64a2f3818432e549430392c6a8159df6c538 Mon Sep 17 00:00:00 2001 From: Darek Date: Mon, 13 Apr 2026 21:15:19 -0500 Subject: [PATCH] Track battery percentage history; skip poll write when value unchanged --- MIGRATION.md | 37 ++++++++++++ app.py | 28 ++++++++- ha_poller.py | 11 +++- models.py | 22 +++++++ templates/battery_detail.html | 31 ++++++++++ tests/test_ha_integration.py | 108 +++++++++++++++++++++++++++++++++- 6 files changed, 230 insertions(+), 7 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 960e6ab..38d3e4c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,42 @@ # Schema Migrations +## Adding battery_pct_log table + +This table was added to track battery percentage change history. + +**Always snapshot first:** +```bash +cp batteries.db batteries.db.$(date +%Y-%m-%d).snapshot +``` + +**SQLite (Python):** +```python +import sqlite3 +conn = sqlite3.connect('batteries.db') +conn.execute('''CREATE TABLE IF NOT EXISTS battery_pct_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + battery_id INTEGER NOT NULL REFERENCES battery(id) ON DELETE CASCADE, + percentage INTEGER NOT NULL, + recorded_at VARCHAR(19) NOT NULL, + source VARCHAR(10) +)''') +conn.commit(); conn.close() +``` + +**MariaDB / MySQL:** +```sql +CREATE TABLE IF NOT EXISTS battery_pct_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + battery_id INT NOT NULL, + percentage INT NOT NULL, + recorded_at VARCHAR(19) NOT NULL, + source VARCHAR(10), + CONSTRAINT fk_bpl_battery FOREIGN KEY (battery_id) REFERENCES battery(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +--- + ## Adding Home Assistant fields (ha_entity_id, battery_percentage) These columns were added in the Home Assistant integration feature. Existing databases diff --git a/app.py b/app.py index f884fa3..1f2d21d 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,9 @@ from flask import Flask, render_template, redirect, url_for, request, flash, abo from sqlalchemy import create_engine, func from sqlalchemy.orm import scoped_session, sessionmaker -from models import Base, Battery, Device, CapacityTest, ChargeLog +from datetime import datetime + +from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog def create_app(config_object="config"): @@ -146,10 +148,15 @@ def create_app(config_object="config"): .filter_by(battery_id=battery_id) .order_by(ChargeLog.charged_date.desc(), ChargeLog.id.desc()) .all()) + pct_logs = (db.query(BatteryPctLog) + .filter_by(battery_id=battery_id) + .order_by(BatteryPctLog.recorded_at.desc()) + .all()) return render_template("battery_detail.html", battery=battery, storage_locations=storage_locations, capacity_tests=capacity_tests, - charge_logs=charge_logs) + charge_logs=charge_logs, + pct_logs=pct_logs) # ------------------------------------------------------------------ # # Battery — edit notes @@ -176,7 +183,16 @@ def create_app(config_object="config"): battery.charge_cycles = _int("charge_cycles") battery.purchase_date = f.get("purchase_date", "").strip() or None battery.storage_location = f.get("storage_location", "").strip() or None - battery.battery_percentage = _int("battery_percentage") + new_pct = _int("battery_percentage") + if new_pct != battery.battery_percentage: + battery.battery_percentage = new_pct + if new_pct is not None: + db.add(BatteryPctLog( + battery_id=battery.id, + percentage=new_pct, + recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + source="manual", + )) db.commit() flash("Details updated.", "success") @@ -251,6 +267,12 @@ def create_app(config_object="config"): if increment: battery.charge_cycles = (battery.charge_cycles or 0) + 1 battery.battery_percentage = 100 + db.add(BatteryPctLog( + battery_id=battery_id, + percentage=100, + recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + source="charge", + )) db.add(ChargeLog(battery_id=battery_id, charged_date=date_val, increment_cycles=increment, notes=notes)) db.commit() diff --git a/ha_poller.py b/ha_poller.py index a89b63f..02e8c5e 100644 --- a/ha_poller.py +++ b/ha_poller.py @@ -1,5 +1,6 @@ import logging import threading +from datetime import datetime logger = logging.getLogger(__name__) @@ -36,7 +37,7 @@ class HaPoller: self._poll_once() def _poll_once(self): - from models import Battery, Device # local import avoids circular-import risk + from models import Battery, BatteryPctLog, Device # local import avoids circular-import risk session = self._Session() try: @@ -49,8 +50,14 @@ class HaPoller: pct = self._client.get_state(device.ha_entity_id) if pct is not None: for battery in device.batteries: - if battery.status == "installed": + if battery.status == "installed" and battery.battery_percentage != pct: battery.battery_percentage = pct + session.add(BatteryPctLog( + battery_id=battery.id, + percentage=pct, + recorded_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + source="poll", + )) session.commit() logger.debug("HA poll complete (%d devices checked)", len(devices)) except Exception as exc: diff --git a/models.py b/models.py index 46cc45c..fa32646 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,5 @@ +from datetime import datetime + from sqlalchemy import Column, Integer, String, Text, ForeignKey from sqlalchemy.orm import declarative_base, relationship @@ -61,6 +63,11 @@ class Battery(Base): order_by="ChargeLog.charged_date", cascade="all, delete-orphan", ) + pct_logs = relationship( + "BatteryPctLog", back_populates="battery", + order_by="BatteryPctLog.recorded_at.desc()", + cascade="all, delete-orphan", + ) def is_available(self): return self.status == "available" @@ -103,3 +110,18 @@ class ChargeLog(Base): def __repr__(self): return f"" + + +class BatteryPctLog(Base): + __tablename__ = "battery_pct_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + battery_id = Column(Integer, ForeignKey("battery.id", ondelete="CASCADE"), nullable=False) + percentage = Column(Integer, nullable=False) + recorded_at = Column(String(19), nullable=False) # "YYYY-MM-DD HH:MM:SS" + source = Column(String(10), nullable=True) # 'poll', 'manual', 'charge' + + battery = relationship("Battery", back_populates="pct_logs") + + def __repr__(self): + return f"" diff --git a/templates/battery_detail.html b/templates/battery_detail.html index 4d8d91f..d17c321 100644 --- a/templates/battery_detail.html +++ b/templates/battery_detail.html @@ -220,6 +220,37 @@ + +
+

Percentage History

+ {% if pct_logs %} +
+ + + + + + {% for entry in pct_logs %} + + + + + + {% endfor %} + +
Date / Time%Source
{{ entry.recorded_at }} + {% if entry.percentage < 20 %} + ⚠ {{ entry.percentage }}% + {% else %} + {{ entry.percentage }}% + {% endif %} + {{ entry.source or '—' }}
+
+ {% else %} +

No percentage history yet.

+ {% endif %} +
+

Edit Details

diff --git a/tests/test_ha_integration.py b/tests/test_ha_integration.py index f52c09f..34a6605 100644 --- a/tests/test_ha_integration.py +++ b/tests/test_ha_integration.py @@ -364,5 +364,109 @@ def test_manual_battery_percentage_edit(ha_client_f): # Clear via edit form ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": ""}) resp2 = ha_client_f.get("/battery/1") - # percentage display row should not appear when None - assert b"Battery %" not in resp2.data or b"55%" not in resp2.data + # "55%" still appears once in history; the info-table row is gone (current value is None) + assert resp2.data.count(b"55%") == 1 + + +# --------------------------------------------------------------------------- +# Group 3 additions — skip-on-no-change + history logging (poller) +# --------------------------------------------------------------------------- + +def test_poll_skips_update_when_percentage_unchanged(ha_app, ha_client_f): + """No write and no pct_log entry when the polled value matches the stored value.""" + ha_client_f.post("/device/add", data={"name": "Dev NoCh", "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 NoCh", "battery_slots": "1", "ha_entity_id": "sensor.noch" + }) + + 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)() + s.get(Battery, 1).battery_percentage = 50 + s.commit(); s.close() + + from ha_client import HomeAssistantClient + from ha_poller import HaPoller + mock_ha = MagicMock(spec=HomeAssistantClient) + mock_ha.enabled = True + mock_ha.get_state.return_value = 50 # same value — should be a no-op + + Session = _make_session_factory(ha_app) + HaPoller(mock_ha, Session, interval=300)._poll_once() + + s2 = sessionmaker(bind=engine)() + assert s2.query(BatteryPctLog).count() == 0 + s2.close() + + +def test_poll_creates_pct_log_on_change(ha_app, ha_client_f): + """A pct_log entry with source='poll' is written when the value changes.""" + ha_client_f.post("/device/add", data={"name": "Dev Chg", "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 Chg", "battery_slots": "1", "ha_entity_id": "sensor.chg" + }) + + from ha_client import HomeAssistantClient + from ha_poller import HaPoller + from models import BatteryPctLog + mock_ha = MagicMock(spec=HomeAssistantClient) + mock_ha.enabled = True + mock_ha.get_state.return_value = 72 + + Session = _make_session_factory(ha_app) + HaPoller(mock_ha, Session, interval=300)._poll_once() + + s = Session() + logs = s.query(BatteryPctLog).filter_by(battery_id=1).all() + assert len(logs) == 1 + assert logs[0].percentage == 72 + assert logs[0].source == "poll" + s.close() + + +# --------------------------------------------------------------------------- +# Group 4 additions — history logging (routes / UI) +# --------------------------------------------------------------------------- + +def test_charge_log_creates_pct_log_entry(ha_app, ha_client_f): + """Logging a charge creates a pct_log entry with percentage=100, source='charge'.""" + ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"}) + ha_client_f.post("/battery/1/charge-log/add", data={"charged_date": "2026-04-13"}) + + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from models import BatteryPctLog + engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"]) + s = sessionmaker(bind=engine)() + logs = s.query(BatteryPctLog).filter_by(battery_id=1).all() + assert len(logs) == 1 + assert logs[0].percentage == 100 + assert logs[0].source == "charge" + s.close() + + +def test_manual_edit_creates_pct_log_entry(ha_client_f): + """Saving a new battery_percentage via edit-details creates a history entry.""" + ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"}) + ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": "65"}) + + resp = ha_client_f.get("/battery/1") + assert b"65%" in resp.data + assert b"manual" in resp.data # source shown in history table + + +def test_manual_edit_no_log_when_unchanged(ha_client_f): + """Saving the same percentage twice produces only one history entry.""" + ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"}) + ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": "40"}) + ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": "40"}) + + 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