not ideal but working bat charging

This commit is contained in:
2026-02-03 15:05:29 +01:00
parent 5b39f80862
commit a7a2da2eb2
11 changed files with 1241 additions and 37 deletions

View File

@ -7,7 +7,7 @@ Użyj mojego kodu E3WOTQ w koszyku w aplikacji. Bonus trafi do Twojego Portfela
!!! Dedykowana Karta do integracji: !!! Dedykowana Karta do integracji:
https://github.com/balgerion/ha_Pstryk_card https://github.com/balgerion/ha_Pstryk_card
[![Wersja](https://img.shields.io/badge/wersja-1.7.2-blue)](https://github.com/balgerion/ha_Pstryk/) [![Wersja](https://img.shields.io/badge/wersja-1.8.0-blue)](https://github.com/balgerion/ha_Pstryk/)
Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii elektrycznej oraz prognoz z platformy Pstryk. Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii elektrycznej oraz prognoz z platformy Pstryk.
@ -27,7 +27,10 @@ Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii
- 📡 Integracja wystawia po lokalnym MQTT tablice cen w natywnym formacie EVCC - 📡 Integracja wystawia po lokalnym MQTT tablice cen w natywnym formacie EVCC
- 📊 Średnia zakupu oraz sprzedaży - miesięczna/roczna - 📊 Średnia zakupu oraz sprzedaży - miesięczna/roczna
- 📈 Bilans miesięczny/roczny - 📈 Bilans miesięczny/roczny
- 🛡️ Debug i logowanie - 🛡️ 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 ## Instalacja
@ -84,6 +87,7 @@ logo.png (opcjonalnie)
| `sensor.pstryk_daily_financial_balance` | Dzienny bilans kupna/sprzedaży | | `sensor.pstryk_daily_financial_balance` | Dzienny bilans kupna/sprzedaży |
| `sensor.pstryk_monthly_financial_balance`| Miesięczny 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_yearly_financial_balance` | Roczny bilans kupna/sprzedaży |
| `sensor.pstryk_battery_recommendation` | **NOWOŚĆ:** Rekomendacja baterii (charge/discharge/standby) |
Przykładowa Automatyzacja: 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 ## EVCC
### Scrnshoty ### Scrnshoty

View 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

View File

@ -51,7 +51,7 @@ class PstrykAPIClient:
if not self._translations_loaded: if not self._translations_loaded:
try: try:
self._translations = await async_get_translations( self._translations = await async_get_translations(
self.hass, self.hass.config.language, DOMAIN, ["debug"] self.hass, self.hass.config.language, DOMAIN
) )
self._translations_loaded = True self._translations_loaded = True
# Debug: log sample keys to understand the format # Debug: log sample keys to understand the format

View File

@ -8,6 +8,7 @@ from homeassistant.core import callback
from homeassistant.components import mqtt from homeassistant.components import mqtt
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import selector
from .const import ( from .const import (
DOMAIN, DOMAIN,
API_URL, API_URL,
@ -25,7 +26,34 @@ from .const import (
MIN_RETRY_ATTEMPTS, MIN_RETRY_ATTEMPTS,
MAX_RETRY_ATTEMPTS, MAX_RETRY_ATTEMPTS,
MIN_RETRY_DELAY, 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): class MQTTNotConfiguredError(HomeAssistantError):
@ -261,6 +289,34 @@ class PstrykOptionsFlowHandler(config_entries.OptionsFlow):
vol.All(vol.Coerce(int), vol.Range(min=MIN_RETRY_DELAY, max=MAX_RETRY_DELAY)), 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 # Add description with section information
description_text = "Configure your energy price monitoring settings" description_text = "Configure your energy price monitoring settings"
if mqtt_enabled: if mqtt_enabled:

View File

@ -2,7 +2,7 @@
DOMAIN = "pstryk" DOMAIN = "pstryk"
API_URL = "https://api.pstryk.pl/integrations/" 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}" BUY_ENDPOINT = "pricing/?resolution=hour&window_start={start}&window_end={end}"
SELL_ENDPOINT = "prosumer-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 MAX_RETRY_ATTEMPTS = 10
MIN_RETRY_DELAY = 5 # seconds MIN_RETRY_DELAY = 5 # seconds
MAX_RETRY_DELAY = 300 # seconds (5 minutes) 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

View File

@ -46,7 +46,7 @@ class PstrykMqttPublisher:
# Load translations # Load translations
try: try:
self._translations = await async_get_translations( 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: except Exception as ex:
_LOGGER.warning("Failed to load translations for MQTT publisher: %s", ex) _LOGGER.warning("Failed to load translations for MQTT publisher: %s", ex)
@ -202,7 +202,8 @@ class PstrykMqttPublisher:
last_time = formatted_prices[-1]["start"] last_time = formatted_prices[-1]["start"]
_LOGGER.debug(f"Formatted {len(formatted_prices)} prices for MQTT from {first_time} to {last_time}") _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 = {} hours_by_date = {}
for fp in formatted_prices: for fp in formatted_prices:
date_part = fp["start"][:10] # YYYY-MM-DD date_part = fp["start"][:10] # YYYY-MM-DD
@ -212,7 +213,15 @@ class PstrykMqttPublisher:
for date, hours in hours_by_date.items(): for date, hours in hours_by_date.items():
if hours != 24: 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: else:
_LOGGER.warning("No prices formatted for MQTT") _LOGGER.warning("No prices formatted for MQTT")

View File

@ -1,12 +1,14 @@
"""Sensor platform for Pstryk Energy integration.""" """Sensor platform for Pstryk Energy integration."""
import logging import logging
import asyncio import asyncio
import math
from datetime import timedelta from datetime import timedelta
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.components.sensor import SensorEntity, SensorStateClass, SensorDeviceClass from homeassistant.components.sensor import SensorEntity, SensorStateClass, SensorDeviceClass
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .update_coordinator import PstrykDataUpdateCoordinator from .update_coordinator import PstrykDataUpdateCoordinator
from .energy_cost_coordinator import PstrykCostDataUpdateCoordinator from .energy_cost_coordinator import PstrykCostDataUpdateCoordinator
@ -17,7 +19,22 @@ from .const import (
CONF_RETRY_ATTEMPTS, CONF_RETRY_ATTEMPTS,
CONF_RETRY_DELAY, CONF_RETRY_DELAY,
DEFAULT_RETRY_ATTEMPTS, DEFAULT_RETRY_ATTEMPTS,
DEFAULT_RETRY_DELAY DEFAULT_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,
) )
from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.translation import async_get_translations
@ -26,28 +43,29 @@ _LOGGER = logging.getLogger(__name__)
# Store translations globally to avoid reloading for each sensor # Store translations globally to avoid reloading for each sensor
_TRANSLATIONS_CACHE = {} _TRANSLATIONS_CACHE = {}
# Cache for manifest version # Cache for manifest version - load at module import time (outside event loop)
_VERSION_CACHE = None _VERSION_CACHE = None
def _load_version_sync() -> str:
def get_integration_version(hass: HomeAssistant) -> str: """Load version synchronously at module import time."""
"""Get integration version from manifest.json."""
global _VERSION_CACHE
if _VERSION_CACHE is not None:
return _VERSION_CACHE
try: try:
import json import json
import os import os
manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json")
with open(manifest_path, "r") as f: with open(manifest_path, "r") as f:
manifest = json.load(f) manifest = json.load(f)
_VERSION_CACHE = manifest.get("version", "unknown") return manifest.get("version", "unknown")
return _VERSION_CACHE except Exception:
except Exception as ex:
_LOGGER.warning("Failed to read version from manifest.json: %s", ex)
return "unknown" return "unknown"
# Load version once at module import (not in event loop)
_VERSION_CACHE = _load_version_sync()
def get_integration_version(hass: HomeAssistant) -> str:
"""Get integration version from manifest.json."""
return _VERSION_CACHE
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -72,7 +90,7 @@ async def async_setup_entry(
if not _TRANSLATIONS_CACHE: if not _TRANSLATIONS_CACHE:
try: try:
_TRANSLATIONS_CACHE = await async_get_translations( _TRANSLATIONS_CACHE = await async_get_translations(
hass, hass.config.language, DOMAIN, ["entity", "debug"] hass, hass.config.language, DOMAIN
) )
except Exception as ex: except Exception as ex:
_LOGGER.warning("Failed to load translations: %s", ex) _LOGGER.warning("Failed to load translations: %s", ex)
@ -139,13 +157,21 @@ async def async_setup_entry(
_LOGGER.info("Starting quick initialization - loading price coordinators only") _LOGGER.info("Starting quick initialization - loading price coordinators only")
async def safe_initial_fetch(coord, coord_type): async def safe_initial_fetch(coord, coord_type):
"""Safely fetch initial data for coordinator.""" """Safely fetch initial data for coordinator with timeout."""
try: try:
data = await coord._async_update_data() # Add timeout to prevent blocking startup
data = await asyncio.wait_for(
coord._async_update_data(),
timeout=20.0 # 20 seconds max per coordinator
)
coord.data = data coord.data = data
coord.last_update_success = True coord.last_update_success = True
_LOGGER.debug("Successfully initialized %s coordinator", coord_type) _LOGGER.debug("Successfully initialized %s coordinator", coord_type)
return True return True
except asyncio.TimeoutError:
_LOGGER.warning("Timeout initializing %s coordinator - will retry later", coord_type)
coord.last_update_success = False
return False
except Exception as err: except Exception as err:
_LOGGER.error("Failed initial fetch for %s coordinator: %s", coord_type, err) _LOGGER.error("Failed initial fetch for %s coordinator: %s", coord_type, err)
coord.last_update_success = False coord.last_update_success = False
@ -161,11 +187,63 @@ async def async_setup_entry(
refresh_results = await asyncio.gather(*initial_refresh_tasks, return_exceptions=True) refresh_results = await asyncio.gather(*initial_refresh_tasks, return_exceptions=True)
# Track failed coordinators for quick retry
failed_coordinators = []
# Check results for price coordinators # Check results for price coordinators
for i, (coordinator, coordinator_type, key) in enumerate(price_coordinators): for i, (coordinator, coordinator_type, key) in enumerate(price_coordinators):
if isinstance(refresh_results[i], Exception): if isinstance(refresh_results[i], Exception) or refresh_results[i] is False:
_LOGGER.error("Failed to initialize %s coordinator: %s", _LOGGER.warning("Coordinator %s failed initial load - scheduling retry with backoff",
coordinator_type, str(refresh_results[i])) coordinator_type)
failed_coordinators.append((coordinator, coordinator_type))
# Schedule exponential backoff retry for failed coordinators
# Delays: 2, 4, 8, 16, 32 minutes (5 attempts)
if failed_coordinators:
async def exponential_backoff_retry():
"""Retry failed coordinators with exponential backoff."""
base_delay = 120 # 2 minutes
max_attempts = 5
for attempt in range(max_attempts):
delay = base_delay * (2 ** attempt) # 2, 4, 8, 16, 32 minutes
# Check if any coordinators still need retry
coords_to_retry = [
(c, t) for c, t in failed_coordinators
if not c.last_update_success
]
if not coords_to_retry:
_LOGGER.info("All coordinators recovered, stopping backoff retry")
return
_LOGGER.info(
"Backoff retry attempt %d/%d in %d seconds for %d coordinator(s)",
attempt + 1, max_attempts, delay, len(coords_to_retry)
)
await asyncio.sleep(delay)
for coord, coord_type in coords_to_retry:
if not coord.last_update_success:
_LOGGER.info("Retry attempt %d for %s coordinator", attempt + 1, coord_type)
try:
await coord.async_request_refresh()
if coord.last_update_success:
_LOGGER.info("%s coordinator recovered on attempt %d", coord_type, attempt + 1)
except Exception as e:
_LOGGER.warning("Retry attempt %d failed for %s: %s", attempt + 1, coord_type, e)
# Final check
still_failed = [t for c, t in failed_coordinators if not c.last_update_success]
if still_failed:
_LOGGER.error(
"Coordinators %s failed after %d retry attempts. Will use hourly schedule.",
still_failed, max_attempts
)
hass.async_create_task(exponential_backoff_retry())
# Store all coordinators and set up scheduling # Store all coordinators and set up scheduling
buy_coord = None buy_coord = None
@ -223,6 +301,24 @@ async def async_setup_entry(
cost_coordinator, period, entry.entry_id cost_coordinator, period, entry.entry_id
)) ))
# Create battery recommendation sensor if enabled
battery_enabled = entry.options.get(CONF_BATTERY_ENABLED, False)
if battery_enabled and buy_coord:
battery_sensor = PstrykBatteryRecommendationSensor(
coordinator=buy_coord,
entry_id=entry.entry_id,
soc_entity_id=entry.options.get(CONF_BATTERY_SOC_ENTITY, ""),
capacity=entry.options.get(CONF_BATTERY_CAPACITY, DEFAULT_BATTERY_CAPACITY),
charge_rate=entry.options.get(CONF_BATTERY_CHARGE_RATE, DEFAULT_BATTERY_CHARGE_RATE),
discharge_rate=entry.options.get(CONF_BATTERY_DISCHARGE_RATE, DEFAULT_BATTERY_DISCHARGE_RATE),
min_soc=entry.options.get(CONF_BATTERY_MIN_SOC, DEFAULT_BATTERY_MIN_SOC),
charge_hours_count=entry.options.get(CONF_BATTERY_CHARGE_HOURS, DEFAULT_BATTERY_CHARGE_HOURS),
discharge_multiplier=entry.options.get(CONF_BATTERY_DISCHARGE_MULTIPLIER, DEFAULT_BATTERY_DISCHARGE_MULTIPLIER),
)
remaining_entities.append(battery_sensor)
_LOGGER.info("Battery recommendation sensor enabled with SoC entity: %s",
entry.options.get(CONF_BATTERY_SOC_ENTITY, "not configured"))
# Register ALL sensors immediately: # Register ALL sensors immediately:
# - Current price sensors (2) with data # - Current price sensors (2) with data
# - Remaining sensors (15) as unavailable until cost coordinator loads # - Remaining sensors (15) as unavailable until cost coordinator loads
@ -258,7 +354,7 @@ async def async_setup_entry(
class PstrykPriceSensor(CoordinatorEntity, SensorEntity): class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
"""Combined price sensor with table data attributes.""" """Combined price sensor with table data attributes."""
_attr_state_class = SensorStateClass.MEASUREMENT # Note: state_class removed - MONETARY device_class doesn't support MEASUREMENT
def __init__(self, coordinator: PstrykDataUpdateCoordinator, price_type: str, top_count: int, worst_count: int, entry_id: str): def __init__(self, coordinator: PstrykDataUpdateCoordinator, price_type: str, top_count: int, worst_count: int, entry_id: str):
super().__init__(coordinator) super().__init__(coordinator)
@ -711,6 +807,8 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
avg_price_sunrise_sunset_key: None, avg_price_sunrise_sunset_key: None,
next_hour_key: None, next_hour_key: None,
all_prices_key: [], all_prices_key: [],
"all_prices": [],
"prices_today": [],
best_prices_key: [], best_prices_key: [],
worst_prices_key: [], worst_prices_key: [],
best_count_key: self.top_count, best_count_key: self.top_count,
@ -723,6 +821,7 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
next_hour_data = self._get_next_hour_price() next_hour_data = self._get_next_hour_price()
today = self.coordinator.data.get("prices_today", []) today = self.coordinator.data.get("prices_today", [])
all_prices_list = self.coordinator.data.get("prices", [])
is_cached = self.coordinator.data.get("is_cached", False) is_cached = self.coordinator.data.get("is_cached", False)
# Calculate average price for remaining hours today (from current hour) # Calculate average price for remaining hours today (from current hour)
@ -757,13 +856,12 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
avg_price_full_day_with_hours = f"{avg_price_key} /24" avg_price_full_day_with_hours = f"{avg_price_key} /24"
# Check if tomorrow's prices are available (more robust check) # Check if tomorrow's prices are available (more robust check)
all_prices = self.coordinator.data.get("prices", [])
tomorrow = (now + timedelta(days=1)).strftime("%Y-%m-%d") tomorrow = (now + timedelta(days=1)).strftime("%Y-%m-%d")
tomorrow_prices = [] tomorrow_prices = []
# Only check for tomorrow prices if we have a reasonable amount of data # Only check for tomorrow prices if we have a reasonable amount of data
if len(all_prices) > 0: if len(all_prices_list) > 0:
tomorrow_prices = [p for p in all_prices if p.get("start", "").startswith(tomorrow)] tomorrow_prices = [p for p in all_prices_list if p.get("start", "").startswith(tomorrow)]
# Log what we found for debugging # Log what we found for debugging
if tomorrow_prices: if tomorrow_prices:
@ -795,6 +893,8 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
avg_price_sunrise_sunset_key: avg_price_sunrise_sunset, avg_price_sunrise_sunset_key: avg_price_sunrise_sunset,
next_hour_key: next_hour_data, next_hour_key: next_hour_data,
all_prices_key: today, all_prices_key: today,
"all_prices": all_prices_list,
"prices_today": today,
best_prices_key: sorted_prices["best"], best_prices_key: sorted_prices["best"],
worst_prices_key: sorted_prices["worst"], worst_prices_key: sorted_prices["worst"],
best_count_key: self.top_count, best_count_key: self.top_count,
@ -803,6 +903,7 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
last_updated_key: now.strftime("%Y-%m-%d %H:%M:%S"), last_updated_key: now.strftime("%Y-%m-%d %H:%M:%S"),
using_cached_key: is_cached, using_cached_key: is_cached,
tomorrow_available_key: tomorrow_available, tomorrow_available_key: tomorrow_available,
"tomorrow_available": tomorrow_available,
mqtt_price_count_key: mqtt_price_count, mqtt_price_count_key: mqtt_price_count,
"mqtt_48h_mode": self.coordinator.mqtt_48h_mode "mqtt_48h_mode": self.coordinator.mqtt_48h_mode
} }
@ -815,7 +916,7 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity):
class PstrykAveragePriceSensor(RestoreEntity, SensorEntity): class PstrykAveragePriceSensor(RestoreEntity, SensorEntity):
"""Average price sensor using weighted averages from API data.""" """Average price sensor using weighted averages from API data."""
_attr_state_class = SensorStateClass.MEASUREMENT # Note: state_class removed - MONETARY device_class doesn't support MEASUREMENT
def __init__(self, cost_coordinator: PstrykCostDataUpdateCoordinator, def __init__(self, cost_coordinator: PstrykCostDataUpdateCoordinator,
price_coordinator: PstrykDataUpdateCoordinator, price_coordinator: PstrykDataUpdateCoordinator,
@ -1160,3 +1261,632 @@ class PstrykFinancialBalanceSensor(CoordinatorEntity, SensorEntity):
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return self.coordinator.last_update_success and self.coordinator.data is not None return self.coordinator.last_update_success and self.coordinator.data is not None
class PstrykBatteryRecommendationSensor(CoordinatorEntity, SensorEntity, RestoreEntity):
"""Battery charging recommendation sensor based on dynamic prices."""
# State values
STATE_CHARGE = "charge"
STATE_DISCHARGE = "discharge"
STATE_STANDBY = "standby"
def __init__(
self,
coordinator: PstrykDataUpdateCoordinator,
entry_id: str,
soc_entity_id: str,
capacity: int,
charge_rate: int,
discharge_rate: int,
min_soc: int,
charge_hours_count: int,
discharge_multiplier: float,
):
"""Initialize the battery recommendation sensor."""
super().__init__(coordinator)
self.entry_id = entry_id
self._soc_entity_id = soc_entity_id
self._capacity = capacity
self._charge_rate = charge_rate
self._discharge_rate = discharge_rate
self._min_soc = min_soc
self._charge_hours_count = charge_hours_count
self._discharge_multiplier = discharge_multiplier
self._attr_icon = "mdi:battery-clock"
self._unsub_soc_listener = None
self._stored_energy_price = 0.0 # Weighted average cost of energy in battery
async def async_added_to_hass(self) -> None:
"""Run when entity is added to hass."""
await super().async_added_to_hass()
# Restore state
last_state = await self.async_get_last_state()
if last_state:
try:
# Restore stored energy price if available
if "stored_energy_price" in last_state.attributes:
self._stored_energy_price = float(last_state.attributes["stored_energy_price"])
_LOGGER.debug("Restored stored energy price: %.4f PLN/kWh", self._stored_energy_price)
except (ValueError, TypeError):
_LOGGER.warning("Could not restore stored energy price")
# Subscribe to SoC entity state changes for immediate updates
if self._soc_entity_id:
@callback
def _async_soc_state_changed(event) -> None:
"""Handle SoC entity state changes."""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if new_state is None or new_state.state in ("unknown", "unavailable"):
return
# Update weighted average cost if SoC increased (Charging)
if old_state and old_state.state not in ("unknown", "unavailable"):
try:
old_soc = float(old_state.state)
new_soc = float(new_state.state)
if new_soc > old_soc:
self._update_weighted_cost(old_soc, new_soc)
except ValueError:
pass
_LOGGER.debug(
"SoC changed from %s to %s, triggering update",
old_state.state if old_state else "None",
new_state.state
)
# Schedule an update
self.async_write_ha_state()
self._unsub_soc_listener = async_track_state_change_event(
self.hass,
[self._soc_entity_id],
_async_soc_state_changed
)
_LOGGER.info(
"Battery recommendation sensor now listening to SoC changes from %s",
self._soc_entity_id
)
def _update_weighted_cost(self, old_soc: float, new_soc: float):
"""Calculate new weighted average cost when charging."""
# Get current price
current_price = self.coordinator.data.get("current")
if current_price is None:
return # Cannot calculate without price
# Calculate energy chunks
# Capacity is in kWh. SoC is %.
# Energy = (SoC / 100) * Capacity
energy_old = (old_soc / 100.0) * self._capacity
energy_added = ((new_soc - old_soc) / 100.0) * self._capacity
# If battery was empty OR if stored price is uninitialized (0.0), take new price as baseline
if energy_old <= 0.1 or self._stored_energy_price == 0.0:
self._stored_energy_price = current_price
else:
# Weighted Average:
# (Old_kWh * Old_Price) + (Added_kWh * Current_Price)
# ---------------------------------------------------
# (Old_kWh + Added_kWh)
total_value = (energy_old * self._stored_energy_price) + (energy_added * current_price)
total_energy = energy_old + energy_added
if total_energy > 0:
self._stored_energy_price = total_value / total_energy
_LOGGER.debug(
"Updated stored energy price: %.4f PLN/kWh (Added %.2f kWh @ %.2f)",
self._stored_energy_price, energy_added, current_price
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity is removed from hass."""
await super().async_will_remove_from_hass()
# Unsubscribe from SoC entity state changes
if self._unsub_soc_listener:
self._unsub_soc_listener()
self._unsub_soc_listener = None
_LOGGER.debug("Unsubscribed from SoC state changes")
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Pstryk Battery Recommendation"
@property
def unique_id(self) -> str:
"""Return unique ID."""
return f"{DOMAIN}_battery_recommendation"
@property
def device_info(self):
"""Return device information."""
return {
"identifiers": {(DOMAIN, "pstryk_energy")},
"name": "Pstryk Energy",
"manufacturer": "Pstryk",
"model": "Energy Price Monitor",
"sw_version": get_integration_version(self.hass),
}
def _get_current_soc(self) -> float | None:
"""Get current SoC from configured entity."""
if not self._soc_entity_id:
return None
state = self.hass.states.get(self._soc_entity_id)
if state is None or state.state in ("unknown", "unavailable"):
return None
try:
return float(state.state)
except (ValueError, TypeError):
_LOGGER.warning("Cannot parse SoC value from %s: %s", self._soc_entity_id, state.state)
return None
def _get_prices_with_hours(self) -> list[dict]:
"""Get prices with hour information from coordinator."""
if not self.coordinator.data:
return []
prices = self.coordinator.data.get("prices", [])
if not prices:
return []
result = []
for price_entry in prices:
try:
start_str = price_entry.get("start", "")
price = price_entry.get("price")
if not start_str or price is None:
continue
dt = dt_util.parse_datetime(start_str)
if dt:
dt_local = dt_util.as_local(dt)
result.append({
"hour": dt_local.hour,
"price": price,
"datetime": dt_local,
"date": dt_local.date()
})
except Exception as e:
_LOGGER.debug("Error parsing price entry: %s", e)
return result
def _calculate_recommendation(self) -> tuple[str, dict]:
"""Calculate battery recommendation based on prices and SoC."""
now = dt_util.now()
current_hour = now.hour
current_soc = self._get_current_soc()
prices = self._get_prices_with_hours()
# Default attributes
attrs = {
"current_price": None,
"current_soc": current_soc,
"stored_energy_price": round(self._stored_energy_price, 4),
"avg_charge_price": None,
"discharge_threshold": None,
"charge_hours": [],
"discharge_hours": [],
"standby_hours": [],
"soc_forecast": [],
"emergency_charge": False,
"pre_peak_charge": False,
"critical_hour": None,
"reason": "No data available",
"next_state_change": None,
"next_state": None,
"prices_horizon": "unknown",
"config": {
"charge_hours_count": self._charge_hours_count,
"discharge_multiplier": self._discharge_multiplier,
"min_soc": self._min_soc,
"charge_rate": self._charge_rate,
"discharge_rate": self._discharge_rate,
"capacity": self._capacity,
"soc_entity": self._soc_entity_id,
},
"last_updated": now.strftime("%Y-%m-%d %H:%M:%S"),
}
if not prices or len(prices) < 12:
return self.STATE_STANDBY, attrs
# Get today's prices only for hour classification
today = now.date()
today_prices = [p for p in prices if p["date"] == today]
if len(today_prices) < 12:
attrs["reason"] = f"Insufficient price data for today ({len(today_prices)} hours)"
return self.STATE_STANDBY, attrs
# ============================================================
# ENHANCED ALGORITHM: Multi-phase arbitrage detection
# ============================================================
# Instead of just picking N cheapest hours globally, we:
# 1. Find primary charge hours (night - cheapest globally)
# 2. Identify peaks (morning 7-10, evening 15-20)
# 3. Identify mid-day valley (11-14)
# 4. If mid-day valley is profitable vs evening peak, charge there too
# ============================================================
# Round-trip efficiency factor (20% losses = multiply by 1.25 to break even)
EFFICIENCY_FACTOR = 1.25
# Time block definitions
NIGHT_HOURS = set(range(0, 6)) # 00:00 - 05:59
MORNING_PEAK = set(range(6, 11)) # 06:00 - 10:59
MIDDAY_VALLEY = set(range(11, 15)) # 11:00 - 14:59
EVENING_PEAK = set(range(15, 21)) # 15:00 - 20:59
LATE_EVENING = set(range(21, 24)) # 21:00 - 23:59
# Helper to get prices for a set of hours
def get_prices_for_hours(hours_set):
return [p for p in today_prices if p["hour"] in hours_set]
def avg_price(price_list):
if not price_list:
return 0
return sum(p["price"] for p in price_list) / len(price_list)
# Get prices for each block
night_prices = get_prices_for_hours(NIGHT_HOURS)
morning_peak_prices = get_prices_for_hours(MORNING_PEAK)
midday_prices = get_prices_for_hours(MIDDAY_VALLEY)
evening_peak_prices = get_prices_for_hours(EVENING_PEAK)
late_evening_prices = get_prices_for_hours(LATE_EVENING)
# Calculate average prices per block
avg_night = avg_price(night_prices)
avg_morning_peak = avg_price(morning_peak_prices)
avg_midday = avg_price(midday_prices)
avg_evening_peak = avg_price(evening_peak_prices)
avg_late_evening = avg_price(late_evening_prices)
# Sort by price to find cheapest hours globally
sorted_by_price = sorted(today_prices, key=lambda x: x["price"])
# PRIMARY CHARGE: N cheapest hours (typically night)
primary_charge_data = sorted_by_price[:self._charge_hours_count]
charge_hours = set(p["hour"] for p in primary_charge_data)
avg_charge_price = avg_price(primary_charge_data)
# DISCHARGE THRESHOLD based on primary charge price
discharge_threshold = avg_charge_price * self._discharge_multiplier
# INTRA-DAY ARBITRAGE CHECK
# If mid-day valley price * efficiency < evening peak price, it's profitable
# to charge during mid-day and discharge in evening
midday_arbitrage_profitable = False
midday_charge_hours = set()
if midday_prices and evening_peak_prices:
# Find the 2-3 cheapest hours in mid-day valley
sorted_midday = sorted(midday_prices, key=lambda x: x["price"])
cheapest_midday = sorted_midday[:3] # Top 3 cheapest in valley
avg_cheapest_midday = avg_price(cheapest_midday)
# Check if charging mid-day is profitable for evening discharge
# breakeven = midday_price * 1.25 (accounting for 20% round-trip losses)
if avg_cheapest_midday * EFFICIENCY_FACTOR < avg_evening_peak:
midday_arbitrage_profitable = True
# Add mid-day valley hours where price * efficiency < evening peak avg
for p in midday_prices:
if p["price"] * EFFICIENCY_FACTOR < avg_evening_peak:
midday_charge_hours.add(p["hour"])
charge_hours.add(p["hour"])
# DETERMINE DISCHARGE HOURS
# Hours where price >= discharge_threshold AND not in charge_hours
discharge_hours = set(
p["hour"] for p in today_prices
if p["price"] >= discharge_threshold and p["hour"] not in charge_hours
)
# STANDBY HOURS = everything else
all_hours = set(range(24))
standby_hours = all_hours - charge_hours - discharge_hours
# Store arbitrage info in attributes
attrs["midday_arbitrage"] = {
"profitable": midday_arbitrage_profitable,
"midday_charge_hours": sorted(midday_charge_hours),
"avg_midday_price": round(avg_midday, 4) if midday_prices else None,
"avg_evening_peak": round(avg_evening_peak, 4) if evening_peak_prices else None,
"breakeven_price": round(avg_midday * EFFICIENCY_FACTOR, 4) if midday_prices else None,
}
# Get current price
current_price_data = next(
(p for p in today_prices if p["hour"] == current_hour),
None
)
current_price = current_price_data["price"] if current_price_data else None
# Update attributes
attrs.update({
"current_price": current_price,
"avg_charge_price": round(avg_charge_price, 4),
"discharge_threshold": round(discharge_threshold, 4),
"charge_hours": sorted(charge_hours),
"discharge_hours": sorted(discharge_hours),
"standby_hours": sorted(standby_hours),
"prices_horizon": "48h" if len(prices) > 24 else "24h",
})
# SoC-based logic (if SoC available)
emergency_charge = False
pre_peak_charge = False
critical_hour = None
if current_soc is not None:
# Simulate SoC forward to detect critical situations
soc_forecast = self._simulate_soc_forward(
current_hour, current_soc, charge_hours, discharge_hours
)
attrs["soc_forecast"] = soc_forecast[:12] # Next 12 hours
# Check for critical SoC drop
# We run this check regardless of current SoC to ensure safety.
for entry in soc_forecast:
if entry["soc"] < self._min_soc and entry["action"] != "charge":
critical_hour = entry["hour"]
# Check if there's a charge hour before critical
hours_until_critical = (critical_hour - current_hour) % 24
has_charge_before = any(
(current_hour + i) % 24 in charge_hours
for i in range(hours_until_critical)
)
# If no scheduled charge saves us, trigger emergency
if not has_charge_before:
emergency_charge = True
break
attrs["critical_hour"] = critical_hour
attrs["emergency_charge"] = emergency_charge
# --- FORWARD COVERAGE STRATEGY (Pre-Peak Charge) ---
# Look ahead 24h for "High Price" blocks where we WANT to discharge
# and ensure we have enough SoC to cover them.
# 1. Identify Target Discharge Hours in next 24h
# We look for prices > discharge_threshold
future_discharge_hours = []
# Filter prices for next 24h window
# We need to find the index of current hour in the prices list
# Since prices are sorted by time, we can just find the current hour entry
# Find index of current hour in the main 'prices' list
start_index = -1
for idx, p in enumerate(prices):
if p["date"] == today and p["hour"] == current_hour:
start_index = idx
break
if start_index != -1:
# Look at next 18 hours (typical planning horizon)
# CRITICAL FIX: Start looking from NEXT hour (start_index + 1).
# We want to find the *upcoming* peak. If we include the current hour,
# and the current hour is marginally high (1.23), it becomes the "peak start",
# making time_until_peak = 0, which disables Pre-Peak charging.
lookahead_window = prices[start_index + 1 : start_index + 19]
for p in lookahead_window:
if p["price"] >= discharge_threshold:
future_discharge_hours.append(p)
# 2. Calculate Required Capacity
# Required = (Hours * Discharge_Rate) + Min_SoC
# We group them into "blocks". If there is a block of 5 hours coming up,
# we need 5 * 10% + 20% = 70% SoC at the start of that block.
if future_discharge_hours:
# Find the start of the first major block
first_discharge_hour = future_discharge_hours[0]
# Count hours in that block (contiguous or close)
# For simplicity, we just count total high hours in next 12h
high_hours_count = len([p for p in future_discharge_hours if (p["datetime"] - first_discharge_hour["datetime"]).total_seconds() < 12*3600])
required_soc = (high_hours_count * self._discharge_rate) + self._min_soc
# 3. Gap Analysis
# Hysteresis Logic:
# If we are already charging due to coverage, we want to KEEP charging
# until we have a buffer (e.g., +5%) to prevent flip-flopping.
threshold_soc = required_soc + 2.0
# CRITICAL FIX: Only plan coverage charging if current price is LOW.
# If we are already in the high-price zone (current_price >= threshold),
# we should just discharge what we have and then stop. We should NOT panic-charge
# expensive energy just to discharge it again.
# REFINEMENT: "Low" is relative. 1.23 is high compared to night (0.80),
# but LOW compared to the upcoming peak (1.60).
# We should charge if current price is notably cheaper than the peak we are protecting against.
# Find min price in the upcoming discharge block
min_future_peak_price = min(p["price"] for p in future_discharge_hours) if future_discharge_hours else 0
# Allow charging if:
# 1. Price is generally cheap (< threshold)
# OR
# 2. Price is cheaper than the future peak (arbitrage opportunity to avoid running dry)
# We apply a safety margin (e.g., current must be < 95% of future peak min)
is_cheap_enough = False
if current_price is not None:
if current_price < discharge_threshold:
is_cheap_enough = True
elif current_price < (min_future_peak_price * 0.95):
is_cheap_enough = True
if current_soc < threshold_soc and is_cheap_enough:
# We have a deficit AND it is cheap enough to charge!
# Check if we are currently in the "Pre-Peak" window (before the high price starts)
time_until_peak = (first_discharge_hour["datetime"] - now).total_seconds() / 3600
if 0 < time_until_peak < 6: # If peak is approaching (within 6 hours)
# We need to charge NOW if this is a relatively cheap hour compared to the peak
# or if it's the only chance left.
# Find all hours between now and peak
available_hours = prices[start_index : start_index + int(time_until_peak) + 1]
# Sort them by price
available_hours_sorted = sorted(available_hours, key=lambda x: x["price"])
# How many hours do we need to charge to fill the gap?
# Gap = 30%. Charge rate = 30%/h. -> Need 1 hour.
soc_deficit = threshold_soc - current_soc
hours_needed = max(1, math.ceil(soc_deficit / self._charge_rate))
# Pick the cheapest N hours
cheapest_pre_peak = available_hours_sorted[:hours_needed]
# Is NOW one of them?
if any(p["hour"] == current_hour and p["date"] == today for p in cheapest_pre_peak):
pre_peak_charge = True
attrs["pre_peak_charge"] = True
attrs["reason"] = f"Forward Coverage: Charging for upcoming {high_hours_count}h peak (Target {threshold_soc:.0f}%)"
# Add to charge set for visualization consistency
charge_hours.add(current_hour)
# Final decision
# First check: if battery is full (100%), don't charge - switch to standby
if current_soc is not None and current_soc >= 99.5: # Hysteresis for top-off
if current_hour in discharge_hours:
state = self.STATE_DISCHARGE
reason = f"Battery full, discharging (price {current_price:.2f} >= threshold {discharge_threshold:.2f})"
else:
state = self.STATE_STANDBY
reason = "Battery full (100%), waiting for discharge opportunity"
elif emergency_charge:
state = self.STATE_CHARGE
reason = f"EMERGENCY: SoC will drop below {self._min_soc}% at {critical_hour}:00"
elif pre_peak_charge:
state = self.STATE_CHARGE
# Reason already set above
elif current_hour in charge_hours:
state = self.STATE_CHARGE
# Check if this is a midday arbitrage hour or primary cheap hour
if current_hour in midday_charge_hours:
reason = f"Mid-day arbitrage charge (price {current_price:.2f} profitable vs evening peak {avg_evening_peak:.2f})"
elif not pre_peak_charge: # Avoid overwriting coverage reason
reason = f"Cheapest hour (price {current_price:.2f} PLN/kWh in top {self._charge_hours_count} lowest)"
elif current_hour in discharge_hours:
if current_soc is not None and current_soc <= self._min_soc:
state = self.STATE_STANDBY
reason = f"Would discharge but SoC ({current_soc:.0f}%) at minimum"
else:
state = self.STATE_DISCHARGE
reason = f"Price {current_price:.2f} >= threshold {discharge_threshold:.2f}"
else:
state = self.STATE_STANDBY
reason = "Price between thresholds"
attrs["reason"] = reason
# Find next state change
next_change = self._find_next_state_change(
current_hour, state, charge_hours, discharge_hours
)
if next_change:
attrs["next_state_change"] = f"{next_change['hour']:02d}:00"
attrs["next_state"] = next_change["state"]
return state, attrs
def _simulate_soc_forward(
self,
from_hour: int,
start_soc: float,
charge_hours: set,
discharge_hours: set
) -> list[dict]:
"""Simulate SoC for next 24 hours."""
forecast = []
soc = start_soc
for i in range(24):
hour = (from_hour + i) % 24
if hour in charge_hours:
# Charging: use configured charge rate, cap at 100
soc = min(100, soc + self._charge_rate)
action = "charge"
elif hour in discharge_hours:
# Discharging: use configured discharge rate, floor at 0
soc = max(0, soc - self._discharge_rate)
action = "discharge"
else:
# Standby: minimal drain (base consumption ~2%/h)
soc = max(0, soc - 2)
action = "standby"
forecast.append({
"hour": hour,
"soc": round(soc, 1),
"action": action
})
return forecast
def _find_next_state_change(
self,
current_hour: int,
current_state: str,
charge_hours: set,
discharge_hours: set
) -> dict | None:
"""Find when the next state change will occur."""
for i in range(1, 25):
hour = (current_hour + i) % 24
if hour in charge_hours:
next_state = self.STATE_CHARGE
elif hour in discharge_hours:
next_state = self.STATE_DISCHARGE
else:
next_state = self.STATE_STANDBY
if next_state != current_state:
return {"hour": hour, "state": next_state}
return None
@property
def native_value(self) -> str:
"""Return the current recommendation state."""
state, _ = self._calculate_recommendation()
return state
@property
def extra_state_attributes(self) -> dict:
"""Return extra state attributes."""
_, attrs = self._calculate_recommendation()
return attrs
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success and self.coordinator.data is not None

View File

@ -49,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
# Get translations for logs # Get translations for logs
try: try:
translations = await async_get_translations( translations = await async_get_translations(
hass, hass.config.language, DOMAIN, ["mqtt"] hass, hass.config.language, DOMAIN
) )
except Exception as e: except Exception as e:
_LOGGER.warning("Failed to load translations for services: %s", e) _LOGGER.warning("Failed to load translations for services: %s", e)

View File

@ -76,7 +76,15 @@
"mqtt_topic_sell": "MQTT Topic for Sell Prices", "mqtt_topic_sell": "MQTT Topic for Sell Prices",
"mqtt_48h_mode": "Enable 48h mode for MQTT", "mqtt_48h_mode": "Enable 48h mode for MQTT",
"retry_attempts": "API retry attempts", "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": { "data_description": {
"buy_top": "How many cheapest buy prices to highlight (1-24 hours)", "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_topic_sell": "MQTT topic where sell prices will be published",
"mqtt_48h_mode": "Publish 48 hours of prices (today + tomorrow) instead of just today", "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_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": { "price_settings": {

View File

@ -65,7 +65,47 @@
"step": { "step": {
"init": { "init": {
"title": "Opcje Pstryk Energy", "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": { "price_settings": {
"title": "Ustawienia Monitorowania Cen", "title": "Ustawienia Monitorowania Cen",

View File

@ -171,7 +171,7 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator):
# Load translations # Load translations
try: try:
self._translations = await async_get_translations( 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: except Exception as ex:
_LOGGER.warning("Failed to load translations for coordinator: %s", ex) _LOGGER.warning("Failed to load translations for coordinator: %s", ex)