Track battery percentage history; skip poll write when value unchanged

This commit is contained in:
2026-04-13 21:15:19 -05:00
parent 279a1f3f3e
commit d7ba64a2f3
6 changed files with 230 additions and 7 deletions
+37
View File
@@ -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
+25 -3
View File
@@ -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()
+9 -2
View File
@@ -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:
+22
View File
@@ -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"<ChargeLog {self.battery_id} {self.charged_date}>"
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"<BatteryPctLog {self.battery_id} {self.recorded_at} {self.percentage}%>"
+31
View File
@@ -220,6 +220,37 @@
</form>
</div>
<!-- Percentage History -->
<div class="card">
<h2>Percentage History</h2>
{% if pct_logs %}
<div class="table-wrap">
<table class="responsive-table">
<thead>
<tr><th>Date / Time</th><th>%</th><th>Source</th></tr>
</thead>
<tbody>
{% for entry in pct_logs %}
<tr>
<td data-label="Date / Time">{{ entry.recorded_at }}</td>
<td data-label="%">
{% if entry.percentage < 20 %}
<span class="badge badge-warning">⚠ {{ entry.percentage }}%</span>
{% else %}
{{ entry.percentage }}%
{% endif %}
</td>
<td data-label="Source" class="text-muted">{{ entry.source or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted" style="margin-bottom:0;">No percentage history yet.</p>
{% endif %}
</div>
<!-- Edit Details -->
<div class="card">
<h2>Edit Details</h2>
+106 -2
View File
@@ -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