test_energy_price_1251.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. """Tests for #1251 — electricity price from Home Assistant, costed hour by hour.
  2. Covers:
  3. - reading a price sensor: units, unusable states, negative prices, fallback,
  4. and remembering the reading in ``energy_cost_per_kwh``
  5. - the cost arithmetic: each step at the price it starts from, resets skipped
  6. - Statistics: date-range and all-time cost follow the snapshot prices instead
  7. of multiplying everything by today's price
  8. - per-print cost across a price change, and the fallbacks for prints that
  9. started before the upgrade or ended on another plug
  10. - the start of a print recording when, from which plug and at what price
  11. - the one-shot backfill of prices onto existing snapshots
  12. """
  13. from datetime import datetime, timedelta, timezone
  14. from unittest.mock import AsyncMock, patch
  15. import pytest
  16. from sqlalchemy import select, text
  17. from backend.app.api.routes.archives import _sum_snapshot_deltas
  18. from backend.app.api.routes.settings import get_setting, set_setting
  19. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  20. from backend.app.services import energy_price
  21. from backend.app.services.energy_price import (
  22. all_time_cost,
  23. cost_at_average_price,
  24. cost_of_readings,
  25. current_price,
  26. new_snapshot,
  27. price_from_state,
  28. print_energy_cost,
  29. snapshot_cost,
  30. )
  31. T0 = datetime(2026, 9, 1, 0, 0) # naive UTC, as the column stores it
  32. def _snap(plug_id: int, hours: float, kwh: float, price: float | None) -> SmartPlugEnergySnapshot:
  33. return SmartPlugEnergySnapshot(
  34. plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price_per_kwh=price
  35. )
  36. def _state(value, unit: str | None = "AUD/kWh") -> dict:
  37. return {"state": value, "attributes": {"unit_of_measurement": unit} if unit else {}}
  38. class TestPriceFromState:
  39. def test_per_kwh_is_taken_as_is(self):
  40. assert price_from_state(_state("0.31")) == pytest.approx(0.31)
  41. def test_per_mwh_is_scaled(self):
  42. # Nord Pool / ENTSO-E day-ahead sensors are often per MWh.
  43. assert price_from_state(_state("120.5", "EUR/MWh")) == pytest.approx(0.1205)
  44. def test_per_wh_is_scaled(self):
  45. assert price_from_state(_state("0.0003", "€/Wh")) == pytest.approx(0.3)
  46. def test_no_unit_is_taken_as_per_kwh(self):
  47. assert price_from_state(_state("0.2", None)) == pytest.approx(0.2)
  48. @pytest.mark.parametrize("value", ["unknown", "unavailable", "", None, "nan", "inf"])
  49. def test_unusable_states_give_none(self, value):
  50. assert price_from_state(_state(value)) is None
  51. def test_no_state_gives_none(self):
  52. assert price_from_state(None) is None
  53. def test_negative_price_is_costed_as_free(self):
  54. assert price_from_state(_state("-0.05")) == 0.0
  55. class TestCostArithmetic:
  56. def test_each_step_at_the_price_it_starts_from(self):
  57. # 2 kWh at 0.10, then 3 kWh at 0.50; the end reading's price is unused.
  58. kwh, cost = cost_of_readings([(10.0, 0.10), (12.0, 0.50), (15.0, 9.99)], fallback_price=1.0)
  59. assert kwh == pytest.approx(5.0)
  60. assert cost == pytest.approx(0.2 + 1.5)
  61. def test_missing_price_uses_fallback(self):
  62. kwh, cost = cost_of_readings([(0.0, None), (2.0, None)], fallback_price=0.25)
  63. assert (kwh, cost) == (pytest.approx(2.0), pytest.approx(0.5))
  64. def test_counter_reset_step_counts_nothing(self):
  65. kwh, cost = cost_of_readings([(100.0, 0.1), (2.0, 0.1), (5.0, 0.1)], fallback_price=1.0)
  66. assert kwh == pytest.approx(3.0)
  67. assert cost == pytest.approx(0.3)
  68. def test_average_price_scales_to_the_energy_shown(self):
  69. assert cost_at_average_price(10.0, 5.0, 2.0, fallback_price=9.0) == pytest.approx(4.0)
  70. def test_average_price_without_costed_energy_uses_fallback(self):
  71. assert cost_at_average_price(10.0, 0.0, 0.0, fallback_price=0.3) == pytest.approx(3.0)
  72. class TestCurrentPrice:
  73. async def _configure_ha(self, db, entity="sensor.amber_price"):
  74. await set_setting(db, "ha_enabled", "true")
  75. await set_setting(db, "ha_url", "http://ha.local:8123")
  76. await set_setting(db, "ha_token", "token") # pragma: allowlist secret
  77. await set_setting(db, "energy_price_source", "homeassistant")
  78. await set_setting(db, "energy_price_ha_entity", entity)
  79. await set_setting(db, "energy_cost_per_kwh", "0.2")
  80. await db.commit()
  81. @pytest.mark.asyncio
  82. async def test_fixed_source_uses_the_setting(self, db_session):
  83. await set_setting(db_session, "energy_cost_per_kwh", "0.27")
  84. await db_session.commit()
  85. with patch.object(energy_price, "_read_state", AsyncMock()) as read:
  86. assert await current_price(db_session) == pytest.approx(0.27)
  87. read.assert_not_called()
  88. @pytest.mark.asyncio
  89. async def test_default_when_nothing_is_set(self, db_session):
  90. assert await current_price(db_session) == pytest.approx(0.15)
  91. @pytest.mark.asyncio
  92. async def test_reads_the_sensor_and_remembers_it(self, db_session, monkeypatch):
  93. monkeypatch.delenv("HA_URL", raising=False)
  94. monkeypatch.delenv("HA_TOKEN", raising=False)
  95. await self._configure_ha(db_session)
  96. with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))) as read:
  97. assert await current_price(db_session, remember=True) == pytest.approx(0.42)
  98. read.assert_awaited_once_with("http://ha.local:8123", "token", "sensor.amber_price")
  99. assert await get_setting(db_session, "energy_cost_per_kwh") == "0.42"
  100. @pytest.mark.asyncio
  101. async def test_print_paths_read_without_writing_a_setting(self, db_session, monkeypatch):
  102. # A setting written in the print-start transaction could cost the
  103. # print its energy reading if that write failed.
  104. monkeypatch.delenv("HA_URL", raising=False)
  105. monkeypatch.delenv("HA_TOKEN", raising=False)
  106. await self._configure_ha(db_session)
  107. with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))):
  108. assert await current_price(db_session) == pytest.approx(0.42)
  109. assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
  110. @pytest.mark.asyncio
  111. async def test_unreadable_sensor_keeps_the_last_price(self, db_session, monkeypatch):
  112. monkeypatch.delenv("HA_URL", raising=False)
  113. monkeypatch.delenv("HA_TOKEN", raising=False)
  114. await self._configure_ha(db_session)
  115. with patch.object(energy_price, "_read_state", AsyncMock(return_value=None)):
  116. assert await current_price(db_session) == pytest.approx(0.2)
  117. assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
  118. @pytest.mark.asyncio
  119. async def test_no_entity_does_not_read(self, db_session, monkeypatch):
  120. monkeypatch.delenv("HA_URL", raising=False)
  121. monkeypatch.delenv("HA_TOKEN", raising=False)
  122. await self._configure_ha(db_session, entity="")
  123. with patch.object(energy_price, "_read_state", AsyncMock()) as read:
  124. assert await current_price(db_session) == pytest.approx(0.2)
  125. read.assert_not_called()
  126. @pytest.mark.asyncio
  127. async def test_home_assistant_switched_off_does_not_read(self, db_session, monkeypatch):
  128. monkeypatch.delenv("HA_URL", raising=False)
  129. monkeypatch.delenv("HA_TOKEN", raising=False)
  130. await self._configure_ha(db_session)
  131. await set_setting(db_session, "ha_enabled", "false")
  132. await db_session.commit()
  133. with patch.object(energy_price, "_read_state", AsyncMock()) as read:
  134. assert await current_price(db_session) == pytest.approx(0.2)
  135. read.assert_not_called()
  136. async def _record(db, plug_id: int, hours: float, kwh: float, price: float) -> SmartPlugEnergySnapshot:
  137. """Add a snapshot the way the hourly loop does, running totals and all."""
  138. row = await new_snapshot(
  139. db, plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price=price
  140. )
  141. db.add(row)
  142. await db.flush()
  143. return row
  144. class TestNewSnapshot:
  145. @pytest.mark.asyncio
  146. async def test_first_snapshot_counts_the_counter_at_its_price(self, db_session, smart_plug_factory):
  147. plug = await smart_plug_factory(name="A")
  148. row = await _record(db_session, plug.id, 0, 100.0, 0.10)
  149. assert (row.kwh_to_date, row.cost_to_date) == (pytest.approx(100.0), pytest.approx(10.0))
  150. @pytest.mark.asyncio
  151. async def test_each_step_at_the_previous_snapshots_price(self, db_session, smart_plug_factory):
  152. plug = await smart_plug_factory(name="A")
  153. await _record(db_session, plug.id, 0, 100.0, 0.10)
  154. await _record(db_session, plug.id, 1, 102.0, 0.50)
  155. row = await _record(db_session, plug.id, 2, 105.0, 0.20)
  156. assert row.kwh_to_date == pytest.approx(105.0)
  157. assert row.cost_to_date == pytest.approx(10.0 + 2 * 0.10 + 3 * 0.50)
  158. assert row.price_per_kwh == pytest.approx(0.20)
  159. @pytest.mark.asyncio
  160. async def test_counter_reset_adds_nothing_and_the_total_carries_on(self, db_session, smart_plug_factory):
  161. plug = await smart_plug_factory(name="A")
  162. await _record(db_session, plug.id, 0, 100.0, 0.10)
  163. await _record(db_session, plug.id, 1, 2.0, 0.10) # device reset
  164. row = await _record(db_session, plug.id, 2, 5.0, 0.10)
  165. assert row.kwh_to_date == pytest.approx(103.0)
  166. assert row.cost_to_date == pytest.approx(10.3)
  167. @pytest.mark.asyncio
  168. async def test_continues_from_a_backfilled_row(self, db_session, smart_plug_factory):
  169. plug = await smart_plug_factory(name="A")
  170. # What the upgrade backfill leaves: totals are the counter at one price.
  171. db_session.add(
  172. SmartPlugEnergySnapshot(
  173. plug_id=plug.id,
  174. recorded_at=T0,
  175. lifetime_kwh=50.0,
  176. price_per_kwh=0.2,
  177. kwh_to_date=50.0,
  178. cost_to_date=10.0,
  179. )
  180. )
  181. await db_session.flush()
  182. row = await _record(db_session, plug.id, 1, 53.0, 0.9)
  183. assert row.kwh_to_date == pytest.approx(53.0)
  184. assert row.cost_to_date == pytest.approx(10.0 + 3 * 0.2)
  185. @pytest.mark.asyncio
  186. async def test_plugs_keep_their_own_totals(self, db_session, smart_plug_factory):
  187. a = await smart_plug_factory(name="A")
  188. b = await smart_plug_factory(name="B")
  189. await _record(db_session, a.id, 0, 10.0, 0.10)
  190. await _record(db_session, b.id, 0, 500.0, 1.00)
  191. row = await _record(db_session, a.id, 1, 11.0, 0.10)
  192. assert row.kwh_to_date == pytest.approx(11.0)
  193. assert row.cost_to_date == pytest.approx(1.1)
  194. class TestSnapshotCost:
  195. @pytest.mark.asyncio
  196. async def test_empty(self, db_session):
  197. result = await snapshot_cost(db_session, fallback_price=0.3)
  198. assert result.kwh == 0.0
  199. assert result.cost == 0.0
  200. @pytest.mark.asyncio
  201. async def test_whole_history(self, db_session, smart_plug_factory):
  202. plug = await smart_plug_factory(name="A")
  203. await _record(db_session, plug.id, 0, 100.0, 0.10)
  204. await _record(db_session, plug.id, 1, 102.0, 0.50)
  205. await _record(db_session, plug.id, 2, 105.0, 0.20)
  206. await db_session.commit()
  207. result = await snapshot_cost(db_session, fallback_price=9.0)
  208. assert result.kwh == pytest.approx(105.0)
  209. assert result.cost == pytest.approx(100 * 0.10 + 2 * 0.10 + 3 * 0.50)
  210. assert result.last_lifetime_kwh == pytest.approx(105.0)
  211. @pytest.mark.asyncio
  212. async def test_range_covers_the_span_the_energy_figure_counts(self, db_session, smart_plug_factory):
  213. plug = await smart_plug_factory(name="A")
  214. await _record(db_session, plug.id, 0, 100.0, 0.10) # baseline: last at or before dt_from
  215. await _record(db_session, plug.id, 2, 104.0, 0.50)
  216. await _record(db_session, plug.id, 4, 110.0, 0.20) # endpoint: last at or before dt_to
  217. await _record(db_session, plug.id, 6, 150.0, 0.90)
  218. await db_session.commit()
  219. dt_from = (T0 + timedelta(hours=1)).replace(tzinfo=timezone.utc)
  220. dt_to = (T0 + timedelta(hours=5)).replace(tzinfo=timezone.utc)
  221. result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=dt_from, dt_to=dt_to)
  222. energy, _ = await _sum_snapshot_deltas(db_session, dt_from=dt_from, dt_to=dt_to)
  223. assert result.kwh == pytest.approx(energy) == pytest.approx(10.0)
  224. assert result.cost == pytest.approx(4 * 0.10 + 6 * 0.50)
  225. @pytest.mark.asyncio
  226. async def test_range_without_a_baseline_starts_at_the_first_snapshot(self, db_session, smart_plug_factory):
  227. plug = await smart_plug_factory(name="A")
  228. await _record(db_session, plug.id, 2, 100.0, 0.10)
  229. await _record(db_session, plug.id, 3, 104.0, 0.50)
  230. await db_session.commit()
  231. result = await snapshot_cost(
  232. db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc), dt_to=None
  233. )
  234. assert result.kwh == pytest.approx(4.0)
  235. assert result.cost == pytest.approx(0.4)
  236. @pytest.mark.asyncio
  237. async def test_plugs_are_summed(self, db_session, smart_plug_factory):
  238. a = await smart_plug_factory(name="A")
  239. b = await smart_plug_factory(name="B")
  240. for plug_id, start, price in ((a.id, 10.0, 0.10), (b.id, 500.0, 1.00)):
  241. await _record(db_session, plug_id, 0, start, price)
  242. await _record(db_session, plug_id, 1, start + 1 + (plug_id == b.id), price)
  243. await db_session.commit()
  244. result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc))
  245. assert result.kwh == pytest.approx(3.0)
  246. assert result.cost == pytest.approx(1 * 0.10 + 2 * 1.00)
  247. @pytest.mark.asyncio
  248. async def test_reset_in_pre_upgrade_history_counts_nothing(self, db_session, smart_plug_factory):
  249. """Backfilled totals are the raw counter, so a reset shows as a drop."""
  250. plug = await smart_plug_factory(name="A")
  251. for hours, kwh in ((0, 1000.0), (2, 5.0)):
  252. db_session.add(
  253. SmartPlugEnergySnapshot(
  254. plug_id=plug.id,
  255. recorded_at=T0 + timedelta(hours=hours),
  256. lifetime_kwh=kwh,
  257. price_per_kwh=0.2,
  258. kwh_to_date=kwh,
  259. cost_to_date=kwh * 0.2,
  260. )
  261. )
  262. await db_session.commit()
  263. result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=(T0 + timedelta(hours=1)))
  264. assert (result.kwh, result.cost) == (0.0, 0.0)
  265. @pytest.mark.asyncio
  266. async def test_row_without_totals_falls_back_to_its_counter(self, db_session, smart_plug_factory):
  267. plug = await smart_plug_factory(name="A")
  268. db_session.add(SmartPlugEnergySnapshot(plug_id=plug.id, recorded_at=T0, lifetime_kwh=4.0))
  269. await db_session.commit()
  270. result = await snapshot_cost(db_session, fallback_price=0.25)
  271. assert (result.kwh, result.cost) == (pytest.approx(4.0), pytest.approx(1.0))
  272. class TestAllTimeCost:
  273. @pytest.mark.asyncio
  274. async def test_history_keeps_its_price_when_the_price_changes(self, db_session, smart_plug_factory):
  275. """The reported problem: today's price must not re-cost past energy."""
  276. plug = await smart_plug_factory(name="A")
  277. await _record(db_session, plug.id, 0, 100.0, 0.10)
  278. await _record(db_session, plug.id, 1, 110.0, 0.10)
  279. await db_session.commit()
  280. # Live counter 112: 110 recorded at 0.10, 2 since the last snapshot at
  281. # today's 0.50.
  282. cost = await all_time_cost(db_session, live_total_kwh=112.0, price_now=0.50)
  283. assert cost == pytest.approx(110 * 0.10 + 2 * 0.50)
  284. @pytest.mark.asyncio
  285. async def test_no_snapshots_uses_the_price_now(self, db_session):
  286. assert await all_time_cost(db_session, live_total_kwh=20.0, price_now=0.3) == pytest.approx(6.0)
  287. @pytest.mark.asyncio
  288. async def test_live_counter_below_the_history_is_costed_at_its_average_price(self, db_session, smart_plug_factory):
  289. # The counter was reset since the last snapshot: the energy shown is
  290. # the live counter, at the average price of the history.
  291. plug = await smart_plug_factory(name="A")
  292. await _record(db_session, plug.id, 0, 100.0, 0.10)
  293. await _record(db_session, plug.id, 1, 200.0, 0.30)
  294. await db_session.commit()
  295. cost = await all_time_cost(db_session, live_total_kwh=50.0, price_now=9.0)
  296. assert cost == pytest.approx(50 * (10.0 + 10.0) / 200.0)
  297. class TestPrintEnergyCost:
  298. @pytest.mark.asyncio
  299. async def test_price_change_during_the_print(self, db_session, smart_plug_factory):
  300. plug = await smart_plug_factory(name="A")
  301. # Started at 50 kWh / 0.10 half an hour before the 1 h snapshot.
  302. db_session.add_all([_snap(plug.id, 1, 51.0, 0.40), _snap(plug.id, 2, 53.0, 0.05)])
  303. # Snapshots from before the print must not count.
  304. db_session.add(_snap(plug.id, 0, 49.0, 9.0))
  305. await db_session.commit()
  306. cost = await print_energy_cost(
  307. db_session,
  308. plug_id=plug.id,
  309. start_at=T0 + timedelta(minutes=30),
  310. start_kwh=50.0,
  311. start_price=0.10,
  312. end_kwh=54.0,
  313. end_plug_id=plug.id,
  314. price_now=0.05,
  315. )
  316. # 1 kWh at 0.10, 2 at 0.40, 1 at 0.05 — not 4 kWh at the end price 0.05.
  317. assert cost == pytest.approx(0.10 + 0.80 + 0.05)
  318. @pytest.mark.asyncio
  319. async def test_short_print_without_snapshots_uses_the_start_price(self, db_session, smart_plug_factory):
  320. plug = await smart_plug_factory(name="A")
  321. cost = await print_energy_cost(
  322. db_session,
  323. plug_id=plug.id,
  324. start_at=T0,
  325. start_kwh=10.0,
  326. start_price=0.30,
  327. end_kwh=10.5,
  328. end_plug_id=plug.id,
  329. price_now=0.90,
  330. )
  331. assert cost == pytest.approx(0.15)
  332. @pytest.mark.asyncio
  333. async def test_print_started_before_the_upgrade_uses_the_price_now(self, db_session):
  334. cost = await print_energy_cost(
  335. db_session,
  336. plug_id=None,
  337. start_at=None,
  338. start_kwh=10.0,
  339. start_price=None,
  340. end_kwh=12.0,
  341. end_plug_id=1,
  342. price_now=0.25,
  343. )
  344. assert cost == pytest.approx(0.5)
  345. @pytest.mark.asyncio
  346. async def test_end_reading_from_another_plug_uses_the_price_now(self, db_session):
  347. cost = await print_energy_cost(
  348. db_session,
  349. plug_id=1,
  350. start_at=T0,
  351. start_kwh=10.0,
  352. start_price=0.10,
  353. end_kwh=12.0,
  354. end_plug_id=2,
  355. price_now=0.25,
  356. )
  357. assert cost == pytest.approx(0.5)
  358. class TestStatsEndpoint:
  359. @pytest.mark.asyncio
  360. async def test_date_range_cost_uses_snapshot_prices(self, async_client, db_session, smart_plug_factory):
  361. plug = await smart_plug_factory(name="A")
  362. # T0 is 2026-09-01 00:00 UTC.
  363. await _record(db_session, plug.id, 23, 100.0, 0.10) # 2026-09-01 23:00, baseline
  364. await _record(db_session, plug.id, 30, 104.0, 0.50)
  365. await _record(db_session, plug.id, 36, 106.0, 0.50)
  366. await set_setting(db_session, "energy_tracking_mode", "total")
  367. await set_setting(db_session, "energy_cost_per_kwh", "3.0") # today's price, must not apply
  368. await db_session.commit()
  369. response = await async_client.get("/api/v1/archives/stats?date_from=2026-09-02&date_to=2026-09-02")
  370. assert response.status_code == 200
  371. data = response.json()
  372. assert data["total_energy_kwh"] == pytest.approx(6.0)
  373. assert data["total_energy_cost"] == pytest.approx(4 * 0.10 + 2 * 0.50)
  374. class TestRecordEnergyStart:
  375. @pytest.mark.asyncio
  376. async def test_records_when_where_and_at_what_price(
  377. self, db_session, printer_factory, smart_plug_factory, archive_factory
  378. ):
  379. printer = await printer_factory()
  380. plug = await smart_plug_factory(name="Power", printer_id=printer.id)
  381. archive = await archive_factory(printer.id)
  382. await set_setting(db_session, "energy_cost_per_kwh", "0.33")
  383. await db_session.commit()
  384. from backend.app.main import _record_energy_start
  385. with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 7.5})):
  386. assert await _record_energy_start(archive, printer.id, db_session) is True
  387. assert archive.energy_start_kwh == 7.5
  388. assert archive.energy_start_plug_id == plug.id
  389. assert archive.energy_start_price == pytest.approx(0.33)
  390. assert archive.energy_start_at is not None
  391. assert archive.energy_start_at.tzinfo is None
  392. class TestSnapshotPriceBackfill:
  393. @pytest.mark.asyncio
  394. async def test_fills_existing_rows_once(self, test_engine, db_session, smart_plug_factory):
  395. from backend.app.core.database import _backfill_snapshot_prices
  396. plug_id = (await smart_plug_factory(name="A")).id
  397. db_session.add_all([_snap(plug_id, 0, 1.0, None), _snap(plug_id, 1, 2.0, None)])
  398. await set_setting(db_session, "energy_cost_per_kwh", "0.28")
  399. await db_session.commit()
  400. async with test_engine.begin() as conn:
  401. await _backfill_snapshot_prices(conn)
  402. db_session.expire_all()
  403. rows = (
  404. await db_session.execute(
  405. select(
  406. SmartPlugEnergySnapshot.price_per_kwh,
  407. SmartPlugEnergySnapshot.kwh_to_date,
  408. SmartPlugEnergySnapshot.cost_to_date,
  409. ).order_by(SmartPlugEnergySnapshot.recorded_at)
  410. )
  411. ).all()
  412. assert [tuple(r) for r in rows] == [
  413. (pytest.approx(0.28), pytest.approx(1.0), pytest.approx(0.28)),
  414. (pytest.approx(0.28), pytest.approx(2.0), pytest.approx(0.56)),
  415. ]
  416. # A second run (next boot) leaves a NULL alone: the flag is set.
  417. db_session.add(_snap(plug_id, 2, 3.0, None))
  418. await db_session.commit()
  419. async with test_engine.begin() as conn:
  420. await _backfill_snapshot_prices(conn)
  421. nulls = (
  422. await conn.execute(text("SELECT COUNT(*) FROM smart_plug_energy_snapshots WHERE price_per_kwh IS NULL"))
  423. ).scalar_one()
  424. assert nulls == 1
  425. class TestSettingsRoute:
  426. @pytest.mark.asyncio
  427. async def test_saves_and_returns_the_price_source(self, async_client):
  428. response = await async_client.put(
  429. "/api/v1/settings/",
  430. json={"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.amber_price"},
  431. )
  432. assert response.status_code == 200
  433. assert response.json()["energy_price_source"] == "homeassistant"
  434. assert response.json()["energy_price_ha_entity"] == "sensor.amber_price"
  435. @pytest.mark.asyncio
  436. async def test_defaults_to_fixed(self, async_client):
  437. response = await async_client.get("/api/v1/settings/")
  438. assert response.json()["energy_price_source"] == "fixed"
  439. assert response.json()["energy_price_ha_entity"] == ""
  440. @pytest.mark.asyncio
  441. @pytest.mark.parametrize("value", ["tibber", None])
  442. async def test_refuses_an_unknown_or_null_source(self, async_client, value):
  443. response = await async_client.put("/api/v1/settings/", json={"energy_price_source": value})
  444. assert response.status_code == 422
  445. # A stored "None" would have broken every later settings read.
  446. assert (await async_client.get("/api/v1/settings/")).status_code == 200
  447. @pytest.mark.asyncio
  448. async def test_reads_the_price_only_when_its_source_changes(self, async_client):
  449. body = {"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.p", "currency": "AUD"}
  450. with patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.3)) as read:
  451. await async_client.put("/api/v1/settings/", json=body)
  452. assert read.await_count == 1
  453. # The page saves every field on each change; same source, no read.
  454. await async_client.put("/api/v1/settings/", json={**body, "currency": "EUR"})
  455. assert read.await_count == 1
  456. await async_client.put("/api/v1/settings/", json={**body, "energy_price_ha_entity": "sensor.q"})
  457. assert read.await_count == 2
  458. class TestSnapshotCapture:
  459. @pytest.mark.asyncio
  460. async def test_every_row_of_a_tick_carries_the_price(self):
  461. from types import SimpleNamespace
  462. from unittest.mock import MagicMock
  463. from backend.app.services import smart_plug_manager as manager_module
  464. added: list[SmartPlugEnergySnapshot] = []
  465. class FakeSession:
  466. async def execute(self, *_a, **_kw):
  467. result = MagicMock()
  468. result.scalars.return_value.all.return_value = [
  469. SimpleNamespace(id=1, plug_type="rest", enabled=True),
  470. SimpleNamespace(id=2, plug_type="tasmota", enabled=True),
  471. ]
  472. # No earlier snapshot for either plug.
  473. result.scalar_one_or_none.return_value = None
  474. return result
  475. def add(self, obj):
  476. added.append(obj)
  477. async def commit(self):
  478. pass
  479. async def __aenter__(self):
  480. return self
  481. async def __aexit__(self, *_a):
  482. return False
  483. manager = manager_module.SmartPlugManager()
  484. with (
  485. patch("backend.app.core.database.async_session", FakeSession),
  486. patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.37)) as read,
  487. patch.object(
  488. manager,
  489. "get_service_for_plug",
  490. new=AsyncMock(return_value=SimpleNamespace(get_energy=AsyncMock(return_value={"total": 5.0}))),
  491. ),
  492. ):
  493. await manager._capture_energy_snapshots()
  494. read.assert_awaited_once()
  495. assert read.await_args.kwargs == {"remember": True}
  496. assert [row.price_per_kwh for row in added] == [0.37, 0.37]
  497. assert [row.cost_to_date for row in added] == [pytest.approx(5.0 * 0.37)] * 2
  498. class TestEnergyMultiplierRange:
  499. """myStrom reports watt-seconds: 1 Ws = 1/3,600,000 kWh (#1251 thread)."""
  500. WS_TO_KWH = 1 / 3_600_000
  501. @pytest.mark.parametrize(
  502. "field",
  503. [
  504. "mqtt_power_multiplier",
  505. "mqtt_energy_multiplier",
  506. "rest_power_multiplier",
  507. "rest_energy_multiplier",
  508. "rest_energy_total_multiplier",
  509. ],
  510. )
  511. def test_watt_seconds_conversion_is_accepted(self, field):
  512. from backend.app.schemas.smart_plug import SmartPlugCreate, SmartPlugUpdate
  513. plug = SmartPlugCreate(
  514. name="myStrom", plug_type="rest", rest_on_url="http://plug/relay?state=1", **{field: self.WS_TO_KWH}
  515. )
  516. assert getattr(plug, field) == pytest.approx(self.WS_TO_KWH)
  517. assert getattr(SmartPlugUpdate(**{field: self.WS_TO_KWH}), field) == pytest.approx(self.WS_TO_KWH)
  518. def test_zero_is_still_refused(self):
  519. from pydantic import ValidationError
  520. from backend.app.schemas.smart_plug import SmartPlugCreate
  521. with pytest.raises(ValidationError):
  522. SmartPlugCreate(
  523. name="myStrom",
  524. plug_type="rest",
  525. rest_on_url="http://plug/relay?state=1",
  526. rest_energy_total_multiplier=0,
  527. )
  528. class TestStrayStoredSource:
  529. @pytest.mark.asyncio
  530. async def test_settings_still_load_and_the_price_is_fixed(self, async_client, db_session):
  531. # Settings restored from a backup are written as stored, unchecked.
  532. await set_setting(db_session, "energy_price_source", "tibber")
  533. await set_setting(db_session, "energy_cost_per_kwh", "0.2")
  534. await db_session.commit()
  535. assert (await async_client.get("/api/v1/settings/")).status_code == 200
  536. with patch.object(energy_price, "_read_state", AsyncMock()) as read:
  537. assert await current_price(db_session) == pytest.approx(0.2)
  538. read.assert_not_called()