8 Commits
Author SHA1 Message Date
tliu93 bfc7aa3031 chore(frontend): regenerate API schema after sell_fee docstring change
frontend / frontend (push) Successful in 9m59s
pytest / test (push) Successful in 12m27s
d07a083 changed the /api/energy/prices docstring (sell now deducts sell_fee)
and refreshed openapi/openapi.json, but frontend/src/api/schema.d.ts was not
regenerated, so CI's "check codegen is in sync" step failed. Comment-only diff.
2026-07-27 18:29:54 +02:00
tliu93 2f63b9630c fix(prices): keep hover marker on the hovered slot past midnight
The price chart keyed its X axis on formatLocalTime() "HH:mm" labels, which
repeat across a today+tomorrow range. Recharts resolves axis tooltips by value
(combineTooltipPayload -> findEntryInArray), so hovering a slot after midnight
matched today's identically labelled point: the tooltip showed today's prices
and the active dot jumped back to today's position instead of following the
cursor.

Key the axis on the ISO instant instead (buildChartRows, sorted by slot start)
and format down to HH:mm in the tick formatter; the tooltip label now carries
the date so today and tomorrow are distinguishable.

Also mark the price slot currently in effect by default: findActiveSlotIndex()
locates the slot containing now, rendered as a ReferenceDot on the buy and sell
lines plus a caption, re-evaluated every 30s.

Regression test drives a real mousemove over a sized chart in jsdom and asserts
the resolved slot and active-dot position.
2026-07-27 18:29:08 +02:00
tliu93 d07a083e03 fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price
frontend / frontend (push) Failing after 5m49s
pytest / test (push) Successful in 20m30s
docker-image / build-and-push (push) Successful in 13m26s
Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.

Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:

    sell = total − energy_tax − sell_fee − sell_adjust

`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
2026-07-20 13:50:13 +02:00
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
26 changed files with 1121 additions and 105 deletions
+5 -3
View File
@@ -169,7 +169,7 @@ def get_prices(
Response ``points`` carries per-slot:
- ``buy = total`` (Tibber all-inclusive price)
- ``sell = total energy_tax sell_adjust`` (from active version values)
- ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)
- ``level`` (Tibber price level, may be null)
``tariff`` is null.
@@ -222,7 +222,8 @@ def get_prices(
)
rows = list(reversed(db.execute(stmt).scalars().all()))
# Derive sell price per-point using version values (energy_tax + sell_adjust).
# Derive sell price per-point using version values
# (energy_tax + sell_fee + sell_adjust).
from decimal import Decimal
def _d(v: Any) -> Decimal:
@@ -230,12 +231,13 @@ def get_prices(
energy = version.values.get("energy", {}) if version.values else {}
energy_tax = _d(energy.get("energy_tax", 0))
sell_fee = _d(energy.get("sell_fee", 0))
sell_adjust = _d(energy.get("sell_adjust", 0))
points = []
for row in rows:
total = _d(row.total)
sell = float(total - energy_tax - sell_adjust)
sell = float(total - energy_tax - sell_fee - sell_adjust)
points.append(
PricePointSchema(
starts_at=_as_utc(row.starts_at),
+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)
+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
+10 -4
View File
@@ -123,6 +123,7 @@ class ManualProfile(BaseModel):
class TibberEnergySpec(BaseModel):
source: str # must be "tibber_api"
energy_tax: FieldSpec # subtracted from total to derive sell price
sell_fee: FieldSpec # verkoopvergoeding (feed-in fee); always subtracted from sell; default 0.0248
sell_adjust: FieldSpec # additional sell-price adjustment; default 0
@@ -268,10 +269,13 @@ def _fill_defaults_manual(values: dict[str, Any], profile: ManualProfile) -> dic
def _fill_defaults_tibber(values: dict[str, Any], profile: TibberProfile) -> dict[str, Any]:
"""Return a copy of *values* with sell_adjust and management_fee defaults applied."""
"""Return a copy of *values* with sell_fee, sell_adjust and management_fee defaults applied."""
filled = dict(values)
energy = dict(filled.get("energy", {}))
# Apply default for sell_fee (default=0.0248) if absent.
if "sell_fee" not in energy and profile.energy.sell_fee.default is not None:
energy["sell_fee"] = profile.energy.sell_fee.default
# Apply default for sell_adjust (default=0) if absent.
if "sell_adjust" not in energy and profile.energy.sell_adjust.default is not None:
energy["sell_adjust"] = profile.energy.sell_adjust.default
@@ -347,6 +351,7 @@ def _validate_tibber_values(values: dict[str, Any], profile: TibberProfile) -> d
# Required energy fields.
_require_numeric("energy", "energy_tax", energy)
_require_numeric("energy", "sell_fee", energy)
_require_numeric("energy", "sell_adjust", energy)
# Required standing fields.
_require_numeric("standing", "management_fee", standing)
@@ -361,9 +366,10 @@ def validate_values(kind: str, values: dict[str, Any]) -> dict[str, Any]:
"""Validate a contract-values dict against the named profile structure.
Fields that carry a ``default`` in the profile (e.g. ``ode``,
``sell_adjust``, tibber ``management_fee``) are silently filled in when
absent from *values*. Fields with no default that are absent, or fields
whose value is not a number, cause a ``ProfileValidationError``.
``sell_fee``, ``sell_adjust``, tibber ``management_fee``) are silently
filled in when absent from *values*. Fields with no default that are
absent, or fields whose value is not a number, cause a
``ProfileValidationError``.
Parameters
----------
@@ -2,9 +2,10 @@ kind: tibber
label: Tibber 动态电价(15 分钟)
energy:
source: tibber_api # buy = total (from API); sell = total energy_tax sell_adjust
source: tibber_api # buy = total (from API); sell = total energy_tax sell_fee sell_adjust
energy_tax: { unit: EUR/kWh } # subtracted from total to derive sell price (incl. VAT)
sell_adjust: { unit: EUR/kWh, default: 0 } # additional sell-price adjustment (residual spread)
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding (feed-in fee, incl. VAT); always subtracted from sell
sell_adjust: { unit: EUR/kWh, default: 0 } # manual sell-price adjustment; net-metering: set = energy_tax to refund the tax
standing: # fixed charges; UI fills per month, engine prorates to days
management_fee: { unit: EUR/month, default: 5.99 }
+16 -3
View File
@@ -209,12 +209,23 @@ def _tibber_strategy(
query on ``starts_at``.
Formula (§3.4):
- ``buy = total`` (Tibber's all-inclusive price, already includes tax)
- ``sell = total energy_tax sell_adjust``
- ``buy = total`` (Tibber's all-inclusive price; already includes energy
tax, VAT and the buy-side ``inkoopvergoeding``)
- ``sell = total energy_tax sell_fee sell_adjust``
- ``import_cost = (Δd1 + Δd2) × buy``
- ``export_revenue = (Δr1 + Δr2) × sell``
- ``net_cost = import_cost export_revenue``
``sell_fee`` models Tibber's per-kWh **verkoopvergoeding** (feed-in fee,
€0.0248/kWh incl. VAT since 2026-01-01). It is always deducted from the
feed-in payout: even under the net-metering (saldering) scheme, Tibber pays
``total verkoopvergoeding`` per returned kWh (Tibber NL: "€0,28 €0,0248
= €0,2552"). ``total`` already contains the equal buy-side
``inkoopvergoeding``, so the two fees do **not** cancel — the feed-in price
sits ``sell_fee`` below the buy price. ``sell_adjust`` is a separate manual
correction: under net metering it carries back the refunded energy tax
(``sell_adjust = energy_tax``), leaving ``sell = total sell_fee``.
Tibber does not differentiate tariff slots (dal vs normal) — the 15-minute
API price applies to the full delivered/returned volume.
@@ -248,11 +259,12 @@ def _tibber_strategy(
energy = values.get("energy", {})
energy_tax = _to_decimal(energy.get("energy_tax", 0))
sell_fee = _to_decimal(energy.get("sell_fee", 0))
sell_adjust = _to_decimal(energy.get("sell_adjust", 0))
total = _to_decimal(price_row.total)
buy = total
sell = total - energy_tax - sell_adjust
sell = total - energy_tax - sell_fee - sell_adjust
total_delivered = deltas.d1 + deltas.d2
total_returned = deltas.r1 + deltas.r2
@@ -269,6 +281,7 @@ def _tibber_strategy(
"buy": str(buy),
"sell": str(sell),
"energy_tax": str(energy_tax),
"sell_fee": str(sell_fee),
"sell_adjust": str(sell_adjust),
}
+42 -14
View File
@@ -30,20 +30,39 @@ logger = logging.getLogger(__name__)
_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").
# GraphQL query to fetch the forward-looking today + tomorrow price curve at
# 15-minute resolution.
#
# ``priceInfo(resolution: QUARTER_HOURLY)`` returns two node lists:
# * ``today`` — always the full current local day (96 quarter-hourly slots,
# 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 = """
{
viewer {
homes {
id
currentSubscription {
priceInfoRange(resolution: QUARTER_HOURLY, first: 96) {
nodes {
priceInfo(resolution: QUARTER_HOURLY) {
today {
startsAt
total
energy
tax
currency
level
}
tomorrow {
startsAt
total
energy
@@ -230,11 +249,15 @@ def fetch_price_range(
*,
timeout: float = _DEFAULT_TIMEOUT,
) -> 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
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 ``priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }`` query
and parses every node from both lists (today first, then tomorrow) into a
``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
----------
@@ -268,10 +291,15 @@ def fetch_price_range(
home = _pick_home(homes, home_id)
try:
nodes = home["currentSubscription"]["priceInfoRange"]["nodes"]
price_info = home["currentSubscription"]["priceInfo"]
today = price_info["today"]
tomorrow = price_info["tomorrow"]
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]
+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)
+5 -4
View File
@@ -118,8 +118,9 @@ credits:
kind: tibber
label: Tibber 动态电价(15 分钟)
energy:
source: tibber_api # buy = total; sell = total energy_tax sell_adjust
source: tibber_api # buy = total; sell = total energy_tax sell_fee sell_adjust
energy_tax: { unit: EUR/kWh }
sell_fee: { unit: EUR/kWh, default: 0.0248 } # verkoopvergoeding, always subtracted
sell_adjust: { unit: EUR/kWh, default: 0 }
standing:
management_fee: { unit: EUR/month, default: 5.99 }
@@ -164,7 +165,7 @@ credits:
1. 取各寄存器在 `t0`/`t1` 的值(`recorded_at ≤ 边界` 的最后一行,Decimal),算 **per-register 差**`Δd1,Δd2,Δr1,Δr2`。
2. 取 active 合同**在 t0 生效的版本** + 其 strategy
- `manual``import_cost = Δd1×(buy_dal) + Δd2×(buy_normal)``buy_x = energy_buy_x + energy_tax + ode`);`export_revenue = Δr1×sell_dal + Δr2×sell_normal`。
- `tibber`:取覆盖 t0 的 `tibber_price``starts_at ≤ t0` 最近一条);`buy = total`、`sell = total energy_tax sell_adjust``import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
- `tibber`:取覆盖 t0 的 `tibber_price``starts_at ≤ t0` 最近一条);`buy = total`、`sell = total energy_tax sell_fee sell_adjust``sell_fee`=verkoopvergoeding,默认 0.0248,见下修正说明)`import_cost = (Δd1+Δd2)×buy`、`export_revenue = (Δr1+Δr2)×sell`。
3. `net_cost = import_cost export_revenue`**upsert** `energy_cost_period`**快照**当时用的价 + `contract_version_id`。
- 缺价/缺数据:跳过或标 `degraded`,留待重算。**不做净计量**(进出口分开累加)。
@@ -240,7 +241,7 @@ credits:
1. **两层电价模型**profile YAML 定结构(仓库、固定、UI 不可编辑)+ `EnergyContract`(+版本) 存数值(UI 填、版本化)+ strategy 按 kind 出价。仿 M5。
2. **kind 不叫 "fixed"**`manual`(人工填、可双费率、可带时段)/ `tibber`API 动态);合同 `name` UI 自由填。
3. **买价**tibber = API `total`(全包,已证 total=energy+tax);manual = `energy_buy_档 + energy_tax`。**卖价**tibber = `total energy_tax sell_adjust`manual = `sell_档`(回送价,无能源税)。均含 VAT。
3. **买价**tibber = API `total`(全包,已证 total=energy+tax;含 inkoopvergoeding);manual = `energy_buy_档 + energy_tax`。**卖价**tibber = `total energy_tax sell_fee sell_adjust``sell_fee`=verkoopvergoeding 默认 0.0248manual = `sell_档`(回送价,无能源税)。均含 VAT。
4. **双费率**manual 用 `delivered_1/2`、`returned_1/2` 分 dal/normal 计价(`_1`=dal/低、`_2`=normal/高);tibber 求和、15min 价不分档。
5. **两层费用**:每 15min `energy_cost_period` 只算计量电费(不可变、快照价);日/月/年汇总再加固定费(按月→天)− heffingskorting(按年→天)。
6. **回送阶梯罚金(terugleverkosten)不做**:按自然年累计、用户住不到年底算不准——不算、不记、不加功能(留痕见 §10)。
@@ -475,7 +476,7 @@ Phase DAPI + 前端)
## 13. 待确认 / TODO(拿到真实 token + 账单后钉死,均已落成配置/默认值,不阻塞实现)
1. **买价**:✅ tibber = API `total`demo 已证 total=energy+tax);manual = energy_buy_档 + energy_tax。无待办。
2. **卖价残差(tibber**`sell = total energy_tax sell_adjust``sell_adjust` 默认 0(买卖费相等抵消)。真实账单确认后若有残差再调。
2. **卖价残差(tibber**~~`sell = total energy_tax sell_adjust``sell_adjust` 默认 0(买卖费相等抵消)~~ → **已修正(2026-07,见 references §3.1**`total` 含 inkoopvergoeding,净计量回送 = `total verkoopvergoeding`,两费**不抵消**。公式改为 `sell = total energy_tax sell_fee sell_adjust`,新增 `sell_fee`(默认 0.0248,始终扣除);`sell_adjust` 净计量期设 `energy_tax`。真实账单确认后若有残差再调 `sell_fee`
3. **双费率寄存器映射**`_1`=dal/低、`_2`=normal/高(NL 惯例)——接价前用真实数据确认别接反(差价小但要对)。
4. **能源税年值**manual/tibber 的 `energy_tax` 默认 ~0.11082026 第一档含 VAT),按当年实际值核。
5. **Tibber 15min + 币种**:✅ 查询/分辨率已 demo 证实;仍需合同生效后用**真实 token** 确认 NL 返回真 15 分钟价 + 币种 EUR。
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` 原始寄存器是否真为全零佐证。
@@ -113,16 +113,26 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
> "De verkoopvergoeding van 2,48 cent is gelijk aan de inkoopvergoeding die je bij je afgenomen stroom betaalt."
> (卖侧 verkoopvergoeding 2.48 分 = 买侧 inkoopvergoeding。)
**买卖服务费相等(均 €0.0248/kWh)**,在买卖里一进一出**相互抵消**。
**买卖服务费金额相等(均 €0.0248/kWh,但两者对住户都是成本、不互相抵消**
- 买侧 inkoopvergoeding 已经**包含在 Tibber API 的 `total` 里**(见下 §3.1 的实证拆解),买电按 `total` 计价即已含它。
- 卖侧 verkoopvergoeding 则是从回送价里**额外扣掉**的一笔——所以回送价 = `total 0.0248`,比买价低 0.0248/kWh。
- ⚠️ **早期版本误判为"一进一出抵消 → 回送=total"**,这是错的:`total` 里那笔 inkoopvergoeding 不会退回来充抵 verkoopvergoeding。代码里用 `energy.sell_fee`(默认 0.0248)建模这笔卖侧费用。
---
## 3. 净计量(saldering)、回送(teruglevering)、负电价、2027
### 3.1 回送价(净计量期内,文档原文)
### 3.1 回送价(净计量期内,文档原文 + 实证
> "Op het moment dat je teruglevert geven we je per kWh de beursprijs die op dat moment geldt …, inclusief energiebelasting en inkoopvergoeding plus de btw minus de verkoopvergoeding."
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw verkoopvergoeding`。因 inkoopvergoeding = verkoopvergoeding 抵消 → **= 全额零售价**spot+能源税+VAT),正是 saldering "回送 1 度 = 用 1 度"的本质。
> **Worked exampleTibber NL 原文)**"Stel dat tussen 14:00 en 14:15 de totale stroomprijs €0,28 per kWh incl. is, dan krijg je €0,28 €0,0248 verkoopvergoeding = **€0,2552** per teruggeleverde kWh terug."
即净计量期内回送价 = `beursprijs + energiebelasting + inkoopvergoeding + btw verkoopvergoeding`,而官方例子直接写成 **`回送价 = totale stroomprijs verkoopvergoeding = total 0.0248`**。能源税**退回**(留在 total 里没动),只有 verkoopvergoeding 这 0.0248 被扣。
**✅ 实证(本项目生产库,2026-07-20 三个刻钟)**:按 21% VAT 拆 `total``total = 现货×1.21 + energiebelasting(0.11085) + inkoopvergoeding(0.0248)`,三段解出的 inkoop 都精确等于 **0.0248**。→ **我们存的 `tibber_price.total` 就是官方 "totale stroomprijs"(含 inkoopvergoeding 的买价)**,因此:
- 买价 `buy = total`(已含 inkoopvergoeding,正确)。
- 净计量回送价 `sell = total verkoopvergoeding = total 0.0248`
- ⚠️ 所以 saldering 下"回送 1 度"仍比"用 1 度"少 0.0248——**不是完全 1:1**。代码用 `sell_fee` 建模这笔扣减,`sell_adjust` 只负责在净计量期把能源税补回(`sell_adjust = energy_tax`)。
### 3.2 年末盈余 / 取消净计量后(文档原文,Scenario 2)
> "Voor de overproductie van 500 kWh heb je recht op de beursprijs en de inkoopvergoeding, maar heb je geen recht op de energiebelasting. … ontvang je nog een factuur van ons voor de te veel uitgekeerde belastingen …"
@@ -146,9 +156,11 @@ curl -s -X POST https://api.tibber.com/v1-beta/gql \
> spot 取 API `energy``total = energy + tax`(全包)。**买价直接用 `total`**,卖价从 `total` 扣掉卖电不交的能源税。
- **Tibber 动态合同**post-2027 口径)
- 买价 `buy = price.total`
- 卖价 `sell = price.total energy_tax_per_kwh sell_adjust``sell_adjust` 默认 0;含 VAT 归己;买卖费抵消已隐含在 total 里)
- **Tibber 动态合同**
- 买价 `buy = price.total`(含 energy_tax + VAT + inkoopvergoeding
- 卖价 `sell = price.total energy_tax sell_fee sell_adjust`
- `sell_fee`verkoopvergoeding(卖侧上网费,默认 **0.0248**,含 VAT),**始终扣除**——即使净计量期也扣(见 §3.1)。
- `sell_adjust`:手动修正项(默认 0)。**净计量期**设为 `energy_tax`(把能源税补回),得 `sell = total sell_fee`;**2027 取消净计量后**设为 0,得 `sell = total energy_tax sell_fee`(无能源税、纯市场价再扣上网费)。
- **固定合同(manual,双费率)**:
- 买价 `buy_档 = energy_buy_档 + energy_tax`(档 ∈ {normal, dal}
- 卖价 `sell_档 = sell_档`(回送价,**无能源税**
@@ -242,7 +254,7 @@ extra_device_timestamp, extra_device_delivered # 燃气表(m³,每
## 8. 待真实数据核对(合同生效后用真实 token / 账单)
1. **真实 token 复核**:跑 §1.4 的 15 分钟 curl,确认 NL 返回**真** 15 分钟价(非重复小时价)+ 币种 EUR。
2. **卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉、回送 VAT 口径 → 调 `sell_adjust`(默认 0
2. ~~**卖价残差**:确认 `total` 里 purchase fee 是否被卖侧 sales fee 完全抵掉~~**已核实(2026-07**`total` 含 inkoopvergoeding0.0248),净计量回送价 = `total verkoopvergoeding(0.0248)`,两费**不抵消**;代码以 `sell_fee`(默认 0.0248)建模。仍待真实账单核对 `sell_fee` / VAT 口径的最终残差
3. **双费率寄存器映射**:确认 `_1`=dal/`_2`=normal 没接反(差价小但要对)。
4. **能源税年值**:按当年实际值与年用电档位核 `energy_tax`
5. **固定合同数值**:回送两档价、电网费、heffingskorting 待用户从账单填。
+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(暂不排期,想到先记下)
+1 -1
View File
@@ -261,7 +261,7 @@ export interface paths {
*
* Response ``points`` carries per-slot:
* - ``buy = total`` (Tibber all-inclusive price)
* - ``sell = total energy_tax sell_adjust`` (from active version values)
* - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)
* - ``level`` (Tibber price level, may be null)
*
* ``tariff`` is null.
+253 -3
View File
@@ -6,10 +6,14 @@
* 2. Empty state (no active contract / no kind).
* 3. Renders tibber chart when tibber kind data is available.
* 4. Shows tariff table for manual kind.
* 5. Marks the currently active price slot (dot + caption).
* 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
* 7. buildChartRows: unique X keys across midnight.
* 8. findActiveSlotIndex: which slot is currently active.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
// ---------------------------------------------------------------------------
@@ -42,7 +46,107 @@ vi.mock('../api/client', () => ({
// Import component
// ---------------------------------------------------------------------------
import { TibberPrices } from './TibberPrices'
import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
// ---------------------------------------------------------------------------
// Chart size harness
//
// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
// pointer interaction is possible. These helpers hand the chart a fixed size:
// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
// that reports the same size,
// - the chart wrapper reports 800x260 (the height the component asks for), which
// is what Recharts uses to translate clientX/clientY into chart coordinates,
// - everything else stays 0x0 so the legend does not eat the whole plot area.
// ---------------------------------------------------------------------------
const CHART_W = 800
const CONTAINER_H = 300
const CHART_H = 260
function fakeRect(width: number, height: number): DOMRect {
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => {},
} as DOMRect
}
const originalResizeObserver = globalThis.ResizeObserver
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetWidth',
)
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
)
function installChartSize() {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.classList.contains('recharts-responsive-container')) {
return fakeRect(CHART_W, CONTAINER_H)
}
if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
return fakeRect(0, 0)
})
// Recharts divides rect size by offset size to undo CSS transform scaling;
// matching them keeps the scale factor at 1.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: CHART_W,
})
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
value: CHART_H,
})
globalThis.ResizeObserver = class implements ResizeObserver {
private readonly cb: ResizeObserverCallback
constructor(cb: ResizeObserverCallback) {
this.cb = cb
}
observe() {
this.cb(
[{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
this,
)
}
unobserve() {}
disconnect() {}
}
}
function restoreChartSize() {
globalThis.ResizeObserver = originalResizeObserver
if (offsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
}
if (offsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
}
}
/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
function hourlyPoints(startUtc: number, count: number) {
return Array.from({ length: count }, (_, i) => ({
starts_at: new Date(startUtc + i * 3600_000).toISOString(),
buy: 0.1 + i / 1000,
sell: 0.05 + i / 1000,
level: 'NORMAL',
}))
}
function tooltipText(): string {
return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
}
// ---------------------------------------------------------------------------
// Tests
@@ -50,6 +154,10 @@ import { TibberPrices } from './TibberPrices'
describe('TibberPrices', () => {
beforeEach(() => vi.clearAllMocks())
afterEach(() => {
vi.restoreAllMocks()
restoreChartSize()
})
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
@@ -135,4 +243,146 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
})
it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize()
const SLOT_MS = 15 * 60 * 1000
// Start of the quarter-hour slot that contains "now".
const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
mockGet.mockResolvedValue({
data: {
kind: 'tibber',
currency: 'EUR',
points: [
{ starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
{ starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
{ starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
],
tariff: null,
},
})
renderWithProviders(<TibberPrices />)
await waitFor(() => {
expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
})
const marker = screen.getByTestId('tibber-current-price')
expect(marker).toHaveTextContent('0.2431')
expect(marker).toHaveTextContent('0.1102')
// One dot on the buy line, one on the sell line — visible without hovering.
await waitFor(() => {
expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
})
})
it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
installChartSize()
// 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
// each appear twice. Fixed past dates keep the "now" marker out of range.
const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
mockGet.mockResolvedValue({
data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
})
renderWithProviders(<TibberPrices />)
await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
const wrapper = document.querySelector('.recharts-wrapper')
expect(wrapper).not.toBeNull()
// Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
await waitFor(() => expect(tooltipText()).toContain('0.1250'))
// Label carries the date, so day 2 is distinguishable from day 1.
expect(tooltipText()).toContain('1/2/2020')
expect(tooltipText()).toContain('0.0750')
// The active dots must sit on the hovered point (right half of the plot).
// The bug put them on day 1's identically-labelled slot near the left edge.
const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
expect(dots).toHaveLength(2)
for (const dot of dots) {
expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
}
})
})
// ---------------------------------------------------------------------------
// buildChartRows
// ---------------------------------------------------------------------------
describe('buildChartRows', () => {
it('keeps X-axis keys unique across midnight', () => {
// Same local time-of-day on two consecutive days: as "HH:mm" labels these
// collided, which made Recharts resolve the hovered point to the first match
// (today) instead of the hovered one (tomorrow).
const rows = buildChartRows([
{ starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
])
expect(rows).toHaveLength(2)
expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
})
it('sorts rows by slot start and parses naive timestamps as UTC', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
{ starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
])
expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
expect(rows.map((r) => r.ts)).toEqual([
'2026-07-27T00:00:00.000Z',
'2026-07-27T01:00:00.000Z',
'2026-07-27T02:00:00.000Z',
])
})
})
// ---------------------------------------------------------------------------
// findActiveSlotIndex
// ---------------------------------------------------------------------------
describe('findActiveSlotIndex', () => {
const rows = buildChartRows([
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
{ starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
{ starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
])
const at = (iso: string) => new Date(iso).getTime()
it('returns the slot containing now', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
})
it('returns the slot at its exact start boundary', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
})
it('returns null before the first slot', () => {
expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
})
it('stays on the last slot until its inferred end, then returns null', () => {
expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
})
it('returns null for empty data', () => {
expect(findActiveSlotIndex([], Date.now())).toBeNull()
})
})
+136 -11
View File
@@ -2,13 +2,15 @@
* TibberPrices — price curve visualization.
*
* - Fetches today + tomorrow price range using useEnergyPrices.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time,
* with the currently active price slot marked by a dot.
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
* - Handles: no active contract, empty data, loading, error.
*
* Recharts imports are isolated to this file only.
*/
import { useEffect, useMemo, useState } from 'react'
import {
Stack,
Text,
@@ -29,10 +31,20 @@ import {
CartesianGrid,
Tooltip,
Legend,
ReferenceDot,
ResponsiveContainer,
} from 'recharts'
import { useEnergyPrices } from './hooks'
import { formatLocalTime } from '../utils/datetime'
import { formatLocalDate, formatLocalTime, parseBackendTimestamp } from '../utils/datetime'
const BUY_COLOR = '#2196f3'
const SELL_COLOR = '#4caf50'
/** Slot length assumed for the very last point, when no next point bounds it. */
const FALLBACK_SLOT_MS = 60 * 60 * 1000
/** How often the "current price" marker re-evaluates which slot is active. */
const NOW_TICK_MS = 30 * 1000
// ---------------------------------------------------------------------------
// Time range helpers
@@ -51,34 +63,121 @@ function getTomorrowEnd(): string {
return d.toISOString()
}
// ---------------------------------------------------------------------------
// Chart data helpers
// ---------------------------------------------------------------------------
export interface PricePoint {
starts_at: string
buy: number
sell: number
level?: string | null
}
export interface ChartRow {
/**
* X-axis category key — the full instant, NOT a "HH:mm" label.
*
* Must be unique per slot: Recharts resolves the hovered point by *value*
* (findEntryInArray on the axis dataKey), so a repeated key makes the tooltip
* and the active dot snap back to the first match. With "HH:mm" labels, every
* time of day appears twice in a today+tomorrow range, which pinned the dot on
* today once the cursor passed midnight. Formatting to HH:mm happens in the
* tick / tooltip formatters instead.
*/
ts: string
/** Slot start as epoch ms; NaN when starts_at is unparseable. */
tsMs: number
buy: number
sell: number
}
/** Map API price points to chart rows with unique X keys, sorted by slot start. */
export function buildChartRows(points: PricePoint[]): ChartRow[] {
return points
.map((p) => {
const d = parseBackendTimestamp(p.starts_at)
const tsMs = d.getTime()
return {
ts: Number.isFinite(tsMs) ? d.toISOString() : p.starts_at,
tsMs,
buy: p.buy,
sell: p.sell,
}
})
.sort((a, b) => {
// Unparseable timestamps sort last so the ascending scan below can stop early.
if (!Number.isFinite(a.tsMs)) return Number.isFinite(b.tsMs) ? 1 : 0
if (!Number.isFinite(b.tsMs)) return -1
return a.tsMs - b.tsMs
})
}
/**
* Index of the row whose slot contains `nowMs`, or null when now is outside the
* fetched range. A slot ends where the next one starts; the last row has no next
* slot, so it falls back to the series spacing (quarter-hourly for Tibber).
*/
export function findActiveSlotIndex(rows: ChartRow[], nowMs: number): number | null {
let idx = -1
for (let i = 0; i < rows.length; i += 1) {
if (!Number.isFinite(rows[i].tsMs) || rows[i].tsMs > nowMs) break
idx = i
}
if (idx < 0) return null
const spacing = rows.length > 1 ? rows[1].tsMs - rows[0].tsMs : NaN
const slotMs = Number.isFinite(spacing) && spacing > 0 ? spacing : FALLBACK_SLOT_MS
const slotEnd = idx + 1 < rows.length ? rows[idx + 1].tsMs : rows[idx].tsMs + slotMs
return nowMs < slotEnd ? idx : null
}
/** Ticking clock so the active-slot marker follows slot boundaries while open. */
function useNowMs(intervalMs = NOW_TICK_MS): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
// ---------------------------------------------------------------------------
// Tibber chart
// ---------------------------------------------------------------------------
interface TibberChartProps {
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
points: PricePoint[]
currency: string
}
function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({
time: formatLocalTime(p.starts_at),
buy: p.buy,
sell: p.sell,
}))
const data = useMemo(() => buildChartRows(points), [points])
const nowMs = useNowMs()
const activeIndex = findActiveSlotIndex(data, nowMs)
const activeRow = activeIndex == null ? null : data[activeIndex]
return (
<Stack gap="xs" data-testid="tibber-chart">
<Group gap="xs" justify="space-between" align="baseline">
<Title order={6} c="dimmed">
Price curve ({currency})
</Title>
{activeRow && (
<Text size="xs" c="dimmed" data-testid="tibber-current-price">
Now {formatLocalTime(activeRow.ts)} · buy {activeRow.buy.toFixed(4)} · sell{' '}
{activeRow.sell.toFixed(4)}
</Text>
)}
</Group>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="time"
dataKey="ts"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
tickFormatter={(v: string) => formatLocalTime(v)}
/>
<YAxis
tick={{ fontSize: 10 }}
@@ -89,12 +188,17 @@ function TibberChart({ points, currency }: TibberChartProps) {
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
labelFormatter={(label) =>
typeof label === 'string'
? `${formatLocalDate(label)} ${formatLocalTime(label)}`
: label
}
/>
<Legend />
<Line
type="monotone"
dataKey="buy"
stroke="#2196f3"
stroke={BUY_COLOR}
dot={false}
strokeWidth={2}
name="Buy"
@@ -102,11 +206,32 @@ function TibberChart({ points, currency }: TibberChartProps) {
<Line
type="monotone"
dataKey="sell"
stroke="#4caf50"
stroke={SELL_COLOR}
dot={false}
strokeWidth={2}
name="Sell"
/>
{/* Currently active price slot, marked by default (no hover needed). */}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.buy}
r={4}
fill={BUY_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
{activeRow && (
<ReferenceDot
x={activeRow.ts}
y={activeRow.sell}
r={4}
fill={SELL_COLOR}
stroke="#fff"
strokeWidth={2}
/>
)}
</LineChart>
</ResponsiveContainer>
</Stack>
+1 -1
View File
@@ -701,7 +701,7 @@
"api-energy"
],
"summary": "Get Prices",
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total energy_tax sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
"description": "Return the price curve for the active contract.\n\n**Tibber contracts** (kind=\"tibber\"):\n Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending\n by ``starts_at``. At most ``limit`` rows are returned (most recent first\n within the window, then reversed to ascending order — identical to the\n modbus readings pattern).\n\n Response ``points`` carries per-slot:\n - ``buy = total`` (Tibber all-inclusive price)\n - ``sell = total energy_tax sell_fee sell_adjust`` (from active version values)\n - ``level`` (Tibber price level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"manual\"):\n ``points`` is empty. ``tariff`` carries the four effective prices\n derived using the billing engine formula:\n - ``buy_dal = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal + energy_tax + ode``\n - ``sell_dal = energy.sell.dal``\n - ``sell_normal = energy.sell.normal``\n\n**No active contract**: returns kind=null, currency=\"EUR\", points=[], tariff=null (200).",
"operationId": "get_prices_api_energy_prices_get",
"parameters": [
{
+3 -3
View File
@@ -527,9 +527,9 @@ paths:
\ (most recent first\n within the window, then reversed to ascending order\
\ — identical to the\n modbus readings pattern).\n\n Response ``points``\
\ carries per-slot:\n - ``buy = total`` (Tibber all-inclusive\
\ price)\n - ``sell = total energy_tax sell_adjust`` (from active\
\ version values)\n - ``level`` (Tibber price level,\
\ may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
\ price)\n - ``sell = total energy_tax sell_fee sell_adjust`` (from\
\ active version values)\n - ``level`` (Tibber price\
\ level, may be null)\n\n ``tariff`` is null.\n\n**Manual contracts** (kind=\"\
manual\"):\n ``points`` is empty. ``tariff`` carries the four effective\
\ prices\n derived using the billing engine formula:\n - ``buy_dal \
\ = energy.buy.dal + energy_tax + ode``\n - ``buy_normal = energy.buy.normal\
+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)
+53 -1
View File
@@ -363,13 +363,65 @@ def test_prices_tibber_contract_returns_points(energy_client):
starts_at_list = [p["starts_at"] for p in body["points"]]
assert starts_at_list == sorted(starts_at_list)
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_adjust=0.245-0.1108-0.0
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
assert p["level"] == "NORMAL"
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
client, engine, _app = energy_client
_login(client)
# Net-metering version: sell_adjust = energy_tax (refund tax), sell_fee = 0.0248.
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Tibber NetMeter",
kind="tibber",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
session.add(
EnergyContractVersion(
contract_id=contract.id,
effective_from=now - timedelta(days=30),
effective_to=None,
values={
"energy": {
"energy_tax": 0.1108,
"sell_fee": 0.0248,
"sell_adjust": -0.1108,
},
"standing": {"management_fee": 5.99, "network_fee": 25.0},
"credits": {"heffingskorting": 600.0},
},
created_at=now,
)
)
session.commit()
_make_tibber_prices(engine, count=3)
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "tibber"
assert len(body["points"]) == 3
# sell = 0.245 0.1108 0.0248 (0.1108) = 0.245 0.0248 = 0.2202
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - 0.2202) < 1e-4
def test_prices_tibber_limit_caps_results(energy_client):
client, engine, _app = energy_client
_login(client)
+6 -1
View File
@@ -1263,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)
@@ -1270,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']}"
+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)."""
+18
View File
@@ -137,6 +137,11 @@ class TestLoadProfileTibber:
profile = load_profile("tibber")
assert profile.energy.sell_adjust.default == 0
def test_sell_fee_has_default_verkoopvergoeding(self) -> None:
profile = load_profile("tibber")
assert profile.energy.sell_fee.unit == "EUR/kWh"
assert profile.energy.sell_fee.default == 0.0248
def test_management_fee_has_default(self) -> None:
profile = load_profile("tibber")
assert profile.standing.management_fee.default is not None
@@ -350,6 +355,19 @@ class TestValidateValuesTibber:
filled = validate_values("tibber", values)
assert filled["energy"]["sell_adjust"] == 0
def test_sell_fee_default_applied_when_absent(self) -> None:
values = {
"energy": {
"energy_tax": 0.1108,
"sell_adjust": 0.0,
# sell_fee absent — has default 0.0248 (verkoopvergoeding)
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
filled = validate_values("tibber", values)
assert filled["energy"]["sell_fee"] == 0.0248
def test_management_fee_default_applied_when_absent(self) -> None:
values = {
"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0},
+63 -1
View File
@@ -6,7 +6,7 @@ Acceptance criteria covered
2. Manual strategy: dual-tariff import/export/net calculated correctly (hand-verified).
3. Manual strategy: Decimal precision no float binary rounding errors.
4. Tibber strategy: queries the most recent TibberPrice with starts_at t0.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_adjust.
5. Tibber strategy: buy=total, sell=totalenergy_taxsell_feesell_adjust.
6. Tibber strategy: negative total negative export_revenue (not clamped).
7. Tibber strategy: raises TibberPriceNotFoundError when no matching row exists.
8. ``register_strategy`` / ``get_strategy`` round-trip works.
@@ -371,6 +371,68 @@ class TestTibberStrategy:
# sell = 0.25 - 0.10 - 0.02 = 0.13; export_revenue = 2 × 0.13 = 0.26
assert result["export_revenue"] == Decimal("2") * Decimal("0.13")
def test_sell_deducts_sell_fee(self, tibber_db) -> None:
"""verkoopvergoeding (sell_fee) is subtracted from the feed-in price.
Under net metering the energy tax is refunded (sell_adjust = energy_tax),
so sell should equal total sell_fee. Verifies the fee is a first-class,
always-deducted term and does NOT cancel against the buy-side inkoopvergoeding
that is already baked into total.
"""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.3073)
session.commit()
# Net-metering config: sell_adjust = energy_tax refunds the tax;
# sell_fee = 0.0248 (Tibber verkoopvergoeding) is still deducted.
values = {
"energy": {
"energy_tax": 0.11085,
"sell_fee": 0.0248,
"sell_adjust": -0.11085,
},
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.3073 0.11085 0.0248 (0.11085) = 0.3073 0.0248 = 0.2825
expected_sell = (
Decimal("0.3073") - Decimal("0.11085") - Decimal("0.0248") - Decimal("-0.11085")
)
assert expected_sell == Decimal("0.2825")
assert result["export_revenue"] == Decimal("1") * expected_sell
assert result["pricing"]["sell_fee"] == "0.0248"
assert Decimal(result["pricing"]["sell"]) == Decimal("0.2825")
def test_sell_fee_absent_defaults_to_zero(self, tibber_db) -> None:
"""A version without sell_fee (pre-migration) reads it as 0 — no silent deduction."""
t0 = _ts(10, 0)
with Session(tibber_db) as session:
_insert_tibber_price(session, starts_at=_ts(9, 45), total=0.25)
session.commit()
values = {
"energy": {"energy_tax": 0.10, "sell_adjust": 0.0}, # no sell_fee key
"standing": {"management_fee": 5.99, "network_fee": 9.87},
"credits": {"heffingskorting": 600.0},
}
with Session(tibber_db) as session:
deltas = PeriodDeltas(
d1=Decimal("0"), d2=Decimal("0"),
r1=Decimal("0"), r2=Decimal("1"),
)
result = self._call(deltas, t0, session, values=values)
# sell = 0.25 0.10 0 0 = 0.15
assert result["export_revenue"] == Decimal("0.15")
assert result["pricing"]["sell_fee"] == "0"
def test_uses_most_recent_price_before_t0(self, tibber_db) -> None:
"""Correct row: starts_at ≤ t0, most recent wins."""
t0 = _ts(10, 0)
+79 -24
View File
@@ -69,15 +69,18 @@ _THREE_NODES = [
},
]
_PRICE_RANGE_RESPONSE = {
def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict:
"""Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response."""
return {
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfoRange": {
"nodes": _THREE_NODES,
"priceInfo": {
"today": today,
"tomorrow": tomorrow if tomorrow is not None else [],
}
},
}
@@ -86,6 +89,9 @@ _PRICE_RANGE_RESPONSE = {
}
}
_PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES)
_CURRENT_PRICE_RESPONSE = {
"data": {
"viewer": {
@@ -173,23 +179,8 @@ def test_fetch_price_range_parses_nodes(monkeypatch):
def test_fetch_price_range_does_not_assume_node_count(monkeypatch):
"""Parser handles an arbitrary number of nodes (not hardcoded to 96)."""
# Build a response with a single node only.
one_node_response = {
"data": {
"viewer": {
"homes": [
{
"id": "home-id-1",
"currentSubscription": {
"priceInfoRange": {
"nodes": [_THREE_NODES[0]],
}
},
}
]
}
}
}
# Build a response with a single today node and no tomorrow yet.
one_node_response = _price_info_response([_THREE_NODES[0]])
transport = _make_transport(200, one_node_response)
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
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):
"""When home_id is specified, the matching home is selected."""
two_homes_response = {
@@ -211,16 +264,18 @@ def test_fetch_price_range_home_id_selection(monkeypatch):
{
"id": "home-id-first",
"currentSubscription": {
"priceInfoRange": {
"nodes": [_THREE_NODES[0]],
"priceInfo": {
"today": [_THREE_NODES[0]],
"tomorrow": [],
}
},
},
{
"id": "home-id-second",
"currentSubscription": {
"priceInfoRange": {
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
"priceInfo": {
"today": [_THREE_NODES[1], _THREE_NODES[2]],
"tomorrow": [],
}
},
},