Compare commits

..
3 Commits
Author SHA1 Message Date
tliu93 7d46cb96d3 M8-R18: stabilize Mantine frontend tests
frontend / frontend (push) Successful in 1m1s
pytest / test (push) Successful in 4m19s
docker-image / build-and-push (push) Successful in 2m2s
2026-08-28 14:26:55 +02:00
tliu93 5e1545efad M8-R17: allocate thermal standing costs by service 2026-08-28 14:24:57 +02:00
tliu93 6e197d7808 M8-R16: expose hot water to HA Energy
pytest / test (push) Successful in 4m2s
docker-image / build-and-push (push) Successful in 1m39s
frontend / frontend (push) Successful in 48s
2026-08-28 10:02:53 +02:00
7 changed files with 147 additions and 7 deletions
+19 -2
View File
@@ -1028,7 +1028,7 @@ def _m8_energy_provider(session: Session) -> list[ExposableEntity]:
elif meter.commodity == "heating": elif meter.commodity == "heating":
unit, device_class = "GJ", "energy" unit, device_class = "GJ", "energy"
else: else:
unit, device_class = "", "volume" unit, device_class = "", "water"
for suffix, getter in ( for suffix, getter in (
("total", _meter_total_getter(binding.id, source.id, channel.id)), ("total", _meter_total_getter(binding.id, source.id, channel.id)),
("today", _meter_today_getter(binding.id, source.id, channel.id)), ("today", _meter_today_getter(binding.id, source.id, channel.id)),
@@ -1230,7 +1230,24 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
if metric == "water_tax": if metric == "water_tax":
return result["breakdown"]["hot_water_tax"] return result["breakdown"]["hot_water_tax"]
if metric == "hot_water_total": if metric == "hot_water_total":
return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"] # Keep the existing component entities variable-only, while making
# the two consumer-facing totals partition the all-in summary.
# Standing costs are assigned by their contractual commodity:
# hot-water network belongs to hot water; the remaining named
# standing fees belong to heating.
return (
result["breakdown"]["hot_water_heating"]
+ result["breakdown"]["hot_water"]
+ result["breakdown"]["hot_water_tax"]
+ result["fixed_breakdown"]["hot_water_network"]
)
if metric == "heating":
return result["breakdown"]["heating"] + sum(
(result["fixed_breakdown"][key] for key in (
"heating_network", "metering", "delivery_set", "other"
)),
0,
)
return result["breakdown"][metric] return result["breakdown"][metric]
return _getter return _getter
+17 -1
View File
@@ -1214,9 +1214,25 @@ M8 收尾的前置条件。agent 不得执行、记录为已执行,或以 mock
## 14. Post-M8 lifecycle repairM8-R08R10 ## 14. Post-M8 lifecycle repairM8-R08R10
### M8-R16 — HA Water Energy Discovery metadata
active `hot_water` Meter 的 total/today MQTT Discovery metadata 从通用 `volume` 修正为 Home
Assistant Energy Water 所要求的 `water`;保留 `m³``total_increasing`。区域供暖继续使用合法的
`energy + GJ` 组合,thermal EUR 成本实体以及既有 key、identity、unique_id 和 topic 均不变。
### M8-R15 — HA Discovery identity/topic repair ### M8-R15 — HA Discovery identity/topic repair
修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`,金额为 `hot_water_heating + hot_water`,不含 `hot_water_tax` 修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`;其后由 R17 明确为 hot-water 的 all-in 分配值
### M8-R17 — Thermal HA standing-cost allocation
保持全部 thermal HA entity key、unique_id 与名称不变,只调整 `Thermal Heating Total/Today`
`Thermal Hot Water Total/Today` 的数值语义。Heating 为 `heating` variable 加上
`heating_network``metering``delivery_set``other` 四项 fixed breakdownHot Water 为
`hot_water_heating + hot_water + hot_water_tax` 加上 `hot_water_network`。因此两者之和精确等于
`Thermal All-in Total/Today`,每项 standing 只分配一次。component 实体(hot-water heating、water、
water tax)继续只显示 variable`Fixed` 继续显示全部 standingprovider 只读取 `meter_cost.summarize`
在 01:05 结算后的结果,不改 15 分钟 ledger、API/schema 或数据库。
M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md` M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然 它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
+7 -1
View File
@@ -77,7 +77,13 @@ docker compose -f docker-compose.yml run --rm migration
成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdownfixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。 成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdownfixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。核对 unit、state class、availability、today reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity 或合同金额放进 HA entity 名称、日志或截图。 在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。`Thermal Heating Total/Today`
显示 heating variable 加上 heating network、metering、delivery set 与 other 固定费;`Thermal Hot Water
Total/Today` 显示 hot-water heating、water、water tax variable 加上 hot-water network 固定费。两条合计
恰好等于 `Thermal All-in Total/Today`component entities 仍仅显示各自 variable`Fixed` 仍显示全部
standing,避免重复计费。数值仍来自在 01:05 后结算的 summary。核对 unit、state class、availability、today
reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity
或合同金额放进 HA entity 名称、日志或截图。
## 安全回滚 ## 安全回滚
+16
View File
@@ -0,0 +1,16 @@
import { screen } from '@testing-library/react'
import { useMantineEnv } from '@mantine/core'
import { describe, expect, it } from 'vitest'
import { renderWithProviders } from './test-utils'
function MantineEnvironmentProbe() {
return <output>{useMantineEnv()}</output>
}
describe('renderWithProviders', () => {
it('uses Mantine test environment', () => {
renderWithProviders(<MantineEnvironmentProbe />)
expect(screen.getByText('test')).toBeInTheDocument()
})
})
+1 -1
View File
@@ -51,7 +51,7 @@ export function renderWithProviders(ui: ReactNode, options: RenderOptions = {})
function Wrapper() { function Wrapper() {
return ( return (
<MantineProvider> <MantineProvider env="test">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={entries}> <MemoryRouter initialEntries={entries}>
<Routes> <Routes>
+3
View File
@@ -19,6 +19,9 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
// Mantine-heavy UI tests allocate substantial jsdom resources. Keep the
// worker pool bounded so concurrent CI jobs remain reliable.
maxWorkers: 2,
setupFiles: ['./src/test-setup.ts'], setupFiles: ['./src/test-setup.ts'],
env: { env: {
// Lock the test timezone to UTC so that date-formatting assertions // Lock the test timezone to UTC so that date-formatting assertions
+84 -2
View File
@@ -2788,7 +2788,11 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
assert entries[f"meter.{heating.uuid}.total"].entity.unit == "GJ" assert entries[f"meter.{heating.uuid}.total"].entity.unit == "GJ"
assert entries[f"meter.{heating.uuid}.total"].entity.device_class == "energy" assert entries[f"meter.{heating.uuid}.total"].entity.device_class == "energy"
assert entries[f"meter.{water.uuid}.total"].entity.unit == "" assert entries[f"meter.{water.uuid}.total"].entity.unit == ""
assert entries[f"meter.{water.uuid}.total"].entity.device_class == "volume" for suffix in ("total", "today"):
water_entity = entries[f"meter.{water.uuid}.{suffix}"].entity
assert water_entity.device_class == "water"
assert water_entity.unit == ""
assert water_entity.state_class == "total_increasing"
assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh" assert entries[f"meter.{electricity.uuid}.total"].entity.unit == "kWh"
assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy" assert entries[f"meter.{electricity.uuid}.total"].entity.device_class == "energy"
assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing" assert entries[f"meter.{electricity.uuid}.today"].entity.state_class == "total_increasing"
@@ -2812,6 +2816,31 @@ def test_m8_catalog_has_source_meter_and_thermal_entities_disabled(energy_db) ->
assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal) assert all(entry.entity.unit == "EUR" and entry.entity.device_class == "monetary" for entry in thermal)
def test_m8_hot_water_discovery_payload_is_ha_energy_compatible(energy_db) -> None:
"""Hot-water Meter discovery exposes the exact HA Energy Water metadata."""
from app.integrations.expose import build_catalog
from app.services.ha_discovery import build_discovery_payload
now = datetime.now(timezone.utc)
with Session(energy_db) as session:
_source, _channel, meter = _make_thermal_source_and_meter(session, "hot_water", now)
session.commit()
entries = {item.entity.key: item.entity for item in build_catalog(session)}
for suffix in ("total", "today"):
entity = entries[f"meter.{meter.uuid}.{suffix}"]
topic, payload = build_discovery_payload(entity, "homeassistant")
assert payload["device_class"] == "water"
assert payload["unit_of_measurement"] == ""
assert payload["state_class"] == "total_increasing"
assert payload["unique_id"] == f"{meter.uuid}_meter_{meter.uuid}_{suffix}"
assert payload["device"]["identifiers"] == [f"home-automation:meter:{meter.uuid}"]
assert topic == (
f"homeassistant/sensor/{meter.uuid.replace('-', '_')}/"
f"meter_{meter.uuid.replace('-', '_')}_{suffix}/config"
)
def test_m8_meter_getter_hides_stale_or_offline_source(energy_db) -> None: def test_m8_meter_getter_hides_stale_or_offline_source(energy_db) -> None:
from app.integrations.expose import build_catalog from app.integrations.expose import build_catalog
@@ -2973,6 +3002,11 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"), "heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"), "hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
}, },
"fixed_breakdown": {
"heating_network": Decimal("0"), "metering": Decimal("0"),
"delivery_set": Decimal("0"), "hot_water_network": Decimal("0"),
"other": Decimal("0"),
},
} }
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict: def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
@@ -2994,5 +3028,53 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
assert entity.value_getter(session) == Decimal("0") assert entity.value_getter(session) == Decimal("0")
hot_water_total = next(item.entity for item in build_catalog(session) hot_water_total = next(item.entity for item in build_catalog(session)
if item.entity.key.endswith(".hot_water_total_today")) if item.entity.key.endswith(".hot_water_total_today"))
assert hot_water_total.value_getter(session) == Decimal("4.00") assert hot_water_total.value_getter(session) == Decimal("13.99")
assert captured["end"] == now assert captured["end"] == now
@pytest.mark.parametrize("suffix", ("total", "today"))
def test_m8_thermal_totals_allocate_standing_costs_and_match_all_in(energy_db, suffix: str) -> None:
"""Heating and hot water each receive their assigned costs, without overlap."""
from app.integrations.expose import build_catalog
from app.services import timezone as tz
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
summary = {
"period_count": 2,
"fixed_cost": Decimal("35"),
"total_cost": Decimal("54"),
"breakdown": {
"heating": Decimal("10"), "hot_water_heating": Decimal("2"),
"hot_water": Decimal("3"), "hot_water_tax": Decimal("4"),
},
"fixed_breakdown": {
"heating_network": Decimal("5"), "metering": Decimal("6"),
"delivery_set": Decimal("7"), "hot_water_network": Decimal("9"),
"other": Decimal("8"),
},
}
with Session(energy_db) as session:
_make_thermal_source_and_meter(session, "heating", now)
_make_thermal_source_and_meter(session, "hot_water", now)
session.commit()
with (
patch("app.integrations.expose._utc_now", return_value=now),
patch.object(tz, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")),
patch.object(tz, "local_now", return_value=now.astimezone(ZoneInfo("Europe/Amsterdam"))),
patch("app.services.meter_cost.summarize", return_value=summary),
):
entities = {item.entity.key.rsplit(".", 1)[-1]: item.entity
for item in build_catalog(session) if item.entity.key.startswith("thermal_cost.")}
heating = entities[f"heating_{suffix}"].value_getter(session)
hot_water = entities[f"hot_water_total_{suffix}"].value_getter(session)
assert heating == Decimal("36") # 10 + heating_network + metering + delivery_set + other
assert hot_water == Decimal("18") # 2 + 3 + 4 + hot_water_network
assert entities[f"fixed_{suffix}"].value_getter(session) == Decimal("35")
assert entities[f"all_in_{suffix}"].value_getter(session) == Decimal("54")
assert heating + hot_water == entities[f"all_in_{suffix}"].value_getter(session)
# Component entities deliberately remain variable-only.
assert entities[f"hot_water_heating_{suffix}"].value_getter(session) == Decimal("2")
assert entities[f"water_{suffix}"].value_getter(session) == Decimal("3")
assert entities[f"water_tax_{suffix}"].value_getter(session) == Decimal("4")