Просмотр исходного кода

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.
maziggy 2 недель назад
Родитель
Сommit
5665babab4

+ 30 - 15
backend/app/main.py

@@ -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.database import async_session, engine, init_db
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 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 import print_dispatch_context
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 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
 from backend.app.services.archive_purge import archive_purge_service
@@ -98,6 +97,7 @@ from backend.app.services.bambu_ftp import (
     with_ftp_retry,
     with_ftp_retry,
 )
 )
 from backend.app.services.bambu_mqtt import PrinterState
 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.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.homeassistant import homeassistant_service
 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__)
     _logger = logging.getLogger(__name__)
     try:
     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)
             _logger.info("[ENERGY] No smart plug for printer %s (archive %s)", printer_id, archive.id)
             return False
             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
             return False
+        plug, energy = selected
         archive.energy_start_kwh = float(energy["total"])
         archive.energy_start_kwh = float(energy["total"])
         await db.commit()
         await db.commit()
         _logger.info(
         _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 "",
             f" ({context})" if context else "",
             archive.id,
             archive.id,
+            plug.name,
             energy["total"],
             energy["total"],
         )
         )
         return True
         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)
                     logger.info("[ENERGY-BG] No start kWh recorded for archive %s", archive_id)
                     return
                     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)
                     logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id)
                     return
                     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
                     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)
                 energy_used = round(energy["total"] - starting_kwh, 4)
                 logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)
                 logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)

+ 104 - 0
backend/app/services/energy_plug.py

@@ -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

+ 306 - 0
backend/tests/unit/test_energy_plug_selection_2859.py

@@ -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

+ 10 - 5
frontend/src/pages/SettingsPage.tsx

@@ -378,14 +378,19 @@ export function SettingsPage() {
       for (const { plug, status } of statuses) {
       for (const { plug, status } of statuses) {
         // For MQTT plugs, consider reachable if we have power data
         // For MQTT plugs, consider reachable if we have power data
         const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
         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) {
         if (isReachable) {
           reachableCount++;
           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;
         }
         }
       }
       }
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CXdmGala.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-C_NRLB80.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов