7 Commits
Author SHA1 Message Date
tliu93 b65f700d56 fix(tibber): fetch forward-looking today+tomorrow via priceInfo(QUARTER_HOURLY)
pytest / test (push) Successful in 20m2s
frontend / frontend (push) Failing after 6m5s
docker-image / build-and-push (push) Successful in 13m27s
priceInfoRange is a historical cursor connection whose range ends at "now": it
never returns upcoming slots. With it, the DB only ever held prices up to the
last hourly refresh, which caused two problems:

  1. The price chart could only show history up to now, never a forward curve.
  2. Worse, per-slot billing was subtly wrong. _tibber_strategy looks up the
     price via `starts_at <= t0` (nearest slot at or before the period). Because
     a period's exact 15-min slot was not fetched until the next hourly refresh
     (~1h later), intra-hour periods were billed with the PREVIOUS quarter's
     price and then locked in by the immutability guard — never corrected.

Switch to priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }, which is
forward-looking AND quarter-hourly: today is always the full local day (96
slots) and tomorrow fills in once Tibber publishes day-ahead prices (picked up
by the next hourly refresh). Every 15-min slot's exact price is now in the DB
before the slot closes, so each period finds its own slot (accurate billing)
and the live current-price entity stays fresh.

Verified against the live Tibber API: fetch_price_range returns 192 points
(96 today + 96 tomorrow), spanning local today 00:00 → tomorrow 23:45, with
future slots present (previously 0).
2026-07-18 20:20:33 +02:00
tliu93 f4cea3874b test(energy-cost): pin local_now in future-window summarize test
frontend / frontend (push) Failing after 6m38s
pytest / test (push) Successful in 20m16s
docker-image / build-and-push (push) Successful in 13m32s
test_future_window_counts_0_days relied on the real wall-clock date and assumed
7/1→8/1 2026 was entirely in the future; once that range started elapsing the
window counted fixed-fee days and the assertion failed. Pin local_now to
June 25 2026 (as the sibling window tests already do) so the case stays
deterministic.
2026-07-17 18:49:26 +02:00
tliu93 134f0abb5f fix(tibber): fetch newest price slots via priceInfoRange last:192
priceInfoRange is a Relay-style cursor connection over the subscription's
entire price history. With no cursor, first:96 returned the OLDEST 96 slots,
anchored at the subscription start date — a fixed window that never advanced.
For a contract added mid-period this meant refresh_prices kept re-upserting the
same day-one slots forever, so GET /api/energy/prices found nothing in the
today+tomorrow window and the UI showed "No Tibber price points available".

Use last:192 to return the newest 192 quarter-hourly slots (2 days), which ends
at the latest published slot and advances daily, fully covering the prices
endpoint's today+tomorrow window.
2026-07-17 18:49:26 +02:00
tliu93 3e04b15656 feat(modbus): add DDSU666 profile and select read function code per profile
frontend / frontend (push) Successful in 2m21s
pytest / test (push) Successful in 9m50s
docker-image / build-and-push (push) Successful in 4m24s
- Add CHINT DDSU666 profile (ddsu666.yaml): FC03 holding registers, voltage/
  current/active power (kW)/reactive power (kvar)/PF/frequency, import+export
  active energy. Word/byte order left at big-endian as a documented best guess
  (manual has no float example) — to be confirmed on-device.
- Add DDSU666-Modbus-Protocol.md reference extracted from the official manual,
  plus the source PDF (parity with the SDM120 reference).
- Generalize driver.read_blocks to dispatch FC03 (holding) or FC04 (input)
  based on a function_code argument (default 4, SDM120 behaviour unchanged);
  the code is validated before any connection is attempted.
- Wire profile.function_code through the CLI read command, the background
  poller, and the device /test endpoint — previously the profile field was
  declared but never honoured (read path was hardcoded to FC04).
- Tests: default -> FC04, function_code=3 -> FC03 holding, invalid FC rejected
  before connecting.
2026-06-30 17:16:25 +02:00
tliu93 f2e8f6a8e7 docs(roadmap): queue Authentication next-steps — sliding session renewal + long-lived token targets (location/poo ingestion)
frontend / frontend (push) Successful in 2m16s
pytest / test (push) Successful in 10m40s
2026-06-27 22:16:02 +02:00
tliu93 90a03e7fd6 FUE-T09: grace-shift *_today daily reset past local midnight + dedicated 00:00:10 publish
docker-image / build-and-push (push) Successful in 4m29s
frontend / frontend (push) Successful in 2m17s
pytest / test (push) Successful in 11m2s
2026-06-26 12:21:51 +02:00
tliu93 d3fc90b320 FUE-T08: defer daily fixed-fee/heffingskorting settlement to local 01:05 in summarize() 2026-06-26 11:44:34 +02:00
16 changed files with 1140 additions and 98 deletions
+1
View File
@@ -488,6 +488,7 @@ def test_read(
device.port, device.port,
device.unit_id, device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks], [{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
) )
payload: dict[str, Any] = decode_profile(profile, registers) payload: dict[str, Any] = decode_profile(profile, registers)
return ModbusTestReadResponse(ok=True, payload=payload) return ModbusTestReadResponse(ok=True, payload=payload)
+12 -2
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Callable, Optional, Protocol from typing import Any, Callable, Optional, Protocol
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -369,6 +370,10 @@ register_provider(_modbus_provider)
# Energy Cost 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]: def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
"""Enumerate ExposableEntity objects for the energy cost subsystem. """Enumerate ExposableEntity objects for the energy cost subsystem.
@@ -735,7 +740,11 @@ def _energy_cost_provider(session: Session) -> list[ExposableEntity]:
return None return None
# Today's window in UTC, using monkeypatch-safe module attribute calls. # 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) tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local) today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_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: if not versions:
return None 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) tomorrow_local = today_local + _td(days=1)
today_start_utc = _tz_mod.local_midnight_utc(today_local) today_start_utc = _tz_mod.local_midnight_utc(today_local)
tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local) tomorrow_start_utc = _tz_mod.local_midnight_utc(tomorrow_local)
+33 -6
View File
@@ -1,8 +1,11 @@
"""Modbus TCP driver — thin wrapper around pymodbus. """Modbus TCP driver — thin wrapper around pymodbus.
This module provides a single public function ``read_blocks`` that performs This module provides a single public function ``read_blocks`` that performs
one or more FC04 (Read Input Registers) block reads against a Modbus TCP one or more block reads against a Modbus TCP gateway — using either FC04
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map. (Read Input Registers) or FC03 (Read Holding Registers), selected per call
via the ``function_code`` argument — and returns a flat
``dict[register_address -> 16-bit_value]`` map. The function code comes from
the device profile (e.g. SDM120 uses FC04, DDSU666 uses FC03).
Design decisions Design decisions
---------------- ----------------
@@ -80,9 +83,10 @@ def read_blocks(
unit_id: int, unit_id: int,
blocks: Sequence[Block], blocks: Sequence[Block],
*, *,
function_code: int = 4,
timeout: float = 3.0, timeout: float = 3.0,
) -> dict[int, int]: ) -> dict[int, int]:
"""Read one or more contiguous register blocks via FC04 (input registers). """Read one or more contiguous register blocks via FC03 or FC04.
Parameters Parameters
---------- ----------
@@ -96,6 +100,11 @@ def read_blocks(
Sequence of ``{"start": int, "count": int}`` dicts describing the Sequence of ``{"start": int, "count": int}`` dicts describing the
contiguous register ranges to read. ``count`` is the number of contiguous register ranges to read. ``count`` is the number of
16-bit registers (not bytes). 16-bit registers (not bytes).
function_code:
Modbus read function code: ``4`` for FC04 (Read Input Registers,
default — SDM120) or ``3`` for FC03 (Read Holding Registers —
DDSU666 and other devices that expose measurements as holding
registers). Comes from the device profile's ``function_code`` field.
timeout: timeout:
TCP connect/read timeout in seconds (default 3 s). TCP connect/read timeout in seconds (default 3 s).
@@ -107,12 +116,21 @@ def read_blocks(
Raises Raises
------ ------
ModbusDriverError
If ``function_code`` is neither 3 nor 4 (validated before any
connection is attempted).
ModbusConnectionError ModbusConnectionError
If the TCP connection to the gateway fails. If the TCP connection to the gateway fails.
ModbusResponseError ModbusResponseError
If the gateway returns a Modbus exception frame or an unexpected If the gateway returns a Modbus exception frame or an unexpected
number of registers. number of registers.
""" """
if function_code not in (3, 4):
raise ModbusDriverError(
f"Unsupported read function code FC{function_code:02d} "
f"(only FC03 holding-register and FC04 input-register reads are supported)"
)
client = ModbusTcpClient(host, port=port, timeout=timeout) client = ModbusTcpClient(host, port=port, timeout=timeout)
try: try:
connected = client.connect() connected = client.connect()
@@ -125,7 +143,7 @@ def read_blocks(
for block in blocks: for block in blocks:
start: int = block["start"] start: int = block["start"]
count: int = block["count"] count: int = block["count"]
_read_block(client, unit_id, start, count, registers) _read_block(client, unit_id, start, count, registers, function_code=function_code)
return registers return registers
@@ -145,10 +163,19 @@ def _read_block(
start: int, start: int,
count: int, count: int,
result: dict[int, int], result: dict[int, int],
*,
function_code: int,
) -> None: ) -> None:
"""Read one block and merge into *result*. Raises on any error.""" """Read one block and merge into *result*. Raises on any error.
``function_code`` is assumed already validated to be 3 or 4 by the caller
(``read_blocks``); 3 dispatches FC03 (holding) and 4 dispatches FC04 (input).
"""
try: try:
response = client.read_input_registers(start, count=count, device_id=unit_id) if function_code == 3:
response = client.read_holding_registers(start, count=count, device_id=unit_id)
else: # function_code == 4 (input registers)
response = client.read_input_registers(start, count=count, device_id=unit_id)
except ConnectionException as exc: except ConnectionException as exc:
raise ModbusConnectionError( raise ModbusConnectionError(
f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}" f"Lost connection while reading registers 0x{start:04X}+{count}: {exc}"
@@ -0,0 +1,82 @@
name: ddsu666
description: CHINT DDSU666 single-phase smart meter
function_code: 3 # holding registers (FC03) — DDSU666 has NO input registers (no FC04)
word_order: big # high register first — ASSUMED; verify with a known voltage reading
byte_order: big # high byte first within each register (confirmed by manual CRC example)
# NOTE: the manual gives no worked float-decode example, so word_order is a best-guess
# (standard big-endian, high register first, matching sdm120). After wiring the meter,
# read 0x2000 (voltage) — it should decode to ~230 V. If it decodes to garbage, the
# device uses the opposite word order and this profile (and the decoder) need adjusting.
# Byte order is confirmed big-endian from the manual (Appendix A, Table A.4: 0x1388 -> 13 88).
blocks:
# Instantaneous quantities 0x20000x200F: voltage, current, P, Q, (rsv), PF, (rsv), Freq.
# 16 contiguous registers — single bulk read. (DDSU666 manual Table 9.)
- { start: 0x2000, count: 0x0010 }
# Active energy — import (0x4000) and export (0x400A) read as two small blocks rather
# than one span, to avoid touching the undocumented/reserved 0x40020x4009 gap.
- { start: 0x4000, count: 0x0002 }
- { start: 0x400A, count: 0x0002 }
metrics:
# Addresses are the raw Modbus protocol addresses (hex) from DDSU666 manual Table 9,
# read via FC03. Each float32 occupies two consecutive 16-bit registers.
- key: voltage
address: 0x2000 # U — Voltage (V)
type: float32
unit: "V"
device_class: voltage
ha_component: sensor
- key: current
address: 0x2002 # I — Current (A)
type: float32
unit: "A"
device_class: current
ha_component: sensor
- key: active_power
address: 0x2004 # P — Active power. Manual unit is kW (NOT W like sdm120).
type: float32
unit: "kW"
device_class: power
ha_component: sensor
- key: reactive_power
address: 0x2006 # Q — Reactive power (kvar)
type: float32
unit: "kvar"
device_class: reactive_power
ha_component: sensor
- key: power_factor
address: 0x200A # PF — Power factor (dimensionless)
type: float32
unit: ""
device_class: power_factor
ha_component: sensor
- key: frequency
address: 0x200E # Freq — Frequency (Hz)
type: float32
unit: "Hz"
device_class: frequency
ha_component: sensor
- key: import_energy
address: 0x4000 # Ep — positive/forward active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
- key: export_energy
address: 0x400A # -Ep — reverse active energy (kWh)
type: float32
unit: "kWh"
device_class: energy
state_class: total_increasing
ha_component: sensor
+42 -14
View File
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql" _TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0 _DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes. # GraphQL query to fetch the forward-looking today + tomorrow price curve at
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to # 15-minute resolution.
# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max, #
# but the Tibber API typically starts from the current slot and returns at # ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
# most the remaining hours of today plus tomorrow, so 96 is a good cap for # * ``today`` — always the full current local day (96 quarter-hourly slots,
# "today + tomorrow"). # 00:00 → 23:45 local), regardless of the current time.
# * ``tomorrow`` — the full next local day (96 slots) once Tibber publishes the
# day-ahead prices (around 13:0015:00 local); empty before that.
#
# This is deliberately NOT ``priceInfoRange``: that field is a historical cursor
# connection whose range ends at "now" (it never returns future slots), so it
# cannot supply upcoming prices. ``priceInfo`` is forward-looking, so every
# 15-minute slot's price is present in the DB *before* the slot closes — which is
# what makes per-slot billing accurate (each period finds its own exact slot
# instead of falling back to a stale earlier price) and keeps the live current-
# price entity fresh. The hourly refresh job re-runs this query, so tomorrow's
# prices are picked up within an hour of publication without a restart.
_PRICE_RANGE_QUERY = """ _PRICE_RANGE_QUERY = """
{ {
viewer { viewer {
homes { homes {
id id
currentSubscription { currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) { priceInfo(resolution: QUARTER_HOURLY) {
nodes { today {
startsAt
total
energy
tax
currency
level
}
tomorrow {
startsAt startsAt
total total
energy energy
@@ -230,11 +249,15 @@ def fetch_price_range(
*, *,
timeout: float = _DEFAULT_TIMEOUT, timeout: float = _DEFAULT_TIMEOUT,
) -> list[PricePoint]: ) -> list[PricePoint]:
"""Fetch a range of 15-minute price nodes from the Tibber API. """Fetch the forward-looking today + tomorrow 15-minute price curve from Tibber.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query Sends the ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
and parses every returned node into a ``PricePoint``. The number of nodes and parses every node from both lists (today first, then tomorrow) into a
is not assumed — all returned nodes are parsed regardless of count. ``PricePoint``. ``priceInfo`` is forward-looking — ``today`` is always the
full current local day and ``tomorrow`` is populated once Tibber publishes the
day-ahead prices — so upcoming slots are returned, unlike ``priceInfoRange``
which only reaches "now". ``tomorrow`` may be empty (before publication); the
number of nodes is not assumed and all returned nodes are parsed.
Parameters Parameters
---------- ----------
@@ -268,10 +291,15 @@ def fetch_price_range(
home = _pick_home(homes, home_id) home = _pick_home(homes, home_id)
try: try:
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"] price_info = home["currentSubscription"]["priceInfo"]
today = price_info["today"]
tomorrow = price_info["tomorrow"]
except (KeyError, TypeError) as exc: except (KeyError, TypeError) as exc:
raise TibberError("Tibber API response missing priceInfoRange nodes") from exc raise TibberError("Tibber API response missing priceInfo today/tomorrow") from exc
# tomorrow is null/empty until Tibber publishes the day-ahead prices; treat
# a missing list as empty so we still return today's slots.
nodes = list(today or []) + list(tomorrow or [])
return [_parse_node(node, "QUARTER_HOURLY") for node in nodes] return [_parse_node(node, "QUARTER_HOURLY") for node in nodes]
+27
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy.orm import Session 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.ha_discovery import publish_discovery, publish_states
from app.services.tibber_prices import refresh_prices from app.services.tibber_prices import refresh_prices
from app.services.energy_cost import compute_closed_periods 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 from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,6 +161,20 @@ def _run_scheduled_ha_state_publish() -> None:
session.close() 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: def ensure_auth_db_ready() -> None:
session_local = get_session_local() session_local = get_session_local()
session: Session = session_local() session: Session = session_local()
@@ -233,6 +249,17 @@ async def lifespan(_: FastAPI):
max_instances=1, max_instances=1,
coalesce=True, 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() scheduler.start()
# MQTT: connect using DB-merged runtime settings so broker configured via UI # MQTT: connect using DB-merged runtime settings so broker configured via UI
+16 -3
View File
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# degraded to prevent negative costs or grossly inflated charges. # degraded to prevent negative costs or grossly inflated charges.
_MAX_DELTA_KWH = Decimal("100") _MAX_DELTA_KWH = Decimal("100")
# 每日固定费/税补在"本地午夜后多久"才结算入账。延后到 01:05 是为了让累计成本的
# 整天阶跃落在新一天、且避开 01:00 整点(HA 长期统计的小时桶边界)。
_SETTLEMENT_OFFSET = timedelta(hours=1, minutes=5)
# DSMR payload register keys (cumulative kWh, JSON string values). # DSMR payload register keys (cumulative kWh, JSON string values).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1) _KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2) _KEY_D2 = "electricity_delivered_2" # delivered high-tariff (normal / _2)
@@ -764,7 +768,8 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
days = _to_decimal(str(total_seconds)) / _to_decimal("86400") days = _to_decimal(str(total_seconds)) / _to_decimal("86400")
# --- Fixed costs and credits: Principle C cross-version whole-day counting --- # --- Fixed costs and credits: Principle C cross-version whole-day counting ---
today_local: _date = local_now().date() _now_local = local_now()
today_local: _date = _now_local.date()
# --- Compute [first_counted, last_counted] local date range (inclusive) --- # --- Compute [first_counted, last_counted] local date range (inclusive) ---
# #
@@ -811,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
else: else:
last_counted = local_end_date - _td(days=1) last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days. # Settlement cap: "today" is only counted once the local clock has passed the
last_counted = min(last_counted, today_local) # settlement offset since midnight (01:05). This defers the daily standing-charge
# step from 00:00 to 01:05, ensuring the cumulative-cost adiabatic jump lands
# inside the new calendar day and avoids the HA 01:00 hourly-bucket boundary.
_today_midnight_utc = _lmu(today_local)
if _now_local >= _today_midnight_utc + _SETTLEMENT_OFFSET:
settled_cap = today_local
else:
settled_cap = today_local - _td(days=1)
last_counted = min(last_counted, settled_cap)
# If the range is empty (first_counted > last_counted), no days are counted. # If the range is empty (first_counted > last_counted), no days are counted.
+1
View File
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
device.port, device.port,
device.unit_id, device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks], [{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
) )
payload = profiles.decode(profile, registers) payload = profiles.decode(profile, registers)
Binary file not shown.
+198
View File
@@ -0,0 +1,198 @@
# DDSU666 Modbus 协议(从官方 PDF 提取)
> 来源:`docs/references/DDSU666 Single phase Smart Meter.pdf`
> CHINT / 正泰仪表 **DDSU666 Single phase Smart Meter — Operation Manual**,文档号 `ZTY0.464.1224`,版本 **V2**2020 年 8 月;厂商 Zhejiang Chint Instrument & Meter Co., Ltd.
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
> 同类文档见 SDM120 的 `SDM120-Modbus-Protocol.md`;两表差异较大,见下方 §6「与 SDM120 的关键差异」。
## 设备速览(来自手册 Table 1 / Table 5
| 项 | DDSU666(直接接入) | DDSU666-CT(经互感器) |
| --- | --- | --- |
| 精度等级 | Active Class B | Active Class C |
| 参考电压 | 230 V | 230 V |
| 电流规格 | 0.255(80) A | 0.0151.5(6) A |
| 表常数 | 800 imp/kWh | 6400 imp/kWh |
| 接入方式 | 直接接入 | 经电流互感器 |
- 单相电子式电能表,DIN35mm 导轨安装;测量电压、电流、有功/无功功率、频率、功率因数、正/反向有功电能。
- 电能测量范围 `0999999.99 kWh`(LCD 只显示 6 位,自动移动小数点)。
- 通信:RS485**Modbus-RTU**(也支持 DL/T 645-2007,可切换,见 §5 `0005H ChangeProtocol`)。
- 手册 Table 1 标注 Frequency Reference = 60Hz,但 LCD 示例又写 `F=50.00Hz`(手册自身不一致);**实际频率以寄存器 `200EH` 读数为准**,不要把 50/60 写死。
## 0. 本项目的接入方式(重要)
DDSU666 物理层是 **Modbus RTURS-485 串口)**,半双工。和 SDM120 一样,本项目通过一个 **Modbus-TCP 网关**接入:
- 后端用 **Modbus TCP**`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
- TCP 帧用 MBAP header、**无 CRC**CRC 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `CRC (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
- **Slave Address / Unit ID = 电表的通信地址 Addr**(范围 1–247;面板按键只能设 1–99;见 §5 `0006H`),在 TCP 请求里作为 unit id 传入。
- 若以后直连串口(RTU),才需要管波特率 / 数据格式 / CRC:**默认串口格式是 8 数据位、无校验、2 停止位(8N2)**,与 SDM120 的 8N1 不同——直连时务必对齐。
- 电表必须处于 **Modbus 协议模式**(而非 DL/T 645)才能用本协议;可经面板长按切换,或写 `0005H = 2`(见 §5)。
## 1. 协议帧格式(Appendix A
异步传输,按字节为单位。一帧 10 位字符 = **1 起始位(0) + 8 数据位(无校验) + 2 停止位(1)**(其它格式可定制)。
### 信息帧结构(Table A.1
| 字段 | 长度 | 说明 |
| --- | --- | --- |
| Start(起始) | >3.5 字符静默 | 帧间至少 3.5 字符空闲时间作为分隔 |
| Address code(地址码) | 1 字节 | 目标从机地址 1–247;每个从机在总线上地址唯一 |
| Function code(功能码) | 1 字节 | 仅支持 **03H / 10H**(见 §2 |
| Data(数据域) | n 字节 | 随功能码不同而不同(起始地址、寄存器数、寄存器数据等) |
| CRC check codeCRC 校验) | 2 字节 | 16-bit CRC(**低字节在前、高字节在后**;多项式 `A001` |
| End(结束) | >3.5 字符静默 | 帧间静默 |
> TCP 网关模式下 Start/End 静默与 CRC 由网关处理,本项目不关心。
### 功能码 03H 示例(读寄存器,Table A.3/A.4
读从机 `01H`、起始地址 `0CH`、2 个寄存器:
- 主机发送:`01 03 00 0C 00 02 04 08`(最后 `04 08` 是 CRC,低字节 `04` 在前)。
- 从机返回(设 `0CH/0DH` 内容为 `0000H``1388H`):`01 03 04 00 00 13 88 F7 65`
- `04` = 字节数;`00 00` = `0CH` 数据;`13 88` = `0DH` 数据;`F7 65` = CRC(低字节 `F7` 在前)。
> **注意**:单个 16-bit 寄存器内是「高字节在前、低字节在后」(Table A.4 里 `0DH` 数据返回 `13 88` = `0x1388`)。这一点对解码浮点的字节序很关键,见 §3。
### 功能码 10H 示例(写多个寄存器,Table A.5/A.6
向从机 `01H`、起始地址 `00H` 连续写 3 个寄存器 `0002H,1388H,000AH`
- 主机发送:`01 10 00 00 00 03 06 00 02 13 88 00 0A 9B E9`
- `06` = 写入字节数(3 寄存器 × 2 字节);随后是 3 个寄存器的数据;末尾 `9B E9` CRC。
- 从机返回:`01 10 00 00 00 03 80 08`(回显起始地址 + 寄存器数 + CRC `80 08`)。
### 异常响应(Table A.7/A.8
- 异常时返回的 Function Code = **原功能码 + 128**(即最高位置 1`03H→83H``10H→90H`)。
- 数据为单字节 Error Code
| Error Code | 含义 | 说明 |
| --- | --- | --- |
| `01H` | Illegal function code | 收到的功能码本表不支持 |
| `02H` | Illegal register address | 寄存器地址超出有效范围 |
| `03H` | Illegal data value | 数据值超出对应地址的取值范围 |
## 2. 功能码
DDSU666 **只支持两个功能码**Table A.2):
| 功能码 | 作用 | 说明 |
| --- | --- | --- |
| **03H** | Read register(读寄存器) | 读一个或多个寄存器——**测量值、电量、配置全部走它** |
| **10H** | Write multiple registers(写多个寄存器) | 向 n 个连续寄存器写 n 个 16-bit 数据(改配置 / 清电量) |
> ⚠️ **DDSU666 没有「输入寄存器 / FC04」概念**——所有数据(包括电压电流功率)都用 **FC 03H** 读保持寄存器。这是它和 SDM120(测量值走 FC04)最大的踩坑差异,见 §6。
## 3. 数据编码
DDSU666 有两类数据:
1. **配置 / 参数寄存器(§5`0000H``0010H`**:每个 1 个寄存器、`16-bit with symbols`**16 位有符号整数**)。
2. **测量 / 电量寄存器(§4`2000H`+ / `4000H`+**:每个参数 = **32-bit IEEE-754 单精度浮点**(手册写 “single precision floating decimal”),占 **2 个相邻寄存器**Length = 2 Word)。
**浮点字节序 / 字序**
- **字节序(byte order)= 大端:寄存器内高字节在前** —— 由 Table A.4 的 `0x1388` 返回为 `13 88` 确认。
- **字序(word order,两个寄存器谁是高 16 位)**:手册**没有给出浮点解码的实例**,未明确标注。按标准 Modbus 浮点惯例应为**大端字序(高寄存器在前,`ABCD`)**,与本项目 SDM120 驱动一致(`registers_to_float``>f`,高寄存器在前)。
- ⚠️ **需上机实测确认**:读 `2000H`(电压)应解出 ~230V 这样的合理值;若解出乱数,多半是字序相反,改成「低寄存器在前」再试。CHINT 同系列(DTSU/DDSU666)现场固件偶有字序差异,**首次接入务必用一个已知量(电压)校验**,不要凭手册想当然。
> Python 解码(大端、高寄存器在前):`struct.unpack('>f', struct.pack('>HH', hi_reg, lo_reg))[0]`。
> pymodbus`BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
## 4. 测量 / 电量寄存器表(FC 03H 读)
全部为只读、`Float`32-bit),每项占 **2 个寄存器**。地址为 Modbus 协议原始地址(即帧里的 Start Register Address Hi/Lo),手册用十六进制。
### 4.1 瞬时量(“Electric quantity of the secondary side”,`2000H` 段)
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
| --- | --- | --- | --- | --- |
| `2000H` | 电压 Voltage | U | V | |
| `2002H` | 电流 Current | I | A | |
| `2004H` | 有功功率 Active power | P | **kW** | 手册标注 “the unit is KW”——**不是 W** |
| `2006H` | 无功功率 Reactive power | Q | **kvar** | |
| `2008H` | (保留 RESERVED | — | — | 占 2 寄存器,跳过 |
| `200AH` | 功率因数 Power factor | PF | — | 无量纲 |
| `200CH` | (保留 RESERVED | — | — | 占 2 寄存器,跳过 |
| `200EH` | 频率 Frequency | Freq | Hz | |
### 4.2 电量(“Electrical data of the secondary side”,`4000H` 段)
| 地址(hex) | 参数 | 代号 | 单位 | 备注 |
| --- | --- | --- | --- | --- |
| `4000H` | 正向(导入)有功电能 Active in electricity | Ep | kWh | 正向 / forward active energy |
| `400AH` | 反向(导出)有功电能 Reverse in electricity | -Ep | kWh | 反向 / reverse active energy |
> 手册里 `4000H` 与 `400AH` 之间(`4002H``4009H`)未列出,视为保留/未文档化。
**读取分块建议**
- 瞬时量:`2000H``200FH`**16 个寄存器连续**,一次块读即可覆盖 U…Freq(含两段 RESERVED,解码时跳过)。
- 电量:`4000H`2 寄存器)与 `400AH`(2 寄存器)相距较远,分两小块读,或读 `4000H``400BH`(12 寄存器)一次取出后挑用——以网关 / 电表是否允许跨保留地址块读为准,谨慎起见分开读更稳。
### 常用核心子集(日常监控够用)
电压 `2000H`、电流 `2002H`、有功功率 `2004H`kW)、功率因数 `200AH`、频率 `200EH`、正向有功电能 `4000H`、反向有功电能 `400AH`
## 5. 配置 / 参数寄存器表(FC 03H 读 / FC 10H 写)(Table 9
每个 1 个寄存器、**16-bit 有符号整数**。`R/W` 列来自手册。
| 地址(hex) | 代号 | 含义 | R/W | 取值 / 说明 |
| --- | --- | --- | --- | --- |
| `0000H` | UCode | 编程密码 Programming password code | R/W | 写配置前的密码字 |
| `0001H` | REV. | 保留;**实际读出的是版本号** | R | |
| `0002H` | ClrE | 电能清零 CLr.E | R/W | **写 `1` 清除总电量**(不可逆,慎用) |
| `0003H``0004H` | RESERVED | 保留 | — | |
| `0005H` | ChangeProtocol | 协议切换 | R/W | **`2` = Modbus-RTU**`1` = DL/T 645-2007 |
| `0006H` | Addr | 通信地址 | R/W | 1247(面板按键仅 199 |
| `0007H``000AH` | RESERVED | 保留 | — | |
| `000BH` | Meter type | 表型 Meter type | R | 只读设备类型标识 |
| `000CH` | BAud | 通信波特率 | R/W | **`1`=2400bps`2`=4800bps`3`=9600bps**(手册寄存器仅列这三档;通信章另提到也支持 1200bps) |
| `000DH``0010H` | RESERVED | 保留 | — | |
> ⚠️ 写 `0002H`(清电量)、`0006H`(改地址)、`000CH`(改波特率)、`0005H`(切协议)都会改变电表状态或通信参数,配错可能**清空累计电量**或**导致通信中断**。本项目默认**只读采集**,不在自动化链路里写电表配置寄存器。
> 写配置通常需先经 `0000H UCode` 密码字校验;具体密码值手册正文未给出,需向厂商确认或经面板操作。
## 6. 与 SDM120 的关键差异(迁移 / 复用驱动时必看)
本项目已有 SDM120 profile`app/integrations/modbus/profiles/sdm120.yaml`)。DDSU666 **不能照搬**,主要差异:
| 维度 | SDM120 (Eastron) | DDSU666 (CHINT) |
| --- | --- | --- |
| 读测量值功能码 | **FC 04**(输入寄存器 3X | **FC 03**(保持寄存器,无 FC04 |
| 测量值起始地址 | `0x0000` 起(30001 | 瞬时量 `0x2000` 起;电量 `0x4000`/`0x400A` |
| 有功功率单位 | **W**(瓦) | **kW(千瓦)** —— 入库前注意换算 / 单位标注 |
| 无功功率单位 | VAr | kvar |
| 配置寄存器格式 | FloatFC03/16 | **16-bit 有符号整数**FC03/10 |
| 写功能码 | 16 / 0x10 | 10H(同 0x10 |
| 串口默认格式 | 8N1(1 停止位) | **8N22 停止位)** |
| 多协议 | 仅 Modbus | Modbus **与 DL/T 645-2007 可切换**(需确保在 Modbus 模式) |
| 浮点字序 | 大端、高寄存器在前(手册有实例佐证) | 字节序大端已确认;**字序手册无实例,需上机实测** |
## 7. 给本项目采集驱动的要点小结
1.**Modbus TCP 网关**`ModbusTcpClient(host, port)``slave=<Addr>`;电表须在 **Modbus 协议模式**
2. 所有读取(测量 + 电量 + 配置)都用 **FC 03H**——**没有 FC04**。
3. 测量值在 `0x2000` 段、电量在 `0x4000`/`0x400A`,均为 **float32**;解码大端字节序,**字序默认高寄存器在前但务必用电压实测校验**。
4. **有功功率单位是 kW、无功是 kvar**——与 SDM120 的 W/VAr 不同,新建 profile / 入库映射时单位别抄错。
5. 配置寄存器(`0x0000``0x0010`)是 **16-bit 有符号整数**,不是 float。
6. 默认**只读**`0002H` 写 1 会**清空累计电量**、`0006H/000CH/0005H` 会改通信参数,自动化链路里一律不写。
7. 新建 profile 时这是 `ddsu666` 这一个 register profile 的定义;建议 `function_code: 3``word_order: big`(先按大端字序,接入后用电压读数验证)、瞬时量与电量分块读。
## 8. 实测记录(真机验证,2026-06-30)
首次接入一台 **DDSU666 直接接入版(5(80)A** 实测,确认以下几点:
- **字序大端,确认无误**:电压 / 电流 / 频率 / 电能用「大端、高寄存器在前」解码全部得到合理值(如 233.9 V / 0.055 A / 49.99 Hz),与 §3 的假设一致。`ddsu666.yaml``word_order: big` / `byte_order: big` **无需修改**,§3 里「字序需上机实测」一项可视为已关闭。
- **FC03 读通**:所有量走 FC03profile `ddsu666` 在采集链路(CLI `read` / 设备 `/test` / 后台轮询)中工作正常。
- **低电流下「瞬时功率读 0、但电能照常累加」**:测试负载仅为一台 PoE 交换机(≈230 V / 0.05 A,真实有功仅几瓦),**远低于本表测量量程下限 Imin≈0.25 A**。此工况下:
- 瞬时 `active_power``0x2004`)与 `power_factor``0x200A`)寄存器返回**全零**(原始 hex `0x0000 0x0000`);电表 LCD 上功率在 0~3.3 W、PF 在 0~0.6 之间抖动。
- 抖动成因 = **低电流测量噪声 + 开关电源(SMPS,无 PFC)畸变电流**(电流为电压峰值附近的窄脉冲、谐波重 → 畸变功率因数天然偏低)。**不是**「采样率与开关频率拍频」:计量芯片 SH79F7019 采样在 kHz 量级,远低于开关电源 50–200 kHz 的开关频率,两者不在一个频段。
- 但累计电能 `import_energy``0x4000`)**正常累加**(实测 0 → 0.01 kWh)——电表内部积分器在防潜动起始电流(`0.004·Ib`≈0.02 A)之上照常计量。
- **结论**:低于量程下限时**瞬时功率 / PF 不可信,但电能计量不丢**;电流进入量程(正常负载)后瞬时量即稳定可信。这是 5(80)A 大量程表对极小负载的固有特性,**非缺陷、非解码问题**。
- **排错提示**:若日后看到 DDSU666「功率一直 0」,先确认负载电流是否在 Imin 以上——多半是负载太轻而非链路故障;可用 `scripts/modbus_cli probe --fc 3 --address 0x2000 --count 16``0x2004/0x2005` 原始寄存器是否真为全零佐证。
+26 -1
View File
@@ -288,11 +288,36 @@ httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖,M6 复用,
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。 **动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**2026-06-27 与用户确认):
- `POST /location/record``app/api/routes/location.py:18`)——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来,上 token 后 **HA 侧需携带该 token**;也可由其他客户端直接上报。
- `POST /poo/record``app/api/routes/poo.py:21`+ `GET /poo/latest``poo.py:57`)——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
- 这些是设备 / 脚本(非浏览器)端点,session cookie 不适用,正是 long-lived token 的用武之地。(浏览器 CRUD `/api/data/*` 已由 session 保护,不在此列。)
**范围(粗略,待细化)** **范围(粗略,待细化)**
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。 - 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。 - 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 token 鉴权依赖
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。 - 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题(一个是设备/脚本的长期凭据,一个是浏览器短会话体验),实现时鉴权层可一并梳理。
### 3. Session 滑动自动续期(Authentication
**动机**2026-06-27 与用户确认):当前 session 是**绝对过期**——登录即定死、活动不续期,满 TTL 必须重新登录,体验割裂。希望改成**滑动续期(sliding / rolling)**:只要用户还在活动就自动延长,提供"在用就不掉线"的体验。
**现状(实现起点,便于快速拾起)**
- TTL 默认 **12 小时**`auth_session_ttl_hours``app/config.py:38`;配置页 `app/services/config_page.py:45` 可运行时改)。
- 登录时**一次性写死**`create_session``expires_at = now + ttl``app/services/auth.py:94`+ cookie `max_age = ttl``app/api/routes/api/session.py:153`)。
- 每请求**只读校验、从不延长**`get_authenticated_session``app/services/auth.py:103`)只判断 `expires_at <= now`,过期时仅顺手标 `revoked``set_cookie` 只在登录路由调用一次,**无 per-request 中间件**。→ 所以是绝对过期,不是滑动。
**设计要点(待写设计文档时展开)**
- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**(滑动窗口)。
- **写节流**:不要每个请求都写 DB——仅当剩余寿命已过半(或距上次续期 > N 分钟)才续期,避免高频写放大。
- **绝对寿命硬顶**:除滑动 TTL 外再设 `created_at + max_lifetime` 上限,防止"永不过期"的会话(安全考量)。
- 新增配置项:滑动 TTL、绝对寿命上限、续期节流阈值。
- 注意:改动只对**新逻辑生效**,已存在 session 的 `expires_at` 行为按新校验路径走即可;上线前过校验闸门。
## Future Ideas(暂不排期,想到先记下) ## Future Ideas(暂不排期,想到先记下)
+2 -1
View File
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
print(f"Profile : {profile.name}{profile.description}") print(f"Profile : {profile.name}{profile.description}")
print(f"Gateway : {host}:{port} unit_id={unit_id}") print(f"Gateway : {host}:{port} unit_id={unit_id}")
print(f"Function : FC{profile.function_code:02d}")
print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}") print(f"Blocks : {[(b.start, b.count) for b in profile.blocks]}")
print() print()
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
from app.integrations.modbus.driver import read_blocks from app.integrations.modbus.driver import read_blocks
try: try:
registers = read_blocks(host, port, unit_id, blocks) registers = read_blocks(host, port, unit_id, blocks, function_code=profile.function_code)
except ModbusConnectionError as exc: except ModbusConnectionError as exc:
print(f"Connection error: {exc}", file=sys.stderr) print(f"Connection error: {exc}", file=sys.stderr)
sys.exit(1) sys.exit(1)
+323 -17
View File
@@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
class TestSummarizePrincipleC: class TestSummarizePrincipleC:
"""Principle C whole-day counting with pinned Europe/Amsterdam timezone.""" """Principle C whole-day counting with pinned Europe/Amsterdam timezone.
Tests that assert a specific "today" (e.g. June 25) must also pin
``local_now`` via *pinned_now* to remain deterministic as wall-clock
time advances. Tests that only care about windows entirely in the past
or entirely in the future do not need to pin ``local_now``.
"""
# Effective_from: June 1 CEST local midnight = May 31 22:00 UTC. # Effective_from: June 1 CEST local midnight = May 31 22:00 UTC.
# Today local = June 25 CEST (since today is 2026-06-25). # Reference "today" pinned in individual tests = June 25 CEST.
def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None: def _make_single_version_contract(self, session: Session, *, effective_from_utc: datetime) -> None:
"""Create an active manual contract with a single version.""" """Create an active manual contract with a single version."""
@@ -1210,10 +1216,27 @@ class TestSummarizePrincipleC:
_make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc) _make_version(session, contract, _MANUAL_VALUES, effective_from=effective_from_utc)
session.commit() session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict: def _run_summarize_ams(
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.""" self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
pinned_now: datetime | None = None,
) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched.
If *pinned_now* is given, also patches ``app.services.energy_cost.local_now``
to that fixed value, making settlement-offset logic deterministic
regardless of wall-clock time.
"""
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
if pinned_now is not None:
with patch.object(_ec, "local_now", return_value=pinned_now):
return summarize(session, start_utc, end_utc)
return summarize(session, start_utc, end_utc) return summarize(session, start_utc, end_utc)
def test_today_window_counts_1_day(self, energy_db: Session) -> None: def test_today_window_counts_1_day(self, energy_db: Session) -> None:
@@ -1240,6 +1263,9 @@ class TestSummarizePrincipleC:
def test_future_window_counts_0_days(self, energy_db: Session) -> None: def test_future_window_counts_0_days(self, energy_db: Session) -> None:
"""A fully future window (all local dates > today) counts 0 days. """A fully future window (all local dates > today) counts 0 days.
Pins ``local_now`` to June 25 2026 noon AMS so the 7/1→8/1 window is
genuinely in the future regardless of the actual wall-clock date
(mirrors the sibling window tests, which all pin ``local_now``).
Matches table row: 7/1→8/1 (all future) → 0 days. Matches table row: 7/1→8/1 (all future) → 0 days.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1247,7 +1273,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 7, 1) start = _ams_midnight(2026, 7, 1)
end = _ams_midnight(2026, 8, 1) end = _ams_midnight(2026, 8, 1)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS so 7/1→8/1 stays fully future.
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
assert result["fixed_costs"] == 0.0, ( assert result["fixed_costs"] == 0.0, (
f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}" f"All-future window must count 0 days; got fixed_costs={result['fixed_costs']}"
@@ -1259,7 +1287,10 @@ class TestSummarizePrincipleC:
def test_this_month_window_counts_25_days(self, energy_db: Session) -> None: def test_this_month_window_counts_25_days(self, energy_db: Session) -> None:
"""This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25). """This Month (June 1 → July 1 local) counts 25 elapsed days (June 1..25).
Today = June 25 local, so June 2630 are not yet elapsed. Pins ``local_now`` to June 25 noon AMS so the test is deterministic
regardless of the actual wall-clock date. June 25 is well past the
01:05 settlement offset, so ``settled_cap = June 25``.
Today = June 25 local, so June 2630 are not yet elapsed → 25 days.
Matches table row: 6/1→7/1 (This Month) → 25 days. Matches table row: 6/1→7/1 (This Month) → 25 days.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1267,7 +1298,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1304,8 +1337,9 @@ class TestSummarizePrincipleC:
def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None: def test_partial_future_window_caps_at_today(self, energy_db: Session) -> None:
"""A window partially in the future caps at today (only elapsed days counted). """A window partially in the future caps at today (only elapsed days counted).
Pins ``local_now`` to June 25 noon AMS so the test is deterministic.
Window: June 25 → June 28 local (4 dates, but June 26/27 are future). Window: June 25 → June 28 local (4 dates, but June 26/27 are future).
Today = June 25 → only 1 day elapsed (June 25). Today = June 25 → settled_cap = June 25 → only 1 day elapsed (June 25).
Matches table row: 6/25→6/28 → 1 day. Matches table row: 6/25→6/28 → 1 day.
""" """
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
@@ -1313,7 +1347,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 6, 28) end = _ams_midnight(2026, 6, 28)
result = self._run_summarize_ams(energy_db, start, end) # Pin local_now to June 25 2026 noon AMS (well past 01:05 settlement).
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, start, end, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -1366,8 +1402,12 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1) start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
# Pin local_now to June 25 2026 noon AMS: today=June 25, settled_cap=June 25.
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end) with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end)
# V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30 # V1: 24 days × (6+6)/30; V2: 1 day × (12+12)/30
expected_fixed = 24 * 12 / 30 + 1 * 24 / 30 expected_fixed = 24 * 12 / 30 + 1 * 24 / 30
@@ -1408,12 +1448,16 @@ class TestSummarizePrincipleC:
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from) _make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
energy_db.commit() energy_db.commit()
# Window = June 25 only (today, 1 day elapsed at V2 rate) # Window = June 25 only (today, 1 day elapsed at V2 rate).
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
start = _ams_midnight(2026, 6, 25) start = _ams_midnight(2026, 6, 25)
end = _ams_midnight(2026, 7, 1) end = _ams_midnight(2026, 7, 1)
from unittest.mock import patch from unittest.mock import patch
import app.services.energy_cost as _ec
pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()): with patch.object(tz_module, "local_tz", return_value=_ams()):
result = summarize(energy_db, start, end) with patch.object(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end)
# Only 1 day at V2 rate # Only 1 day at V2 rate
expected_fixed = 1 * 24 / 30 expected_fixed = 1 * 24 / 30
@@ -1434,13 +1478,11 @@ class TestSummarizePrincipleC:
within the day start_utc falls. June 24 is the anchor day → its charge is counted. within the day start_utc falls. June 24 is the anchor day → its charge is counted.
Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)). Window: [June 24 07:18 UTC (= 09:18 CEST), June 26 22:00 UTC (= June 27 00:00 CEST)).
Today local = June 25 (2026-06-25). Pins local_now to June 25 noon AMS → today=June 25, settled_cap=June 25.
first_counted = June 24 (anchor day, previously dropped). first_counted = June 24 (anchor day, previously dropped).
last_counted = min(June 26, June 25) = June 25. last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2. n_days = June 25 - June 24 + 1 = 2.
""" """
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1) eff_utc = _ams_midnight(2026, 6, 1)
self._make_single_version_contract(energy_db, effective_from_utc=eff_utc) self._make_single_version_contract(energy_db, effective_from_utc=eff_utc)
@@ -1449,8 +1491,9 @@ class TestSummarizePrincipleC:
# end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped) # end = June 26 22:00 UTC = June 27 00:00 CEST (past today June 25, capped)
end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC) end_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=_ams()): # Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc) pinned_now = datetime(2026, 6, 25, 12, 0, 0, tzinfo=_ams())
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc, pinned_now=pinned_now)
daily_fixed = (9.87 + 9.87) / 30 daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365 daily_credit = 600.0 / 365
@@ -2409,3 +2452,266 @@ class TestMeterAwareComputePeriod:
assert row.degraded is False, "All-zero deltas must not trigger the D6 guard" assert row.degraded is False, "All-zero deltas must not trigger the D6 guard"
assert row.import_cost == 0.0 assert row.import_cost == 0.0
assert row.net_cost == 0.0 assert row.net_cost == 0.0
# ---------------------------------------------------------------------------
# FUE-T08. summarize — settlement offset (local 01:05)
# ---------------------------------------------------------------------------
class TestSummarizeSettlementOffset:
"""FUE-T08: daily fixed-fee/heffingskorting settled after local 01:05.
Reference date: June 26, 2026 AMS (CEST = UTC+2).
- AMS midnight June 26 = June 25 22:00 UTC
- Settlement threshold = June 25 22:00 + 01:05 = June 25 23:05 UTC
= June 26 01:05 AMS
- "before offset" now = June 26 00:30 AMS = June 25 22:30 UTC
- "after offset" now = June 26 01:10 AMS = June 25 23:10 UTC
All tests monkeypatch both local_tz (Europe/Amsterdam) and
``app.services.energy_cost.local_now`` to be fully deterministic.
"""
# UTC instants used as "now":
# June 25 22:30 UTC = June 26 00:30 AMS (before settlement threshold 23:05 UTC)
_NOW_BEFORE = datetime(2026, 6, 25, 22, 30, 0, tzinfo=UTC)
# June 25 23:10 UTC = June 26 01:10 AMS (after settlement threshold 23:05 UTC)
_NOW_AFTER = datetime(2026, 6, 25, 23, 10, 0, tzinfo=UTC)
def _setup_single_version_contract(self, session: Session) -> None:
"""Insert a manual contract effective Jan 1 2026 (covers all test dates)."""
c = _make_contract(session, kind="manual", active=True)
_make_version(session, c, _MANUAL_VALUES, effective_from=_ams_midnight(2026, 1, 1))
session.commit()
def _run(
self,
session: Session,
start_utc: datetime,
end_utc: datetime,
*,
now_utc: datetime,
) -> dict:
"""Run summarize with AMS timezone and pinned local_now.
*now_utc* is converted to AMS before being used as the ``local_now``
return value, so ``.date()`` yields the correct AMS local date.
"""
from unittest.mock import patch
import app.services.energy_cost as _ec
now_ams = now_utc.astimezone(_ams())
with patch.object(tz_module, "local_tz", return_value=_ams()):
with patch.object(_ec, "local_now", return_value=now_ams):
return summarize(session, start_utc, end_utc)
# --- AC1: before 01:05 → today not settled → fixed_costs/credits == 0 ---
def test_today_window_before_offset_yields_zero(self, energy_db: Session) -> None:
"""AC1: At local 00:30 (before 01:05), today's window → credits == 0 and fixed_costs == 0.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 00:30 AMS (before 01:05) → settled_cap = June 25.
first_counted = June 26 > last_counted = June 25 → 0 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
end = _ams_midnight(2026, 6, 27) # June 26 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
assert result["fixed_costs"] == 0.0, (
"AC1: before settlement offset, today's fixed_costs must be 0; "
f"got {result['fixed_costs']}"
)
assert result["credits"] == 0.0, (
"AC1: before settlement offset, today's credits must be 0; "
f"got {result['credits']}"
)
# --- AC2: >= 01:05 → today settled → 1 day fixed/credits ---
def test_today_window_after_offset_yields_one_day(self, energy_db: Session) -> None:
"""AC2: At local 01:10 (>= 01:05), today's window → 1 day of fixed/credits.
Window: [June 26 AMS midnight, June 27 AMS midnight).
now = June 26 01:10 AMS (after 01:05) → settled_cap = June 26.
first_counted = June 26 = last_counted → 1 day.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed) < 1e-9, (
"AC2: after settlement offset, today's fixed_costs must equal 1 day; "
f"expected {daily_fixed:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit) < 1e-9, (
"AC2: after settlement offset, today's credits must equal 1 day; "
f"expected {daily_credit:.6f}, got {result['credits']}"
)
# --- AC3a: cumulative window, before offset → today (June 26) not counted ---
def test_cumulative_before_offset_excludes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 00:30 (before offset) excludes today from count.
Window: [June 24 AMS midnight, June 26 00:30 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) = June 25 22:00 < 22:30).
settled_cap = June 25 (before offset) → min(June 26, June 25) = June 25.
Counted: June 24 + June 25 = 2 days (today June 26 excluded).
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_BEFORE # June 25 22:30 UTC = June 26 00:30 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 2) < 1e-9, (
"AC3 before offset: today (June 26) must not be counted; expected 2 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 2) < 1e-9, (
"AC3 before offset: credits must be 2 days; "
f"got {result['credits']}"
)
# --- AC3b: cumulative window, after offset → today (June 26) counted ---
def test_cumulative_after_offset_includes_today(self, energy_db: Session) -> None:
"""AC3: Cumulative window at 01:10 (after offset) includes today.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
first_counted = June 24.
last_counted (from window) = June 26 (lmu(June 26) < 23:10 UTC).
settled_cap = June 26 (after offset) → last_counted = June 26.
Counted: June 24 + June 25 + June 26 = 3 days.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
assert abs(result["fixed_costs"] - daily_fixed * 3) < 1e-9, (
"AC3 after offset: today (June 26) must be counted; expected 3 days; "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 3) < 1e-9, (
"AC3 after offset: credits must be 3 days; "
f"got {result['credits']}"
)
# --- AC4: past days always fully counted regardless of offset ---
def test_past_days_always_counted_regardless_of_offset(self, energy_db: Session) -> None:
"""AC4: Past days (all before today) are fully counted even before 01:05.
Window: [June 20 AMS midnight, June 26 AMS midnight) — all before today.
now = June 26 00:30 AMS (before offset) → settled_cap = June 25.
last_counted (from window) = June 25 (lmu(June 26) = end_utc, not <).
min(June 25, June 25) = June 25 → 6 days (June 20-25), all past.
"""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 20) # June 19 22:00 UTC
end = _ams_midnight(2026, 6, 26) # June 25 22:00 UTC
result = self._run(energy_db, start, end, now_utc=self._NOW_BEFORE)
daily_fixed = (9.87 + 9.87) / 30
daily_credit = 600.0 / 365
# June 20-25 = 6 days, all past — unaffected by settlement offset.
assert abs(result["fixed_costs"] - daily_fixed * 6) < 1e-9, (
"AC4: past days must be fully counted regardless of settlement offset; "
f"expected 6 days = {daily_fixed * 6:.6f}, got {result['fixed_costs']}"
)
assert abs(result["credits"] - daily_credit * 6) < 1e-9, (
"AC4: past credits must be 6 days; "
f"got {result['credits']}"
)
# --- AC5: cross-version segments still correct with settlement offset ---
def test_cross_version_with_settlement_offset(self, energy_db: Session) -> None:
"""AC5: Cross-version day split is correct when settlement offset is active.
V1 covers June 24; V2 covers June 25+.
Window: [June 24 AMS midnight, June 26 01:10 AMS).
After offset (01:10) → settled_cap = June 26 → 3 days: V1=1, V2=2.
V1: network_fee=6, management_fee=6 → daily_fixed = 12/30
heffingskorting=300 → daily_credit = 300/365
V2: network_fee=12, management_fee=12 → daily_fixed = 24/30
heffingskorting=600 → daily_credit = 600/365
Expected:
fixed_costs = 1×(12/30) + 2×(24/30) = 0.4 + 1.6 = 2.0
credits = 1×(300/365) + 2×(600/365) = 1500/365
"""
_VALUES_V1 = {
"energy": {"buy": {"normal": 0.10, "dal": 0.10},
"sell": {"normal": 0.05, "dal": 0.05},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 6.0, "management_fee": 6.0},
"credits": {"heffingskorting": 300.0},
}
_VALUES_V2 = {
"energy": {"buy": {"normal": 0.20, "dal": 0.20},
"sell": {"normal": 0.08, "dal": 0.08},
"energy_tax": 0.0, "ode": 0.0},
"standing": {"network_fee": 12.0, "management_fee": 12.0},
"credits": {"heffingskorting": 600.0},
}
v1_from = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
v2_from = _ams_midnight(2026, 6, 25) # June 24 22:00 UTC
c = _make_contract(energy_db, kind="manual", active=True)
_make_version(energy_db, c, _VALUES_V1, effective_from=v1_from, effective_to=v2_from)
_make_version(energy_db, c, _VALUES_V2, effective_from=v2_from)
energy_db.commit()
start = _ams_midnight(2026, 6, 24) # June 23 22:00 UTC
end = self._NOW_AFTER # June 25 23:10 UTC = June 26 01:10 AMS
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_fixed = 1 * 12 / 30 + 2 * 24 / 30 # V1: 1 day, V2: 2 days
expected_credits = 1 * 300 / 365 + 2 * 600 / 365
assert abs(result["fixed_costs"] - expected_fixed) < 1e-9, (
f"AC5: cross-version fixed_costs wrong; expected {expected_fixed}, "
f"got {result['fixed_costs']}"
)
assert abs(result["credits"] - expected_credits) < 1e-9, (
f"AC5: cross-version credits wrong; expected {expected_credits}, "
f"got {result['credits']}"
)
# --- AC6: return dict key-set unchanged ---
def test_return_dict_keys_unchanged(self, energy_db: Session) -> None:
"""AC6: summarize() return dict keys are unchanged by the settlement offset."""
self._setup_single_version_contract(energy_db)
start = _ams_midnight(2026, 6, 26)
end = _ams_midnight(2026, 6, 27)
result = self._run(energy_db, start, end, now_utc=self._NOW_AFTER)
expected_keys = {
"currency", "metered_import", "metered_export", "metered_net",
"fixed_costs", "credits", "total_payable", "period_count",
"degraded_count", "days",
}
assert set(result.keys()) == expected_keys, (
f"AC6: summarize() key set changed; expected {expected_keys}, "
f"got {set(result.keys())}"
)
+236 -19
View File
@@ -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. """FU11: import_cost_today getter returns value for today's local window.
Setup: period at local 'today' (within today's UTC window), active contract. 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). (Principle C: today counts as elapsed if we're past local midnight — which UTC is).
FUE-T05: provider requires an active electricity meter. 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:0000:00:04.
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch 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.integrations.expose import build_catalog
from app.services import timezone as _tz_mod from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
# Place a period in today's window (1 hour ago, well within today UTC midnight→tomorrow) fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
# When TZ=UTC, local today = UTC today, so a period 1h ago is in today's window. today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
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) # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
if t0.date() < now_utc.date(): t0 = fake_now.replace(hour=11, minute=0, second=0, microsecond=0)
t0 = now_utc.replace(hour=0, minute=0, second=0, microsecond=0)
# Contract starting well before today # Contract starting well before today
effective_from = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) effective_from = today_midnight - timedelta(days=30)
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) meter_start = today_midnight - timedelta(days=30)
import_cost_today = 1.23 import_cost_today = 1.23
@@ -1766,8 +1768,12 @@ def test_import_cost_today_getter_uses_local_day_window(energy_db) -> None:
session.commit() session.commit()
with Session(energy_db) as session: with Session(energy_db) as session:
# Pin timezone to UTC so today_local = UTC date (deterministic on any CI host). # Pin both local_tz (UTC) and local_now (deterministic midday) so the today window
with patch.object(_tz_mod, "local_tz", return_value=ZoneInfo("UTC")): # 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) catalog = build_catalog(session)
today_entry = next( today_entry = next(
e for e in catalog if e.entity.key == "energy.import_cost_today" 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. """FU11: export_revenue_today getter returns value for today's local window.
FUE-T05: provider requires an active electricity meter. 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:0000:00:04.
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch 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.integrations.expose import build_catalog
from app.services import timezone as _tz_mod from app.services import timezone as _tz_mod
now_utc = datetime.now(timezone.utc) # Pin to a deterministic midday moment — (fake_now - 5s).date() == fake_now.date() always.
t0 = now_utc.replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) fake_now = datetime(2026, 6, 26, 12, 0, 0, tzinfo=timezone.utc)
if t0.date() < now_utc.date(): today_midnight = fake_now.replace(hour=0, minute=0, second=0, microsecond=0)
t0 = now_utc.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) # Place a period 1 hour before fake_now — well within today's [midnight, tomorrow midnight).
meter_start = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=30) 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 export_today = 0.75
with Session(energy_db) as session: 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() session.commit()
with Session(energy_db) as session: 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) catalog = build_catalog(session)
today_entry = next( today_entry = next(
e for e in catalog if e.entity.key == "energy.export_revenue_today" 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, ( assert entity.device.identifiers[1] != old_uuid, (
f"After swap, old uuid {old_uuid!r} must not appear in identifiers" 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()
+51
View File
@@ -217,6 +217,57 @@ class TestReadBlocks:
mock_client.close.assert_called_once() mock_client.close.assert_called_once()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_default_function_code_uses_fc04_input_registers(
self, mock_client_cls: MagicMock
) -> None:
"""With no function_code given, read_blocks uses FC04 (read_input_registers)."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.connect.return_value = True
mock_client.read_input_registers.return_value = _make_ok_response([0x4366, 0x3334])
read_blocks("127.0.0.1", 502, 1, [{"start": 0x0000, "count": 2}])
mock_client.read_input_registers.assert_called_once_with(0x0000, count=2, device_id=1)
mock_client.read_holding_registers.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_function_code_3_uses_fc03_holding_registers(
self, mock_client_cls: MagicMock
) -> None:
"""function_code=3 dispatches FC03 (read_holding_registers), e.g. DDSU666."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
mock_client.connect.return_value = True
mock_client.read_holding_registers.return_value = _make_ok_response(
[0x4366, 0x3334, 0x3F80, 0x0000]
)
blocks = [{"start": 0x2000, "count": 4}]
result = read_blocks("127.0.0.1", 502, 1, blocks, function_code=3)
assert result == {0x2000: 0x4366, 0x2001: 0x3334, 0x2002: 0x3F80, 0x2003: 0x0000}
mock_client.read_holding_registers.assert_called_once_with(
0x2000, count=4, device_id=1
)
mock_client.read_input_registers.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_invalid_function_code_raises_before_connecting(
self, mock_client_cls: MagicMock
) -> None:
"""An unsupported function code is rejected without opening a connection."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with pytest.raises(ModbusDriverError, match="Unsupported read function code"):
read_blocks("127.0.0.1", 502, 1, [{"start": 0, "count": 2}], function_code=16)
# No client should have been constructed or connected for a bad FC.
mock_client_cls.assert_not_called()
mock_client.connect.assert_not_called()
@patch("app.integrations.modbus.driver.ModbusTcpClient") @patch("app.integrations.modbus.driver.ModbusTcpClient")
def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None: def test_unit_id_passed_as_device_id(self, mock_client_cls: MagicMock) -> None:
"""unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x).""" """unit_id is forwarded as device_id= keyword argument (pymodbus 3.13.x)."""
+90 -35
View File
@@ -69,22 +69,28 @@ _THREE_NODES = [
}, },
] ]
_PRICE_RANGE_RESPONSE = { def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
"data": { """Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
"viewer": { return {
"homes": [ "data": {
{ "viewer": {
"id": "home-id-1", "homes": [
"currentSubscription": { {
"priceInfoRange": { "id": "home-id-1",
"nodes": _THREE_NODES, "currentSubscription": {
} "priceInfo": {
}, "today": today,
} "tomorrow": tomorrow if tomorrow is not None else [],
] }
},
}
]
}
} }
} }
}
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
_CURRENT_PRICE_RESPONSE = { _CURRENT_PRICE_RESPONSE = {
"data": { "data": {
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
def test_fetch_price_range_does_not_assume_node_count(monkeypatch): def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
"""Parser handles an arbitrary number of nodes (not hardcoded to 96).""" """Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
# Build a response with a single node only. # Build a response with a single today node and no tomorrow yet.
one_node_response = { one_node_response = _price_info_response([_THREE_NODES[0]])
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfoRange": {
"nodes": [_THREE_NODES[0]],
}
},
}
]
}
}
}
transport = _make_transport(200, one_node_response) transport = _make_transport(200, one_node_response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002 def _patched_post(url, *, json, headers, timeout): # noqa: A002
@@ -202,6 +193,68 @@ def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
assert len(points) == 1 assert len(points) == 1
def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch):
"""today and tomorrow node lists are both parsed (today first, then tomorrow)."""
tomorrow_nodes = [
{
"startsAt": "2026-06-24T00:00:00.000+02:00",
"total": 0.40,
"energy": 0.32,
"tax": 0.08,
"currency": "EUR",
"level": "EXPENSIVE",
},
]
response = _price_info_response(_THREE_NODES, tomorrow_nodes)
transport = _make_transport(200, response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002
client = httpx.Client(transport=transport)
return client.post(url, json=json, headers=headers, timeout=timeout)
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
points = fetch_price_range(_FAKE_TOKEN)
# 3 today + 1 tomorrow, in order.
assert len(points) == 4
# First node is today's first; last node is tomorrow's.
assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC)
# 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z
assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC)
assert points[-1].total == pytest.approx(0.40)
def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch):
"""A null tomorrow (before day-ahead publication) yields today's nodes only."""
response = {
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfo": {
"today": _THREE_NODES,
"tomorrow": None,
}
},
}
]
}
}
}
transport = _make_transport(200, response)
def _patched_post(url, *, json, headers, timeout): # noqa: A002
client = httpx.Client(transport=transport)
return client.post(url, json=json, headers=headers, timeout=timeout)
monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post)
points = fetch_price_range(_FAKE_TOKEN)
assert len(points) == 3
def test_fetch_price_range_home_id_selection(monkeypatch): def test_fetch_price_range_home_id_selection(monkeypatch):
"""When home_id is specified, the matching home is selected.""" """When home_id is specified, the matching home is selected."""
two_homes_response = { two_homes_response = {
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
{ {
"id": "home-id-first", "id": "home-id-first",
"currentSubscription": { "currentSubscription": {
"priceInfoRange": { "priceInfo": {
"nodes": [_THREE_NODES[0]], "today": [_THREE_NODES[0]],
"tomorrow": [],
} }
}, },
}, },
{ {
"id": "home-id-second", "id": "home-id-second",
"currentSubscription": { "currentSubscription": {
"priceInfoRange": { "priceInfo": {
"nodes": [_THREE_NODES[1], _THREE_NODES[2]], "today": [_THREE_NODES[1], _THREE_NODES[2]],
"tomorrow": [],
} }
}, },
}, },