diff --git a/app/integrations/expose.py b/app/integrations/expose.py index d762c44..d4f304d 100644 --- a/app/integrations/expose.py +++ b/app/integrations/expose.py @@ -27,6 +27,7 @@ from __future__ import annotations import logging from dataclasses import dataclass, field +from datetime import timedelta from typing import Any, Callable, Optional, Protocol from sqlalchemy.orm import Session @@ -369,6 +370,10 @@ register_provider(_modbus_provider) # Energy Cost provider # --------------------------------------------------------------------------- +# *_today 当日窗口翻天的宽限:本地午夜后 5 秒才切到新的一天,避免在 00:00:0x 把 +# 归零后的值发出去、被慢几秒的 HA 钟记成前一天的 23:59:59(归错小时桶)。 +_TODAY_RESET_GRACE = timedelta(seconds=5) + def _energy_cost_provider(session: Session) -> list[ExposableEntity]: """Enumerate ExposableEntity objects for the energy cost subsystem. @@ -735,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: return None # Today's window in UTC, using monkeypatch-safe module attribute calls. - today_local = _tz_mod.local_now().date() + # Grace: subtract _TODAY_RESET_GRACE so that in the first 5 seconds after + # local midnight the getter still returns yesterday's window. This prevents + # a "归零后的值" from being published while HA's clock (which may lag a few + # seconds) would stamp it as 23:59:59 of the previous day. + today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date() tomorrow_local = today_local + _td(days=1) today_start_utc = _tz_mod.local_midnight_utc(today_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) @@ -772,7 +781,8 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]: if not versions: return None - today_local = _tz_mod.local_now().date() + # Grace: same logic as import_cost_today — see that getter's comment. + today_local = (_tz_mod.local_now() - _TODAY_RESET_GRACE).date() tomorrow_local = today_local + _td(days=1) today_start_utc = _tz_mod.local_midnight_utc(today_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) diff --git a/app/main.py b/app/main.py index 4c1573d..923d23d 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy.orm import Session @@ -36,6 +37,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE from app.services.ha_discovery import publish_discovery, publish_states from app.services.tibber_prices import refresh_prices from app.services.energy_cost import compute_closed_periods +from app.services.timezone import local_tz from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db logger = logging.getLogger(__name__) @@ -159,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None: session.close() +def _run_midnight_state_publish() -> None: + """本地午夜后不久专门发布一次状态,让 *_today 的每日归零稳稳落在午夜之后 + (对 HA 钟慢几秒鲁棒)。best-effort:失败仅记日志,不影响调度器。""" + session_local = get_session_local() + session = session_local() + try: + from app.services.ha_discovery import publish_states + publish_states(session) + except Exception: + logger.exception("_run_midnight_state_publish: failed (non-fatal)") + finally: + session.close() + + def ensure_auth_db_ready() -> None: session_local = get_session_local() session: Session = session_local() @@ -233,6 +249,17 @@ async def lifespan(_: FastAPI): max_instances=1, coalesce=True, ) + # Dedicated midnight publish: fire at local 00:00:10 so *_today grace (5 s) has + # already elapsed and the day-rolled value is pushed to HA immediately, rather + # than waiting for the next 60-second ha-state-publish sweep. + scheduler.add_job( + _run_midnight_state_publish, + trigger=CronTrigger(hour=0, minute=0, second=10, timezone=local_tz()), + id="midnight-today-publish", + replace_existing=True, + max_instances=1, + coalesce=True, + ) scheduler.start() # MQTT: connect using DB-merged runtime settings so broker configured via UI diff --git a/tests/test_energy_expose.py b/tests/test_energy_expose.py index 7416e34..b346d15 100644 --- a/tests/test_energy_expose.py +++ b/tests/test_energy_expose.py @@ -1729,10 +1729,13 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: """FU11: import_cost_today getter returns value for today's local window. Setup: period at local 'today' (within today's UTC window), active contract. - Timezone pinned to UTC. Verifies: value = metered_import + fixed_costs for 1 day + Timezone pinned to UTC, local_now pinned to 2026-06-26 12:00:00 UTC (deterministic midday). + Verifies: value = metered_import + fixed_costs for 1 day (Principle C: today counts as elapsed if we're past local midnight — which UTC is). FUE-T05: provider requires an active electricity meter. + FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced + by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:00–00:00:04. """ from datetime import timedelta from unittest.mock import patch @@ -1740,17 +1743,16 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: from app.integrations.expose import build_catalog from app.services import timezone as _tz_mod - now_utc = datetime.now(timezone.utc) - # Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow) - # When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window. - t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) - # Ensure t0 is still today UTC (handle edge case where now is < 1h past UTC midnight) - if t0.date() < now_utc.date(): - t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always. + fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc) + today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0) + + # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight). + t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0) # Contract starting well before today - effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) - meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + effective_from = today_midnight - timedelta(days=30) + meter_start = today_midnight - timedelta(days=30) import_cost_today = 1.23 @@ -1766,8 +1768,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None: session.commit() with Session(energy_db) as session: - # Pin timezone to UTC so today_local = UTC date (deterministic on any CI host). - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + # Pin both local_tz (UTC) and local_now (deterministic midday) so the today window + # is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock. + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + ): catalog = build_catalog(session) today_entry = next( e for e in catalog if e.entity.key == "energy.import_cost_today" @@ -1789,6 +1795,8 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: """FU11: export_revenue_today getter returns value for today's local window. FUE-T05: provider requires an active electricity meter. + FUE-T09: local_now pinned (not real time) to eliminate the ~5s flaky window introduced + by the grace period: (local_now()-5s).date() is unpredictable near UTC 00:00:00–00:00:04. """ from datetime import timedelta from unittest.mock import patch @@ -1796,13 +1804,15 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: from app.integrations.expose import build_catalog from app.services import timezone as _tz_mod - now_utc = datetime.now(timezone.utc) - t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) - if t0.date() < now_utc.date(): - t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) + # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always. + fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc) + today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0) - effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) - meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) + # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight). + t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0) + + effective_from = today_midnight - timedelta(days=30) + meter_start = today_midnight - timedelta(days=30) export_today = 0.75 with Session(energy_db) as session: @@ -1817,7 +1827,12 @@ def test_export_revenue_today_getter_uses_local_day_window(energy_db) -> None: session.commit() with Session(energy_db) as session: - with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): + # Pin both local_tz (UTC) and local_now (deterministic midday) so the today window + # is always [2026-06-26 00:00:00 UTC, 2026-06-27 00:00:00 UTC), regardless of CI clock. + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + ): catalog = build_catalog(session) today_entry = next( e for e in catalog if e.entity.key == "energy.export_revenue_today" @@ -2468,3 +2483,205 @@ def test_energy_cost_identifiers_change_after_meter_swap(energy_db) -> None: assert entity.device.identifiers[1] != old_uuid, ( f"After swap, old uuid {old_uuid!r} must not appear in identifiers" ) + + +# --------------------------------------------------------------------------- +# FUE-T09: *_today grace — window selection at / around local midnight +# --------------------------------------------------------------------------- +# +# Strategy: patch local_now + local_tz (to UTC) and spy on summarize to capture +# the start/end arguments. Verifies that the 5-second grace means: +# - 00:00:03 → today_local = yesterday (grace not yet elapsed) +# - 00:00:12 → today_local = today (grace elapsed) +# - 12:00:00 → today_local = today (midday, unaffected) + + +def _spy_summarize_factory(captured: dict): + """Return a summarize spy that records start/end and returns a zero-value dict.""" + def _spy(sess, start, end): + captured["start"] = start + captured["end"] = end + return {"metered_import": 0.0, "fixed_costs": 0.0, "metered_export": 0.0, "credits": 0.0} + return _spy + + +def _setup_grace_db(energy_db) -> None: + """Insert active meter + active contract + one non-degraded period for grace tests.""" + anchor = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc) + with Session(energy_db) as session: + _make_active_meter(session, started_at=anchor, label="Grace Test Meter") + _make_contract_with_version( + session, + values=_STANDING_VALUES, + effective_from=anchor, + active=True, + ) + _make_period(session, period_start=anchor, import_cost=1.0, export_revenue=0.5, degraded=False) + session.commit() + + +def test_today_getter_grace_uses_yesterday_window_at_00_00_03(energy_db) -> None: + """FUE-T09 AC1a: at local 00:00:03 (< grace=5s), both today getters use YESTERDAY's window. + + With grace=5s, (local_now() - 5s) = 2026-06-25 23:59:58, so today_local = 2026-06-25 + (yesterday). summarize must be called with start = 2026-06-25 00:00:00 UTC. + Verifies both import and export today getters. + """ + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod + + _setup_grace_db(energy_db) + + # 2026-06-26 00:00:03 UTC — 3 seconds past midnight, still within 5-second grace. + fake_now = datetime(2026, 6, 26, 0, 0, 3, tzinfo=timezone.utc) + + captured_import: dict = {} + captured_export: dict = {} + + with Session(energy_db) as session: + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)), + ): + catalog = build_catalog(session) + import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today") + import_entry.entity.value_getter(session) + + # Reset and test export getter (separate patch context so we get a fresh spy dict) + captured_export = {} + with Session(energy_db) as session: + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)), + ): + catalog = build_catalog(session) + export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today") + export_entry.entity.value_getter(session) + + # Grace not elapsed: today_local = 2026-06-25 (yesterday), so window starts there. + expected_start = datetime(2026, 6, 25, 0, 0, 0, tzinfo=timezone.utc) + assert captured_import.get("start") == expected_start, ( + f"import_cost_today at 00:00:03: window start must be yesterday ({expected_start}), " + f"got {captured_import.get('start')}" + ) + assert captured_export.get("start") == expected_start, ( + f"export_revenue_today at 00:00:03: window start must be yesterday ({expected_start}), " + f"got {captured_export.get('start')}" + ) + + +def test_today_getter_grace_uses_today_window_at_00_00_12(energy_db) -> None: + """FUE-T09 AC1b: at local 00:00:12 (> grace=5s), both today getters use TODAY's window. + + With grace=5s, (local_now() - 5s) = 2026-06-26 00:00:07, so today_local = 2026-06-26 + (today). summarize must be called with start = 2026-06-26 00:00:00 UTC. + """ + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod + + _setup_grace_db(energy_db) + + # 2026-06-26 00:00:12 UTC — 12 seconds past midnight, beyond the 5-second grace. + fake_now = datetime(2026, 6, 26, 0, 0, 12, tzinfo=timezone.utc) + + captured_import: dict = {} + captured_export: dict = {} + + with Session(energy_db) as session: + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_import)), + ): + catalog = build_catalog(session) + import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today") + import_entry.entity.value_getter(session) + + captured_export = {} + with Session(energy_db) as session: + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured_export)), + ): + catalog = build_catalog(session) + export_entry = next(e for e in catalog if e.entity.key == "energy.export_revenue_today") + export_entry.entity.value_getter(session) + + # Grace elapsed: today_local = 2026-06-26 (today), window starts there. + expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc) + assert captured_import.get("start") == expected_start, ( + f"import_cost_today at 00:00:12: window start must be today ({expected_start}), " + f"got {captured_import.get('start')}" + ) + assert captured_export.get("start") == expected_start, ( + f"export_revenue_today at 00:00:12: window start must be today ({expected_start}), " + f"got {captured_export.get('start')}" + ) + + +def test_today_getter_grace_unaffected_at_midday(energy_db) -> None: + """FUE-T09 AC2: at 12:00, grace doesn't shift the window — today_local still = today. + + Well past midnight, (local_now() - 5s).date() == local_now().date() always. + """ + from unittest.mock import patch + from zoneinfo import ZoneInfo + from app.integrations.expose import build_catalog + from app.services import timezone as _tz_mod + + _setup_grace_db(energy_db) + + # 2026-06-26 12:00:00 UTC — midday. + fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc) + + captured: dict = {} + + with Session(energy_db) as session: + with ( + patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")), + patch.object(_tz_mod, "local_now", return_value=fake_now), + patch("app.services.energy_cost.summarize", side_effect=_spy_summarize_factory(captured)), + ): + catalog = build_catalog(session) + import_entry = next(e for e in catalog if e.entity.key == "energy.import_cost_today") + import_entry.entity.value_getter(session) + + # At 12:00 the grace (5s) does not push the date back: today_local = 2026-06-26. + expected_start = datetime(2026, 6, 26, 0, 0, 0, tzinfo=timezone.utc) + assert captured.get("start") == expected_start, ( + f"import_cost_today at 12:00: window start must be today ({expected_start}), " + f"got {captured.get('start')}" + ) + + +def test_midnight_state_publish_no_raise_when_mqtt_disabled() -> None: + """FUE-T09 AC3: _run_midnight_state_publish is safe when MQTT is disabled. + + _run_midnight_state_publish delegates to publish_states, which is internally + guarded (_should_publish returns False when MQTT is disabled or not connected). + This test verifies publish_states itself doesn't raise under those conditions — + the job's try/except swallows any other exception as well. + """ + settings = _make_settings(mqtt_enabled=False) + mock_mgr = _make_mock_manager(is_connected=False) + + with ( + patch("app.services.ha_discovery.build_runtime_settings", return_value=settings), + patch("app.services.ha_discovery.mqtt_manager", mock_mgr), + ): + from app.services.ha_discovery import publish_states + from sqlalchemy import create_engine as _ce + from sqlalchemy.orm import Session as _S + + eng = _ce("sqlite:///:memory:") + with _S(eng) as sess: + publish_states(sess) # must not raise + + mock_mgr.publish.assert_not_called()