mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Use the printer's own plug for energy when several are linked (issue #2859)
Per-print energy is one plug's meter read at the start of a print and
again at the end. Both readings asked for "the plug on this printer"
with scalar_one_or_none(), which raises on two rows. Linking a second
plug to a printer - a dry box, a filter fan, a lights script - therefore
stopped energy tracking on that printer outright, and did it silently:
the print-start handler logged the exception as an ordinary failure and
the print-end handler then reported "no start kWh recorded", which is
also what it says for a printer with nothing linked to it.
The assumption was never enforced anywhere else. The plug API rejects a
second Tasmota plug and deliberately allows any number of Home Assistant
entities, the UNIQUE constraint on smart_plugs.printer_id was dropped on
purpose, and every other consumer reads a list. These two call sites were
the last ones left from before that.
Energy now ranks a printer's plugs - the one that powers it first, then
by id so the start and end readings agree - and takes the first that
actually reports a counter, so accessories drop out with nothing
configured. Ranking rather than filtering: a printer whose only linked
row is disabled, or a script, used it before and still does. When none
of them measures anything the log names the ones it tried, so that stops
reading like "no plug configured".
Also: the plug page counted an online plug as offline unless it reported
energy, so a switch with no power sensor showed as offline for as long
as it stayed linked.
Existing archives cannot be backfilled - the starting reading was never
taken, so there is nothing to compute a delta from.
This commit is contained in:
+30
-15
@@ -83,7 +83,6 @@ from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
from backend.app.core.database import async_session, engine, init_db
|
||||
from backend.app.core.tasks import spawn_background_task
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services import print_dispatch_context
|
||||
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
|
||||
from backend.app.services.archive_purge import archive_purge_service
|
||||
@@ -98,6 +97,7 @@ from backend.app.services.bambu_ftp import (
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
from backend.app.services.ha_sensor_manager import ha_sensor_manager
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
@@ -896,21 +896,30 @@ async def _record_energy_start(archive, printer_id: int, db, *, context: str = "
|
||||
"""
|
||||
_logger = logging.getLogger(__name__)
|
||||
try:
|
||||
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = plug_result.scalar_one_or_none()
|
||||
if not plug:
|
||||
candidates = await energy_plug_candidates(db, printer_id)
|
||||
if not candidates:
|
||||
_logger.info("[ENERGY] No smart plug for printer %s (archive %s)", printer_id, archive.id)
|
||||
return False
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
if not energy or energy.get("total") is None:
|
||||
_logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id)
|
||||
selected = await select_energy_reading(candidates, _get_plug_energy, db)
|
||||
if selected is None:
|
||||
# Naming the plugs matters here: with several linked to one printer
|
||||
# this is the difference between "the meter is offline" and "you
|
||||
# linked only accessories" (#2859).
|
||||
_logger.warning(
|
||||
"[ENERGY] No plug on printer %s reports a lifetime energy counter for archive %s (tried: %s)",
|
||||
printer_id,
|
||||
archive.id,
|
||||
", ".join(plug.name for plug in candidates),
|
||||
)
|
||||
return False
|
||||
plug, energy = selected
|
||||
archive.energy_start_kwh = float(energy["total"])
|
||||
await db.commit()
|
||||
_logger.info(
|
||||
"[ENERGY] Recorded starting energy%s for archive %s: %s kWh",
|
||||
"[ENERGY] Recorded starting energy%s for archive %s from plug '%s': %s kWh",
|
||||
f" ({context})" if context else "",
|
||||
archive.id,
|
||||
plug.name,
|
||||
energy["total"],
|
||||
)
|
||||
return True
|
||||
@@ -6345,17 +6354,23 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
logger.info("[ENERGY-BG] No start kWh recorded for archive %s", archive_id)
|
||||
return
|
||||
|
||||
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = plug_result.scalar_one_or_none()
|
||||
if plug is None:
|
||||
candidates = await energy_plug_candidates(db, printer_id)
|
||||
if not candidates:
|
||||
logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id)
|
||||
return
|
||||
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
logger.info("[ENERGY-BG] Energy response: %s", energy)
|
||||
if not energy or energy.get("total") is None:
|
||||
logger.warning("[ENERGY-BG] No 'total' in energy response")
|
||||
# Same ordering as the start reading, so the delta below is
|
||||
# against the counter that produced `starting_kwh` (#2859).
|
||||
selected = await select_energy_reading(candidates, _get_plug_energy, db)
|
||||
if selected is None:
|
||||
logger.warning(
|
||||
"[ENERGY-BG] No plug on printer %s reports a lifetime energy counter (tried: %s)",
|
||||
printer_id,
|
||||
", ".join(plug.name for plug in candidates),
|
||||
)
|
||||
return
|
||||
plug, energy = selected
|
||||
logger.info("[ENERGY-BG] Energy response from plug '%s': %s", plug.name, energy)
|
||||
|
||||
energy_used = round(energy["total"] - starting_kwh, 4)
|
||||
logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Which of a printer's plugs measures its energy? (#2859)
|
||||
|
||||
Per-print energy is the delta of one plug's lifetime counter between print start
|
||||
and print end, so both readings have to come from the same plug. The two call
|
||||
sites used to assume a printer had exactly one: they selected every plug with
|
||||
``SmartPlug.printer_id == printer_id`` and then called ``scalar_one_or_none()``.
|
||||
|
||||
Nothing enforces that assumption. The plug API rejects a second *Tasmota* plug
|
||||
on a printer and deliberately allows any number of Home Assistant entities --
|
||||
"allow multiple per printer (for different automations)" -- which is how a
|
||||
filter fan, a dry box or a lights script ends up linked beside the printer's own
|
||||
plug. On those installs the query returned two rows, ``scalar_one_or_none()``
|
||||
raised, the print-start handler caught it as just another failure and logged a
|
||||
warning, and no archive on that printer ever carried an energy figure again. It
|
||||
was silent because the print-end handler then reports "no start kWh recorded",
|
||||
which reads exactly like "this printer has no plug".
|
||||
|
||||
The rule below picks the printer's own plug without asking the user to nominate
|
||||
one. ``controls_printer_power`` (#2629) already means "this plug really feeds
|
||||
the printer" rather than an accessory that merely follows the print cycle, so it
|
||||
ranks above one that does not, and the id breaks ties so the start and end
|
||||
readings agree on the answer. The decisive test in practice is the last one: a
|
||||
candidate has to actually report a lifetime counter to be chosen, and accessory
|
||||
plugs are usually switch-only, so they drop out with nothing configured.
|
||||
|
||||
Deliberately *not* enforced: one plug per printer. Bambuddy dropped the UNIQUE
|
||||
constraint on ``smart_plugs.printer_id`` on purpose, and the flag defaults to on
|
||||
for every existing plug, so clearing it to make it unique would change which
|
||||
plugs may mark a printer offline on auto-off (#2629) -- not this module's
|
||||
business.
|
||||
|
||||
Equally deliberate: nothing is excluded, only ranked. A printer with one linked
|
||||
plug used it whatever it was, and must keep doing so, so a disabled row or a
|
||||
script still gets its turn once the plausible candidates have declined.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
|
||||
# Reads a plug's energy dict, or None when the device did not answer. Injected
|
||||
# rather than imported so this module stays independent of the plug-type
|
||||
# dispatch that lives with the callers.
|
||||
EnergyReader = Callable[[SmartPlug, AsyncSession], Awaitable[dict | None]]
|
||||
|
||||
|
||||
def _is_script_entity(plug: SmartPlug) -> bool:
|
||||
"""A Home Assistant ``script.*`` entity linked to a printer for automation.
|
||||
|
||||
Stored as plugs so they can follow the print cycle (see
|
||||
``trigger_associated_scripts``), but a script has nothing to meter.
|
||||
"""
|
||||
return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
|
||||
|
||||
|
||||
def _rank(plug: SmartPlug) -> tuple:
|
||||
"""Sort key: least surprising source of a printer's meter first.
|
||||
|
||||
Ranking rather than filtering, deliberately. A printer with exactly one
|
||||
linked row behaved the same before this module existed whatever that row
|
||||
was -- disabled, a script, an accessory -- and it has to keep behaving that
|
||||
way, so nothing is excluded outright and every rejection is left to the one
|
||||
test that cannot be wrong: does it actually report a counter.
|
||||
"""
|
||||
return (
|
||||
_is_script_entity(plug),
|
||||
not plug.enabled,
|
||||
not plug.controls_printer_power,
|
||||
plug.id,
|
||||
)
|
||||
|
||||
|
||||
async def energy_plug_candidates(db: AsyncSession, printer_id: int | None) -> list[SmartPlug]:
|
||||
"""Plugs on *printer_id* that could supply its energy counter, best first."""
|
||||
if printer_id is None:
|
||||
# `printer_id == None` compiles to `IS NULL`, which would return every
|
||||
# plug linked to no printer at all and bill a print against whichever
|
||||
# one happened to answer. Callers are typed `int`, so this is a guard
|
||||
# against a future one rather than a live path.
|
||||
return []
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
return sorted(result.scalars().all(), key=_rank)
|
||||
|
||||
|
||||
async def select_energy_reading(
|
||||
candidates: list[SmartPlug],
|
||||
read_energy: EnergyReader,
|
||||
db: AsyncSession,
|
||||
) -> tuple[SmartPlug, dict] | None:
|
||||
"""First candidate that actually reports a lifetime counter, with its reading.
|
||||
|
||||
Returns the reading alongside the plug so the caller does not poll twice --
|
||||
the value that decided the choice is the value it needs.
|
||||
"""
|
||||
for plug in candidates:
|
||||
energy = await read_energy(plug, db)
|
||||
if energy and energy.get("total") is not None:
|
||||
return plug, energy
|
||||
return None
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Energy tracking with more than one plug linked to a printer (#2859).
|
||||
|
||||
The reporter had two Home Assistant plugs on a P1S -- its own plug plus a dry
|
||||
box he wanted to switch from the printer card -- and no archive on that printer
|
||||
ever carried an energy figure, while his single-plug X2D was fine. Both energy
|
||||
call sites selected every plug for the printer and then called
|
||||
``scalar_one_or_none()``, which raises on two rows; the print-start handler
|
||||
caught that as an ordinary failure, so ``energy_start_kwh`` was never written
|
||||
and the print-end handler reported "no start kWh recorded" -- indistinguishable
|
||||
from having no plug at all.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
|
||||
|
||||
|
||||
def _plug(plug_id: int, name: str, *, power: bool = True, ha_entity_id: str | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=plug_id,
|
||||
name=name,
|
||||
controls_printer_power=power,
|
||||
plug_type="homeassistant" if ha_entity_id else "tasmota",
|
||||
ha_entity_id=ha_entity_id,
|
||||
)
|
||||
|
||||
|
||||
class TestCandidateOrdering:
|
||||
"""Ordering has to be stable: the print-end delta is only meaningful if it
|
||||
reads the same counter the print-start reading came from."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_plugs_for_printer(self, db_session, printer_factory):
|
||||
printer = await printer_factory()
|
||||
|
||||
assert await energy_plug_candidates(db_session, printer.id) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_plug_is_the_candidate(self, db_session, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
plug = await smart_plug_factory(name="P1S Power", printer_id=printer.id)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
assert [c.id for c in candidates] == [plug.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_power_plug_sorts_ahead_of_earlier_accessory(self, db_session, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
accessory = await smart_plug_factory(
|
||||
name="Dry Box",
|
||||
plug_type="homeassistant",
|
||||
printer_id=printer.id,
|
||||
controls_printer_power=False,
|
||||
)
|
||||
power = await smart_plug_factory(
|
||||
name="P1S Power",
|
||||
plug_type="homeassistant",
|
||||
printer_id=printer.id,
|
||||
controls_printer_power=True,
|
||||
)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
assert [c.id for c in candidates] == [power.id, accessory.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ties_break_by_id(self, db_session, printer_factory, smart_plug_factory):
|
||||
"""`controls_printer_power` defaults to on, so two plugs claiming it is
|
||||
the normal case rather than a misconfiguration."""
|
||||
printer = await printer_factory()
|
||||
first = await smart_plug_factory(name="First", plug_type="homeassistant", printer_id=printer.id)
|
||||
second = await smart_plug_factory(name="Second", plug_type="homeassistant", printer_id=printer.id)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
assert [c.id for c in candidates] == [first.id, second.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_printers_plugs_are_not_candidates(self, db_session, printer_factory, smart_plug_factory):
|
||||
mine = await printer_factory(name="P1S")
|
||||
theirs = await printer_factory(name="X2D")
|
||||
plug = await smart_plug_factory(name="P1S Power", printer_id=mine.id)
|
||||
await smart_plug_factory(name="X2D Power", printer_id=theirs.id)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, mine.id)
|
||||
|
||||
assert [c.id for c in candidates] == [plug.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlinked_plug_is_not_a_candidate(self, db_session, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="Bench Plug", printer_id=None)
|
||||
|
||||
assert await energy_plug_candidates(db_session, printer.id) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_printer_matches_nothing(self, db_session, smart_plug_factory):
|
||||
"""`printer_id == None` would compile to `IS NULL` and hand back every
|
||||
unlinked plug, so a print could be billed against a bench plug."""
|
||||
await smart_plug_factory(name="Bench Plug", printer_id=None)
|
||||
|
||||
assert await energy_plug_candidates(db_session, None) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_plug_sorts_last(self, db_session, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
retired = await smart_plug_factory(
|
||||
name="Retired Plug",
|
||||
plug_type="homeassistant",
|
||||
printer_id=printer.id,
|
||||
enabled=False,
|
||||
)
|
||||
live = await smart_plug_factory(name="P1S Power", plug_type="homeassistant", printer_id=printer.id)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
assert [c.id for c in candidates] == [live.id, retired.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_plug_is_used_even_when_disabled(self, db_session, printer_factory, smart_plug_factory):
|
||||
"""Ranking, not filtering: a printer whose single plug is disabled
|
||||
tracked energy before this module existed and has to keep doing so."""
|
||||
printer = await printer_factory()
|
||||
retired = await smart_plug_factory(name="Retired Plug", printer_id=printer.id, enabled=False)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
assert [c.id for c in candidates] == [retired.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_home_assistant_script_sorts_last(self, db_session, printer_factory, smart_plug_factory):
|
||||
"""A `script.*` entity is linked for the automation triggers and has
|
||||
nothing to meter, so it is asked only if nothing else answers."""
|
||||
printer = await printer_factory()
|
||||
script = await smart_plug_factory(
|
||||
name="Notify Script",
|
||||
plug_type="homeassistant",
|
||||
ha_entity_id="script.notify_done",
|
||||
printer_id=printer.id,
|
||||
)
|
||||
plug = await smart_plug_factory(
|
||||
name="P1S Power",
|
||||
plug_type="homeassistant",
|
||||
ha_entity_id="switch.p1s",
|
||||
printer_id=printer.id,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
candidates = await energy_plug_candidates(db_session, printer.id)
|
||||
|
||||
# Even disabled, a real switch outranks a script.
|
||||
assert [c.id for c in candidates] == [plug.id, script.id]
|
||||
|
||||
|
||||
class TestSelectEnergyReading:
|
||||
@pytest.mark.asyncio
|
||||
async def test_picks_the_plug_that_reports_a_counter(self):
|
||||
"""The decisive test in practice: an accessory is usually switch-only,
|
||||
so it drops out without the user configuring anything."""
|
||||
dry_box = _plug(1, "Dry Box")
|
||||
printer_plug = _plug(2, "P1S Power")
|
||||
|
||||
async def read(plug, _db):
|
||||
return {"power": 3.0} if plug is dry_box else {"power": 120.0, "total": 41.5}
|
||||
|
||||
selected = await select_energy_reading([dry_box, printer_plug], read, db=None)
|
||||
|
||||
assert selected is not None
|
||||
plug, energy = selected
|
||||
assert plug is printer_plug
|
||||
assert energy["total"] == 41.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stops_at_the_first_usable_reading(self):
|
||||
first = _plug(1, "P1S Power")
|
||||
second = _plug(2, "Dry Box")
|
||||
seen = []
|
||||
|
||||
async def read(plug, _db):
|
||||
seen.append(plug.name)
|
||||
return {"total": 1.0}
|
||||
|
||||
selected = await select_energy_reading([first, second], read, db=None)
|
||||
|
||||
assert selected[0] is first
|
||||
assert seen == ["P1S Power"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreachable_plug_does_not_end_the_search(self):
|
||||
offline = _plug(1, "Offline")
|
||||
printer_plug = _plug(2, "P1S Power")
|
||||
|
||||
async def read(plug, _db):
|
||||
return None if plug is offline else {"total": 7.0}
|
||||
|
||||
selected = await select_energy_reading([offline, printer_plug], read, db=None)
|
||||
|
||||
assert selected[0] is printer_plug
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_is_a_reading(self):
|
||||
"""A freshly reset counter is a perfectly good baseline; treating 0 as
|
||||
missing would drop the first print after a plug replacement."""
|
||||
plug = _plug(1, "P1S Power")
|
||||
|
||||
selected = await select_energy_reading([plug], AsyncMock(return_value={"total": 0.0}), db=None)
|
||||
|
||||
assert selected is not None
|
||||
assert selected[1]["total"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_when_nothing_reports_a_counter(self):
|
||||
async def read(_plug, _db):
|
||||
return {"power": 3.0, "total": None}
|
||||
|
||||
assert await select_energy_reading([_plug(1, "Dry Box")], read, db=None) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_for_an_empty_candidate_list(self):
|
||||
assert await select_energy_reading([], AsyncMock(), db=None) is None
|
||||
|
||||
|
||||
class TestRecordEnergyStart:
|
||||
"""The reported failure, end to end."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_plugs_no_longer_lose_the_start_reading(
|
||||
self, db_session, printer_factory, smart_plug_factory, archive_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(
|
||||
name="Dry Box",
|
||||
plug_type="homeassistant",
|
||||
printer_id=printer.id,
|
||||
controls_printer_power=False,
|
||||
)
|
||||
await smart_plug_factory(
|
||||
name="P1S Power",
|
||||
plug_type="homeassistant",
|
||||
printer_id=printer.id,
|
||||
controls_printer_power=True,
|
||||
)
|
||||
archive = await archive_factory(printer.id)
|
||||
|
||||
from backend.app.main import _record_energy_start
|
||||
|
||||
async def read(plug, _db):
|
||||
return {"power": 120.0, "total": 41.5} if plug.name == "P1S Power" else {"power": 2.0}
|
||||
|
||||
with patch("backend.app.main._get_plug_energy", side_effect=read):
|
||||
recorded = await _record_energy_start(archive, printer.id, db_session)
|
||||
|
||||
assert recorded is True
|
||||
assert archive.energy_start_kwh == 41.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_plug_is_unchanged(self, db_session, printer_factory, smart_plug_factory, archive_factory):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="X2D Power", printer_id=printer.id)
|
||||
archive = await archive_factory(printer.id)
|
||||
|
||||
from backend.app.main import _record_energy_start
|
||||
|
||||
with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 12.25})):
|
||||
recorded = await _record_energy_start(archive, printer.id, db_session)
|
||||
|
||||
assert recorded is True
|
||||
assert archive.energy_start_kwh == 12.25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_plug_records_nothing(self, db_session, printer_factory, archive_factory):
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id)
|
||||
|
||||
from backend.app.main import _record_energy_start
|
||||
|
||||
recorded = await _record_energy_start(archive, printer.id, db_session)
|
||||
|
||||
assert recorded is False
|
||||
assert archive.energy_start_kwh is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_names_the_plugs_it_tried_when_none_measures(
|
||||
self, db_session, printer_factory, smart_plug_factory, archive_factory, capture_logs
|
||||
):
|
||||
""" "No plug reports energy" and "no plug at all" used to log the same
|
||||
way, which is what made this invisible for the reporter."""
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="Dry Box", plug_type="homeassistant", printer_id=printer.id)
|
||||
await smart_plug_factory(name="Chamber Light", plug_type="homeassistant", printer_id=printer.id)
|
||||
archive = await archive_factory(printer.id)
|
||||
|
||||
from backend.app.main import _record_energy_start
|
||||
|
||||
with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"power": 1.0})):
|
||||
recorded = await _record_energy_start(archive, printer.id, db_session)
|
||||
|
||||
assert recorded is False
|
||||
assert archive.energy_start_kwh is None
|
||||
logged = "\n".join(record.getMessage() for record in capture_logs.get_warnings())
|
||||
assert "Dry Box" in logged
|
||||
assert "Chamber Light" in logged
|
||||
@@ -378,14 +378,19 @@ export function SettingsPage() {
|
||||
for (const { plug, status } of statuses) {
|
||||
// For MQTT plugs, consider reachable if we have power data
|
||||
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
|
||||
const isReachable = (status?.reachable || hasMqttData) && status?.energy;
|
||||
// Reachability is about the device answering, not about it measuring
|
||||
// anything. Requiring `status.energy` here counted a perfectly online
|
||||
// switch-only plug -- no power sensor, so the backend leaves `energy`
|
||||
// null -- as offline, and the counter labelled "plugs online" quietly
|
||||
// reported "plugs reporting energy" instead (#2859).
|
||||
const isReachable = status?.reachable || hasMqttData;
|
||||
|
||||
if (isReachable) {
|
||||
reachableCount++;
|
||||
if (status.energy?.power != null) totalPower += status.energy.power;
|
||||
if (status.energy?.today != null) totalToday += status.energy.today;
|
||||
if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
|
||||
if (status.energy?.total != null) totalLifetime += status.energy.total;
|
||||
if (status?.energy?.power != null) totalPower += status.energy.power;
|
||||
if (status?.energy?.today != null) totalToday += status.energy.today;
|
||||
if (status?.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
|
||||
if (status?.energy?.total != null) totalLifetime += status.energy.total;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Q-Fl-YCy.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CXdmGala.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user