From a7a2da2eb271f13be32927fd2d8d4130e072e297 Mon Sep 17 00:00:00 2001 From: DiTus Date: Tue, 3 Feb 2026 15:05:29 +0100 Subject: [PATCH] not ideal but working bat charging --- README.md | 90 +- automations/battery_control_pstryk.yaml | 237 ++++++ custom_components/pstryk/api_client.py | 2 +- custom_components/pstryk/config_flow.py | 58 +- custom_components/pstryk/const.py | 32 +- custom_components/pstryk/mqtt_publisher.py | 15 +- custom_components/pstryk/sensor.py | 778 +++++++++++++++++- custom_components/pstryk/services.py | 2 +- custom_components/pstryk/translations/en.json | 20 +- custom_components/pstryk/translations/pl.json | 42 +- .../pstryk/update_coordinator.py | 2 +- 11 files changed, 1241 insertions(+), 37 deletions(-) create mode 100644 automations/battery_control_pstryk.yaml diff --git a/README.md b/README.md index 169dfeb..f836713 100644 --- a/README.md +++ b/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 -[![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. @@ -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 - 📊 Średnia zakupu oraz sprzedaży - miesięczna/roczna - 📈 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 @@ -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 diff --git a/automations/battery_control_pstryk.yaml b/automations/battery_control_pstryk.yaml new file mode 100644 index 0000000..412b6d5 --- /dev/null +++ b/automations/battery_control_pstryk.yaml @@ -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 diff --git a/custom_components/pstryk/api_client.py b/custom_components/pstryk/api_client.py index 4d9e68c..1cbf163 100644 --- a/custom_components/pstryk/api_client.py +++ b/custom_components/pstryk/api_client.py @@ -51,7 +51,7 @@ class PstrykAPIClient: if not self._translations_loaded: try: 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 # Debug: log sample keys to understand the format diff --git a/custom_components/pstryk/config_flow.py b/custom_components/pstryk/config_flow.py index c8e6585..85adb69 100644 --- a/custom_components/pstryk/config_flow.py +++ b/custom_components/pstryk/config_flow.py @@ -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): @@ -261,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: diff --git a/custom_components/pstryk/const.py b/custom_components/pstryk/const.py index 4c57330..8bbcea8 100644 --- a/custom_components/pstryk/const.py +++ b/custom_components/pstryk/const.py @@ -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 diff --git a/custom_components/pstryk/mqtt_publisher.py b/custom_components/pstryk/mqtt_publisher.py index 08dd38a..77d2aec 100644 --- a/custom_components/pstryk/mqtt_publisher.py +++ b/custom_components/pstryk/mqtt_publisher.py @@ -46,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) @@ -202,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 @@ -212,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") diff --git a/custom_components/pstryk/sensor.py b/custom_components/pstryk/sensor.py index 5dc3f4c..977b5f3 100644 --- a/custom_components/pstryk/sensor.py +++ b/custom_components/pstryk/sensor.py @@ -1,12 +1,14 @@ """Sensor platform for Pstryk Energy integration.""" import logging import asyncio +import math from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.components.sensor import SensorEntity, SensorStateClass, SensorDeviceClass from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 .update_coordinator import PstrykDataUpdateCoordinator from .energy_cost_coordinator import PstrykCostDataUpdateCoordinator @@ -17,7 +19,22 @@ from .const import ( CONF_RETRY_ATTEMPTS, CONF_RETRY_DELAY, 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 @@ -26,28 +43,29 @@ _LOGGER = logging.getLogger(__name__) # Store translations globally to avoid reloading for each sensor _TRANSLATIONS_CACHE = {} -# Cache for manifest version +# Cache for manifest version - load at module import time (outside event loop) _VERSION_CACHE = None - -def get_integration_version(hass: HomeAssistant) -> str: - """Get integration version from manifest.json.""" - global _VERSION_CACHE - if _VERSION_CACHE is not None: - return _VERSION_CACHE - +def _load_version_sync() -> str: + """Load version synchronously at module import time.""" try: import json import os manifest_path = os.path.join(os.path.dirname(__file__), "manifest.json") with open(manifest_path, "r") as f: manifest = json.load(f) - _VERSION_CACHE = manifest.get("version", "unknown") - return _VERSION_CACHE - except Exception as ex: - _LOGGER.warning("Failed to read version from manifest.json: %s", ex) + return manifest.get("version", "unknown") + except Exception: 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( hass: HomeAssistant, @@ -72,7 +90,7 @@ async def async_setup_entry( if not _TRANSLATIONS_CACHE: try: _TRANSLATIONS_CACHE = await async_get_translations( - hass, hass.config.language, DOMAIN, ["entity", "debug"] + hass, hass.config.language, DOMAIN ) except Exception as 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") async def safe_initial_fetch(coord, coord_type): - """Safely fetch initial data for coordinator.""" + """Safely fetch initial data for coordinator with timeout.""" 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.last_update_success = True _LOGGER.debug("Successfully initialized %s coordinator", coord_type) 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: _LOGGER.error("Failed initial fetch for %s coordinator: %s", coord_type, err) 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) + # Track failed coordinators for quick retry + failed_coordinators = [] + # Check results for price coordinators for i, (coordinator, coordinator_type, key) in enumerate(price_coordinators): - if isinstance(refresh_results[i], Exception): - _LOGGER.error("Failed to initialize %s coordinator: %s", - coordinator_type, str(refresh_results[i])) + if isinstance(refresh_results[i], Exception) or refresh_results[i] is False: + _LOGGER.warning("Coordinator %s failed initial load - scheduling retry with backoff", + 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 buy_coord = None @@ -223,6 +301,24 @@ async def async_setup_entry( 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: # - Current price sensors (2) with data # - Remaining sensors (15) as unavailable until cost coordinator loads @@ -258,7 +354,7 @@ async def async_setup_entry( class PstrykPriceSensor(CoordinatorEntity, SensorEntity): """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): super().__init__(coordinator) @@ -711,6 +807,8 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity): avg_price_sunrise_sunset_key: None, next_hour_key: None, all_prices_key: [], + "all_prices": [], + "prices_today": [], best_prices_key: [], worst_prices_key: [], best_count_key: self.top_count, @@ -723,6 +821,7 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity): next_hour_data = self._get_next_hour_price() today = self.coordinator.data.get("prices_today", []) + all_prices_list = self.coordinator.data.get("prices", []) is_cached = self.coordinator.data.get("is_cached", False) # 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" # 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_prices = [] # Only check for tomorrow prices if we have a reasonable amount of data - if len(all_prices) > 0: - tomorrow_prices = [p for p in all_prices if p.get("start", "").startswith(tomorrow)] + if len(all_prices_list) > 0: + tomorrow_prices = [p for p in all_prices_list if p.get("start", "").startswith(tomorrow)] # Log what we found for debugging if tomorrow_prices: @@ -795,6 +893,8 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity): avg_price_sunrise_sunset_key: avg_price_sunrise_sunset, next_hour_key: next_hour_data, all_prices_key: today, + "all_prices": all_prices_list, + "prices_today": today, best_prices_key: sorted_prices["best"], worst_prices_key: sorted_prices["worst"], 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"), using_cached_key: is_cached, tomorrow_available_key: tomorrow_available, + "tomorrow_available": tomorrow_available, mqtt_price_count_key: mqtt_price_count, "mqtt_48h_mode": self.coordinator.mqtt_48h_mode } @@ -815,7 +916,7 @@ class PstrykPriceSensor(CoordinatorEntity, SensorEntity): class PstrykAveragePriceSensor(RestoreEntity, SensorEntity): """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, price_coordinator: PstrykDataUpdateCoordinator, @@ -1160,3 +1261,632 @@ class PstrykFinancialBalanceSensor(CoordinatorEntity, SensorEntity): def available(self) -> bool: """Return if entity is available.""" 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 diff --git a/custom_components/pstryk/services.py b/custom_components/pstryk/services.py index 06f6171..8e5956f 100644 --- a/custom_components/pstryk/services.py +++ b/custom_components/pstryk/services.py @@ -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) diff --git a/custom_components/pstryk/translations/en.json b/custom_components/pstryk/translations/en.json index 32cef49..d214d86 100644 --- a/custom_components/pstryk/translations/en.json +++ b/custom_components/pstryk/translations/en.json @@ -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": { diff --git a/custom_components/pstryk/translations/pl.json b/custom_components/pstryk/translations/pl.json index fb1f289..20b025a 100644 --- a/custom_components/pstryk/translations/pl.json +++ b/custom_components/pstryk/translations/pl.json @@ -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", diff --git a/custom_components/pstryk/update_coordinator.py b/custom_components/pstryk/update_coordinator.py index 3c90d68..1d00c57 100644 --- a/custom_components/pstryk/update_coordinator.py +++ b/custom_components/pstryk/update_coordinator.py @@ -171,7 +171,7 @@ class PstrykDataUpdateCoordinator(DataUpdateCoordinator): # Load translations 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)