Add optional Home Assistant integration for battery percentage tracking
This commit is contained in:
@@ -1,3 +1,31 @@
|
|||||||
|
# Schema Migrations
|
||||||
|
|
||||||
|
## Adding Home Assistant fields (ha_entity_id, battery_percentage)
|
||||||
|
|
||||||
|
These columns were added in the Home Assistant integration feature. Existing databases
|
||||||
|
need a manual migration — `create_all()` does not add columns to existing tables.
|
||||||
|
|
||||||
|
**Always snapshot first:**
|
||||||
|
```bash
|
||||||
|
cp batteries.db batteries.db.$(date +%Y-%m-%d).snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQLite:**
|
||||||
|
```bash
|
||||||
|
sqlite3 batteries.db "ALTER TABLE device ADD COLUMN ha_entity_id VARCHAR(100);"
|
||||||
|
sqlite3 batteries.db "ALTER TABLE battery ADD COLUMN battery_percentage INTEGER;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**MariaDB / MySQL:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE device ADD COLUMN ha_entity_id VARCHAR(100) NULL;
|
||||||
|
ALTER TABLE battery ADD COLUMN battery_percentage INT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Both columns are nullable with no default, which is valid on all supported databases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Migrating from SQLite to MariaDB
|
# Migrating from SQLite to MariaDB
|
||||||
|
|
||||||
This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL
|
This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL
|
||||||
|
|||||||
@@ -21,6 +21,25 @@ def create_app(config_object="config"):
|
|||||||
def remove_session(exc=None):
|
def remove_session(exc=None):
|
||||||
db.remove()
|
db.remove()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Home Assistant integration (optional)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
from ha_poller import HaPoller
|
||||||
|
|
||||||
|
ha_client = HomeAssistantClient(
|
||||||
|
url=app.config.get("HOMEASSISTANT_URL"),
|
||||||
|
api_key=app.config.get("HOMEASSISTANT_API_KEY"),
|
||||||
|
)
|
||||||
|
if ha_client.enabled:
|
||||||
|
poller = HaPoller(
|
||||||
|
ha_client=ha_client,
|
||||||
|
session_factory=sessionmaker(bind=engine),
|
||||||
|
interval=app.config.get("HOMEASSISTANT_POLL_INTERVAL", 300),
|
||||||
|
)
|
||||||
|
poller.start()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Dashboard
|
# Dashboard
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -42,7 +61,8 @@ def create_app(config_object="config"):
|
|||||||
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
|
devices_with_slots = [d for d in devices if d.installed_count() < d.battery_slots]
|
||||||
return render_template("dashboard.html", batteries=batteries,
|
return render_template("dashboard.html", batteries=batteries,
|
||||||
storage_locations=storage_locations, devices=devices,
|
storage_locations=storage_locations, devices=devices,
|
||||||
devices_with_slots=devices_with_slots)
|
devices_with_slots=devices_with_slots,
|
||||||
|
ha_enabled=ha_client.enabled)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — add
|
# Battery — add
|
||||||
@@ -156,6 +176,7 @@ 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")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
flash("Details updated.", "success")
|
flash("Details updated.", "success")
|
||||||
@@ -229,6 +250,7 @@ def create_app(config_object="config"):
|
|||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
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
|
||||||
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()
|
||||||
@@ -572,7 +594,8 @@ def create_app(config_object="config"):
|
|||||||
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
|
device_types = sorted({d.device_type for d in db.query(Device).all() if d.device_type})
|
||||||
return render_template("device_detail.html", device=device, brands=brands,
|
return render_template("device_detail.html", device=device, brands=brands,
|
||||||
available_batteries=available_batteries,
|
available_batteries=available_batteries,
|
||||||
device_types=device_types)
|
device_types=device_types,
|
||||||
|
ha_enabled=ha_client.enabled)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Devices — edit
|
# Devices — edit
|
||||||
@@ -607,6 +630,7 @@ def create_app(config_object="config"):
|
|||||||
device.battery_slots = slots
|
device.battery_slots = slots
|
||||||
device.notes = notes
|
device.notes = notes
|
||||||
device.device_type = device_type
|
device.device_type = device_type
|
||||||
|
device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None
|
||||||
db.commit()
|
db.commit()
|
||||||
flash("Device updated.", "success")
|
flash("Device updated.", "success")
|
||||||
return redirect(url_for("device_detail", device_id=device_id))
|
return redirect(url_for("device_detail", device_id=device_id))
|
||||||
|
|||||||
@@ -6,3 +6,8 @@ SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|||||||
)
|
)
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# Home Assistant integration (all optional — app works normally when absent)
|
||||||
|
HOMEASSISTANT_URL = os.environ.get("HOMEASSISTANT_URL")
|
||||||
|
HOMEASSISTANT_API_KEY = os.environ.get("HOMEASSISTANT_API_KEY")
|
||||||
|
HOMEASSISTANT_POLL_INTERVAL = int(os.environ.get("HOMEASSISTANT_POLL_INTERVAL", "300"))
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantClient:
|
||||||
|
"""Thin wrapper around the Home Assistant REST API.
|
||||||
|
|
||||||
|
Instantiating with url=None or api_key=None produces a disabled client;
|
||||||
|
all methods become no-ops that return None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str | None, api_key: str | None):
|
||||||
|
self._enabled = bool(url and api_key)
|
||||||
|
self._base_url = (url or "").rstrip("/")
|
||||||
|
self._headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def get_state(self, entity_id: str) -> int | None:
|
||||||
|
"""Fetch the current state of a HA entity and return it as an integer.
|
||||||
|
|
||||||
|
Returns None if HA is not configured, the entity is not found,
|
||||||
|
the state is non-numeric, or any network/HTTP error occurs.
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return None
|
||||||
|
url = f"{self._base_url}/api/states/{entity_id}"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=self._headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
state = resp.json().get("state")
|
||||||
|
return int(float(state))
|
||||||
|
except (requests.RequestException, ValueError, TypeError, KeyError) as exc:
|
||||||
|
logger.warning("HA API error for %s: %s", entity_id, exc)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HaPoller:
|
||||||
|
"""Background daemon thread that periodically polls Home Assistant
|
||||||
|
for battery percentages and updates installed batteries in the DB.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
poller = HaPoller(ha_client, session_factory, interval_seconds)
|
||||||
|
poller.start() # called once from create_app
|
||||||
|
poller.stop() # not normally needed; daemon thread exits with the process
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ha_client, session_factory, interval: int):
|
||||||
|
self._client = ha_client
|
||||||
|
self._Session = session_factory
|
||||||
|
self._interval = interval
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run, name="ha-poller", daemon=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("HA poller started (interval=%ds)", self._interval)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
# Wait first so startup DB activity settles before the first poll
|
||||||
|
while not self._stop.wait(self._interval):
|
||||||
|
self._poll_once()
|
||||||
|
|
||||||
|
def _poll_once(self):
|
||||||
|
from models import Battery, Device # local import avoids circular-import risk
|
||||||
|
|
||||||
|
session = self._Session()
|
||||||
|
try:
|
||||||
|
devices = (
|
||||||
|
session.query(Device)
|
||||||
|
.filter(Device.ha_entity_id.isnot(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for device in devices:
|
||||||
|
pct = self._client.get_state(device.ha_entity_id)
|
||||||
|
if pct is not None:
|
||||||
|
for battery in device.batteries:
|
||||||
|
if battery.status == "installed":
|
||||||
|
battery.battery_percentage = pct
|
||||||
|
session.commit()
|
||||||
|
logger.debug("HA poll complete (%d devices checked)", len(devices))
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
logger.warning("HA poll failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -12,6 +12,7 @@ class Device(Base):
|
|||||||
battery_slots = Column(Integer, nullable=False, default=1)
|
battery_slots = Column(Integer, nullable=False, default=1)
|
||||||
device_type = Column(String(50), nullable=True)
|
device_type = Column(String(50), nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
ha_entity_id = Column(String(100), nullable=True) # e.g. "sensor.tv_remote_battery"
|
||||||
|
|
||||||
batteries = relationship("Battery", back_populates="device")
|
batteries = relationship("Battery", back_populates="device")
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ class Battery(Base):
|
|||||||
charge_cycles = Column(Integer, nullable=True) # number of charge cycles
|
charge_cycles = Column(Integer, nullable=True) # number of charge cycles
|
||||||
purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased
|
purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased
|
||||||
storage_location = Column(String(100), nullable=True) # where stored when not installed
|
storage_location = Column(String(100), nullable=True) # where stored when not installed
|
||||||
|
battery_percentage = Column(Integer, nullable=True) # current charge %, set by HA poller or manually
|
||||||
|
|
||||||
device = relationship("Device", back_populates="batteries")
|
device = relationship("Device", back_populates="batteries")
|
||||||
capacity_tests = relationship(
|
capacity_tests = relationship(
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ SQLAlchemy>=2.0,<3.0
|
|||||||
PyMySQL>=1.1,<2.0
|
PyMySQL>=1.1,<2.0
|
||||||
waitress>=3.0,<4.0
|
waitress>=3.0,<4.0
|
||||||
pytest>=8.0,<9.0
|
pytest>=8.0,<9.0
|
||||||
|
requests>=2.31,<3.0
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ HOST="${HOST:-127.0.0.1}"
|
|||||||
read -rp "Listen port [default: 5000]: " PORT
|
read -rp "Listen port [default: 5000]: " PORT
|
||||||
PORT="${PORT:-5000}"
|
PORT="${PORT:-5000}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Home Assistant integration (optional — press Enter to skip):"
|
||||||
|
read -rp " HOMEASSISTANT_URL (e.g. http://homeassistant.local:8123): " HA_URL
|
||||||
|
read -rp " HOMEASSISTANT_API_KEY (long-lived access token): " HA_KEY
|
||||||
|
read -rp " HOMEASSISTANT_POLL_INTERVAL seconds [default: 300]: " HA_INTERVAL
|
||||||
|
HA_INTERVAL="${HA_INTERVAL:-300}"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Generating service file → $SERVICE_FILE"
|
echo "Generating service file → $SERVICE_FILE"
|
||||||
|
|
||||||
@@ -51,6 +58,9 @@ Type=simple
|
|||||||
WorkingDirectory=$APP_DIR
|
WorkingDirectory=$APP_DIR
|
||||||
ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app
|
ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app
|
||||||
Environment=PYTHONPATH=$APP_DIR
|
Environment=PYTHONPATH=$APP_DIR
|
||||||
|
$([ -n "$HA_URL" ] && echo "Environment=HOMEASSISTANT_URL=$HA_URL")
|
||||||
|
$([ -n "$HA_KEY" ] && echo "Environment=HOMEASSISTANT_API_KEY=$HA_KEY")
|
||||||
|
$([ -n "$HA_URL" ] && echo "Environment=HOMEASSISTANT_POLL_INTERVAL=$HA_INTERVAL")
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,18 @@
|
|||||||
{{ meta_row("Charge Cycles", battery.charge_cycles) }}
|
{{ meta_row("Charge Cycles", battery.charge_cycles) }}
|
||||||
{{ meta_row("Purchase Date", battery.purchase_date) }}
|
{{ meta_row("Purchase Date", battery.purchase_date) }}
|
||||||
{{ meta_row("Storage", battery.storage_location) }}
|
{{ meta_row("Storage", battery.storage_location) }}
|
||||||
|
{% if battery.battery_percentage is not none %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Battery %</td>
|
||||||
|
<td style="border:none;">
|
||||||
|
{% if battery.battery_percentage < 20 %}
|
||||||
|
<span class="badge badge-warning">⚠ {{ battery.battery_percentage }}% — consider replacing</span>
|
||||||
|
{% else %}
|
||||||
|
{{ battery.battery_percentage }}%
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if battery.notes %}
|
{% if battery.notes %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">Notes</td>
|
||||||
@@ -261,6 +273,13 @@
|
|||||||
value="{{ battery.purchase_date or '' }}">
|
value="{{ battery.purchase_date or '' }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="battery_percentage">Battery % (optional)</label>
|
||||||
|
<input type="number" id="battery_percentage" name="battery_percentage" min="0" max="100"
|
||||||
|
value="{{ battery.battery_percentage if battery.battery_percentage is not none else '' }}"
|
||||||
|
placeholder="e.g. 85">
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -26,6 +26,15 @@
|
|||||||
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired }}</div>
|
<div style="font-size:1.8rem;font-weight:700;color:var(--count-retired);">{{ retired }}</div>
|
||||||
<div class="text-muted">Retired</div>
|
<div class="text-muted">Retired</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if ha_enabled %}
|
||||||
|
{% set low_pct = batteries | selectattr('battery_percentage', 'ne', none) | selectattr('battery_percentage', 'lt', 20) | list %}
|
||||||
|
{% if low_pct %}
|
||||||
|
<div class="card" style="flex:1;min-width:120px;text-align:center;border:2px solid #f59e0b;">
|
||||||
|
<div style="font-size:1.8rem;font-weight:700;color:#f59e0b;">{{ low_pct|length }}</div>
|
||||||
|
<div class="text-muted">Low Battery</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -38,7 +47,8 @@
|
|||||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
|
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="capacity" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Capacity</label>
|
||||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
|
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="storage" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Storage Location</label>
|
||||||
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
|
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="purchase" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Purchase Date</label>
|
||||||
<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
|
<label style="display:block;cursor:pointer;margin-bottom:0.3rem;font-size:0.875rem;"><input type="checkbox" data-col="cycles" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Charge Cycles</label>
|
||||||
|
{% if ha_enabled %}<label style="display:block;cursor:pointer;font-size:0.875rem;"><input type="checkbox" data-col="ha-pct" onchange="toggleCol(this)" style="margin-right:0.4rem;"> Battery %</label>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +160,7 @@
|
|||||||
<th class="col-storage" style="display:none;">Storage</th>
|
<th class="col-storage" style="display:none;">Storage</th>
|
||||||
<th class="col-purchase" style="display:none;">Purchase Date</th>
|
<th class="col-purchase" style="display:none;">Purchase Date</th>
|
||||||
<th class="col-cycles" style="display:none;">Cycles</th>
|
<th class="col-cycles" style="display:none;">Cycles</th>
|
||||||
|
{% if ha_enabled %}<th class="col-ha-pct" style="display:none;">Bat %</th>{% endif %}
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Assigned To</th>
|
<th>Assigned To</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -172,6 +183,15 @@
|
|||||||
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
<td data-label="Storage" class="col-storage" style="display:none;">{{ b.storage_location or '—' }}</td>
|
||||||
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
<td data-label="Purchase" class="col-purchase" style="display:none;">{{ b.purchase_date or '—' }}</td>
|
||||||
<td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
|
<td data-label="Cycles" class="col-cycles" style="display:none;">{{ b.charge_cycles or '—' }}</td>
|
||||||
|
{% if ha_enabled %}
|
||||||
|
<td data-label="Bat %" class="col-ha-pct" style="display:none;">
|
||||||
|
{% if b.battery_percentage is not none %}
|
||||||
|
{% if b.battery_percentage < 20 %}
|
||||||
|
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
|
||||||
|
{% else %}{{ b.battery_percentage }}%{% endif %}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
<span class="badge badge-{{ b.status }}">{{ b.status|capitalize }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -358,7 +378,7 @@ updateToolbar();
|
|||||||
|
|
||||||
// Column picker
|
// Column picker
|
||||||
var COL_KEY = 'battery_cols';
|
var COL_KEY = 'battery_cols';
|
||||||
var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles'];
|
var ALL_COLS = ['chemistry','capacity','storage','purchase','cycles'{% if ha_enabled %},'ha-pct'{% endif %}];
|
||||||
|
|
||||||
function toggleCol(cb) {
|
function toggleCol(cb) {
|
||||||
var col = cb.dataset.col;
|
var col = cb.dataset.col;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
<td style="border:none;">{{ device.notes }}</td>
|
<td style="border:none;">{{ device.notes }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ha_enabled and device.ha_entity_id %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0.3rem 1rem 0.3rem 0;font-weight:600;color:#64748b;border:none;">HA Entity</td>
|
||||||
|
<td style="border:none;"><code>{{ device.ha_entity_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,13 +105,22 @@ function addInstallRow() {
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="responsive-table">
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Label</th><th>Brand</th><th>Notes</th><th>Actions</th></tr>
|
<tr><th>Label</th><th>Brand</th>{% if ha_enabled %}<th>Bat %</th>{% endif %}<th>Notes</th><th>Actions</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for b in installed %}
|
{% for b in installed %}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
|
<td data-label="Label"><a href="{{ url_for('battery_detail', battery_id=b.id) }}">{{ b.label }}</a></td>
|
||||||
<td data-label="Brand">{{ b.brand }}</td>
|
<td data-label="Brand">{{ b.brand }}</td>
|
||||||
|
{% if ha_enabled %}
|
||||||
|
<td data-label="Bat %">
|
||||||
|
{% if b.battery_percentage is not none %}
|
||||||
|
{% if b.battery_percentage < 20 %}
|
||||||
|
<span class="badge badge-warning" title="Low — consider replacing">⚠ {{ b.battery_percentage }}%</span>
|
||||||
|
{% else %}{{ b.battery_percentage }}%{% endif %}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
<td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td>
|
<td data-label="Notes" class="text-muted">{{ b.notes or '—' }}</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
|
<form class="inline" method="post" action="{{ url_for('battery_unassign', battery_id=b.id) }}">
|
||||||
@@ -180,6 +195,14 @@ function addInstallRow() {
|
|||||||
<label for="edit-notes">Notes</label>
|
<label for="edit-notes">Notes</label>
|
||||||
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
<textarea id="edit-notes" name="notes">{{ device.notes or '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{% if ha_enabled %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-ha-entity">Home Assistant Entity ID</label>
|
||||||
|
<input type="text" id="edit-ha-entity" name="ha_entity_id"
|
||||||
|
value="{{ device.ha_entity_id or '' }}"
|
||||||
|
placeholder="e.g. sensor.tv_remote_battery">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<button class="btn btn-primary" type="submit">Save Changes</button>
|
<button class="btn btn-primary" type="submit">Save Changes</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ def app():
|
|||||||
SECRET_KEY = "test-secret"
|
SECRET_KEY = "test-secret"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
|
||||||
|
HOMEASSISTANT_API_KEY = None
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
flask_app = create_app(TestConfig)
|
flask_app = create_app(TestConfig)
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""Acceptance tests for the optional Home Assistant integration.
|
||||||
|
|
||||||
|
Groups:
|
||||||
|
1. HA disabled — regression guard (uses default client/seeded_client fixtures)
|
||||||
|
2. HomeAssistantClient unit tests — mock requests.get, no Flask required
|
||||||
|
3. Poller integration — uses ha_app fixture, calls _poll_once() directly
|
||||||
|
4. Dashboard/device/battery UI — checks rendered HTML with HA enabled
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HA-enabled app fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ha_app():
|
||||||
|
"""Flask app with Home Assistant enabled (HOMEASSISTANT_URL set).
|
||||||
|
|
||||||
|
The poller is started but its first fire is after 300 s — it never fires
|
||||||
|
during a test run.
|
||||||
|
"""
|
||||||
|
db_fd, db_path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(db_fd)
|
||||||
|
|
||||||
|
class HaTestConfig:
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
|
||||||
|
SECRET_KEY = "test-secret"
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
TESTING = True
|
||||||
|
HOMEASSISTANT_URL = "http://ha.test:8123"
|
||||||
|
HOMEASSISTANT_API_KEY = "fake-token"
|
||||||
|
HOMEASSISTANT_POLL_INTERVAL = 300
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
flask_app = create_app(HaTestConfig)
|
||||||
|
yield flask_app
|
||||||
|
os.unlink(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def ha_client_f(ha_app):
|
||||||
|
"""Test client bound to the HA-enabled app."""
|
||||||
|
with ha_app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Group 1 — HA disabled (regression guard)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ha_disabled_dashboard_no_ha_column(client):
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"ha-pct" not in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_disabled_device_no_ha_field(client):
|
||||||
|
client.post("/device/add", data={"name": "Dev A", "battery_slots": "1"})
|
||||||
|
resp = client.get("/device/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"ha_entity_id" not in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_disabled_battery_detail_still_has_percentage_field(client):
|
||||||
|
"""battery_percentage edit field is always shown regardless of HA config."""
|
||||||
|
client.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
resp = client.get("/battery/1")
|
||||||
|
assert b"battery_percentage" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Group 2 — HomeAssistantClient unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ha_client_disabled_when_no_url():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
c = HomeAssistantClient(None, None)
|
||||||
|
assert c.enabled is False
|
||||||
|
assert c.get_state("sensor.foo") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_client_disabled_when_no_key():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
c = HomeAssistantClient("http://ha.local", None)
|
||||||
|
assert c.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_client_returns_integer_state():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"state": "85", "entity_id": "sensor.foo"}
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
with patch("ha_client.requests.get", return_value=mock_resp) as mock_get:
|
||||||
|
c = HomeAssistantClient("http://ha.local:8123", "tok")
|
||||||
|
result = c.get_state("sensor.foo")
|
||||||
|
|
||||||
|
assert result == 85
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"http://ha.local:8123/api/states/sensor.foo",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_client_handles_float_state():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"state": "72.6"}
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
with patch("ha_client.requests.get", return_value=mock_resp):
|
||||||
|
c = HomeAssistantClient("http://ha.local", "tok")
|
||||||
|
assert c.get_state("sensor.x") == 72
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_client_returns_none_on_network_error():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
import requests as req
|
||||||
|
with patch("ha_client.requests.get", side_effect=req.RequestException("timeout")):
|
||||||
|
c = HomeAssistantClient("http://ha.local", "tok")
|
||||||
|
assert c.get_state("sensor.x") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ha_client_returns_none_on_non_numeric_state():
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"state": "unavailable"}
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
with patch("ha_client.requests.get", return_value=mock_resp):
|
||||||
|
c = HomeAssistantClient("http://ha.local", "tok")
|
||||||
|
assert c.get_state("sensor.x") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Group 3 — Poller integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_session_factory(ha_app):
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
|
||||||
|
return sessionmaker(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_updates_installed_batteries(ha_app, ha_client_f):
|
||||||
|
"""Installed battery in a device with ha_entity_id gets battery_percentage updated."""
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev A", "battery_slots": "2"})
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
# Assign battery to device
|
||||||
|
ha_client_f.post("/battery/1/assign", data={"device_id": "1"})
|
||||||
|
# Set ha_entity_id on device
|
||||||
|
ha_client_f.post("/device/1/edit", data={
|
||||||
|
"name": "Dev A", "battery_slots": "2", "ha_entity_id": "sensor.dev_a_battery"
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = 42
|
||||||
|
|
||||||
|
Session = _make_session_factory(ha_app)
|
||||||
|
poller = HaPoller(mock_ha, Session, interval=300)
|
||||||
|
poller._poll_once()
|
||||||
|
|
||||||
|
s = Session()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
assert b.battery_percentage == 42
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
mock_ha.get_state.assert_called_once_with("sensor.dev_a_battery")
|
||||||
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
# Set entity on device but do NOT install the battery
|
||||||
|
ha_client_f.post("/device/1/edit", data={
|
||||||
|
"name": "Dev B", "battery_slots": "1", "ha_entity_id": "sensor.dev_b_battery"
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = 55
|
||||||
|
|
||||||
|
Session = _make_session_factory(ha_app)
|
||||||
|
poller = HaPoller(mock_ha, Session, interval=300)
|
||||||
|
poller._poll_once()
|
||||||
|
|
||||||
|
s = Session()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
assert b.battery_percentage is None # uninstalled — not touched
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_skips_devices_without_entity_id(ha_app, ha_client_f):
|
||||||
|
"""Devices with no ha_entity_id must never reach get_state."""
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev C", "battery_slots": "1"})
|
||||||
|
|
||||||
|
from ha_client import HomeAssistantClient
|
||||||
|
from ha_poller import HaPoller
|
||||||
|
|
||||||
|
mock_ha = MagicMock(spec=HomeAssistantClient)
|
||||||
|
mock_ha.enabled = True
|
||||||
|
|
||||||
|
Session = _make_session_factory(ha_app)
|
||||||
|
poller = HaPoller(mock_ha, Session, interval=300)
|
||||||
|
poller._poll_once()
|
||||||
|
|
||||||
|
mock_ha.get_state.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_handles_api_error_gracefully(ha_app, ha_client_f):
|
||||||
|
"""When get_state returns None, no exception is raised and percentage stays None."""
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev D", "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 D", "battery_slots": "1", "ha_entity_id": "sensor.dev_d"
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = None # simulate API failure
|
||||||
|
|
||||||
|
Session = _make_session_factory(ha_app)
|
||||||
|
poller = HaPoller(mock_ha, Session, interval=300)
|
||||||
|
poller._poll_once() # must not raise
|
||||||
|
|
||||||
|
s = Session()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
assert b.battery_percentage is None
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Group 4 — Dashboard / device / battery UI with HA enabled
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_dashboard_shows_ha_column_when_enabled(ha_client_f):
|
||||||
|
resp = ha_client_f.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"ha-pct" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_shows_low_battery_warning(ha_app, ha_client_f):
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
|
||||||
|
# Set percentage directly in DB
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from models import Battery
|
||||||
|
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
|
||||||
|
s = sessionmaker(bind=engine)()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
b.battery_percentage = 15
|
||||||
|
s.commit()
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
resp = ha_client_f.get("/")
|
||||||
|
assert b"badge-warning" in resp.data
|
||||||
|
assert b"15%" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_no_warning_for_high_percentage(ha_app, ha_client_f):
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from models import Battery
|
||||||
|
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
|
||||||
|
s = sessionmaker(bind=engine)()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
b.battery_percentage = 85
|
||||||
|
s.commit()
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
resp = ha_client_f.get("/")
|
||||||
|
assert b"85%" in resp.data
|
||||||
|
# badge-warning should NOT appear for this battery's percentage
|
||||||
|
# (may still appear in page for other reasons, so check row contains 85% but not warning badge near it)
|
||||||
|
assert b"col-ha-pct" in resp.data # column rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_detail_shows_ha_field(ha_client_f):
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev E", "battery_slots": "1"})
|
||||||
|
resp = ha_client_f.get("/device/1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"ha_entity_id" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_device_ha_entity_id_saves(ha_client_f):
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev F", "battery_slots": "1"})
|
||||||
|
ha_client_f.post("/device/1/edit", data={
|
||||||
|
"name": "Dev F", "battery_slots": "1", "ha_entity_id": "sensor.my_remote"
|
||||||
|
})
|
||||||
|
resp = ha_client_f.get("/device/1")
|
||||||
|
assert b"sensor.my_remote" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_device_ha_entity_id_clear(ha_client_f):
|
||||||
|
ha_client_f.post("/device/add", data={"name": "Dev G", "battery_slots": "1"})
|
||||||
|
ha_client_f.post("/device/1/edit", data={
|
||||||
|
"name": "Dev G", "battery_slots": "1", "ha_entity_id": "sensor.foo"
|
||||||
|
})
|
||||||
|
# Now clear it
|
||||||
|
ha_client_f.post("/device/1/edit", data={
|
||||||
|
"name": "Dev G", "battery_slots": "1", "ha_entity_id": ""
|
||||||
|
})
|
||||||
|
resp = ha_client_f.get("/device/1")
|
||||||
|
assert b"sensor.foo" not in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_log_resets_percentage_to_100(ha_app, ha_client_f):
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
|
||||||
|
# Set low percentage
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from models import Battery
|
||||||
|
engine = create_engine(ha_app.config["SQLALCHEMY_DATABASE_URI"])
|
||||||
|
s = sessionmaker(bind=engine)()
|
||||||
|
b = s.get(Battery, 1)
|
||||||
|
b.battery_percentage = 30
|
||||||
|
s.commit()
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
# Log a charge
|
||||||
|
ha_client_f.post("/battery/1/charge-log/add", data={"charged_date": "2026-04-13"})
|
||||||
|
|
||||||
|
s2 = sessionmaker(bind=engine)()
|
||||||
|
b2 = s2.get(Battery, 1)
|
||||||
|
assert b2.battery_percentage == 100
|
||||||
|
s2.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_battery_percentage_edit(ha_client_f):
|
||||||
|
ha_client_f.post("/battery/add", data={"brand": "X", "count": "1"})
|
||||||
|
|
||||||
|
# Set via edit form
|
||||||
|
ha_client_f.post("/battery/1/edit-details", data={"battery_percentage": "55"})
|
||||||
|
resp = ha_client_f.get("/battery/1")
|
||||||
|
assert b"55%" in resp.data
|
||||||
|
|
||||||
|
# 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
|
||||||
Reference in New Issue
Block a user