6 Commits
Author SHA1 Message Date
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
15 changed files with 1025 additions and 58 deletions
+1
View File
@@ -488,6 +488,7 @@ def test_read(
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
)
payload: dict[str, Any] = decode_profile(profile, registers)
return ModbusTestReadResponse(ok=True, payload=payload)
+12 -2
View File
@@ -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)
+32 -5
View File
@@ -1,8 +1,11 @@
"""Modbus TCP driver — thin wrapper around pymodbus.
This module provides a single public function ``read_blocks`` that performs
one or more FC04 (Read Input Registers) block reads against a Modbus TCP
gateway and returns a flat ``dict[register_address -> 16-bit_value]`` map.
one or more block reads against a Modbus TCP gateway — using either FC04
(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
----------------
@@ -80,9 +83,10 @@ def read_blocks(
unit_id: int,
blocks: Sequence[Block],
*,
function_code: int = 4,
timeout: float = 3.0,
) -> 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
----------
@@ -96,6 +100,11 @@ def read_blocks(
Sequence of ``{"start": int, "count": int}`` dicts describing the
contiguous register ranges to read. ``count`` is the number of
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:
TCP connect/read timeout in seconds (default 3 s).
@@ -107,12 +116,21 @@ def read_blocks(
Raises
------
ModbusDriverError
If ``function_code`` is neither 3 nor 4 (validated before any
connection is attempted).
ModbusConnectionError
If the TCP connection to the gateway fails.
ModbusResponseError
If the gateway returns a Modbus exception frame or an unexpected
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)
try:
connected = client.connect()
@@ -125,7 +143,7 @@ def read_blocks(
for block in blocks:
start: int = block["start"]
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
@@ -145,9 +163,18 @@ def _read_block(
start: int,
count: int,
result: dict[int, int],
*,
function_code: int,
) -> 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:
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:
raise ModbusConnectionError(
@@ -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
+17 -9
View File
@@ -31,18 +31,24 @@ _TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql"
_DEFAULT_TIMEOUT = 15.0
# GraphQL query to fetch a range of 15-minute price nodes.
# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to
# 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
# most the remaining hours of today plus tomorrow, so 96 is a good cap for
# "today + tomorrow").
# ``priceInfoRange`` is a Relay-style cursor connection over the subscription's
# entire available price history. With no before/after cursor:
# * ``first: N`` returns the OLDEST N nodes (anchored at the subscription
# start date) — a fixed window that never advances, so it is WRONG for
# "current + upcoming" prices.
# * ``last: N`` returns the NEWEST N nodes (ending at the latest published
# slot: tomorrow 23:45 once day-ahead prices are out, else today 23:45).
# We want the newest slots, so we use ``last``. 192 = 2 days × 24 h × 4 slots,
# which fully covers the "today + tomorrow" window that GET /api/energy/prices
# queries (the earliest of the 192 newest slots reaches back past today 00:00
# UTC in either publish state).
_PRICE_RANGE_QUERY = """
{
viewer {
homes {
id
currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
priceInfoRange(resolution: QUARTER_HOURLY, last: 192) {
nodes {
startsAt
total
@@ -232,9 +238,11 @@ def fetch_price_range(
) -> list[PricePoint]:
"""Fetch a range of 15-minute price nodes from the Tibber API.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query
and parses every returned node into a ``PricePoint``. The number of nodes
is not assumed — all returned nodes are parsed regardless of count.
Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, last: 192)`` query
and parses every returned node into a ``PricePoint``. ``last`` (not
``first``) is used so the newest slots are returned; ``first`` would anchor
at the subscription start date and never advance. The number of nodes is
not assumed — all returned nodes are parsed regardless of count.
Parameters
----------
+27
View File
@@ -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
+16 -3
View File
@@ -123,6 +123,10 @@ _READING_MAX_STALENESS = timedelta(minutes=_PERIOD_MINUTES)
# degraded to prevent negative costs or grossly inflated charges.
_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).
_KEY_D1 = "electricity_delivered_1" # delivered low-tariff (dal / _1)
_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")
# --- 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) ---
#
@@ -811,8 +816,16 @@ def summarize(session: Session, start: datetime, end: datetime) -> dict[str, Any
else:
last_counted = local_end_date - _td(days=1)
# Cap: only elapsed or today's days.
last_counted = min(last_counted, today_local)
# Settlement cap: "today" is only counted once the local clock has passed the
# 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.
+1
View File
@@ -64,6 +64,7 @@ def poll_device(session: Session, device: ModbusDevice) -> ModbusReading | None:
device.port,
device.unit_id,
[{"start": b.start, "count": b.count} for b in profile.blocks],
function_code=profile.function_code,
)
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。
**本次明确的首要目标 = 给现在裸奔的 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;明文只在**生成时展示一次**,此后只存哈希。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响);给上述 ingestion 端点加 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(暂不排期,想到先记下)
+2 -1
View File
@@ -131,6 +131,7 @@ def cmd_read(args: argparse.Namespace) -> None:
print(f"Profile : {profile.name}{profile.description}")
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()
@@ -141,7 +142,7 @@ def cmd_read(args: argparse.Namespace) -> None:
from app.integrations.modbus.driver import read_blocks
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:
print(f"Connection error: {exc}", file=sys.stderr)
sys.exit(1)
+321 -15
View File
@@ -1199,10 +1199,16 @@ def _ams_midnight(year: int, month: int, day: int) -> datetime:
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.
# 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:
"""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)
session.commit()
def _run_summarize_ams(self, session: Session, start_utc: datetime, end_utc: datetime) -> dict:
"""Run summarize with Europe/Amsterdam local_tz monkeypatched."""
def _run_summarize_ams(
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
import app.services.energy_cost as _ec
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)
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:
"""A fully future window (all local dates > today) counts 0 days.
Pins ``local_now`` to June 25 2026 noon AMS so the 7/18/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/18/1 (all future) 0 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
@@ -1247,7 +1273,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 7, 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, (
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:
"""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/17/1 (This Month) 25 days.
"""
eff_utc = _ams_midnight(2026, 6, 1)
@@ -1267,7 +1298,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 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_credit = 600.0 / 365
@@ -1304,8 +1337,9 @@ class TestSummarizePrincipleC:
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).
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).
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/256/28 1 day.
"""
eff_utc = _ams_midnight(2026, 6, 1)
@@ -1313,7 +1347,9 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 25)
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_credit = 600.0 / 365
@@ -1366,7 +1402,11 @@ class TestSummarizePrincipleC:
start = _ams_midnight(2026, 6, 1)
end = _ams_midnight(2026, 7, 1)
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(_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
@@ -1408,11 +1448,15 @@ class TestSummarizePrincipleC:
_make_version(energy_db, contract, _VALUES_V2, effective_from=v2_from)
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)
end = _ams_midnight(2026, 7, 1)
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(_ec, "local_now", return_value=pinned_now):
result = summarize(energy_db, start, end)
# Only 1 day at V2 rate
@@ -1434,13 +1478,11 @@ class TestSummarizePrincipleC:
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)).
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).
last_counted = min(June 26, June 25) = June 25.
n_days = June 25 - June 24 + 1 = 2.
"""
from unittest.mock import patch
eff_utc = _ams_midnight(2026, 6, 1)
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_utc = datetime(2026, 6, 26, 22, 0, 0, tzinfo=_UTC)
with patch.object(tz_module, "local_tz", return_value=_ams()):
result = self._run_summarize_ams(energy_db, anchor_utc, end_utc)
# Pin local_now to June 25 noon AMS: today=June 25, settled_cap=June 25.
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_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.import_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.
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:0000: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:0000: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()
+51
View File
@@ -217,6 +217,57 @@ class TestReadBlocks:
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")
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)."""