Compare commits
10 Commits
877c068c28
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7a2da2eb2 | |||
| 5b39f80862 | |||
| d9e03384ff | |||
| 85bad099e8 | |||
| f57bc622da | |||
| 86a4f1ad0c | |||
| bcf36a439a | |||
| 85c0e6ce24 | |||
| af05a78cb1 | |||
| eacdf707f1 |
88
README.md
88
README.md
@ -7,7 +7,7 @@ Użyj mojego kodu E3WOTQ w koszyku w aplikacji. Bonus trafi do Twojego Portfela
|
||||
!!! Dedykowana Karta do integracji:
|
||||
https://github.com/balgerion/ha_Pstryk_card
|
||||
|
||||
[](https://github.com/balgerion/ha_Pstryk/)
|
||||
[](https://github.com/balgerion/ha_Pstryk/)
|
||||
|
||||
Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii elektrycznej oraz prognoz z platformy Pstryk.
|
||||
|
||||
@ -28,6 +28,9 @@ Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii
|
||||
- 📊 Średnia zakupu oraz sprzedaży - miesięczna/roczna
|
||||
- 📈 Bilans miesięczny/roczny
|
||||
- 🛡️ Debug i logowanie
|
||||
- 🔋 **NOWOŚĆ:** Sensor rekomendacji baterii (charge/discharge/standby)
|
||||
- ⚡ Algorytm intra-day arbitrage (ładowanie w taniej godziny, rozładowywanie w drogie)
|
||||
- 📊 Prognoza SoC na 24h z automatycznym planowaniem
|
||||
|
||||
|
||||
## Instalacja
|
||||
@ -84,6 +87,7 @@ logo.png (opcjonalnie)
|
||||
| `sensor.pstryk_daily_financial_balance` | Dzienny bilans kupna/sprzedaży |
|
||||
| `sensor.pstryk_monthly_financial_balance`| Miesięczny bilans kupna/sprzedaży |
|
||||
| `sensor.pstryk_yearly_financial_balance` | Roczny bilans kupna/sprzedaży |
|
||||
| `sensor.pstryk_battery_recommendation` | **NOWOŚĆ:** Rekomendacja baterii (charge/discharge/standby) |
|
||||
|
||||
|
||||
Przykładowa Automatyzacja:
|
||||
@ -148,6 +152,88 @@ actions:
|
||||
|
||||
```
|
||||
|
||||
## Sensor Rekomendacji Baterii 🔋
|
||||
|
||||
Sensor `sensor.pstryk_battery_recommendation` automatycznie oblicza kiedy ładować/rozładowywać magazyn energii bazując na dynamicznych cenach Pstryk.
|
||||
|
||||
### Stany sensora
|
||||
|
||||
| Stan | Opis |
|
||||
|------|------|
|
||||
| `charge` | Ładuj baterię (tania energia) |
|
||||
| `discharge` | Rozładuj baterię do domu/sieci (droga energia) |
|
||||
| `standby` | Bez akcji |
|
||||
|
||||
### Algorytm Intra-day Arbitrage
|
||||
|
||||
Algorytm wykrywa **wiele okien arbitrażowych w ciągu dnia**:
|
||||
|
||||
1. **Nocne ładowanie** (00:00-05:59) - najtańsze godziny
|
||||
2. **Poranny szczyt** (06:00-10:59) - rozładowanie
|
||||
3. **Dolina południowa** (11:00-14:59) - ładowanie jeśli opłacalne vs wieczór
|
||||
4. **Wieczorny szczyt** (15:00-20:59) - rozładowanie
|
||||
|
||||
**Przykład typowych cen polskich:**
|
||||
```
|
||||
Noc (0.80 PLN) → CHARGE
|
||||
Poranek (2.58 PLN) → DISCHARGE
|
||||
Południe (1.46 PLN)→ CHARGE (arbitraż: 1.46 × 1.25 = 1.83 < 2.63 avg wieczór)
|
||||
Wieczór (3.10 PLN) → DISCHARGE
|
||||
```
|
||||
|
||||
### Konfiguracja Baterii
|
||||
|
||||
W opcjach integracji dostępne są ustawienia:
|
||||
|
||||
| Parametr | Domyślnie | Opis |
|
||||
|----------|-----------|------|
|
||||
| Włącz sensor baterii | false | Aktywuje sensor |
|
||||
| Entity SoC | - | Sensor stanu naładowania baterii |
|
||||
| Pojemność | 15 kWh | Pojemność magazynu |
|
||||
| Szybkość ładowania | 28 %/h | Jak szybko ładuje się bateria |
|
||||
| Szybkość rozładowania | 10 %/h | Jak szybko rozładowuje się bateria |
|
||||
| Minimalny SoC | 20% | Próg poniżej którego nie rozładowujemy |
|
||||
| Liczba godzin ładowania | 6 | Ile najtańszych godzin do ładowania |
|
||||
| Mnożnik progu discharge | 1.3 | Cena musi być 1.3x wyższa od avg charge |
|
||||
|
||||
### Atrybuty sensora
|
||||
|
||||
```yaml
|
||||
sensor.pstryk_battery_recommendation:
|
||||
state: "charge"
|
||||
attributes:
|
||||
current_price: 0.45
|
||||
current_soc: 65
|
||||
avg_charge_price: 0.25
|
||||
discharge_threshold: 0.325
|
||||
charge_hours: [0,1,2,3,4,11,12,13,14,23]
|
||||
discharge_hours: [6,7,8,9,10,15,16,17,18,19,20]
|
||||
standby_hours: [5,21,22]
|
||||
midday_arbitrage:
|
||||
profitable: true
|
||||
midday_charge_hours: [11,12,13,14]
|
||||
reason: "Mid-day arbitrage charge..."
|
||||
next_state_change: "15:00"
|
||||
next_state: "discharge"
|
||||
```
|
||||
|
||||
### Automatyzacja sterowania falownikiem
|
||||
|
||||
Przykładowa automatyzacja do sterowania falownikami jest dostępna w pliku:
|
||||
📁 `automations/battery_control_pstryk.yaml`
|
||||
|
||||
**Funkcje:**
|
||||
- Natychmiastowa reakcja na zmianę sensora (30s debounce)
|
||||
- Ochrona przed przeładowaniem przyłącza (np. Tesla charging > 2000W → standby)
|
||||
- Sterowanie wieloma falownikami
|
||||
- Logowanie do logbook
|
||||
|
||||
**Jak użyć:**
|
||||
1. Skopiuj zawartość `automations/battery_control_pstryk.yaml`
|
||||
2. W HA: Ustawienia → Automatyzacje → Utwórz → Edytuj w YAML → Wklej
|
||||
3. Dostosuj `device_id` i `entity_id` do swoich urządzeń
|
||||
4. Zapisz i włącz
|
||||
|
||||
## EVCC
|
||||
|
||||
### Scrnshoty
|
||||
|
||||
237
automations/battery_control_pstryk.yaml
Normal file
237
automations/battery_control_pstryk.yaml
Normal file
@ -0,0 +1,237 @@
|
||||
alias: "Battery Control - Pstryk Recommendations"
|
||||
description: "React instantly to Pstryk battery recommendations with Tesla charging protection"
|
||||
mode: single
|
||||
|
||||
trigger:
|
||||
# React instantly when recommendation changes
|
||||
- platform: state
|
||||
entity_id: sensor.pstryk_battery_recommendation
|
||||
to:
|
||||
- "charge"
|
||||
- "discharge"
|
||||
- "standby"
|
||||
for:
|
||||
seconds: 30
|
||||
|
||||
# Also react when Tesla power changes significantly
|
||||
- platform: numeric_state
|
||||
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||
above: 2000
|
||||
for:
|
||||
seconds: 10
|
||||
- platform: numeric_state
|
||||
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||
below: 2000
|
||||
for:
|
||||
seconds: 60
|
||||
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ states('sensor.pstryk_battery_recommendation') not in ['unavailable', 'unknown'] }}
|
||||
|
||||
action:
|
||||
- choose:
|
||||
# ==========================================
|
||||
# OVERRIDE: Tesla charging > 2000W = STANDBY
|
||||
# ==========================================
|
||||
- conditions:
|
||||
- type: is_power
|
||||
condition: device
|
||||
device_id: 371785f33a0d9b3ea38ed224f9e17a4b
|
||||
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||
domain: sensor
|
||||
above: 2000
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: "Battery STANDBY (Tesla Override)"
|
||||
message: >
|
||||
Tesla charging detected - switching to standby to protect grid connection.
|
||||
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||
|
||||
# Falownik 1 - STANDBY mode
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
- delay:
|
||||
seconds: 15
|
||||
|
||||
# Falownik 2 - STANDBY mode
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: c94531b376614314af08b17931f69980
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.1h_battery_boost
|
||||
|
||||
# ==========================================
|
||||
# CHARGE (when Tesla < 2000W)
|
||||
# ==========================================
|
||||
- conditions:
|
||||
- condition: state
|
||||
entity_id: sensor.pstryk_battery_recommendation
|
||||
state: "charge"
|
||||
- type: is_power
|
||||
condition: device
|
||||
device_id: 371785f33a0d9b3ea38ed224f9e17a4b
|
||||
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||
domain: sensor
|
||||
below: 2000
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: "Battery CHARGE"
|
||||
message: >
|
||||
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||
|
||||
# Falownik 1 - CHARGE mode
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||
type: select_option
|
||||
option: PV priority
|
||||
- delay:
|
||||
seconds: 15
|
||||
|
||||
# Falownik 2 - CHARGE mode
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: c94531b376614314af08b17931f69980
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||
type: select_option
|
||||
option: PV priority
|
||||
|
||||
# ==========================================
|
||||
# DISCHARGE
|
||||
# ==========================================
|
||||
- conditions:
|
||||
- condition: state
|
||||
entity_id: sensor.pstryk_battery_recommendation
|
||||
state: "discharge"
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: "Battery DISCHARGE"
|
||||
message: >
|
||||
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||
|
||||
# Falownik 1 - DISCHARGE mode
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||
type: select_option
|
||||
option: PV-Battery-Utility (SBU)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
- delay:
|
||||
seconds: 15
|
||||
|
||||
# Falownik 2 - DISCHARGE mode
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: c94531b376614314af08b17931f69980
|
||||
type: select_option
|
||||
option: PV-Battery-Utility (SBU)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.1h_battery_boost
|
||||
|
||||
# ==========================================
|
||||
# STANDBY
|
||||
# ==========================================
|
||||
- conditions:
|
||||
- condition: state
|
||||
entity_id: sensor.pstryk_battery_recommendation
|
||||
state: "standby"
|
||||
sequence:
|
||||
- service: logbook.log
|
||||
data:
|
||||
name: "Battery STANDBY"
|
||||
message: >
|
||||
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||
|
||||
# Falownik 1 - STANDBY mode
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||
domain: select
|
||||
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
- delay:
|
||||
seconds: 15
|
||||
|
||||
# Falownik 2 - STANDBY mode
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: c94531b376614314af08b17931f69980
|
||||
type: select_option
|
||||
option: PV-Utility-Battery (SUB)
|
||||
- delay:
|
||||
seconds: 15
|
||||
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||
domain: select
|
||||
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||
type: select_option
|
||||
option: Only PV charging is allowed
|
||||
|
||||
- service: input_boolean.turn_off
|
||||
target:
|
||||
entity_id: input_boolean.1h_battery_boost
|
||||
370
custom_components/pstryk/api_client.py
Normal file
370
custom_components/pstryk/api_client.py
Normal file
@ -0,0 +1,370 @@
|
||||
"""Shared API client for Pstryk Energy integration with caching and rate limiting."""
|
||||
import logging
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
|
||||
from .const import API_URL, API_TIMEOUT, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PstrykAPIClient:
|
||||
"""Shared API client with caching, rate limiting, and proper error handling."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api_key: str):
|
||||
"""Initialize the API client."""
|
||||
self.hass = hass
|
||||
self.api_key = api_key
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self._translations: Dict[str, str] = {}
|
||||
self._translations_loaded = False
|
||||
|
||||
# Rate limiting: {endpoint_key: {"retry_after": datetime, "backoff": float}}
|
||||
self._rate_limits: Dict[str, Dict[str, Any]] = {}
|
||||
self._rate_limit_lock = asyncio.Lock()
|
||||
|
||||
# Request throttling - limit concurrent requests
|
||||
self._request_semaphore = asyncio.Semaphore(3) # Max 3 concurrent requests
|
||||
|
||||
# Deduplication - track in-flight requests
|
||||
self._in_flight: Dict[str, asyncio.Task] = {}
|
||||
self._in_flight_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create aiohttp session."""
|
||||
if self._session is None:
|
||||
self._session = async_get_clientsession(self.hass)
|
||||
return self._session
|
||||
|
||||
async def _load_translations(self):
|
||||
"""Load translations for error messages."""
|
||||
if not self._translations_loaded:
|
||||
try:
|
||||
self._translations = await async_get_translations(
|
||||
self.hass, self.hass.config.language, DOMAIN
|
||||
)
|
||||
self._translations_loaded = True
|
||||
# Debug: log sample keys to understand the format
|
||||
if self._translations:
|
||||
sample_keys = list(self._translations.keys())[:3]
|
||||
_LOGGER.debug("Loaded %d translation keys, samples: %s",
|
||||
len(self._translations), sample_keys)
|
||||
except Exception as ex:
|
||||
_LOGGER.warning("Failed to load translations for API client: %s", ex)
|
||||
self._translations = {}
|
||||
self._translations_loaded = True
|
||||
|
||||
def _t(self, key: str, **kwargs) -> str:
|
||||
"""Get translated string with fallback."""
|
||||
# Try different key formats as async_get_translations may return different formats
|
||||
possible_keys = [
|
||||
f"component.{DOMAIN}.debug.{key}", # Full format: component.pstryk.debug.key
|
||||
f"{DOMAIN}.debug.{key}", # Domain format: pstryk.debug.key
|
||||
f"debug.{key}", # Short format: debug.key
|
||||
key # Just the key
|
||||
]
|
||||
|
||||
template = None
|
||||
for possible_key in possible_keys:
|
||||
template = self._translations.get(possible_key)
|
||||
if template:
|
||||
break
|
||||
|
||||
# If translation not found, create a fallback message
|
||||
if not template:
|
||||
# Fallback patterns for common error types
|
||||
if key == "api_error_html":
|
||||
template = "API error {status} for {endpoint} (HTML error page received)"
|
||||
elif key == "rate_limited":
|
||||
template = "Endpoint '{endpoint}' is rate limited. Will retry after {seconds} seconds"
|
||||
elif key == "waiting_rate_limit":
|
||||
template = "Waiting {seconds} seconds for rate limit to clear"
|
||||
else:
|
||||
_LOGGER.debug("Translation key not found: %s (tried formats: %s)", key, possible_keys)
|
||||
template = key
|
||||
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except (KeyError, ValueError) as e:
|
||||
_LOGGER.warning("Failed to format translation template '%s': %s", template, e)
|
||||
return template
|
||||
|
||||
def _get_endpoint_key(self, url: str) -> str:
|
||||
"""Extract endpoint key from URL for rate limiting."""
|
||||
# Extract the main endpoint (e.g., "pricing", "prosumer-pricing", "energy-cost")
|
||||
if "pricing/?resolution" in url:
|
||||
return "pricing"
|
||||
elif "prosumer-pricing/?resolution" in url:
|
||||
return "prosumer-pricing"
|
||||
elif "meter-data/energy-cost" in url:
|
||||
return "energy-cost"
|
||||
elif "meter-data/energy-usage" in url:
|
||||
return "energy-usage"
|
||||
return "unknown"
|
||||
|
||||
async def _check_rate_limit(self, endpoint_key: str) -> Optional[float]:
|
||||
"""Check if we're rate limited and return wait time if needed."""
|
||||
async with self._rate_limit_lock:
|
||||
if endpoint_key in self._rate_limits:
|
||||
limit_info = self._rate_limits[endpoint_key]
|
||||
retry_after = limit_info.get("retry_after")
|
||||
|
||||
if retry_after and datetime.now() < retry_after:
|
||||
wait_time = (retry_after - datetime.now()).total_seconds()
|
||||
return wait_time
|
||||
elif retry_after and datetime.now() >= retry_after:
|
||||
# Rate limit expired, clear it
|
||||
del self._rate_limits[endpoint_key]
|
||||
|
||||
return None
|
||||
|
||||
def _calculate_backoff(self, attempt: int, base_delay: float = 20.0) -> float:
|
||||
"""Calculate exponential backoff with jitter."""
|
||||
# Exponential backoff: base_delay * (2 ^ attempt)
|
||||
backoff = base_delay * (2 ** attempt)
|
||||
# Add jitter: ±20% randomization
|
||||
jitter = backoff * 0.2 * (2 * random.random() - 1)
|
||||
return max(1.0, backoff + jitter)
|
||||
|
||||
async def _handle_rate_limit(self, response: aiohttp.ClientResponse, endpoint_key: str):
|
||||
"""Handle 429 rate limit response."""
|
||||
# Ensure translations are loaded
|
||||
await self._load_translations()
|
||||
|
||||
retry_after_header = response.headers.get("Retry-After")
|
||||
wait_time = None
|
||||
|
||||
if retry_after_header:
|
||||
try:
|
||||
# Try parsing as seconds
|
||||
wait_time = int(retry_after_header)
|
||||
except ValueError:
|
||||
# Try parsing as HTTP date
|
||||
try:
|
||||
retry_date = parsedate_to_datetime(retry_after_header)
|
||||
wait_time = (retry_date - datetime.now()).total_seconds()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to 3600 seconds (1 hour) if not specified
|
||||
if wait_time is None:
|
||||
wait_time = 3600
|
||||
|
||||
retry_after_dt = datetime.now() + timedelta(seconds=wait_time)
|
||||
|
||||
async with self._rate_limit_lock:
|
||||
self._rate_limits[endpoint_key] = {
|
||||
"retry_after": retry_after_dt,
|
||||
"backoff": wait_time
|
||||
}
|
||||
|
||||
_LOGGER.warning(
|
||||
self._t("rate_limited", endpoint=endpoint_key, seconds=int(wait_time))
|
||||
)
|
||||
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
url: str,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 20.0
|
||||
) -> Dict[str, Any]:
|
||||
"""Make API request with retries, rate limiting, and deduplication."""
|
||||
# Load translations if not already loaded
|
||||
await self._load_translations()
|
||||
|
||||
endpoint_key = self._get_endpoint_key(url)
|
||||
|
||||
# Check if we're rate limited
|
||||
wait_time = await self._check_rate_limit(endpoint_key)
|
||||
if wait_time and wait_time > 0:
|
||||
# If wait time is reasonable, wait
|
||||
if wait_time <= 60:
|
||||
_LOGGER.info(
|
||||
self._t("waiting_rate_limit", seconds=int(wait_time))
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"API rate limited for {endpoint_key}. Please try again in {int(wait_time/60)} minutes."
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": self.api_key,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Use semaphore to limit concurrent requests
|
||||
async with self._request_semaphore:
|
||||
async with asyncio.timeout(API_TIMEOUT):
|
||||
async with self.session.get(url, headers=headers) as response:
|
||||
# Handle different status codes
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data
|
||||
|
||||
elif response.status == 429:
|
||||
# Handle rate limiting
|
||||
await self._handle_rate_limit(response, endpoint_key)
|
||||
|
||||
# Retry with exponential backoff
|
||||
if attempt < max_retries - 1:
|
||||
backoff = self._calculate_backoff(attempt, base_delay)
|
||||
_LOGGER.debug(
|
||||
"Rate limited, retrying in %.1f seconds (attempt %d/%d)",
|
||||
backoff, attempt + 1, max_retries
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"API rate limit exceeded after {max_retries} attempts"
|
||||
)
|
||||
|
||||
elif response.status == 500:
|
||||
error_text = await response.text()
|
||||
# Extract plain text from HTML if present
|
||||
if error_text.strip().startswith('<!doctype html>') or error_text.strip().startswith('<html'):
|
||||
# Just log that it's HTML, not the whole HTML
|
||||
_LOGGER.error(
|
||||
self._t("api_error_html", status=500, endpoint=endpoint_key)
|
||||
)
|
||||
else:
|
||||
# Log actual error text (truncated)
|
||||
_LOGGER.error(
|
||||
"API returned 500 for %s: %s",
|
||||
endpoint_key, error_text[:100]
|
||||
)
|
||||
|
||||
# Retry with backoff
|
||||
if attempt < max_retries - 1:
|
||||
backoff = self._calculate_backoff(attempt, base_delay)
|
||||
_LOGGER.debug(
|
||||
"Retrying after 500 error in %.1f seconds (attempt %d/%d)",
|
||||
backoff, attempt + 1, max_retries
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"API server error (500) for {endpoint_key} after {max_retries} attempts"
|
||||
)
|
||||
|
||||
elif response.status in (401, 403):
|
||||
raise UpdateFailed(
|
||||
f"Authentication failed (status {response.status}). Please check your API key."
|
||||
)
|
||||
|
||||
elif response.status == 404:
|
||||
raise UpdateFailed(
|
||||
f"API endpoint not found (404): {endpoint_key}"
|
||||
)
|
||||
|
||||
else:
|
||||
error_text = await response.text()
|
||||
# Clean HTML from error messages
|
||||
if error_text.strip().startswith('<!doctype html>') or error_text.strip().startswith('<html'):
|
||||
_LOGGER.error(
|
||||
self._t("api_error_html", status=response.status, endpoint=endpoint_key)
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"API error %d for %s: %s",
|
||||
response.status, endpoint_key, error_text[:100]
|
||||
)
|
||||
|
||||
# For other errors, retry with backoff
|
||||
if attempt < max_retries - 1:
|
||||
backoff = self._calculate_backoff(attempt, base_delay)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
raise UpdateFailed(
|
||||
f"API error {response.status} for {endpoint_key}"
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError as err:
|
||||
last_exception = err
|
||||
_LOGGER.warning(
|
||||
"Timeout fetching from %s (attempt %d/%d)",
|
||||
endpoint_key, attempt + 1, max_retries
|
||||
)
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
backoff = self._calculate_backoff(attempt, base_delay)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
last_exception = err
|
||||
_LOGGER.warning(
|
||||
"Network error fetching from %s: %s (attempt %d/%d)",
|
||||
endpoint_key, err, attempt + 1, max_retries
|
||||
)
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
backoff = self._calculate_backoff(attempt, base_delay)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
except Exception as err:
|
||||
last_exception = err
|
||||
_LOGGER.exception(
|
||||
"Unexpected error fetching from %s: %s",
|
||||
endpoint_key, err
|
||||
)
|
||||
break
|
||||
|
||||
# All retries exhausted, raise the error
|
||||
if last_exception:
|
||||
raise UpdateFailed(
|
||||
f"Failed to fetch data from {endpoint_key} after {max_retries} attempts"
|
||||
) from last_exception
|
||||
|
||||
raise UpdateFailed(f"Failed to fetch data from {endpoint_key}")
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
url: str,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 20.0
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch data with deduplication of concurrent requests."""
|
||||
# Check if there's already an in-flight request for this URL
|
||||
async with self._in_flight_lock:
|
||||
if url in self._in_flight:
|
||||
_LOGGER.debug("Deduplicating request for %s", url)
|
||||
# Wait for the existing request to complete
|
||||
try:
|
||||
return await self._in_flight[url]
|
||||
except Exception:
|
||||
# If the in-flight request failed, create a new one
|
||||
pass
|
||||
|
||||
# Create new request task
|
||||
task = asyncio.create_task(
|
||||
self._make_request(url, max_retries, base_delay)
|
||||
)
|
||||
self._in_flight[url] = task
|
||||
|
||||
try:
|
||||
result = await task
|
||||
return result
|
||||
finally:
|
||||
# Remove from in-flight requests
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight.pop(url, None)
|
||||
@ -8,6 +8,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers import selector
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
API_URL,
|
||||
@ -25,7 +26,34 @@ from .const import (
|
||||
MIN_RETRY_ATTEMPTS,
|
||||
MAX_RETRY_ATTEMPTS,
|
||||
MIN_RETRY_DELAY,
|
||||
MAX_RETRY_DELAY
|
||||
MAX_RETRY_DELAY,
|
||||
# Battery recommendation constants
|
||||
CONF_BATTERY_ENABLED,
|
||||
CONF_BATTERY_SOC_ENTITY,
|
||||
CONF_BATTERY_CAPACITY,
|
||||
CONF_BATTERY_CHARGE_RATE,
|
||||
CONF_BATTERY_DISCHARGE_RATE,
|
||||
CONF_BATTERY_MIN_SOC,
|
||||
CONF_BATTERY_CHARGE_HOURS,
|
||||
CONF_BATTERY_DISCHARGE_MULTIPLIER,
|
||||
DEFAULT_BATTERY_CAPACITY,
|
||||
DEFAULT_BATTERY_CHARGE_RATE,
|
||||
DEFAULT_BATTERY_DISCHARGE_RATE,
|
||||
DEFAULT_BATTERY_MIN_SOC,
|
||||
DEFAULT_BATTERY_CHARGE_HOURS,
|
||||
DEFAULT_BATTERY_DISCHARGE_MULTIPLIER,
|
||||
MIN_BATTERY_CAPACITY,
|
||||
MAX_BATTERY_CAPACITY,
|
||||
MIN_BATTERY_CHARGE_RATE,
|
||||
MAX_BATTERY_CHARGE_RATE,
|
||||
MIN_BATTERY_DISCHARGE_RATE,
|
||||
MAX_BATTERY_DISCHARGE_RATE,
|
||||
MIN_BATTERY_MIN_SOC,
|
||||
MAX_BATTERY_MIN_SOC,
|
||||
MIN_BATTERY_CHARGE_HOURS,
|
||||
MAX_BATTERY_CHARGE_HOURS,
|
||||
MIN_BATTERY_DISCHARGE_MULTIPLIER,
|
||||
MAX_BATTERY_DISCHARGE_MULTIPLIER,
|
||||
)
|
||||
|
||||
class MQTTNotConfiguredError(HomeAssistantError):
|
||||
@ -192,16 +220,12 @@ class PstrykConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return PstrykOptionsFlowHandler(config_entry)
|
||||
return PstrykOptionsFlowHandler()
|
||||
|
||||
|
||||
class PstrykOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for Pstryk Energy - single page view."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize options flow."""
|
||||
super().__init__(config_entry)
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the options - single page for quick configuration."""
|
||||
errors = {}
|
||||
@ -265,6 +289,34 @@ class PstrykOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_RETRY_DELAY, max=MAX_RETRY_DELAY)),
|
||||
})
|
||||
|
||||
# Battery Recommendation Configuration
|
||||
schema.update({
|
||||
vol.Optional(CONF_BATTERY_ENABLED, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_ENABLED, False)): bool,
|
||||
vol.Optional(CONF_BATTERY_SOC_ENTITY, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_SOC_ENTITY, "")): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor")
|
||||
),
|
||||
vol.Optional(CONF_BATTERY_CAPACITY, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_CAPACITY, DEFAULT_BATTERY_CAPACITY)):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_BATTERY_CAPACITY, max=MAX_BATTERY_CAPACITY)),
|
||||
vol.Optional(CONF_BATTERY_CHARGE_RATE, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_CHARGE_RATE, DEFAULT_BATTERY_CHARGE_RATE)):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_BATTERY_CHARGE_RATE, max=MAX_BATTERY_CHARGE_RATE)),
|
||||
vol.Optional(CONF_BATTERY_DISCHARGE_RATE, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_DISCHARGE_RATE, DEFAULT_BATTERY_DISCHARGE_RATE)):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_BATTERY_DISCHARGE_RATE, max=MAX_BATTERY_DISCHARGE_RATE)),
|
||||
vol.Optional(CONF_BATTERY_MIN_SOC, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_MIN_SOC, DEFAULT_BATTERY_MIN_SOC)):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_BATTERY_MIN_SOC, max=MAX_BATTERY_MIN_SOC)),
|
||||
vol.Optional(CONF_BATTERY_CHARGE_HOURS, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_CHARGE_HOURS, DEFAULT_BATTERY_CHARGE_HOURS)):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=MIN_BATTERY_CHARGE_HOURS, max=MAX_BATTERY_CHARGE_HOURS)),
|
||||
vol.Optional(CONF_BATTERY_DISCHARGE_MULTIPLIER, default=self.config_entry.options.get(
|
||||
CONF_BATTERY_DISCHARGE_MULTIPLIER, DEFAULT_BATTERY_DISCHARGE_MULTIPLIER)):
|
||||
vol.All(vol.Coerce(float), vol.Range(min=MIN_BATTERY_DISCHARGE_MULTIPLIER, max=MAX_BATTERY_DISCHARGE_MULTIPLIER)),
|
||||
})
|
||||
|
||||
# Add description with section information
|
||||
description_text = "Configure your energy price monitoring settings"
|
||||
if mqtt_enabled:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
DOMAIN = "pstryk"
|
||||
API_URL = "https://api.pstryk.pl/integrations/"
|
||||
API_TIMEOUT = 60
|
||||
API_TIMEOUT = 30 # Reduced from 60 to allow faster startup
|
||||
|
||||
BUY_ENDPOINT = "pricing/?resolution=hour&window_start={start}&window_end={end}"
|
||||
SELL_ENDPOINT = "prosumer-pricing/?resolution=hour&window_start={start}&window_end={end}"
|
||||
@ -32,3 +32,33 @@ MIN_RETRY_ATTEMPTS = 1
|
||||
MAX_RETRY_ATTEMPTS = 10
|
||||
MIN_RETRY_DELAY = 5 # seconds
|
||||
MAX_RETRY_DELAY = 300 # seconds (5 minutes)
|
||||
|
||||
# Battery recommendation sensor constants
|
||||
CONF_BATTERY_ENABLED = "battery_enabled"
|
||||
CONF_BATTERY_SOC_ENTITY = "battery_soc_entity"
|
||||
CONF_BATTERY_CAPACITY = "battery_capacity"
|
||||
CONF_BATTERY_CHARGE_RATE = "battery_charge_rate"
|
||||
CONF_BATTERY_DISCHARGE_RATE = "battery_discharge_rate"
|
||||
CONF_BATTERY_MIN_SOC = "battery_min_soc"
|
||||
CONF_BATTERY_CHARGE_HOURS = "battery_charge_hours"
|
||||
CONF_BATTERY_DISCHARGE_MULTIPLIER = "battery_discharge_multiplier"
|
||||
|
||||
DEFAULT_BATTERY_CAPACITY = 15 # kWh
|
||||
DEFAULT_BATTERY_CHARGE_RATE = 28 # %/h
|
||||
DEFAULT_BATTERY_DISCHARGE_RATE = 10 # %/h
|
||||
DEFAULT_BATTERY_MIN_SOC = 20 # %
|
||||
DEFAULT_BATTERY_CHARGE_HOURS = 6 # number of cheapest hours to charge
|
||||
DEFAULT_BATTERY_DISCHARGE_MULTIPLIER = 1.3 # discharge when price >= avg_charge_price * multiplier
|
||||
|
||||
MIN_BATTERY_CAPACITY = 1
|
||||
MAX_BATTERY_CAPACITY = 100
|
||||
MIN_BATTERY_CHARGE_RATE = 5
|
||||
MAX_BATTERY_CHARGE_RATE = 100
|
||||
MIN_BATTERY_DISCHARGE_RATE = 5
|
||||
MAX_BATTERY_DISCHARGE_RATE = 50
|
||||
MIN_BATTERY_MIN_SOC = 5
|
||||
MAX_BATTERY_MIN_SOC = 50
|
||||
MIN_BATTERY_CHARGE_HOURS = 3
|
||||
MAX_BATTERY_CHARGE_HOURS = 12
|
||||
MIN_BATTERY_DISCHARGE_MULTIPLIER = 1.1
|
||||
MAX_BATTERY_DISCHARGE_MULTIPLIER = 2.0
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "pstryk",
|
||||
"name": "Pstryk Energy",
|
||||
"version": "1.7.2",
|
||||
"version": "1.8.0",
|
||||
"codeowners": ["@balgerion"],
|
||||
"requirements": ["aiohttp>=3.7"],
|
||||
"dependencies": ["mqtt"],
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
"""MQTT Publisher for Pstryk Energy integration."""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.components import mqtt
|
||||
@ -47,7 +46,7 @@ class PstrykMqttPublisher:
|
||||
# Load translations
|
||||
try:
|
||||
self._translations = await async_get_translations(
|
||||
self.hass, self.hass.config.language, DOMAIN, ["mqtt"]
|
||||
self.hass, self.hass.config.language, DOMAIN
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.warning("Failed to load translations for MQTT publisher: %s", ex)
|
||||
@ -203,7 +202,8 @@ class PstrykMqttPublisher:
|
||||
last_time = formatted_prices[-1]["start"]
|
||||
_LOGGER.debug(f"Formatted {len(formatted_prices)} prices for MQTT from {first_time} to {last_time}")
|
||||
|
||||
# Verify we have complete days
|
||||
# Verify we have complete days (debug only, not critical)
|
||||
today = dt_util.now().strftime("%Y-%m-%d")
|
||||
hours_by_date = {}
|
||||
for fp in formatted_prices:
|
||||
date_part = fp["start"][:10] # YYYY-MM-DD
|
||||
@ -213,7 +213,15 @@ class PstrykMqttPublisher:
|
||||
|
||||
for date, hours in hours_by_date.items():
|
||||
if hours != 24:
|
||||
_LOGGER.warning(f"Incomplete day {date}: only {hours} hours instead of 24")
|
||||
# Only log as debug - incomplete days are normal for past/future data
|
||||
# Past days get cleaned up, future days may not be available yet
|
||||
if date < today:
|
||||
_LOGGER.debug(f"Past day {date}: {hours} hours (old data being cleaned)")
|
||||
elif date == today and hours >= 20:
|
||||
# Today with 20+ hours is acceptable (may be missing 1-2 hours at edges)
|
||||
_LOGGER.debug(f"Today {date}: {hours}/24 hours (acceptable)")
|
||||
else:
|
||||
_LOGGER.debug(f"Incomplete day {date}: {hours}/24 hours")
|
||||
else:
|
||||
_LOGGER.warning("No prices formatted for MQTT")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -49,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
# Get translations for logs
|
||||
try:
|
||||
translations = await async_get_translations(
|
||||
hass, hass.config.language, DOMAIN, ["mqtt"]
|
||||
hass, hass.config.language, DOMAIN
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Failed to load translations for services: %s", e)
|
||||
|
||||
@ -76,7 +76,15 @@
|
||||
"mqtt_topic_sell": "MQTT Topic for Sell Prices",
|
||||
"mqtt_48h_mode": "Enable 48h mode for MQTT",
|
||||
"retry_attempts": "API retry attempts",
|
||||
"retry_delay": "API retry delay (seconds)"
|
||||
"retry_delay": "API retry delay (seconds)",
|
||||
"battery_enabled": "Enable Battery Recommendation",
|
||||
"battery_soc_entity": "Battery SoC Sensor",
|
||||
"battery_capacity": "Battery Capacity (kWh)",
|
||||
"battery_charge_rate": "Charge Rate (%/h)",
|
||||
"battery_discharge_rate": "Discharge Rate (%/h)",
|
||||
"battery_min_soc": "Minimum SoC (%)",
|
||||
"battery_charge_hours": "Number of Charge Hours",
|
||||
"battery_discharge_multiplier": "Discharge Price Multiplier"
|
||||
},
|
||||
"data_description": {
|
||||
"buy_top": "How many cheapest buy prices to highlight (1-24 hours)",
|
||||
@ -88,7 +96,15 @@
|
||||
"mqtt_topic_sell": "MQTT topic where sell prices will be published",
|
||||
"mqtt_48h_mode": "Publish 48 hours of prices (today + tomorrow) instead of just today",
|
||||
"retry_attempts": "How many times to retry API requests on failure",
|
||||
"retry_delay": "Wait time between API retry attempts"
|
||||
"retry_delay": "Wait time between API retry attempts",
|
||||
"battery_enabled": "Enable smart battery charging recommendation sensor",
|
||||
"battery_soc_entity": "Entity that provides current battery State of Charge (%)",
|
||||
"battery_capacity": "Total battery capacity in kWh",
|
||||
"battery_charge_rate": "How fast the battery charges (% per hour)",
|
||||
"battery_discharge_rate": "How fast the battery discharges (% per hour)",
|
||||
"battery_min_soc": "Never discharge below this level (%)",
|
||||
"battery_charge_hours": "How many cheapest hours to use for charging (3-12)",
|
||||
"battery_discharge_multiplier": "Discharge when price >= avg_charge_price * this value"
|
||||
}
|
||||
},
|
||||
"price_settings": {
|
||||
|
||||
@ -65,7 +65,47 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Opcje Pstryk Energy",
|
||||
"description": "Zmodyfikuj konfigurację Pstryk Energy"
|
||||
"description": "Zmodyfikuj konfigurację Pstryk Energy",
|
||||
"data": {
|
||||
"buy_top": "Liczba najlepszych cen zakupu",
|
||||
"sell_top": "Liczba najlepszych cen sprzedaży",
|
||||
"buy_worst": "Liczba najgorszych cen zakupu",
|
||||
"sell_worst": "Liczba najgorszych cen sprzedaży",
|
||||
"mqtt_enabled": "Włącz mostek MQTT",
|
||||
"mqtt_topic_buy": "Temat MQTT dla cen zakupu",
|
||||
"mqtt_topic_sell": "Temat MQTT dla cen sprzedaży",
|
||||
"mqtt_48h_mode": "Włącz tryb 48h dla MQTT",
|
||||
"retry_attempts": "Liczba prób API",
|
||||
"retry_delay": "Opóźnienie między próbami (sekundy)",
|
||||
"battery_enabled": "Włącz rekomendacje baterii",
|
||||
"battery_soc_entity": "Sensor SoC baterii",
|
||||
"battery_capacity": "Pojemność baterii (kWh)",
|
||||
"battery_charge_rate": "Tempo ładowania (%/h)",
|
||||
"battery_discharge_rate": "Tempo rozładowania (%/h)",
|
||||
"battery_min_soc": "Minimalny SoC (%)",
|
||||
"battery_charge_hours": "Liczba godzin ładowania",
|
||||
"battery_discharge_multiplier": "Mnożnik progu rozładowania"
|
||||
},
|
||||
"data_description": {
|
||||
"buy_top": "Ile najtańszych cen zakupu wyróżnić (1-24 godzin)",
|
||||
"sell_top": "Ile najwyższych cen sprzedaży wyróżnić (1-24 godzin)",
|
||||
"buy_worst": "Ile najdroższych cen zakupu wyróżnić (1-24 godzin)",
|
||||
"sell_worst": "Ile najniższych cen sprzedaży wyróżnić (1-24 godzin)",
|
||||
"mqtt_enabled": "Publikuj ceny do MQTT dla systemów zewnętrznych jak EVCC",
|
||||
"mqtt_topic_buy": "Temat MQTT gdzie będą publikowane ceny zakupu",
|
||||
"mqtt_topic_sell": "Temat MQTT gdzie będą publikowane ceny sprzedaży",
|
||||
"mqtt_48h_mode": "Publikuj 48 godzin cen (dziś + jutro) zamiast tylko dzisiaj",
|
||||
"retry_attempts": "Ile razy ponawiać żądania API w przypadku błędu",
|
||||
"retry_delay": "Czas oczekiwania między próbami połączenia z API",
|
||||
"battery_enabled": "Włącz inteligentny sensor rekomendacji ładowania baterii",
|
||||
"battery_soc_entity": "Encja dostarczająca aktualny stan naładowania baterii (%)",
|
||||
"battery_capacity": "Całkowita pojemność baterii w kWh",
|
||||
"battery_charge_rate": "Jak szybko ładuje się bateria (% na godzinę)",
|
||||
"battery_discharge_rate": "Jak szybko rozładowuje się bateria (% na godzinę)",
|
||||
"battery_min_soc": "Nigdy nie rozładowuj poniżej tego poziomu (%)",
|
||||
"battery_charge_hours": "Ile najtańszych godzin wykorzystać do ładowania (3-12)",
|
||||
"battery_discharge_multiplier": "Rozładowuj gdy cena >= średnia_ładowania × ta wartość"
|
||||
}
|
||||
},
|
||||
"price_settings": {
|
||||
"title": "Ustawienia Monitorowania Cen",
|
||||
|
||||
@ -2,106 +2,22 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from .const import (
|
||||
API_URL,
|
||||
API_TIMEOUT,
|
||||
BUY_ENDPOINT,
|
||||
SELL_ENDPOINT,
|
||||
DOMAIN,
|
||||
CONF_MQTT_48H_MODE,
|
||||
CONF_RETRY_ATTEMPTS,
|
||||
CONF_RETRY_DELAY,
|
||||
DEFAULT_RETRY_ATTEMPTS,
|
||||
DEFAULT_RETRY_DELAY
|
||||
)
|
||||
from .api_client import PstrykAPIClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class ExponentialBackoffRetry:
|
||||
"""Implementacja wykładniczego opóźnienia przy ponawianiu prób."""
|
||||
|
||||
def __init__(self, max_retries=DEFAULT_RETRY_ATTEMPTS, base_delay=DEFAULT_RETRY_DELAY):
|
||||
"""Inicjalizacja mechanizmu ponowień.
|
||||
|
||||
Args:
|
||||
max_retries: Maksymalna liczba prób
|
||||
base_delay: Podstawowe opóźnienie w sekundach (zwiększane wykładniczo)
|
||||
"""
|
||||
self.max_retries = max_retries
|
||||
self.base_delay = base_delay
|
||||
self._translations = {}
|
||||
|
||||
async def load_translations(self, hass):
|
||||
"""Załaduj tłumaczenia dla aktualnego języka."""
|
||||
try:
|
||||
self._translations = await async_get_translations(
|
||||
hass, hass.config.language, DOMAIN, ["debug"]
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.warning("Failed to load translations for retry mechanism: %s", ex)
|
||||
|
||||
async def execute(self, func, *args, price_type=None, **kwargs):
|
||||
"""Wykonaj funkcję z ponawianiem prób.
|
||||
|
||||
Args:
|
||||
func: Funkcja asynchroniczna do wykonania
|
||||
args, kwargs: Argumenty funkcji
|
||||
price_type: Typ ceny (do logów)
|
||||
|
||||
Returns:
|
||||
Wynik funkcji
|
||||
|
||||
Raises:
|
||||
UpdateFailed: Po wyczerpaniu wszystkich prób
|
||||
"""
|
||||
last_exception = None
|
||||
for retry in range(self.max_retries):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as err:
|
||||
last_exception = err
|
||||
# Nie czekamy po ostatniej próbie
|
||||
if retry < self.max_retries - 1:
|
||||
delay = self.base_delay * (2 ** retry)
|
||||
|
||||
# Użyj przetłumaczonego komunikatu jeśli dostępny
|
||||
retry_msg = self._translations.get(
|
||||
"debug.retry_attempt",
|
||||
"Retry {retry}/{max_retries} after error: {error} (delay: {delay}s)"
|
||||
).format(
|
||||
retry=retry + 1,
|
||||
max_retries=self.max_retries,
|
||||
error=str(err),
|
||||
delay=round(delay, 1)
|
||||
)
|
||||
|
||||
_LOGGER.debug(retry_msg)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Jeśli wszystkie próby zawiodły i mamy timeout
|
||||
if isinstance(last_exception, asyncio.TimeoutError) and price_type:
|
||||
timeout_msg = self._translations.get(
|
||||
"debug.timeout_after_retries",
|
||||
"Timeout fetching {price_type} data from API after {retries} retries"
|
||||
).format(price_type=price_type, retries=self.max_retries)
|
||||
|
||||
_LOGGER.error(timeout_msg)
|
||||
|
||||
api_timeout_msg = self._translations.get(
|
||||
"debug.api_timeout_message",
|
||||
"API timeout after {timeout} seconds (tried {retries} times)"
|
||||
).format(timeout=API_TIMEOUT, retries=self.max_retries)
|
||||
|
||||
raise UpdateFailed(api_timeout_msg)
|
||||
|
||||
# Dla innych typów błędów
|
||||
raise last_exception
|
||||
|
||||
def convert_price(value):
|
||||
"""Convert price string to float."""
|
||||
@ -111,174 +27,60 @@ def convert_price(value):
|
||||
_LOGGER.warning("Price conversion error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Coordinator to fetch both current price and today's table."""
|
||||
|
||||
def __del__(self):
|
||||
"""Properly clean up when object is deleted."""
|
||||
if hasattr(self, '_unsub_hourly') and self._unsub_hourly:
|
||||
self._unsub_hourly()
|
||||
if hasattr(self, '_unsub_midnight') and self._unsub_midnight:
|
||||
self._unsub_midnight()
|
||||
if hasattr(self, '_unsub_afternoon') and self._unsub_afternoon:
|
||||
self._unsub_afternoon()
|
||||
|
||||
def __init__(self, hass, api_key, price_type, mqtt_48h_mode=False, retry_attempts=None, retry_delay=None):
|
||||
def __init__(self, hass, api_client: PstrykAPIClient, price_type, mqtt_48h_mode=False, retry_attempts=None, retry_delay=None):
|
||||
"""Initialize the coordinator."""
|
||||
self.hass = hass
|
||||
self.api_key = api_key
|
||||
self.api_client = api_client
|
||||
self.price_type = price_type
|
||||
self.mqtt_48h_mode = mqtt_48h_mode
|
||||
self._unsub_hourly = None
|
||||
self._unsub_midnight = None
|
||||
self._unsub_afternoon = None
|
||||
# Inicjalizacja tłumaczeń
|
||||
self._translations = {}
|
||||
# Track if we had tomorrow prices in last update
|
||||
self._had_tomorrow_prices = False
|
||||
|
||||
# Get retry configuration from entry options
|
||||
if retry_attempts is None or retry_delay is None:
|
||||
# Try to find the config entry to get retry options
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data.get("api_key") == api_key:
|
||||
retry_attempts = entry.options.get(CONF_RETRY_ATTEMPTS, DEFAULT_RETRY_ATTEMPTS)
|
||||
retry_delay = entry.options.get(CONF_RETRY_DELAY, DEFAULT_RETRY_DELAY)
|
||||
break
|
||||
else:
|
||||
# Use defaults if no matching entry found
|
||||
# Get retry configuration
|
||||
if retry_attempts is None:
|
||||
retry_attempts = DEFAULT_RETRY_ATTEMPTS
|
||||
if retry_delay is None:
|
||||
retry_delay = DEFAULT_RETRY_DELAY
|
||||
|
||||
# Inicjalizacja mechanizmu ponowień z konfigurowalnymi wartościami
|
||||
self.retry_mechanism = ExponentialBackoffRetry(max_retries=retry_attempts, base_delay=retry_delay)
|
||||
self.retry_attempts = retry_attempts
|
||||
self.retry_delay = retry_delay
|
||||
|
||||
# Set a default update interval as a fallback (1 hour)
|
||||
# This ensures data is refreshed even if scheduled updates fail
|
||||
# Set update interval as fallback
|
||||
update_interval = timedelta(hours=1)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_{price_type}",
|
||||
update_interval=update_interval, # Add fallback interval
|
||||
update_interval=update_interval,
|
||||
)
|
||||
|
||||
async def _make_api_request(self, url):
|
||||
"""Make API request with proper error handling."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with async_timeout.timeout(API_TIMEOUT):
|
||||
resp = await session.get(
|
||||
url,
|
||||
headers={"Authorization": self.api_key, "Accept": "application/json"}
|
||||
)
|
||||
|
||||
# Obsługa różnych kodów błędu
|
||||
if resp.status == 401:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_401",
|
||||
"API authentication failed for {price_type} - invalid API key"
|
||||
).format(price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_401_user",
|
||||
"API authentication failed - invalid API key"
|
||||
))
|
||||
elif resp.status == 403:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_403",
|
||||
"API access forbidden for {price_type} - permissions issue"
|
||||
).format(price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_403_user",
|
||||
"API access forbidden - check permissions"
|
||||
))
|
||||
elif resp.status == 404:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_404",
|
||||
"API endpoint not found for {price_type} - check URL"
|
||||
).format(price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_404_user",
|
||||
"API endpoint not found"
|
||||
))
|
||||
elif resp.status == 429:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_429",
|
||||
"API rate limit exceeded for {price_type}"
|
||||
).format(price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_429_user",
|
||||
"API rate limit exceeded - try again later"
|
||||
))
|
||||
elif resp.status == 502:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_502",
|
||||
"API Gateway error (502) for {price_type} - server may be down"
|
||||
).format(price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_502_user",
|
||||
"API Gateway error (502) - server may be down"
|
||||
))
|
||||
elif 500 <= resp.status < 600:
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_5xx",
|
||||
"API server error ({status}) for {price_type} - server issue"
|
||||
).format(status=resp.status, price_type=self.price_type)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_5xx_user",
|
||||
"API server error ({status}) - server issue"
|
||||
).format(status=resp.status))
|
||||
elif resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
# Pokaż tylko pierwsze 50 znaków błędu dla krótszego logu
|
||||
short_error = error_text[:50] + ("..." if len(error_text) > 50 else "")
|
||||
error_msg = self._translations.get(
|
||||
"debug.api_error_generic",
|
||||
"API error {status} for {price_type}: {error}"
|
||||
).format(status=resp.status, price_type=self.price_type, error=short_error)
|
||||
_LOGGER.error(error_msg)
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.api_error_generic_user",
|
||||
"API error {status}: {error}"
|
||||
).format(status=resp.status, error=short_error))
|
||||
|
||||
return await resp.json()
|
||||
|
||||
def _is_likely_placeholder_data(self, prices_for_day):
|
||||
"""Check if prices for a day are likely placeholders.
|
||||
|
||||
Returns True if:
|
||||
- There are no prices
|
||||
- ALL prices have exactly the same value (suggesting API returned default values)
|
||||
- There are too many consecutive hours with the same value (e.g., 10+ hours)
|
||||
"""
|
||||
"""Check if prices for a day are likely placeholders."""
|
||||
if not prices_for_day:
|
||||
return True
|
||||
|
||||
# Get all price values
|
||||
price_values = [p.get("price") for p in prices_for_day if p.get("price") is not None]
|
||||
|
||||
if not price_values:
|
||||
return True
|
||||
|
||||
# If we have less than 20 prices for a day, it's incomplete data
|
||||
if len(price_values) < 20:
|
||||
_LOGGER.debug(f"Only {len(price_values)} prices for the day, likely incomplete data")
|
||||
return True
|
||||
|
||||
# Check if ALL values are identical
|
||||
unique_values = set(price_values)
|
||||
if len(unique_values) == 1:
|
||||
_LOGGER.debug(f"All {len(price_values)} prices have the same value ({price_values[0]}), likely placeholders")
|
||||
return True
|
||||
|
||||
# Additional check: if more than 90% of values are the same, it's suspicious
|
||||
most_common_value = max(set(price_values), key=price_values.count)
|
||||
count_most_common = price_values.count(most_common_value)
|
||||
if count_most_common / len(price_values) > 0.9:
|
||||
@ -287,30 +89,6 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
return False
|
||||
|
||||
def _count_consecutive_same_values(self, prices):
|
||||
"""Count maximum consecutive hours with the same price."""
|
||||
if not prices:
|
||||
return 0
|
||||
|
||||
# Sort by time to ensure consecutive checking
|
||||
sorted_prices = sorted(prices, key=lambda x: x.get("start", ""))
|
||||
|
||||
max_consecutive = 1
|
||||
current_consecutive = 1
|
||||
last_value = None
|
||||
|
||||
for price in sorted_prices:
|
||||
value = price.get("price")
|
||||
if value is not None:
|
||||
if value == last_value:
|
||||
current_consecutive += 1
|
||||
max_consecutive = max(max_consecutive, current_consecutive)
|
||||
else:
|
||||
current_consecutive = 1
|
||||
last_value = value
|
||||
|
||||
return max_consecutive
|
||||
|
||||
async def _check_and_publish_mqtt(self, new_data):
|
||||
"""Check if we should publish to MQTT after update."""
|
||||
if not self.mqtt_48h_mode:
|
||||
@ -319,58 +97,39 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
now = dt_util.now()
|
||||
tomorrow = (now + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
# Check if tomorrow prices are available in new data
|
||||
all_prices = new_data.get("prices", [])
|
||||
tomorrow_prices = [p for p in all_prices if p["start"].startswith(tomorrow)]
|
||||
|
||||
# Check if tomorrow's data is valid (not placeholders)
|
||||
has_valid_tomorrow_prices = (
|
||||
len(tomorrow_prices) >= 20 and
|
||||
not self._is_likely_placeholder_data(tomorrow_prices)
|
||||
)
|
||||
|
||||
# Log what we found for debugging
|
||||
if tomorrow_prices:
|
||||
unique_values = set(p.get("price") for p in tomorrow_prices if p.get("price") is not None)
|
||||
consecutive = self._count_consecutive_same_values(tomorrow_prices)
|
||||
_LOGGER.debug(
|
||||
f"Tomorrow has {len(tomorrow_prices)} prices, "
|
||||
f"{len(unique_values)} unique values, "
|
||||
f"max {consecutive} consecutive same values, "
|
||||
f"valid: {has_valid_tomorrow_prices}"
|
||||
)
|
||||
|
||||
# If we didn't have valid tomorrow prices before, but now we do, publish to MQTT immediately
|
||||
if not self._had_tomorrow_prices and has_valid_tomorrow_prices:
|
||||
_LOGGER.info("Valid tomorrow prices detected for %s, triggering immediate MQTT publish", self.price_type)
|
||||
|
||||
# Find our config entry
|
||||
entry_id = None
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.data.get("api_key") == self.api_key:
|
||||
if self.api_client.api_key == entry.data.get("api_key"):
|
||||
entry_id = entry.entry_id
|
||||
break
|
||||
|
||||
if entry_id:
|
||||
# Check if both coordinators are initialized before publishing
|
||||
buy_coordinator = self.hass.data[DOMAIN].get(f"{entry_id}_buy")
|
||||
sell_coordinator = self.hass.data[DOMAIN].get(f"{entry_id}_sell")
|
||||
|
||||
if not buy_coordinator or not sell_coordinator:
|
||||
_LOGGER.debug("Coordinators not yet initialized, skipping MQTT publish for now")
|
||||
# Don't update _had_tomorrow_prices so we'll try again on next update
|
||||
return
|
||||
|
||||
# Get MQTT topics from config
|
||||
from .const import CONF_MQTT_TOPIC_BUY, CONF_MQTT_TOPIC_SELL, DEFAULT_MQTT_TOPIC_BUY, DEFAULT_MQTT_TOPIC_SELL
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
mqtt_topic_buy = entry.options.get(CONF_MQTT_TOPIC_BUY, DEFAULT_MQTT_TOPIC_BUY)
|
||||
mqtt_topic_sell = entry.options.get(CONF_MQTT_TOPIC_SELL, DEFAULT_MQTT_TOPIC_SELL)
|
||||
|
||||
# Wait a moment for both coordinators to update
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Publish to MQTT
|
||||
from .mqtt_common import publish_mqtt_prices
|
||||
success = await publish_mqtt_prices(self.hass, entry_id, mqtt_topic_buy, mqtt_topic_sell)
|
||||
|
||||
@ -379,26 +138,21 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
else:
|
||||
_LOGGER.error("Failed to publish to MQTT after detecting tomorrow prices")
|
||||
|
||||
# Update state for next check
|
||||
self._had_tomorrow_prices = has_valid_tomorrow_prices
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Fetch 48h of frames and extract current + today's list."""
|
||||
_LOGGER.debug("Starting %s price update (48h mode: %s)", self.price_type, self.mqtt_48h_mode)
|
||||
|
||||
# Store the previous data for fallback
|
||||
previous_data = None
|
||||
if hasattr(self, 'data') and self.data:
|
||||
previous_data = self.data.copy() if self.data else None
|
||||
if previous_data:
|
||||
previous_data["is_cached"] = True
|
||||
|
||||
# Get today's start in LOCAL time, then convert to UTC
|
||||
# This ensures we get the correct "today" for the user's timezone
|
||||
today_local = dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
window_end_local = today_local + timedelta(days=2)
|
||||
|
||||
# Convert to UTC for API request
|
||||
start_utc = dt_util.as_utc(today_local)
|
||||
end_utc = dt_util.as_utc(window_end_local)
|
||||
|
||||
@ -411,25 +165,22 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
_LOGGER.debug("Requesting %s data from %s", self.price_type, url)
|
||||
|
||||
# Get current time in UTC for price comparison
|
||||
now_utc = dt_util.utcnow()
|
||||
|
||||
try:
|
||||
# Load translations
|
||||
await self.retry_mechanism.load_translations(self.hass)
|
||||
|
||||
try:
|
||||
self._translations = await async_get_translations(
|
||||
self.hass, self.hass.config.language, DOMAIN, ["debug"]
|
||||
self.hass, self.hass.config.language, DOMAIN
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.warning("Failed to load translations for coordinator: %s", ex)
|
||||
|
||||
# Use retry mechanism
|
||||
data = await self.retry_mechanism.execute(
|
||||
self._make_api_request,
|
||||
# Use shared API client
|
||||
data = await self.api_client.fetch(
|
||||
url,
|
||||
price_type=self.price_type
|
||||
max_retries=self.retry_attempts,
|
||||
base_delay=self.retry_delay
|
||||
)
|
||||
|
||||
frames = data.get("frames", [])
|
||||
@ -451,15 +202,12 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.warning("Invalid datetime format in frames for %s", self.price_type)
|
||||
continue
|
||||
|
||||
# Convert to local time for display (we need this for hourly data)
|
||||
local_start = dt_util.as_local(start).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
prices.append({"start": local_start, "price": val})
|
||||
|
||||
# Check if this is the current price
|
||||
if start <= now_utc < end:
|
||||
current_price = val
|
||||
|
||||
# Filter today's prices
|
||||
today_local = dt_util.now().strftime("%Y-%m-%d")
|
||||
prices_today = [p for p in prices if p["start"].startswith(today_local)]
|
||||
|
||||
@ -473,31 +221,17 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"is_cached": False,
|
||||
}
|
||||
|
||||
# Check if we should publish to MQTT
|
||||
if self.mqtt_48h_mode:
|
||||
await self._check_and_publish_mqtt(new_data)
|
||||
|
||||
return new_data
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
error_msg = self._translations.get(
|
||||
"debug.network_error",
|
||||
"Network error fetching {price_type} data: {error}"
|
||||
).format(price_type=self.price_type, error=str(err))
|
||||
_LOGGER.error(error_msg)
|
||||
|
||||
except UpdateFailed:
|
||||
# UpdateFailed already has proper error message from API client
|
||||
if previous_data:
|
||||
cache_msg = self._translations.get(
|
||||
"debug.using_cache",
|
||||
"Using cached data from previous update due to API failure"
|
||||
)
|
||||
_LOGGER.warning(cache_msg)
|
||||
_LOGGER.warning("Using cached data from previous update due to API failure")
|
||||
return previous_data
|
||||
|
||||
raise UpdateFailed(self._translations.get(
|
||||
"debug.network_error_user",
|
||||
"Network error: {error}"
|
||||
).format(error=err))
|
||||
raise
|
||||
|
||||
except Exception as err:
|
||||
error_msg = self._translations.get(
|
||||
@ -507,11 +241,7 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.exception(error_msg)
|
||||
|
||||
if previous_data:
|
||||
cache_msg = self._translations.get(
|
||||
"debug.using_cache",
|
||||
"Using cached data from previous update due to API failure"
|
||||
)
|
||||
_LOGGER.warning(cache_msg)
|
||||
_LOGGER.warning("Using cached data from previous update due to API failure")
|
||||
return previous_data
|
||||
|
||||
raise UpdateFailed(self._translations.get(
|
||||
@ -519,7 +249,6 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"Error: {error}"
|
||||
).format(error=err))
|
||||
|
||||
|
||||
def schedule_hourly_update(self):
|
||||
"""Schedule next refresh 1 min after each full hour."""
|
||||
if self._unsub_hourly:
|
||||
@ -527,7 +256,6 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
self._unsub_hourly = None
|
||||
|
||||
now = dt_util.now()
|
||||
# Keep original timing: 1 minute past the hour
|
||||
next_run = (now.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1, minutes=1))
|
||||
|
||||
@ -539,50 +267,37 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
|
||||
async def _check_tomorrow_prices_after_15(self):
|
||||
"""Check if tomorrow prices are available and publish to MQTT if needed.
|
||||
This runs after 15:00 until midnight in 48h mode.
|
||||
"""
|
||||
"""Check if tomorrow prices are available and publish to MQTT if needed."""
|
||||
if not self.mqtt_48h_mode:
|
||||
return
|
||||
|
||||
now = dt_util.now()
|
||||
# Only run between 15:00 and 23:59
|
||||
if now.hour < 15:
|
||||
return
|
||||
|
||||
tomorrow = (now + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
# Check if we already have valid tomorrow data
|
||||
if self.data and self.data.get("prices"):
|
||||
tomorrow_prices = [p for p in self.data.get("prices", []) if p.get("start", "").startswith(tomorrow)]
|
||||
|
||||
# Check if tomorrow's data is valid (not placeholders)
|
||||
has_valid_tomorrow = (
|
||||
len(tomorrow_prices) >= 20 and
|
||||
not self._is_likely_placeholder_data(tomorrow_prices)
|
||||
)
|
||||
|
||||
if has_valid_tomorrow:
|
||||
# We already have valid data, nothing to do
|
||||
return
|
||||
else:
|
||||
_LOGGER.info("Missing or invalid tomorrow prices at %s, will refresh data", now.strftime("%H:%M"))
|
||||
|
||||
# If we don't have valid tomorrow data, force a refresh
|
||||
# The refresh will automatically trigger MQTT publish via _check_and_publish_mqtt
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def _handle_hourly_update(self, _):
|
||||
"""Handle hourly update."""
|
||||
_LOGGER.debug("Running scheduled hourly update for %s", self.price_type)
|
||||
|
||||
# First do the regular update
|
||||
await self.async_request_refresh()
|
||||
|
||||
# Then check if we need tomorrow prices (only in 48h mode after 15:00)
|
||||
await self._check_tomorrow_prices_after_15()
|
||||
|
||||
# Schedule next hourly update
|
||||
self.schedule_hourly_update()
|
||||
|
||||
def schedule_midnight_update(self):
|
||||
@ -592,7 +307,6 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
self._unsub_midnight = None
|
||||
|
||||
now = dt_util.now()
|
||||
# Keep original timing: 1 minute past midnight
|
||||
next_mid = (now + timedelta(days=1)).replace(hour=0, minute=1, second=0, microsecond=0)
|
||||
|
||||
_LOGGER.debug("Scheduling next midnight update for %s at %s",
|
||||
@ -620,13 +334,9 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
now = dt_util.now()
|
||||
|
||||
# Determine next check time
|
||||
# If we're before 14:00, start at 14:00
|
||||
if now.hour < 14:
|
||||
next_check = now.replace(hour=14, minute=0, second=0, microsecond=0)
|
||||
# If we're between 14:00-15:00, find next 15-minute slot
|
||||
elif now.hour == 14:
|
||||
# Calculate minutes to next 15-minute mark
|
||||
current_minutes = now.minute
|
||||
if current_minutes < 15:
|
||||
next_minutes = 15
|
||||
@ -635,19 +345,15 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
elif current_minutes < 45:
|
||||
next_minutes = 45
|
||||
else:
|
||||
# Move to 15:00
|
||||
next_check = now.replace(hour=15, minute=0, second=0, microsecond=0)
|
||||
next_minutes = None
|
||||
|
||||
if next_minutes is not None:
|
||||
next_check = now.replace(minute=next_minutes, second=0, microsecond=0)
|
||||
# If we're at 15:00 or later, schedule for tomorrow 14:00
|
||||
else:
|
||||
next_check = (now + timedelta(days=1)).replace(hour=14, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Make sure next_check is in the future
|
||||
if next_check <= now:
|
||||
# This shouldn't happen, but just in case
|
||||
next_check = next_check + timedelta(minutes=15)
|
||||
|
||||
_LOGGER.info("Scheduling afternoon update check for %s at %s (48h mode, checking every 15min between 14:00-15:00)",
|
||||
@ -663,25 +369,10 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
_LOGGER.debug("Running scheduled afternoon update check for %s at %s",
|
||||
self.price_type, now.strftime("%H:%M"))
|
||||
|
||||
# Perform the update
|
||||
await self.async_request_refresh()
|
||||
|
||||
# Schedule next check only if we're still in the 14:00-15:00 window
|
||||
if now.hour < 15:
|
||||
self.schedule_afternoon_update()
|
||||
else:
|
||||
# We've finished the afternoon window, schedule for tomorrow
|
||||
_LOGGER.info("Finished afternoon update window for %s, next cycle tomorrow", self.price_type)
|
||||
self.schedule_afternoon_update()
|
||||
|
||||
def unschedule_all_updates(self):
|
||||
"""Unschedule all updates."""
|
||||
if self._unsub_hourly:
|
||||
self._unsub_hourly()
|
||||
self._unsub_hourly = None
|
||||
if self._unsub_midnight:
|
||||
self._unsub_midnight()
|
||||
self._unsub_midnight = None
|
||||
if self._unsub_afternoon:
|
||||
self._unsub_afternoon()
|
||||
self._unsub_afternoon = None
|
||||
|
||||
Reference in New Issue
Block a user