Track battery percentage history; skip poll write when value unchanged
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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}%>"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user