not ideal but working bat charging
This commit is contained in:
90
README.md
90
README.md
@ -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
|
||||||
|
|
||||||
[](https://github.com/balgerion/ha_Pstryk/)
|
[](https://github.com/balgerion/ha_Pstryk/)
|
||||||
|
|
||||||
Integracja dla Home Assistant umożliwiająca śledzenie aktualnych cen energii elektrycznej oraz prognoz z platformy Pstryk.
|
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
|
||||||
|
|||||||
237
automations/battery_control_pstryk.yaml
Normal file
237
automations/battery_control_pstryk.yaml
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
alias: "Battery Control - Pstryk Recommendations"
|
||||||
|
description: "React instantly to Pstryk battery recommendations with Tesla charging protection"
|
||||||
|
mode: single
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
# React instantly when recommendation changes
|
||||||
|
- platform: state
|
||||||
|
entity_id: sensor.pstryk_battery_recommendation
|
||||||
|
to:
|
||||||
|
- "charge"
|
||||||
|
- "discharge"
|
||||||
|
- "standby"
|
||||||
|
for:
|
||||||
|
seconds: 30
|
||||||
|
|
||||||
|
# Also react when Tesla power changes significantly
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||||
|
above: 2000
|
||||||
|
for:
|
||||||
|
seconds: 10
|
||||||
|
- platform: numeric_state
|
||||||
|
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||||
|
below: 2000
|
||||||
|
for:
|
||||||
|
seconds: 60
|
||||||
|
|
||||||
|
condition:
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ states('sensor.pstryk_battery_recommendation') not in ['unavailable', 'unknown'] }}
|
||||||
|
|
||||||
|
action:
|
||||||
|
- choose:
|
||||||
|
# ==========================================
|
||||||
|
# OVERRIDE: Tesla charging > 2000W = STANDBY
|
||||||
|
# ==========================================
|
||||||
|
- conditions:
|
||||||
|
- type: is_power
|
||||||
|
condition: device
|
||||||
|
device_id: 371785f33a0d9b3ea38ed224f9e17a4b
|
||||||
|
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||||
|
domain: sensor
|
||||||
|
above: 2000
|
||||||
|
sequence:
|
||||||
|
- service: logbook.log
|
||||||
|
data:
|
||||||
|
name: "Battery STANDBY (Tesla Override)"
|
||||||
|
message: >
|
||||||
|
Tesla charging detected - switching to standby to protect grid connection.
|
||||||
|
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||||
|
|
||||||
|
# Falownik 1 - STANDBY mode
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
|
||||||
|
# Falownik 2 - STANDBY mode
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: c94531b376614314af08b17931f69980
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
|
||||||
|
- service: input_boolean.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.1h_battery_boost
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# CHARGE (when Tesla < 2000W)
|
||||||
|
# ==========================================
|
||||||
|
- conditions:
|
||||||
|
- condition: state
|
||||||
|
entity_id: sensor.pstryk_battery_recommendation
|
||||||
|
state: "charge"
|
||||||
|
- type: is_power
|
||||||
|
condition: device
|
||||||
|
device_id: 371785f33a0d9b3ea38ed224f9e17a4b
|
||||||
|
entity_id: 9eed3a28cda747219c2d04d079725d9e
|
||||||
|
domain: sensor
|
||||||
|
below: 2000
|
||||||
|
sequence:
|
||||||
|
- service: logbook.log
|
||||||
|
data:
|
||||||
|
name: "Battery CHARGE"
|
||||||
|
message: >
|
||||||
|
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||||
|
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||||
|
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||||
|
|
||||||
|
# Falownik 1 - CHARGE mode
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||||
|
type: select_option
|
||||||
|
option: PV priority
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
|
||||||
|
# Falownik 2 - CHARGE mode
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: c94531b376614314af08b17931f69980
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||||
|
type: select_option
|
||||||
|
option: PV priority
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# DISCHARGE
|
||||||
|
# ==========================================
|
||||||
|
- conditions:
|
||||||
|
- condition: state
|
||||||
|
entity_id: sensor.pstryk_battery_recommendation
|
||||||
|
state: "discharge"
|
||||||
|
sequence:
|
||||||
|
- service: logbook.log
|
||||||
|
data:
|
||||||
|
name: "Battery DISCHARGE"
|
||||||
|
message: >
|
||||||
|
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||||
|
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||||
|
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||||
|
|
||||||
|
# Falownik 1 - DISCHARGE mode
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||||
|
type: select_option
|
||||||
|
option: PV-Battery-Utility (SBU)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
|
||||||
|
# Falownik 2 - DISCHARGE mode
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: c94531b376614314af08b17931f69980
|
||||||
|
type: select_option
|
||||||
|
option: PV-Battery-Utility (SBU)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
|
||||||
|
- service: input_boolean.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.1h_battery_boost
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# STANDBY
|
||||||
|
# ==========================================
|
||||||
|
- conditions:
|
||||||
|
- condition: state
|
||||||
|
entity_id: sensor.pstryk_battery_recommendation
|
||||||
|
state: "standby"
|
||||||
|
sequence:
|
||||||
|
- service: logbook.log
|
||||||
|
data:
|
||||||
|
name: "Battery STANDBY"
|
||||||
|
message: >
|
||||||
|
{{ state_attr('sensor.pstryk_battery_recommendation', 'reason') }}
|
||||||
|
SoC: {{ states('sensor.wifiplug_battery_state_of_charge') }}%
|
||||||
|
Price: {{ states('sensor.pstryk_current_buy_price') }} PLN/kWh
|
||||||
|
|
||||||
|
# Falownik 1 - STANDBY mode
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 563f86d007910857cbd24d428ff665b0
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: ef18bab10bdf401736c3e075d9bdf9b5
|
||||||
|
domain: select
|
||||||
|
entity_id: 3ae13e5dc606d367078291bda9b40274
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
|
||||||
|
# Falownik 2 - STANDBY mode
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: c94531b376614314af08b17931f69980
|
||||||
|
type: select_option
|
||||||
|
option: PV-Utility-Battery (SUB)
|
||||||
|
- delay:
|
||||||
|
seconds: 15
|
||||||
|
- device_id: d65f655bdd00e2cdb019739f974b8c7c
|
||||||
|
domain: select
|
||||||
|
entity_id: b069e234e5478ed26733d4d85b2d00a5
|
||||||
|
type: select_option
|
||||||
|
option: Only PV charging is allowed
|
||||||
|
|
||||||
|
- service: input_boolean.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: input_boolean.1h_battery_boost
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user