diff --git a/MIGRATION.md b/MIGRATION.md index 64e7b2b..960e6ab 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 This guide covers moving the Battery Tracker database from SQLite to MariaDB/MySQL diff --git a/app.py b/app.py index 5ec4089..f884fa3 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,25 @@ def create_app(config_object="config"): def remove_session(exc=None): 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 # ------------------------------------------------------------------ # @@ -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] return render_template("dashboard.html", batteries=batteries, 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 @@ -156,6 +176,7 @@ 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") db.commit() flash("Details updated.", "success") @@ -229,6 +250,7 @@ def create_app(config_object="config"): notes = request.form.get("notes", "").strip() or None if increment: battery.charge_cycles = (battery.charge_cycles or 0) + 1 + battery.battery_percentage = 100 db.add(ChargeLog(battery_id=battery_id, charged_date=date_val, increment_cycles=increment, notes=notes)) 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}) return render_template("device_detail.html", device=device, brands=brands, available_batteries=available_batteries, - device_types=device_types) + device_types=device_types, + ha_enabled=ha_client.enabled) # ------------------------------------------------------------------ # # Devices — edit @@ -607,6 +630,7 @@ def create_app(config_object="config"): device.battery_slots = slots device.notes = notes device.device_type = device_type + device.ha_entity_id = request.form.get("ha_entity_id", "").strip() or None db.commit() flash("Device updated.", "success") return redirect(url_for("device_detail", device_id=device_id)) diff --git a/config.py b/config.py index ef5ec42..87c5cf9 100644 --- a/config.py +++ b/config.py @@ -6,3 +6,8 @@ SQLALCHEMY_DATABASE_URI = os.environ.get( ) SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod") 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")) diff --git a/ha_client.py b/ha_client.py new file mode 100644 index 0000000..0db568e --- /dev/null +++ b/ha_client.py @@ -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 diff --git a/ha_poller.py b/ha_poller.py new file mode 100644 index 0000000..a89b63f --- /dev/null +++ b/ha_poller.py @@ -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() diff --git a/models.py b/models.py index 976b95e..46cc45c 100644 --- a/models.py +++ b/models.py @@ -12,6 +12,7 @@ class Device(Base): battery_slots = Column(Integer, nullable=False, default=1) device_type = Column(String(50), 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") @@ -47,6 +48,7 @@ class Battery(Base): charge_cycles = Column(Integer, nullable=True) # number of charge cycles purchase_date = Column(String(10), nullable=True) # YYYY-MM-DD when purchased 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") capacity_tests = relationship( diff --git a/requirements.txt b/requirements.txt index f21e109..b866a6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ SQLAlchemy>=2.0,<3.0 PyMySQL>=1.1,<2.0 waitress>=3.0,<4.0 pytest>=8.0,<9.0 +requests>=2.31,<3.0 diff --git a/sbin/install-service.sh b/sbin/install-service.sh index c042aa4..bb2b5fe 100755 --- a/sbin/install-service.sh +++ b/sbin/install-service.sh @@ -36,6 +36,13 @@ HOST="${HOST:-127.0.0.1}" read -rp "Listen port [default: 5000]: " PORT 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 "Generating service file → $SERVICE_FILE" @@ -51,6 +58,9 @@ Type=simple WorkingDirectory=$APP_DIR ExecStart=$VENV_WAITRESS --host=$HOST --port=$PORT app:app 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 RestartSec=5 diff --git a/templates/battery_detail.html b/templates/battery_detail.html index d469cd4..4d8d91f 100644 --- a/templates/battery_detail.html +++ b/templates/battery_detail.html @@ -67,6 +67,18 @@ {{ meta_row("Charge Cycles", battery.charge_cycles) }} {{ meta_row("Purchase Date", battery.purchase_date) }} {{ meta_row("Storage", battery.storage_location) }} + {% if battery.battery_percentage is not none %} +
{{ device.ha_entity_id }}| Label | Brand | Notes | Actions | |
|---|---|---|---|---|
| Label | Brand | {% if ha_enabled %}Bat % | {% endif %}Notes | Actions |
| {{ b.label }} | {{ b.brand }} | + {% if ha_enabled %} ++ {% if b.battery_percentage is not none %} + {% if b.battery_percentage < 20 %} + ⚠ {{ b.battery_percentage }}% + {% else %}{{ b.battery_percentage }}%{% endif %} + {% else %}—{% endif %} + | + {% endif %}{{ b.notes or '—' }} | diff --git a/tests/conftest.py b/tests/conftest.py index 1695b02..3487106 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,8 @@ def app(): SECRET_KEY = "test-secret" SQLALCHEMY_TRACK_MODIFICATIONS = False TESTING = True + HOMEASSISTANT_URL = None # prevent HA poller from starting in tests + HOMEASSISTANT_API_KEY = None from app import create_app flask_app = create_app(TestConfig) diff --git a/tests/test_ha_integration.py b/tests/test_ha_integration.py new file mode 100644 index 0000000..f52c09f --- /dev/null +++ b/tests/test_ha_integration.py @@ -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 |