Skip to content

Commit ff3f8b3

Browse files
committed
Update version to 0.1.2 in pyproject.toml. Add new commands to CLI for fetching meter readings and current consumption, and enhance README.md with usage examples for these commands. Introduce new models for MeterReading and CurrentConsumption in models.py.
1 parent 2629c99 commit ff3f8b3

File tree

5 files changed

+201
-2
lines changed

5 files changed

+201
-2
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ poetry run brunata readings --kind heating
4848
poetry run brunata readings --kind hot_water
4949
```
5050

51+
Zählerstand abrufen:
52+
53+
```bash
54+
poetry run brunata meter
55+
```
56+
57+
Aktuellen Verbrauch (wie im Dashboard) abrufen:
58+
59+
```bash
60+
poetry run brunata current --kind heating
61+
poetry run brunata current --kind hot_water
62+
```
63+
5164
## Nutzung als Library (Home Assistant)
5265

5366
Der Client ist async und für HA-Coordinator-Patterns geeignet:

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-api"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Async client for BRUdirekt (Brunata) portal"
55
authors = [
66
{name = "Felix Fricke"}

src/brunata_api/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ async def _cmd_readings(args: argparse.Namespace) -> int:
100100
return 0
101101

102102

103+
async def _cmd_meter(_: argparse.Namespace) -> int:
104+
base_url, username, password, sap_client = _load_config()
105+
async with BrunataClient(
106+
base_url=base_url, username=username, password=password, sap_client=sap_client
107+
) as c:
108+
readings = await c.get_meter_readings()
109+
payload: list[dict[str, Any]] = [r.model_dump(mode="json") for r in readings]
110+
print(json.dumps(payload, indent=2))
111+
return 0
112+
113+
114+
async def _cmd_current(args: argparse.Namespace) -> int:
115+
base_url, username, password, sap_client = _load_config()
116+
kind = ReadingKind(args.kind)
117+
async with BrunataClient(
118+
base_url=base_url, username=username, password=password, sap_client=sap_client
119+
) as c:
120+
cur = await c.get_current_consumption(kind)
121+
print(json.dumps(cur.model_dump(mode="json"), indent=2))
122+
return 0
123+
124+
103125
def build_parser() -> argparse.ArgumentParser:
104126
p = argparse.ArgumentParser(prog="brunata")
105127
sub = p.add_subparsers(dest="cmd", required=True)
@@ -115,6 +137,13 @@ def build_parser() -> argparse.ArgumentParser:
115137
p_readings.add_argument("--kind", choices=[k.value for k in ReadingKind], required=True)
116138
p_readings.set_defaults(func=_cmd_readings)
117139

140+
p_meter = sub.add_parser("meter", help="Fetch Zählerstand (cumulative meter readings).")
141+
p_meter.set_defaults(func=_cmd_meter)
142+
143+
p_current = sub.add_parser("current", help="Fetch dashboard 'aktueller Verbrauch' (kWh).")
144+
p_current.add_argument("--kind", choices=[k.value for k in ReadingKind], required=True)
145+
p_current.set_defaults(func=_cmd_current)
146+
118147
return p
119148

120149

src/brunata_api/client.py

Lines changed: 138 additions & 1 deletion
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 Reading, ReadingKind
16+
from .models import CurrentConsumption, MeterReading, Reading, ReadingKind
1717

1818

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

498+
@staticmethod
499+
def _parse_sap_number(value: str) -> float:
500+
# Values may be padded with spaces and use dot decimals.
501+
return float(value.strip().replace(",", "."))
502+
503+
async def get_meter_reading(self, *, cost_type: str) -> MeterReading:
504+
"""
505+
Get the cumulative meter/index value (Zählerstand) for a cost type.
506+
507+
Uses `NP_DASHBOARD_SRV/CumuConsumptionSet`.
508+
"""
509+
if not self._logged_in:
510+
await self.login()
511+
if not self._user_unit_id:
512+
raise LoginError("Missing UserUnitID.")
513+
514+
dates = await self.get_dashboard_dates()
515+
results = (dates.get("d") or {}).get("results") or []
516+
if not results:
517+
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.")
522+
523+
bis_raw = period.get("Bisdatum")
524+
if not isinstance(bis_raw, str):
525+
raise LoginError("Missing Bisdatum in dashboard period.")
526+
527+
bis_dt = self._sap_date_to_datetime(bis_raw)
528+
if not bis_dt:
529+
raise LoginError("Could not parse Bisdatum.")
530+
531+
bis_filter_dt = bis_dt.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(
532+
hours=23
533+
)
534+
bis_filter = bis_filter_dt.strftime("%Y-%m-%dT%H:%M:%S")
535+
536+
rel = (
537+
f"CumuConsumptionSet?sap-client={quote(self.sap_client)}&$filter="
538+
f"Nutzein%20eq%20%27{quote(self._user_unit_id)}%27%20and%20"
539+
f"Bis%20eq%20datetime%27{quote(bis_filter)}%27%20and%20"
540+
f"Kotyp%20eq%20%27{quote(cost_type)}%27"
541+
)
542+
543+
data = await self._dashboard_batch_get(rel)
544+
rows = (data.get("d") or {}).get("results") or []
545+
if not rows:
546+
raise LoginError(f"No CumuConsumptionSet results for cost type {cost_type}.")
547+
548+
row = rows[0]
549+
val_raw = row.get("Verbrauch")
550+
if not isinstance(val_raw, str):
551+
raise LoginError("CumuConsumptionSet missing Verbrauch.")
552+
value = self._parse_sap_number(val_raw)
553+
unit = row.get("MassreadTxt") or row.get("Massread")
554+
unit_s = str(unit) if unit else None
555+
kind = ReadingKind.heating if cost_type.startswith("HZ") else ReadingKind.hot_water
556+
557+
# CumuConsumptionSet uses the period end (Bis) as the timestamp.
558+
return MeterReading(
559+
timestamp=bis_dt,
560+
value=value,
561+
unit=unit_s,
562+
cost_type=cost_type,
563+
kind=kind,
564+
)
565+
566+
async def get_meter_readings(self) -> list[MeterReading]:
567+
"""
568+
Convenience helper: return meter readings for the first HZ.. and WW.. cost types.
569+
"""
570+
dates = await self.get_dashboard_dates()
571+
results = (dates.get("d") or {}).get("results") or []
572+
period = next((p for p in results if isinstance(p, dict)), None)
573+
if not period:
574+
return []
575+
unit_rows = ((period.get("Units") or {}).get("results") or [])
576+
cost_types = [
577+
r.get("CostType")
578+
for r in unit_rows
579+
if isinstance(r, dict) and isinstance(r.get("CostType"), str)
580+
]
581+
hz = next((ct for ct in cost_types if ct.startswith("HZ")), None)
582+
ww = next((ct for ct in cost_types if ct.startswith("WW")), None)
583+
out: list[MeterReading] = []
584+
if hz:
585+
out.append(await self.get_meter_reading(cost_type=hz))
586+
if ww:
587+
out.append(await self.get_meter_reading(cost_type=ww))
588+
return out
589+
498590
async def get_supported_cost_types(self) -> dict[str, set[str]]:
499591
"""
500592
Return cost types available per period.
@@ -590,6 +682,51 @@ async def get_monthly_consumption(
590682

591683
return []
592684

685+
async def get_current_consumption(self, kind: ReadingKind) -> CurrentConsumption:
686+
"""
687+
Returns the value shown in the dashboard cards:
688+
'aktueller Verbrauch' and 'aktueller Verbrauch vom <date>'.
689+
690+
Implementation: sum the monthly kWh series for the active period.
691+
"""
692+
if not self._logged_in:
693+
await self.login()
694+
695+
dates = await self.get_dashboard_dates()
696+
results = (dates.get("d") or {}).get("results") or []
697+
period = next((p for p in results if isinstance(p, dict)), None)
698+
if not period:
699+
raise LoginError("No dashboard period found.")
700+
701+
bis_raw = period.get("Bisdatum")
702+
if not isinstance(bis_raw, str):
703+
raise LoginError("Missing Bisdatum in dashboard period.")
704+
as_of = self._sap_date_to_datetime(bis_raw)
705+
if not as_of:
706+
raise LoginError("Could not parse Bisdatum.")
707+
708+
unit_rows = ((period.get("Units") or {}).get("results") or [])
709+
cost_types = [
710+
r.get("CostType")
711+
for r in unit_rows
712+
if isinstance(r, dict) and isinstance(r.get("CostType"), str)
713+
]
714+
715+
if kind == ReadingKind.heating:
716+
cost_type = next((ct for ct in cost_types if ct.startswith("HZ")), "HZ01")
717+
else:
718+
cost_type = next((ct for ct in cost_types if ct.startswith("WW")), "WW01")
719+
720+
monthly = await self.get_monthly_consumption(cost_type=cost_type, in_kwh=True)
721+
total = round(sum(r.value for r in monthly), 3)
722+
return CurrentConsumption(
723+
as_of=as_of,
724+
value=total,
725+
unit="kWh",
726+
kind=kind,
727+
cost_type=cost_type,
728+
)
729+
593730
async def get_readings(self, kind: ReadingKind) -> list[Reading]:
594731
if not self._logged_in:
595732
await self.login()

src/brunata_api/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,23 @@ class Reading(BaseModel):
2424
unit: str | None = Field(default=None, description="Optional unit, if known.")
2525
kind: ReadingKind
2626

27+
28+
class MeterReading(BaseModel):
29+
"""A cumulative meter/index reading (Zählerstand) for a given cost type."""
30+
31+
timestamp: datetime = Field(..., description="Timestamp for the reading (period end).")
32+
value: float = Field(..., description="Numeric value of the meter/index.")
33+
unit: str | None = Field(default=None, description="Unit label (e.g. m³, Einh.).")
34+
cost_type: str = Field(..., description="Portal cost type (e.g. HZ01, WW01).")
35+
kind: ReadingKind
36+
37+
38+
class CurrentConsumption(BaseModel):
39+
"""Current cumulative consumption (e.g. year-to-date) shown in the dashboard cards."""
40+
41+
as_of: datetime = Field(..., description="The dashboard's 'as of' date.")
42+
value: float = Field(..., description="Cumulative consumption up to as_of.")
43+
unit: str = Field(..., description="Unit label (typically kWh in the dashboard toggle).")
44+
kind: ReadingKind
45+
cost_type: str
46+

0 commit comments

Comments
 (0)