Track battery percentage history; skip poll write when value unchanged
This commit is contained in:
@@ -1,5 +1,42 @@
|
|||||||
# Schema Migrations
|
# 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)
|
## Adding Home Assistant fields (ha_entity_id, battery_percentage)
|
||||||
|
|
||||||
These columns were added in the Home Assistant integration feature. Existing databases
|
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 import create_engine, func
|
||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
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"):
|
def create_app(config_object="config"):
|
||||||
@@ -146,10 +148,15 @@ def create_app(config_object="config"):
|
|||||||
.filter_by(battery_id=battery_id)
|
.filter_by(battery_id=battery_id)
|
||||||
.order_by(ChargeLog.charged_date.desc(), ChargeLog.id.desc())
|
.order_by(ChargeLog.charged_date.desc(), ChargeLog.id.desc())
|
||||||
.all())
|
.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,
|
return render_template("battery_detail.html", battery=battery,
|
||||||
storage_locations=storage_locations,
|
storage_locations=storage_locations,
|
||||||
capacity_tests=capacity_tests,
|
capacity_tests=capacity_tests,
|
||||||
charge_logs=charge_logs)
|
charge_logs=charge_logs,
|
||||||
|
pct_logs=pct_logs)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — edit notes
|
# Battery — edit notes
|
||||||
@@ -176,7 +183,16 @@ def create_app(config_object="config"):
|
|||||||
battery.charge_cycles = _int("charge_cycles")
|
battery.charge_cycles = _int("charge_cycles")
|
||||||
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
||||||
battery.storage_location = f.get("storage_location", "").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()
|
db.commit()
|
||||||
flash("Details updated.", "success")
|
flash("Details updated.", "success")
|
||||||
@@ -251,6 +267,12 @@ def create_app(config_object="config"):
|
|||||||
if increment:
|
if increment:
|
||||||
battery.charge_cycles = (battery.charge_cycles or 0) + 1
|
battery.charge_cycles = (battery.charge_cycles or 0) + 1
|
||||||
battery.battery_percentage = 100
|
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,
|
db.add(ChargeLog(battery_id=battery_id, charged_date=date_val,
|
||||||
increment_cycles=increment, notes=notes))
|
increment_cycles=increment, notes=notes))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+9
-2
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ class HaPoller:
|
|||||||
self._poll_once()
|
self._poll_once()
|
||||||
|
|
||||||
def _poll_once(self):
|
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()
|
session = self._Session()
|
||||||
try:
|
try:
|
||||||
@@ -49,8 +50,14 @@ class HaPoller:
|
|||||||
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:
|
||||||
for battery in device.batteries:
|
for battery in device.batteries:
|
||||||
if battery.status == "installed":
|
if battery.status == "installed" and battery.battery_percentage != pct:
|
||||||
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()
|
session.commit()
|
||||||
logger.debug("HA poll complete (%d devices checked)", len(devices))
|
logger.debug("HA poll complete (%d devices checked)", len(devices))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey
|
from sqlalchemy import Column, Integer, String, Text, ForeignKey
|
||||||
from sqlalchemy.orm import declarative_base, relationship
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
|
|
||||||
@@ -61,6 +63,11 @@ class Battery(Base):
|
|||||||
order_by="ChargeLog.charged_date",
|
order_by="ChargeLog.charged_date",
|
||||||
cascade="all, delete-orphan",
|
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):
|
def is_available(self):
|
||||||
return self.status == "available"
|
return self.status == "available"
|
||||||
@@ -103,3 +110,18 @@ class ChargeLog(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<ChargeLog {self.battery_id} {self.charged_date}>"
|
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>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- Edit Details -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Edit Details</h2>
|
<h2>Edit Details</h2>
|
||||||
|
|||||||
@@ -364,5 +364,109 @@ def test_manual_battery_percentage_edit(ha_client_f):
|
|||||||
# Clear via edit form
|
# Clear via edit form
|
||||||
ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": ""})
|
ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": ""})
|
||||||
resp2 = ha_client_f.get("/battery/1")
|
resp2 = ha_client_f.get("/battery/1")
|
||||||
# percentage display row should not appear when None
|
# "55%" still appears once in history; the info-table row is gone (current value is None)
|
||||||
assert b"Battery %" not in resp2.data or b"55%" not in resp2.data
|
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