fix: reset budgets using state file mtime when server was stopped across the daily reset

The old per-device last_check timestamp was only persisted while a
device was consuming budget, so a stop/restart across days left stale
budgets. Check the state file mtime instead: if it predates today's 7 AM
and we're past 7 AM, the server was offline during the daily reset, so
give every device a fresh budget. Also drops the redundant 2-minute
reset window and the persisted last_check field.
This commit is contained in:
2026-09-02 16:21:47 +02:00
parent 06771cd866
commit fdfced6901

View File

@ -5,7 +5,7 @@ import subprocess
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
import config import config
@ -152,12 +152,11 @@ def _get_budget(device: str) -> float:
def _set_budget(device: str, budget_seconds: float): def _set_budget(device: str, budget_seconds: float):
"""Set budget and track last check time for accurate elapsed calculation.""" """Persist the remaining budget for a device."""
state = _load_state() state = _load_state()
if device not in state: if device not in state:
state[device] = {"budget": float(BUDGET_S)} state[device] = {"budget": float(BUDGET_S)}
state[device]["budget"] = budget_seconds state[device]["budget"] = budget_seconds
state[device]["last_check"] = time.time() # track for elapsed calculation
_save_state(state) _save_state(state)
@ -270,17 +269,31 @@ def _shutdown_devices(device_ids: list[str]):
_timer_running = False _timer_running = False
_timer_lock = threading.Lock() _timer_lock = threading.Lock()
_last_check_times: dict[str, float] = {} # in-memory only, not persisted
def _reset_budgets_if_new_day():
"""Reset all budgets if today's 7 AM daily reset was missed.
The state file is only written while the server is running, so its
mtime is the last time the server did anything. If that's before
today's 7 AM (and it's now past 7 AM), the server was stopped across
the daily reset — give every device a fresh budget.
"""
if not STATE_FILE.exists():
return
now = datetime.now()
today_reset = now.replace(hour=7, minute=0, second=0)
mtime = datetime.fromtimestamp(STATE_FILE.stat().st_mtime)
if mtime < today_reset <= now:
for dev_id in DEVICES:
_reset_budget(dev_id)
def _budget_tick(): def _budget_tick():
"""Called every 10 seconds by the background timer.""" """Called every 10 seconds by the background timer."""
now = datetime.now() # New day and we were offline at 7 AM? Reset all budgets to full.
reset_time = now.replace(hour=7, minute=0, second=0) _reset_budgets_if_new_day()
# Reset all budgets at 7:00 AM each day
if now >= reset_time and now < reset_time + timedelta(minutes=2):
for dev_id in DEVICES:
_reset_budget(dev_id)
# Check all devices in parallel # Check all devices in parallel
online_status = {} online_status = {}
@ -299,20 +312,29 @@ def _budget_tick():
# Process results sequentially (budget operations need to be thread-safe) # Process results sequentially (budget operations need to be thread-safe)
with _timer_lock: with _timer_lock:
current_time = time.time() # float timestamp for precise elapsed calculation
# Initialize last check times on first run (server restart)
if not _last_check_times:
for dev_id in DEVICES:
_last_check_times[dev_id] = current_time
if not is_curfew_allowed(): if not is_curfew_allowed():
# Curfew in effect — turn off all online devices in parallel # Curfew in effect — turn off all online devices in parallel
online_devices = [dev_id for dev_id, online in online_status.items() if online] online_devices = [dev_id for dev_id, online in online_status.items() if online]
_shutdown_devices(online_devices) _shutdown_devices(online_devices)
# Update last check times even during curfew
for dev_id in DEVICES:
_last_check_times[dev_id] = current_time
return return
current_time = time.time() # float timestamp for precise elapsed calculation
devices_to_shutdown = [] devices_to_shutdown = []
for dev_id, online in online_status.items(): for dev_id, online in online_status.items():
dev_info = DEVICES[dev_id] dev_info = DEVICES[dev_id]
state = _load_state() state = _load_state()
current_budget = state.get(dev_id, {}).get("budget", float(BUDGET_S)) current_budget = state.get(dev_id, {}).get("budget", float(BUDGET_S))
last_check = state.get(dev_id, {}).get("last_check", current_time) last_check = _last_check_times.get(dev_id, current_time)
# Expired budget + device back online → shut it down # Expired budget + device back online → shut it down
if current_budget <= 0 and online: if current_budget <= 0 and online:
@ -331,8 +353,11 @@ def _budget_tick():
if new_budget <= 0: if new_budget <= 0:
devices_to_shutdown.append(dev_id) devices_to_shutdown.append(dev_id)
else: else:
# Always update last_check even when offline # Always update last_check even when offline (in-memory only)
_set_budget(dev_id, current_budget) pass
# Update last check time for next interval
_last_check_times[dev_id] = current_time
# Shutdown all devices that hit zero budget in parallel # Shutdown all devices that hit zero budget in parallel
if devices_to_shutdown: if devices_to_shutdown: