Skip to content

Commit f138ee3

Browse files
fjfrickecursoragent
andcommitted
Release 0.2.0: periods API, get_periods(), period_index for yearly reset
- Add get_periods() and Period model; optional period_index on meter/current/monthly methods - Document per-period semantics and yearly reset in docstrings and README - Bump version to 0.2.0 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6643d43 commit f138ee3

File tree

6 files changed

+145
-36
lines changed

6 files changed

+145
-36
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,15 @@ async def fetch():
104104
Key methods:
105105
- `BrunataClient.login()`
106106
- `BrunataClient.get_account()`
107+
- `BrunataClient.get_periods()` – list of dashboard periods (start/end); use to see which year a value belongs to
107108
- `BrunataClient.get_supported_cost_types()`
108109
- `BrunataClient.get_readings(...)`
109-
- `BrunataClient.get_monthly_consumption(cost_type=..., in_kwh=...)`
110-
- `BrunataClient.get_monthly_consumptions(kind, in_kwh=...)` (all matching cost types)
111-
- `BrunataClient.get_meter_readings()` (all `HZ..` and `WW..`, keyed by `cost_type`)
110+
- `BrunataClient.get_monthly_consumption(cost_type=..., in_kwh=..., period_index=...)`
111+
- `BrunataClient.get_monthly_consumptions(kind, in_kwh=..., period_index=...)`
112+
- `BrunataClient.get_meter_readings(period_index=...)` (all `HZ..` and `WW..`, keyed by `cost_type`)
113+
- `BrunataClient.get_current_consumption(kind, period_index=...)` (YTD for the selected period)
114+
115+
**Periods and yearly reset:** The portal exposes data per dashboard period (usually one per calendar year). Cumulative values (meter reading, “current consumption”) are **per period** and typically **reset when a new year starts**. Default is `period_index=0` (first period, often the current year). Use `get_periods()` to list periods and `period_index` to request a specific one (e.g. `period_index=1` for the previous year). Integrations (e.g. Home Assistant) can use this to show “2024 total” vs “2025 YTD” or to handle the reset (e.g. new sensor per year or state_class per period).
112116

113117
## Development
114118

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "brunata-nutzerportal-api"
3-
version = "0.1.4"
3+
version = "0.2.0"
44
description = "Async client for BRUdirekt (Brunata) portal"
55
authors = [
66
{name = "Felix Fricke"}

src/brunata_api/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from importlib.metadata import PackageNotFoundError, version
44

55
from .client import BrunataClient
6-
from .models import Reading, ReadingKind
6+
from .models import CurrentConsumption, MeterReading, Period, Reading, ReadingKind
77

88

99
def __getattr__(name: str): # pragma: no cover
@@ -15,5 +15,13 @@ def __getattr__(name: str): # pragma: no cover
1515
raise AttributeError(name)
1616

1717

18-
__all__ = ["BrunataClient", "Reading", "ReadingKind", "__version__"]
18+
__all__ = [
19+
"BrunataClient",
20+
"CurrentConsumption",
21+
"MeterReading",
22+
"Period",
23+
"Reading",
24+
"ReadingKind",
25+
"__version__",
26+
]
1927

src/brunata_api/client.py

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import httpx
1414

1515
from .errors import LoginError
16-
from .models import CurrentConsumption, MeterReading, Reading, ReadingKind
16+
from .models import CurrentConsumption, MeterReading, Period, Reading, ReadingKind
1717

1818

1919
def _default_headers() -> dict[str, str]:
@@ -495,16 +495,45 @@ async def get_dashboard_dates(self) -> dict[str, Any]:
495495
)
496496
return await self._dashboard_batch_get(rel)
497497

498+
async def get_periods(self) -> list[Period]:
499+
"""
500+
Return all dashboard periods (e.g. calendar years) as start/end datetimes.
501+
502+
Useful for integrations to show "which year" a value belongs to or to request
503+
a specific period via period_index. Values from meter/current consumption are
504+
per-period and typically reset when a new period (e.g. new year) starts.
505+
"""
506+
dates = await self.get_dashboard_dates()
507+
results = (dates.get("d") or {}).get("results") or []
508+
out: list[Period] = []
509+
for p in results:
510+
if not isinstance(p, dict):
511+
continue
512+
ab_raw = p.get("Abdatum")
513+
bis_raw = p.get("Bisdatum")
514+
if not isinstance(ab_raw, str) or not isinstance(bis_raw, str):
515+
continue
516+
start = self._sap_date_to_datetime(ab_raw)
517+
end = self._sap_date_to_datetime(bis_raw)
518+
if start and end:
519+
out.append(Period(start=start, end=end))
520+
return out
521+
498522
@staticmethod
499523
def _parse_sap_number(value: str) -> float:
500524
# Values may be padded with spaces and use dot decimals.
501525
return float(value.strip().replace(",", "."))
502526

503-
async def get_meter_reading(self, *, cost_type: str) -> MeterReading:
527+
async def get_meter_reading(
528+
self, *, cost_type: str, period_index: int = 0
529+
) -> MeterReading:
504530
"""
505531
Get the cumulative meter/index value (Zählerstand) for a cost type.
506532
507-
Uses `NP_DASHBOARD_SRV/CumuConsumptionSet`.
533+
Uses `NP_DASHBOARD_SRV/CumuConsumptionSet`. Values are per dashboard period
534+
(e.g. calendar year); they typically reset when a new period starts. Use
535+
get_periods() to list periods and period_index to request a specific one
536+
(0 = first, often the current period).
508537
"""
509538
if not self._logged_in:
510539
await self.login()
@@ -513,12 +542,14 @@ async def get_meter_reading(self, *, cost_type: str) -> MeterReading:
513542

514543
dates = await self.get_dashboard_dates()
515544
results = (dates.get("d") or {}).get("results") or []
516-
if not results:
545+
periods_list = [p for p in results if isinstance(p, dict)]
546+
if not periods_list:
517547
raise LoginError("No dashboard periods found.")
518-
519-
period = next((p for p in results if isinstance(p, dict)), None)
520-
if not period:
521-
raise LoginError("No dashboard period object found.")
548+
if period_index < 0 or period_index >= len(periods_list):
549+
raise LoginError(
550+
f"period_index {period_index} out of range (0..{len(periods_list) - 1})."
551+
)
552+
period = periods_list[period_index]
522553

523554
bis_raw = period.get("Bisdatum")
524555
if not isinstance(bis_raw, str):
@@ -563,17 +594,21 @@ async def get_meter_reading(self, *, cost_type: str) -> MeterReading:
563594
kind=kind,
564595
)
565596

566-
async def get_meter_readings(self) -> dict[str, MeterReading]:
597+
async def get_meter_readings(
598+
self, *, period_index: int = 0
599+
) -> dict[str, MeterReading]:
567600
"""
568-
Convenience helper: return meter readings for *all* HZ.. and WW.. cost types.
601+
Return meter readings for *all* HZ.. and WW.. cost types in the given period.
569602
570-
Output is keyed by `cost_type` (stable mapping for HA entity IDs).
603+
Output is keyed by `cost_type`. Values are per-period and may reset each year.
604+
Use period_index to select period (0 = first, e.g. current year).
571605
"""
572606
dates = await self.get_dashboard_dates()
573607
results = (dates.get("d") or {}).get("results") or []
574-
period = next((p for p in results if isinstance(p, dict)), None)
575-
if not period:
608+
periods_list = [p for p in results if isinstance(p, dict)]
609+
if not periods_list or period_index < 0 or period_index >= len(periods_list):
576610
return {}
611+
period = periods_list[period_index]
577612

578613
unit_rows = ((period.get("Units") or {}).get("results") or [])
579614
all_cost_types = sorted(
@@ -587,7 +622,9 @@ async def get_meter_readings(self) -> dict[str, MeterReading]:
587622

588623
out: dict[str, MeterReading] = {}
589624
for ct in wanted:
590-
out[ct] = await self.get_meter_reading(cost_type=ct)
625+
out[ct] = await self.get_meter_reading(
626+
cost_type=ct, period_index=period_index
627+
)
591628
return out
592629

593630
async def get_supported_cost_types(self) -> dict[str, set[str]]:
@@ -618,9 +655,13 @@ async def get_monthly_consumption(
618655
*,
619656
cost_type: str,
620657
in_kwh: bool = True,
658+
period_index: int | None = None,
621659
) -> list[Reading]:
622660
"""
623661
Fetch monthly consumption series for a given CostType (e.g. HZ01, WW01).
662+
663+
If period_index is None, tries all periods and returns the first with data.
664+
If set, only that period is queried (0 = first, e.g. current year).
624665
"""
625666
kind = ReadingKind.heating if cost_type.startswith("HZ") else ReadingKind.hot_water
626667
if not self._logged_in:
@@ -630,9 +671,15 @@ async def get_monthly_consumption(
630671

631672
dates = await self.get_dashboard_dates()
632673
results = (dates.get("d") or {}).get("results") or []
633-
periods: list[dict[str, Any]] = [p for p in results if isinstance(p, dict)]
674+
periods_list: list[dict[str, Any]] = [
675+
p for p in results if isinstance(p, dict)
676+
]
677+
if period_index is not None:
678+
if period_index < 0 or period_index >= len(periods_list):
679+
return []
680+
periods_list = [periods_list[period_index]]
634681

635-
for period in periods:
682+
for period in periods_list:
636683
bis_raw = period.get("Bisdatum")
637684
if not isinstance(bis_raw, str):
638685
continue
@@ -690,17 +737,19 @@ async def get_monthly_consumptions(
690737
kind: ReadingKind,
691738
*,
692739
in_kwh: bool = True,
740+
period_index: int = 0,
693741
) -> dict[str, list[Reading]]:
694742
"""
695743
Fetch monthly consumption series for *all* cost types matching `kind`.
696744
697-
Output is keyed by `cost_type` (e.g. HZ01, HZ02, WW01...).
745+
Output is keyed by `cost_type`. Use period_index to select period (0 = first).
698746
"""
699747
dates = await self.get_dashboard_dates()
700748
results = (dates.get("d") or {}).get("results") or []
701-
period = next((p for p in results if isinstance(p, dict)), None)
702-
if not period:
749+
periods_list = [p for p in results if isinstance(p, dict)]
750+
if not periods_list or period_index < 0 or period_index >= len(periods_list):
703751
return {}
752+
period = periods_list[period_index]
704753

705754
unit_rows = ((period.get("Units") or {}).get("results") or [])
706755
all_cost_types = sorted(
@@ -715,24 +764,34 @@ async def get_monthly_consumptions(
715764

716765
out: dict[str, list[Reading]] = {}
717766
for ct in wanted:
718-
out[ct] = await self.get_monthly_consumption(cost_type=ct, in_kwh=in_kwh)
767+
out[ct] = await self.get_monthly_consumption(
768+
cost_type=ct, in_kwh=in_kwh, period_index=period_index
769+
)
719770
return out
720771

721-
async def get_current_consumption(self, kind: ReadingKind) -> CurrentConsumption:
772+
async def get_current_consumption(
773+
self, kind: ReadingKind, *, period_index: int = 0
774+
) -> CurrentConsumption:
722775
"""
723-
Returns the value shown in the dashboard cards:
724-
'aktueller Verbrauch' and 'aktueller Verbrauch vom <date>'.
776+
Return the dashboard-style cumulative consumption (e.g. YTD) for the period.
725777
726-
Implementation: sum the monthly kWh series for the active period.
778+
This is the sum of monthly kWh for the selected period, so it resets when a
779+
new period (e.g. new year) starts. Use period_index to select period
780+
(0 = first, often current year). as_of is the period end date.
727781
"""
728782
if not self._logged_in:
729783
await self.login()
730784

731785
dates = await self.get_dashboard_dates()
732786
results = (dates.get("d") or {}).get("results") or []
733-
period = next((p for p in results if isinstance(p, dict)), None)
734-
if not period:
787+
periods_list = [p for p in results if isinstance(p, dict)]
788+
if not periods_list:
735789
raise LoginError("No dashboard period found.")
790+
if period_index < 0 or period_index >= len(periods_list):
791+
raise LoginError(
792+
f"period_index {period_index} out of range (0..{len(periods_list) - 1})."
793+
)
794+
period = periods_list[period_index]
736795

737796
bis_raw = period.get("Bisdatum")
738797
if not isinstance(bis_raw, str):
@@ -753,7 +812,9 @@ async def get_current_consumption(self, kind: ReadingKind) -> CurrentConsumption
753812
else:
754813
cost_type = next((ct for ct in cost_types if ct.startswith("WW")), "WW01")
755814

756-
monthly = await self.get_monthly_consumption(cost_type=cost_type, in_kwh=True)
815+
monthly = await self.get_monthly_consumption(
816+
cost_type=cost_type, in_kwh=True, period_index=period_index
817+
)
757818
total = round(sum(r.value for r in monthly), 3)
758819
return CurrentConsumption(
759820
as_of=as_of,

src/brunata_api/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ class MeterReading(BaseModel):
3535
kind: ReadingKind
3636

3737

38+
class Period(BaseModel):
39+
"""A dashboard period (e.g. calendar year) with start and end dates."""
40+
41+
start: datetime = Field(..., description="Period start (Abdatum).")
42+
end: datetime = Field(..., description="Period end (Bisdatum).")
43+
44+
3845
class CurrentConsumption(BaseModel):
3946
"""Current cumulative consumption (e.g. year-to-date) shown in the dashboard cards."""
4047

tests/test_client.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import httpx
77

88
from brunata_api.client import BrunataClient
9-
from brunata_api.models import MeterReading, Reading, ReadingKind
9+
from brunata_api.models import MeterReading, Period, Reading, ReadingKind
1010

1111

1212
def test_extract_json_objects_finds_embedded_json():
@@ -61,6 +61,31 @@ async def fake_get_dashboard_dates():
6161
assert out["/Date(1704067200000)/"] == {"HZ01", "WW01"}
6262

6363

64+
def test_get_periods_returns_parsed_periods_without_network():
65+
client = BrunataClient(base_url="https://example.invalid", username="u", password="p")
66+
client._logged_in = True # avoid login()
67+
68+
async def fake_get_dashboard_dates():
69+
return {
70+
"d": {
71+
"results": [
72+
{
73+
"Abdatum": "/Date(1704067200000)/", # 2024-01-01
74+
"Bisdatum": "/Date(1735689599000)/", # 2024-12-31
75+
"Units": {"results": [{"CostType": "HZ01"}]},
76+
}
77+
]
78+
}
79+
}
80+
81+
client.get_dashboard_dates = fake_get_dashboard_dates # type: ignore[method-assign]
82+
out = asyncio.run(client.get_periods())
83+
assert len(out) == 1
84+
assert isinstance(out[0], Period)
85+
assert out[0].start.year == 2024 and out[0].start.month == 1
86+
assert out[0].end.year == 2024 and out[0].end.month == 12
87+
88+
6489
def test_get_monthly_consumption_without_network():
6590
client = BrunataClient(base_url="https://example.invalid", username="u", password="p")
6691
client._logged_in = True
@@ -129,7 +154,9 @@ async def fake_get_dashboard_dates():
129154
}
130155
}
131156

132-
async def fake_get_meter_reading(*, cost_type: str) -> MeterReading:
157+
async def fake_get_meter_reading(
158+
*, cost_type: str, period_index: int = 0
159+
) -> MeterReading:
133160
kind = ReadingKind.heating if cost_type.startswith("HZ") else ReadingKind.hot_water
134161
return MeterReading(
135162
timestamp=datetime(2025, 1, 1, tzinfo=UTC),
@@ -170,7 +197,9 @@ async def fake_get_dashboard_dates():
170197

171198
seen: list[tuple[str, bool]] = []
172199

173-
async def fake_get_monthly_consumption(*, cost_type: str, in_kwh: bool = True) -> list[Reading]:
200+
async def fake_get_monthly_consumption(
201+
*, cost_type: str, in_kwh: bool = True, period_index: int | None = None
202+
) -> list[Reading]:
174203
seen.append((cost_type, in_kwh))
175204
kind = ReadingKind.heating if cost_type.startswith("HZ") else ReadingKind.hot_water
176205
return [

0 commit comments

Comments
 (0)