Skip to content

Commit 93d4a66

Browse files
authored
Merge pull request #16 from enoch85/fix/import-organization
Fix anti-windup blocking and pyright/left errors
2 parents 1a58fc7 + e48ef9a commit 93d4a66

22 files changed

+418
-149
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<img src="icons/logo.png" alt="EffektGuard Logo" width="200"/>
66

77
[![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
8-
![Version](https://img.shields.io/badge/version-0.4.25-blue)
8+
![Version](https://img.shields.io/badge/version-0.4.26-beta.8-blue)
99
![HA](https://img.shields.io/badge/Home%20Assistant-2025.10%2B-blue)
1010
[![Sponsor on GitHub](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-1f425f?logo=github&style=for-the-badge)](https://github.com/sponsors/enoch85)
1111

custom_components/effektguard/adapters/gespot_adapter.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
import logging
2121
from dataclasses import dataclass
2222
from datetime import datetime
23-
from typing import Any
23+
from typing import TYPE_CHECKING, Any
2424

2525
from homeassistant.core import HomeAssistant
2626
from homeassistant.util import dt as dt_util
2727

2828
from ..const import CONF_GESPOT_ENTITY, DAYTIME_END_HOUR, DAYTIME_START_HOUR
2929
from ..utils.time_utils import get_current_quarter
3030

31+
if TYPE_CHECKING:
32+
from ..models.types import AdapterConfigDict
33+
3134
_LOGGER = logging.getLogger(__name__)
3235

3336

@@ -109,7 +112,7 @@ def current_quarter(self) -> int | None:
109112
class GESpotAdapter:
110113
"""Adapter for reading GE-Spot price entities."""
111114

112-
def __init__(self, hass: HomeAssistant, config: dict[str, Any]):
115+
def __init__(self, hass: HomeAssistant, config: "AdapterConfigDict"):
113116
"""Initialize GE-Spot adapter.
114117
115118
Args:

custom_components/effektguard/adapters/nibe_adapter.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717

1818
import logging
1919
from dataclasses import dataclass
20-
from datetime import datetime
21-
from typing import Any
20+
from datetime import datetime, timedelta
21+
from typing import TYPE_CHECKING
2222

2323
from homeassistant.core import HomeAssistant
2424
from homeassistant.helpers import entity_registry as er
@@ -44,6 +44,9 @@
4444
TEMP_FACTOR_MIN,
4545
)
4646

47+
if TYPE_CHECKING:
48+
from ..models.types import AdapterConfigDict
49+
4750
_LOGGER = logging.getLogger(__name__)
4851

4952

@@ -97,7 +100,7 @@ def dhw_temp(self) -> float | None:
97100
class NibeAdapter:
98101
"""Adapter for reading NIBE Myuplink entities."""
99102

100-
def __init__(self, hass: HomeAssistant, config: dict[str, Any]):
103+
def __init__(self, hass: HomeAssistant, config: "AdapterConfigDict"):
101104
"""Initialize NIBE adapter.
102105
103106
Args:
@@ -111,6 +114,7 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]):
111114
self._additional_indoor_sensors = config.get(CONF_ADDITIONAL_INDOOR_SENSORS, []) # Optional
112115
self._indoor_temp_method = config.get(CONF_INDOOR_TEMP_METHOD, DEFAULT_INDOOR_TEMP_METHOD)
113116
self._last_write: datetime | None = None
117+
self._last_ventilation_write: datetime | None = None
114118
self._entity_cache: dict[str, str] = {}
115119
# Fractional accumulator for precise offset tracking
116120
# NIBE only accepts integers, so we accumulate fractional parts
@@ -119,6 +123,20 @@ def __init__(self, hass: HomeAssistant, config: dict[str, Any]):
119123
# Track last integer offset sent to NIBE (to avoid redundant writes)
120124
self._last_nibe_offset: int | None = None
121125

126+
@property
127+
def entity_cache(self) -> dict[str, str]:
128+
"""Public access to discovered entity cache."""
129+
return self._entity_cache
130+
131+
@property
132+
def power_sensor_entity(self) -> str | None:
133+
"""Public access to configured power sensor entity."""
134+
return self._power_sensor_entity
135+
136+
async def discover_entities(self) -> None:
137+
"""Discover NIBE entities (public wrapper)."""
138+
await self._discover_nibe_entities()
139+
122140
async def get_current_state(self) -> NibeState:
123141
"""Read current NIBE heat pump state from entities.
124142
@@ -291,8 +309,6 @@ async def set_curve_offset(self, offset: float) -> bool:
291309
Note:
292310
Requires NIBE Myuplink Premium subscription for write access.
293311
"""
294-
from datetime import timedelta
295-
296312
# Rate limiting - minimum time between writes
297313
now = dt_util.utcnow()
298314
if self._last_write and now - self._last_write < timedelta(
@@ -339,7 +355,7 @@ async def set_curve_offset(self, offset: float) -> bool:
339355
self._fractional_accumulator = offset - self._last_nibe_offset
340356

341357
_LOGGER.debug(
342-
"Offset calculation: calculated=%.2f°C, NIBE_current=%d°C, " "accumulator=%.2f°C",
358+
"Offset calculation: calculated=%.2f°C, NIBE_current=%d°C, accumulator=%.2f°C",
343359
offset,
344360
self._last_nibe_offset,
345361
self._fractional_accumulator,
@@ -433,8 +449,6 @@ async def set_enhanced_ventilation(self, enabled: bool) -> bool:
433449
Note:
434450
Based on NIBE myuplink entity: switch.f750_cu_3x400v_increased_ventilation
435451
"""
436-
from datetime import timedelta
437-
438452
# Get ventilation switch entity from cache
439453
ventilation_entity = self._entity_cache.get("increased_ventilation")
440454

@@ -463,9 +477,6 @@ async def set_enhanced_ventilation(self, enabled: bool) -> bool:
463477

464478
# Rate limiting - minimum time between writes
465479
now = dt_util.utcnow()
466-
if not hasattr(self, "_last_ventilation_write"):
467-
self._last_ventilation_write = None
468-
469480
if self._last_ventilation_write and now - self._last_ventilation_write < timedelta(
470481
minutes=SERVICE_RATE_LIMIT_MINUTES
471482
):

custom_components/effektguard/adapters/weather_adapter.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
import random
1414
from dataclasses import dataclass
1515
from datetime import datetime, timedelta
16-
from typing import Any
16+
from typing import TYPE_CHECKING
1717

1818
from homeassistant.core import HomeAssistant
1919
from homeassistant.util import dt as dt_util
2020

2121
from ..const import CONF_WEATHER_ENTITY
2222

23+
if TYPE_CHECKING:
24+
from ..models.types import AdapterConfigDict
25+
2326
_LOGGER = logging.getLogger(__name__)
2427

2528

@@ -45,7 +48,7 @@ class WeatherData:
4548
class WeatherAdapter:
4649
"""Adapter for reading weather forecast entities."""
4750

48-
def __init__(self, hass: HomeAssistant, config: dict[str, Any]):
51+
def __init__(self, hass: HomeAssistant, config: "AdapterConfigDict"):
4952
"""Initialize weather adapter.
5053
5154
Args:
@@ -177,14 +180,14 @@ async def get_forecast(self) -> WeatherData | None:
177180
source_method = "service_call"
178181
else:
179182
_LOGGER.warning(
180-
"Service call succeeded but returned no forecast data. " "Response: %s",
183+
"Service call succeeded but returned no forecast data. Response: %s",
181184
forecast_data,
182185
)
183186
# Schedule next random attempt
184187
self._schedule_next_random_attempt()
185188
else:
186189
_LOGGER.warning(
187-
"Service call succeeded but returned no forecast data. " "Response: %s",
190+
"Service call succeeded but returned no forecast data. Response: %s",
188191
forecast_data,
189192
)
190193
# Schedule next random attempt

custom_components/effektguard/climate.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,10 @@
77
import logging
88
from typing import Any
99

10-
from homeassistant.components.climate import (
11-
ClimateEntity,
10+
from homeassistant.components.climate import ClimateEntity
11+
from homeassistant.components.climate.const import (
1212
ClimateEntityFeature,
1313
HVACMode,
14-
)
15-
from homeassistant.components.climate.const import (
1614
PRESET_AWAY,
1715
PRESET_COMFORT,
1816
PRESET_ECO,
@@ -54,13 +52,14 @@ async def async_setup_entry(
5452
async_add_entities([EffektGuardClimate(coordinator, entry)])
5553

5654

57-
class EffektGuardClimate(CoordinatorEntity, RestoreEntity, ClimateEntity):
55+
class EffektGuardClimate(CoordinatorEntity[EffektGuardCoordinator], RestoreEntity, ClimateEntity):
5856
"""Climate entity for EffektGuard.
5957
6058
Main user interface displaying current optimization status and allowing
6159
manual control of target temperature and optimization mode via presets.
6260
"""
6361

62+
coordinator: EffektGuardCoordinator
6463
_attr_has_entity_name = True
6564
_attr_name = None
6665
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@@ -230,6 +229,48 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
230229
# This automatically calls async_reload_entry() which updates coordinator config
231230
# via async_update_config() - no need for explicit refresh
232231

232+
# Sync method stubs required by ClimateEntity abstract base class.
233+
# These delegate to async versions - Home Assistant handles the async call.
234+
def set_temperature(self, **kwargs: Any) -> None:
235+
"""Set new target temperature (sync wrapper)."""
236+
raise NotImplementedError("Use async_set_temperature")
237+
238+
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
239+
"""Set new target hvac mode (sync wrapper)."""
240+
raise NotImplementedError("Use async_set_hvac_mode")
241+
242+
def set_preset_mode(self, preset_mode: str) -> None:
243+
"""Set new preset mode (sync wrapper)."""
244+
raise NotImplementedError("Use async_set_preset_mode")
245+
246+
def turn_on(self) -> None:
247+
"""Turn the entity on (not used - use set_hvac_mode)."""
248+
raise NotImplementedError("Use async_set_hvac_mode with HVACMode.HEAT")
249+
250+
def turn_off(self) -> None:
251+
"""Turn the entity off (not used - use set_hvac_mode)."""
252+
raise NotImplementedError("Use async_set_hvac_mode with HVACMode.OFF")
253+
254+
def toggle(self) -> None:
255+
"""Toggle the entity (not supported)."""
256+
raise NotImplementedError("Toggle not supported")
257+
258+
def set_humidity(self, humidity: int) -> None:
259+
"""Set new target humidity (not supported)."""
260+
raise NotImplementedError("Humidity control not supported")
261+
262+
def set_fan_mode(self, fan_mode: str) -> None:
263+
"""Set new target fan mode (not supported)."""
264+
raise NotImplementedError("Fan mode not supported")
265+
266+
def set_swing_mode(self, swing_mode: str) -> None:
267+
"""Set new target swing mode (not supported)."""
268+
raise NotImplementedError("Swing mode not supported")
269+
270+
def set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
271+
"""Set new target horizontal swing mode (not supported)."""
272+
raise NotImplementedError("Horizontal swing mode not supported")
273+
233274
@property
234275
def extra_state_attributes(self) -> dict[str, Any]:
235276
"""Return additional state attributes."""

custom_components/effektguard/config_flow.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import voluptuous as vol
77

88
from homeassistant import config_entries
9+
from homeassistant.config_entries import ConfigFlowResult
910
from homeassistant.core import callback
10-
from homeassistant.data_entry_flow import FlowResult
11-
from homeassistant.helpers import selector
11+
from homeassistant.helpers import entity_registry as er, selector
1212

1313
from .options import EffektGuardOptionsFlow
1414
from .const import (
@@ -36,11 +36,16 @@ class EffektGuardConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
3636

3737
VERSION = 1
3838

39-
def __init__(self):
39+
def __init__(self) -> None:
4040
"""Initialize config flow."""
4141
self._data: dict[str, Any] = {}
4242

43-
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
43+
def is_matching(self, other_flow: "EffektGuardConfigFlow") -> bool:
44+
"""Return True if this flow matches another flow in progress."""
45+
# Only one EffektGuard instance is supported
46+
return True
47+
48+
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
4449
"""Handle the initial step - NIBE integration selection."""
4550
errors = {}
4651

@@ -87,7 +92,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Flo
8792
},
8893
)
8994

90-
async def async_step_gespot(self, user_input: dict[str, Any] | None = None) -> FlowResult:
95+
async def async_step_gespot(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
9196
"""Configure spot price integration."""
9297
errors = {}
9398

@@ -154,7 +159,7 @@ async def async_step_gespot(self, user_input: dict[str, Any] | None = None) -> F
154159
},
155160
)
156161

157-
async def async_step_model(self, user_input: dict[str, Any] | None = None) -> FlowResult:
162+
async def async_step_model(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
158163
"""Handle heat pump model selection."""
159164
errors = {}
160165

@@ -185,7 +190,9 @@ async def async_step_model(self, user_input: dict[str, Any] | None = None) -> Fl
185190
},
186191
)
187192

188-
async def async_step_optional(self, user_input: dict[str, Any] | None = None) -> FlowResult:
193+
async def async_step_optional(
194+
self, user_input: dict[str, Any] | None = None
195+
) -> ConfigFlowResult:
189196
"""Configure optional features."""
190197
if user_input is not None:
191198
# Store optional settings
@@ -219,7 +226,7 @@ async def async_step_optional(self, user_input: dict[str, Any] | None = None) ->
219226

220227
async def async_step_optional_sensors(
221228
self, user_input: dict[str, Any] | None = None
222-
) -> FlowResult:
229+
) -> ConfigFlowResult:
223230
"""Configure optional sensors (degree minutes, power meter, extra temp sensors)."""
224231
if user_input is not None:
225232
# Store optional sensor settings
@@ -346,8 +353,6 @@ def _discover_nibe_entities(self) -> list[str]:
346353
- OR number.* entities with 'offset' in name AND 'nibe' in entity_id
347354
- Excludes entities with translation errors
348355
"""
349-
from homeassistant.helpers import entity_registry as er
350-
351356
entities = []
352357
ent_reg = er.async_get(self.hass)
353358

@@ -528,8 +533,6 @@ def _discover_temp_lux_entities(self) -> list[str]:
528533
- switch.*50004* (NIBE parameter ID)
529534
- Related to NIBE/MyUplink integration
530535
"""
531-
from homeassistant.helpers import entity_registry as er
532-
533536
entities = []
534537
ent_reg = er.async_get(self.hass)
535538

@@ -566,7 +569,9 @@ def _discover_temp_lux_entities(self) -> list[str]:
566569

567570
return entities
568571

569-
async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> FlowResult:
572+
async def async_step_reconfigure(
573+
self, user_input: dict[str, Any] | None = None
574+
) -> ConfigFlowResult:
570575
"""Handle reconfiguration of entity selections.
571576
572577
Allows users to change entity selections (weather, power sensor, etc.)

custom_components/effektguard/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,12 @@ class OptimizationModeConfig:
672672
VOLATILE_MIN_DURATION_MINUTES // MINUTES_PER_QUARTER
673673
) # 3 quarters (45min / 15min)
674674

675+
# Volatile timing tolerance (Feb 2, 2026)
676+
# Prevents floating-point rounding in time.time() from blocking at the displayed boundary.
677+
# Jan 31 2026 incident: log showed "45min < 45min" still blocking because actual seconds
678+
# were 2699.x which rounds to 45min display but is still < 2700 seconds.
679+
VOLATILE_TIMING_TOLERANCE_SECONDS: Final = 2
680+
675681
# Price forecast lookahead (Nov 27, 2025)
676682
# Forward-looking price optimization: reduce heating when cheaper period coming soon
677683
# Updated Nov 28, 2025: Horizon scales with thermal_mass (configurable 0.5-2.0)

0 commit comments

Comments
 (0)