|
@@ -13,6 +13,7 @@ from backend.app.services.homeassistant import homeassistant_service
|
|
|
from backend.app.services.printer_manager import printer_manager
|
|
from backend.app.services.printer_manager import printer_manager
|
|
|
from backend.app.services.rest_smart_plug import rest_smart_plug_service
|
|
from backend.app.services.rest_smart_plug import rest_smart_plug_service
|
|
|
from backend.app.services.tasmota import tasmota_service
|
|
from backend.app.services.tasmota import tasmota_service
|
|
|
|
|
+from backend.app.utils.local_time import next_local_hour, to_naive_utc, utcnow_naive
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
if TYPE_CHECKING:
|
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
@@ -98,26 +99,34 @@ class SmartPlugManager:
|
|
|
await asyncio.sleep(60)
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
|
|
async def _snapshot_loop(self):
|
|
async def _snapshot_loop(self):
|
|
|
- """Background loop that captures each plug's lifetime energy counter hourly.
|
|
|
|
|
-
|
|
|
|
|
- Powers date-range queries in "total consumption" energy mode (#941). Takes
|
|
|
|
|
- a snapshot shortly after startup so the first bucket isn't empty, then
|
|
|
|
|
- every hour.
|
|
|
|
|
|
|
+ """Background loop that captures each plug's lifetime energy counter.
|
|
|
|
|
+
|
|
|
|
|
+ Powers date-range queries in "total consumption" energy mode (#941) and,
|
|
|
|
|
+ since #2539, the derived Today / Yesterday figures for every plug that
|
|
|
|
|
+ reports only a cumulative counter.
|
|
|
|
|
+
|
|
|
|
|
+ Ticks on the local hour rather than every 3600s from boot. That is what
|
|
|
|
|
+ makes the derivation exact: a drifting timer leaves the last snapshot
|
|
|
|
|
+ before midnight up to an hour early, and an hour of a printer's draw is
|
|
|
|
|
+ a real number of watt-hours to lose off the day boundary. Aligning to the
|
|
|
|
|
+ *local* hour also lands a tick on local midnight in the half-hour-offset
|
|
|
|
|
+ timezones (India, Nepal), where midnight is not on a UTC hour at all.
|
|
|
"""
|
|
"""
|
|
|
- # Short warm-up delay so other services finish booting; still gives us
|
|
|
|
|
- # an initial snapshot well before the first hour mark.
|
|
|
|
|
|
|
+ # Short warm-up delay so other services finish booting; still gives us an
|
|
|
|
|
+ # initial snapshot well before the first boundary.
|
|
|
await asyncio.sleep(30)
|
|
await asyncio.sleep(30)
|
|
|
while True:
|
|
while True:
|
|
|
try:
|
|
try:
|
|
|
await self._capture_energy_snapshots()
|
|
await self._capture_energy_snapshots()
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.error("Error in energy snapshot capture: %s", e)
|
|
logger.error("Error in energy snapshot capture: %s", e)
|
|
|
- await asyncio.sleep(3600) # 1 hour
|
|
|
|
|
|
|
+
|
|
|
|
|
+ now = datetime.now(timezone.utc)
|
|
|
|
|
+ delay = (next_local_hour(now) - now).total_seconds()
|
|
|
|
|
+ await asyncio.sleep(max(delay, 60))
|
|
|
|
|
|
|
|
async def _capture_energy_snapshots(self):
|
|
async def _capture_energy_snapshots(self):
|
|
|
"""Capture one energy snapshot row per plug with a usable lifetime counter."""
|
|
"""Capture one energy snapshot row per plug with a usable lifetime counter."""
|
|
|
- from datetime import timezone
|
|
|
|
|
-
|
|
|
|
|
from backend.app.core.database import async_session
|
|
from backend.app.core.database import async_session
|
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
from backend.app.models.smart_plug import SmartPlug
|
|
|
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
|
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
|
@@ -128,7 +137,10 @@ class SmartPlugManager:
|
|
|
if not plugs:
|
|
if not plugs:
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- now = datetime.now(timezone.utc)
|
|
|
|
|
|
|
+ # Naive UTC: the column is naive, and asyncpg rejects an aware value
|
|
|
|
|
+ # outright (SQLite quietly drops the offset, which is why this went
|
|
|
|
|
+ # unnoticed — on Postgres the whole capture raised).
|
|
|
|
|
+ now = utcnow_naive()
|
|
|
captured = 0
|
|
captured = 0
|
|
|
for plug in plugs:
|
|
for plug in plugs:
|
|
|
# MQTT plugs only publish a "today" counter that resets at midnight —
|
|
# MQTT plugs only publish a "today" counter that resets at midnight —
|
|
@@ -146,8 +158,9 @@ class SmartPlugManager:
|
|
|
continue
|
|
continue
|
|
|
lifetime = energy.get("total")
|
|
lifetime = energy.get("total")
|
|
|
if lifetime is None:
|
|
if lifetime is None:
|
|
|
- # MQTT / REST plugs that only expose "today" can't be used for
|
|
|
|
|
- # cumulative snapshots — skip them.
|
|
|
|
|
|
|
+ # The plug exposes no cumulative counter — a REST plug with only
|
|
|
|
|
+ # rest_energy_path set, say. Nothing to snapshot, and its Today
|
|
|
|
|
+ # comes straight from the device anyway.
|
|
|
continue
|
|
continue
|
|
|
db.add(
|
|
db.add(
|
|
|
SmartPlugEnergySnapshot(
|
|
SmartPlugEnergySnapshot(
|
|
@@ -189,7 +202,7 @@ class SmartPlugManager:
|
|
|
success = await service.turn_on(plug)
|
|
success = await service.turn_on(plug)
|
|
|
if success:
|
|
if success:
|
|
|
plug.last_state = "ON"
|
|
plug.last_state = "ON"
|
|
|
- plug.last_checked = datetime.now(timezone.utc)
|
|
|
|
|
|
|
+ plug.last_checked = utcnow_naive()
|
|
|
self._last_schedule_check[plug.id] = f"on:{current_time}"
|
|
self._last_schedule_check[plug.id] = f"on:{current_time}"
|
|
|
|
|
|
|
|
# Check if we should turn off
|
|
# Check if we should turn off
|
|
@@ -200,7 +213,7 @@ class SmartPlugManager:
|
|
|
success = await service.turn_off(plug)
|
|
success = await service.turn_off(plug)
|
|
|
if success:
|
|
if success:
|
|
|
plug.last_state = "OFF"
|
|
plug.last_state = "OFF"
|
|
|
- plug.last_checked = datetime.now(timezone.utc)
|
|
|
|
|
|
|
+ plug.last_checked = utcnow_naive()
|
|
|
self._last_schedule_check[plug.id] = f"off:{current_time}"
|
|
self._last_schedule_check[plug.id] = f"off:{current_time}"
|
|
|
# Mark printer offline if linked
|
|
# Mark printer offline if linked
|
|
|
if plug.printer_id:
|
|
if plug.printer_id:
|
|
@@ -245,7 +258,7 @@ class SmartPlugManager:
|
|
|
|
|
|
|
|
if success:
|
|
if success:
|
|
|
plug.last_state = "ON"
|
|
plug.last_state = "ON"
|
|
|
- plug.last_checked = datetime.now(timezone.utc)
|
|
|
|
|
|
|
+ plug.last_checked = utcnow_naive()
|
|
|
plug.auto_off_executed = False # Reset flag when turning on
|
|
plug.auto_off_executed = False # Reset flag when turning on
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
|
|
logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
|
|
@@ -617,7 +630,7 @@ class SmartPlugManager:
|
|
|
plug = result.scalar_one_or_none()
|
|
plug = result.scalar_one_or_none()
|
|
|
if plug:
|
|
if plug:
|
|
|
plug.auto_off_pending = pending
|
|
plug.auto_off_pending = pending
|
|
|
- plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
|
|
|
|
|
|
|
+ plug.auto_off_pending_since = utcnow_naive() if pending else None
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
|
|
logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
@@ -639,7 +652,7 @@ class SmartPlugManager:
|
|
|
plug.auto_off_pending = False # Clear pending state
|
|
plug.auto_off_pending = False # Clear pending state
|
|
|
plug.auto_off_pending_since = None
|
|
plug.auto_off_pending_since = None
|
|
|
plug.last_state = "OFF"
|
|
plug.last_state = "OFF"
|
|
|
- plug.last_checked = datetime.now(timezone.utc)
|
|
|
|
|
|
|
+ plug.last_checked = utcnow_naive()
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
if plug.auto_off_persistent:
|
|
if plug.auto_off_persistent:
|
|
|
logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
|
|
logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
|
|
@@ -685,10 +698,8 @@ class SmartPlugManager:
|
|
|
for plug in pending_plugs:
|
|
for plug in pending_plugs:
|
|
|
# Check how long it's been pending (timeout after 2 hours)
|
|
# Check how long it's been pending (timeout after 2 hours)
|
|
|
if plug.auto_off_pending_since:
|
|
if plug.auto_off_pending_since:
|
|
|
- pending_since = plug.auto_off_pending_since
|
|
|
|
|
- if pending_since.tzinfo is None:
|
|
|
|
|
- pending_since = pending_since.replace(tzinfo=timezone.utc)
|
|
|
|
|
- elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
|
|
|
|
|
|
|
+ pending_since = to_naive_utc(plug.auto_off_pending_since)
|
|
|
|
|
+ elapsed = (utcnow_naive() - pending_since).total_seconds()
|
|
|
if elapsed > 7200: # 2 hours
|
|
if elapsed > 7200: # 2 hours
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
|
|
f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
|