Skip to content

Commit 6c9c931

Browse files
committed
init
0 parents  commit 6c9c931

File tree

21 files changed

+1861
-0
lines changed

21 files changed

+1861
-0
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.env
2+
__pycache__/
3+
.DS_Store
4+
/raw_json
5+
pstryk_api_tests.py

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# ⚡ Pstryk – integracja z systemem Home Assistant
2+
3+
Integracja **Pstryk** umożliwia połączenie Twojego konta [Pstryk Energy](https://pstryk.pl) z Home Assistant.
4+
Dzięki niej możesz monitorować **zużycie energii, sprzedaż prądu, bilans dobowy oraz koszty energii** bezpośrednio w panelu HA
5+
6+
> 🛠️ Projekt rozwijany — aktualnie stabilny i w pełni funkcjonalny! Docelowo funkcjonalność będzie podobna do oficjalnej aplikacji mobilnej
7+
8+
---
9+
10+
## 🧩 Funkcje
11+
12+
- 🔐 Konfiguracja przez login i hasło
13+
- 🔐 Aktualizacje przez tokeny
14+
- ⚙️ Pobieranie listy liczników (meters) przypisanych do konta
15+
- ⏰ Automatyczne odświeżanie danych co godzinę (o `xx:59:30`)
16+
- ⚡ Sensory energii (kWh):
17+
- Dzisiejsze zużycie (`fae_total_usage`)
18+
- Dzisiejsza sprzedaż (`rae_total`)
19+
- Dzisiejszy bilans (`energy_balance`)
20+
- 💰 Sensory kosztów (PLN):
21+
- Dzisiejszy koszt (`fae_total_cost`)
22+
- Atrybuty z rozbiciem kosztów: VAT, dystrybucja, energia z serwisem
23+
- 👤 Sensory diagnostyczne:
24+
- ID użytkownika
25+
- ID licznika (`meter_id`)
26+
- Adres IP licznika (`details.device.ip`)
27+
- Timestamp ostatniego odświeżenia danych z API
28+
29+
---
30+
31+
## ⚙️ Instalacja
32+
33+
### 🔹 1. Instalacja przez HACS (zalecana)
34+
1. Otwórz **HACS → Integrations → Custom repositories**
35+
2. Dodaj repozytorium:
36+
```
37+
https://github.com/aLAN-LDZ/Pstryk_HA
38+
```
39+
3. Typ: `Integration`
40+
4. Po zainstalowaniu restartuj Home Assistant.
41+
42+
### 🔹 2. Instalacja ręczna
43+
1. Sklonuj repozytorium lub pobierz paczkę ZIP:
44+
2. Skopiuj folder do:
45+
```
46+
config/custom_components/pstryk
47+
```
48+
3. Uruchom ponownie Home Assistant.
49+
50+
---
51+
52+
## 🕒 Odświeżanie danych
53+
54+
Koordynator danych (`PstrykCoordinator`) odświeża dane:
55+
- co godzinę o `XX:59:30`
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from typing import Any
5+
6+
from homeassistant.config_entries import ConfigEntry
7+
from homeassistant.core import HomeAssistant, callback
8+
from homeassistant.helpers.event import async_track_utc_time_change
9+
10+
# Stałe i „klucze” współdzielone w integracji:
11+
# - DOMAIN: nazwa domeny integracji (np. "pstryk")
12+
# - PLATFORMS: lista platform HA, które rejestrujemy (np. ["sensor"])
13+
# - CONF_*: nazwy pól przechowywanych w entry.data (tokeny, user_id)
14+
# - DATA_*: klucze do słownika hass.data[DOMAIN][entry_id]
15+
from .const import (
16+
DOMAIN,
17+
PLATFORMS,
18+
CONF_USER_ID,
19+
CONF_ACCESS,
20+
CONF_REFRESH,
21+
DATA_CLIENT,
22+
DATA_COORDINATOR,
23+
DATA_UNSUB,
24+
)
25+
26+
# Koordynator to warstwa „cache + harmonogram” z HA (DataUpdateCoordinator),
27+
# do której podłączają się encje (np. sensory), aby pobierać z niej aktualne dane.
28+
from .coordinator import PstrykCoordinator
29+
30+
# Klient HTTP do Pstryk API. Potrafi:
31+
# - zbudować się z tokenów (from_tokens)
32+
# - odświeżyć access_token (refresh_access)
33+
# - samodzielnie retry’ować request po 401 (w _get_json)
34+
from .pstryklib.pstryk_api_client import PstrykApiClient
35+
36+
37+
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
38+
"""Minimalny setup integracji na starcie HA.
39+
40+
Wywoływany wcześnie, przed wczytaniem config entries.
41+
W tej integracji nic tu nie robimy — zwracamy True, aby HA wiedział, że „setup” się powiódł.
42+
"""
43+
return True
44+
45+
46+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
47+
"""Główna inicjalizacja JEDNEGO wpisu konfiguracyjnego (ConfigEntry).
48+
49+
Co się tu dzieje (kolejność kroków):
50+
1) Przygotowanie przestrzeni w hass.data i lok. store na uchwyty tej instancji.
51+
2) Odczyt tokenów i user_id z entry.data zapisanych przez config_flow.
52+
3) Jeśli tokenów brakuje — inicjujemy reauth i PRZERYWAMY setup (return False).
53+
4) Tworzymy klienta API z tokenów.
54+
5) Wykonujemy „opportunistic refresh” access_token na starcie (jeśli refresh_token ważny).
55+
- jeżeli access się zmienił, aktualizujemy entry.data (persist).
56+
- jeśli refresh się nie uda (np. refresh_token wygasł) — odpalamy reauth i PRZERYWAMY setup.
57+
6) Tworzymy koordynatora + robimy pierwsze odświeżenie danych.
58+
7) Rejestrujemy zegarowy callback (UTC) na HH:59:30, który żąda odświeżenia koordynatora.
59+
Uwaga: to idzie w UTC — przy DST offset względem czasu lokalnego się zmienia.
60+
8) Odkładamy uchwyty (client, coordinator, unsub) do hass.data[DOMAIN][entry_id].
61+
9) Forwardujemy setup platform (np. sensor.py).
62+
"""
63+
64+
# 1) Struktura pamięci dla tej instancji wpisu (entry)
65+
hass.data.setdefault(DOMAIN, {})
66+
store: dict[str, Any] = {}
67+
68+
# 2) Dane konfiguracyjne zapisane przez config_flow (tokeny, user_id)
69+
data = entry.data or {}
70+
access = data.get(CONF_ACCESS)
71+
refresh = data.get(CONF_REFRESH)
72+
user_id = data.get(CONF_USER_ID)
73+
74+
# 3) Jeśli nie mamy kompletu tokenów — nie uruchamiamy pół-aktywnej integracji.
75+
# Wywołujemy reauth flow i kończymy. Po udanym reauth HA ponownie spróbuje setup_entry.
76+
if not access or not refresh:
77+
hass.async_create_task(
78+
hass.config_entries.flow.async_init(
79+
DOMAIN,
80+
context={"source": "reauth", "entry_id": entry.entry_id},
81+
data=entry.data,
82+
)
83+
)
84+
return False
85+
86+
# 4) Budujemy klienta z tokenów. Klient sam umie odświeżyć access przy 401.
87+
client = PstrykApiClient.from_tokens(access=access, refresh=refresh, user_id=user_id)
88+
89+
# 5) „Opportunistic refresh”: na starcie odśwież access_token (o ile refresh_token jeszcze żyje).
90+
# Dzięki temu od początku mamy „świeży” access i unikamy 401 przy pierwszych callach koordynatora.
91+
try:
92+
old_access = access
93+
await client.refresh_access()
94+
if client.access_token and client.access_token != old_access:
95+
# Zapisz nowy access do entry.data (persist na dysk), opcjonalnie user_id jeśli backend zwrócił.
96+
new_data = dict(data)
97+
new_data[CONF_ACCESS] = client.access_token
98+
new_data[CONF_USER_ID] = client.user_id
99+
hass.config_entries.async_update_entry(entry, data=new_data)
100+
except Exception:
101+
# Jeżeli tu wpadniemy, to najczęściej znaczy: refresh_token wygasł / jest nieprawidłowy.
102+
# Uruchamiamy reauth i NIE podnosimy integracji (return False). Po reauth HA znów zawoła setup_entry.
103+
hass.async_create_task(
104+
hass.config_entries.flow.async_init(
105+
DOMAIN,
106+
context={"source": "reauth", "entry_id": entry.entry_id},
107+
data=entry.data,
108+
)
109+
)
110+
return False
111+
112+
# 6) Koordynator pobiera dane z API i udostępnia je encjom. Pierwsze odświeżenie na starcie.
113+
coordinator = PstrykCoordinator(hass, client)
114+
await coordinator.async_config_entry_first_refresh()
115+
116+
# 7) Harmonogram odświeżeń: celowo „tuż przed” zmianą doby (xx:59:30 UTC),
117+
@callback
118+
async def _on_tick(now: datetime) -> None:
119+
# Ręcznie wołamy koordynatora o refresh. Sam coordinator decyduje co i jak pobrać.
120+
await coordinator.async_request_refresh()
121+
122+
unsub = async_track_utc_time_change(
123+
hass,
124+
_on_tick,
125+
minute=59,
126+
second=30,
127+
)
128+
129+
# 8) Odkładamy uchwyty do hass.data, żeby inne moduły (np. sensor.py) mogły ich użyć.
130+
# store trzymamy „per entry”, aby przy wielu licznikach każde entry miało własne zasoby.
131+
store[DATA_CLIENT] = client
132+
store[DATA_COORDINATOR] = coordinator
133+
store[DATA_UNSUB] = unsub
134+
hass.data[DOMAIN][entry.entry_id] = store
135+
136+
# 9) Rejestrujemy platformy (np. tworzenie sensorów). HA zawoła odpowiednie pliki (sensor.py itd.).
137+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
138+
return True
139+
140+
141+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
142+
"""Sprzątanie po danym wpisie (ConfigEntry) przy wyłączaniu/usuwaniu integracji.
143+
144+
Kroki:
145+
1) Wyjmij store dla tego entry z hass.data[DOMAIN].
146+
2) Odsubskrybuj timer (jeśli był zarejestrowany).
147+
3) Poproś HA o unload platform (usunie encje z UI i zatrzyma ich aktualizacje).
148+
4) Zamknij klienta HTTP (zamyka sesję aiohttp).
149+
5) Zwróć True/False czy unload platform się powiódł.
150+
"""
151+
152+
# 1) Zdejmujemy uchwyty ze wspólnej przestrzeni. .pop() zwróci pusty dict, jeśli nie znajdzie.
153+
store = hass.data.get(DOMAIN, {}).pop(entry.entry_id, {})
154+
155+
# 2) Zdejmujemy harmonogram (jeśli był).
156+
unsub = store.get(DATA_UNSUB)
157+
if callable(unsub):
158+
unsub()
159+
160+
# 3) Odłącz platformy (np. sensory). Jeśli HA zwróci False — coś nie poszło.
161+
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
162+
163+
# 4) Zamknij klienta (sesję HTTP). Defensywnie: jeśli klienta nie ma — pomiń.
164+
client = store.get(DATA_CLIENT)
165+
try:
166+
await client.aclose()
167+
except Exception:
168+
pass
169+
170+
# 5) Zwracamy wynik unloadu platform. Jeśli False — HA może spróbować ponownie.
171+
return ok
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
import voluptuous as vol
4+
from homeassistant import config_entries
5+
from homeassistant.data_entry_flow import FlowResult
6+
7+
# Wspólne stałe używane w całej integracji:
8+
# - DOMAIN: nazwa domeny integracji
9+
# - CONF_EMAIL / CONF_USER_ID / CONF_ACCESS / CONF_REFRESH: klucze używane w entry.data
10+
from .const import DOMAIN, CONF_EMAIL, CONF_USER_ID, CONF_ACCESS, CONF_REFRESH
11+
12+
# Klient do API Pstryk. W tym flow używamy go TYLKO do jednorazowego logowania,
13+
# aby pozyskać tokeny i user_id (potem sesję zamykamy w finally).
14+
from .pstryklib.pstryk_api_client import PstrykApiClient
15+
16+
# Schemat formularza pierwszego kroku (step_id="user"):
17+
# - wymagamy email i password
18+
STEP_USER_DATA_SCHEMA = vol.Schema(
19+
{
20+
vol.Required("email"): str,
21+
vol.Required("password"): str,
22+
}
23+
)
24+
25+
26+
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
27+
"""Flow tworzenia wpisu konfiguracyjnego (ConfigEntry) dla integracji Pstryk.
28+
29+
Ten flow uruchamia się, gdy użytkownik dodaje integrację w UI.
30+
Zakres odpowiedzialności:
31+
- zebrać od użytkownika email/hasło,
32+
- zalogować się do API, by otrzymać refresh/access tokeny oraz user_id,
33+
- zapisać te dane w `entry.data` (HA przechowuje je później na dysku),
34+
- NIE tworzyć długotrwałych połączeń (klient zamykamy w `finally`).
35+
"""
36+
37+
# Wersjonowanie flow — ułatwia przyszłe migracje entry:
38+
VERSION = 1
39+
40+
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
41+
"""Pierwszy (i jedyny) krok kreatora — prosi o email i hasło.
42+
43+
Przebieg:
44+
1) Gdy brak user_input → pokaż formularz (email, password).
45+
2) Gdy input jest podany → waliduj i loguj do API:
46+
- ustaw unikalne ID wpisu na bazie emaila (by uniknąć duplikatów),
47+
- odpal `PstrykApiClient(email, password).login()` — otrzymasz tokeny i user_id,
48+
- utwórz `ConfigEntry` z danymi (email, user_id, access, refresh).
49+
3) W razie błędów logowania → pokaż formularz ponownie z `errors["base"] = "auth"`.
50+
4) W `finally` zawsze zamknij klienta (zamyka sesję HTTP).
51+
"""
52+
# 1) Brak danych — wyświetlamy formularz w UI
53+
if user_input is None:
54+
return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA)
55+
56+
# 2) Mamy dane z formularza — wyciągamy i wstępnie normalizujemy
57+
email: str = user_input["email"].strip()
58+
password: str = user_input["password"]
59+
60+
# Ustal unikalność wpisu po emailu: "pstryk:<email>".
61+
# Jeśli już istnieje wpis o takim unique_id → przerwij (HA pokaże info, że już skonfigurowano).
62+
await self.async_set_unique_id(f"pstryk:{email.lower()}")
63+
self._abort_if_unique_id_configured()
64+
65+
errors: dict[str, str] = {}
66+
67+
# Tymczasowy klient do zalogowania i pozyskania tokenów.
68+
client = PstrykApiClient(email=email, password=password)
69+
70+
try:
71+
# 3) Logowanie do API — jeśli się powiedzie, klient będzie miał:
72+
# - client.access_token
73+
# - client.refresh_token
74+
# - client.user_id
75+
await client.login()
76+
77+
# 4) Przygotuj dane do zapisania w ConfigEntry (persist w HA)
78+
data = {
79+
CONF_EMAIL: email,
80+
CONF_USER_ID: getattr(client, "user_id", None),
81+
CONF_ACCESS: getattr(client, "access_token", None),
82+
CONF_REFRESH: getattr(client, "refresh_token", None),
83+
}
84+
85+
# 5) Zakończ flow utworzeniem wpisu. Tytuł pojawi się w UI.
86+
return self.async_create_entry(title=f"Pstryk ({email})", data=data)
87+
88+
except Exception:
89+
# 6) Coś poszło nie tak (błędny login/hasło, problemy sieciowe lub inny błąd).
90+
# Pokaż ponownie formularz z błędem bazowym "auth".
91+
errors["base"] = "auth"
92+
return self.async_show_form(
93+
step_id="user",
94+
data_schema=STEP_USER_DATA_SCHEMA,
95+
errors=errors,
96+
)
97+
finally:
98+
# 7) Niezależnie od wyniku — zamykamy klienta (zamyka sesję aiohttp).
99+
try:
100+
await client.aclose()
101+
except Exception:
102+
pass

custom_components/pstryk/const.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from __future__ import annotations
2+
from homeassistant.const import Platform
3+
4+
DOMAIN = "pstryk"
5+
6+
PLATFORMS: list[Platform] = [Platform.SENSOR]
7+
8+
# entry.data
9+
CONF_EMAIL = "email"
10+
CONF_USER_ID = "user_id"
11+
CONF_ACCESS = "access"
12+
CONF_REFRESH = "refresh"
13+
14+
# hass.data klucze
15+
DATA_CLIENT = "client"
16+
DATA_COORDINATOR = "coordinator"
17+
DATA_UNSUB = "unsub" # do odpinania timera

0 commit comments

Comments
 (0)