|
| 1 | +"""EV SOC Monitor component for EV Smart Charger integration (v1.4.0). |
| 2 | +
|
| 3 | +Monitors cloud-based EV SOC sensor and maintains reliable cached value. |
| 4 | +""" |
| 5 | +from datetime import datetime, timedelta |
| 6 | +from homeassistant.core import HomeAssistant |
| 7 | +from homeassistant.helpers.event import async_track_time_interval |
| 8 | +from homeassistant.util import dt as dt_util |
| 9 | + |
| 10 | +from .const import ( |
| 11 | + CONF_SOC_CAR, |
| 12 | + EV_SOC_MONITOR_INTERVAL, |
| 13 | + HELPER_CACHED_EV_SOC_SUFFIX, |
| 14 | +) |
| 15 | +from .utils.logging_helper import EVSCLogger |
| 16 | +from .utils import entity_helper |
| 17 | + |
| 18 | + |
| 19 | +class EVSOCMonitor: |
| 20 | + """ |
| 21 | + EV SOC Monitor - Reliability layer for cloud-based EV SOC sensors. |
| 22 | +
|
| 23 | + Polls cloud sensor every 5 seconds and updates cached sensor only when |
| 24 | + cloud sensor has valid values. Maintains last known good value when |
| 25 | + cloud sensor is unavailable. |
| 26 | + """ |
| 27 | + |
| 28 | + def __init__(self, hass: HomeAssistant, entry_id: str, config: dict): |
| 29 | + """Initialize EV SOC Monitor.""" |
| 30 | + self.hass = hass |
| 31 | + self.entry_id = entry_id |
| 32 | + self.config = config |
| 33 | + self.logger = EVSCLogger("EV SOC MONITOR") |
| 34 | + |
| 35 | + # Source sensor (cloud-based, user-configured) |
| 36 | + self._source_entity = config.get(CONF_SOC_CAR) |
| 37 | + |
| 38 | + # Cache sensor (internal, reliable) |
| 39 | + self._cache_entity = None # Discovered in async_setup |
| 40 | + |
| 41 | + # State tracking |
| 42 | + self._last_valid_value = None |
| 43 | + self._last_valid_time = None |
| 44 | + self._last_source_state = None # For change detection |
| 45 | + self._timer_unsub = None |
| 46 | + |
| 47 | + async def async_setup(self): |
| 48 | + """Setup: discover cache sensor and start polling timer.""" |
| 49 | + self.logger.info("Setting up EV SOC Monitor") |
| 50 | + |
| 51 | + # Discover cached sensor entity |
| 52 | + self._cache_entity = entity_helper.find_by_suffix( |
| 53 | + self.hass, HELPER_CACHED_EV_SOC_SUFFIX |
| 54 | + ) |
| 55 | + |
| 56 | + if not self._cache_entity: |
| 57 | + self.logger.error( |
| 58 | + f"Cached EV SOC sensor not found! " |
| 59 | + f"Monitor cannot function without cache sensor." |
| 60 | + ) |
| 61 | + return |
| 62 | + |
| 63 | + self.logger.info(f"Source sensor: {self._source_entity}") |
| 64 | + self.logger.info(f"Cache sensor: {self._cache_entity}") |
| 65 | + |
| 66 | + # Start polling timer (every 5 seconds) |
| 67 | + self._timer_unsub = async_track_time_interval( |
| 68 | + self.hass, |
| 69 | + self._async_poll_source_sensor, |
| 70 | + timedelta(seconds=EV_SOC_MONITOR_INTERVAL), |
| 71 | + ) |
| 72 | + |
| 73 | + self.logger.success( |
| 74 | + f"EV SOC Monitor active - polling every {EV_SOC_MONITOR_INTERVAL}s" |
| 75 | + ) |
| 76 | + |
| 77 | + async def _async_poll_source_sensor(self, now=None): |
| 78 | + """ |
| 79 | + Poll source sensor and update cache if valid. |
| 80 | +
|
| 81 | + Logging strategy: |
| 82 | + - Silent when source sensor provides valid values (normal operation) |
| 83 | + - WARNING only when using cached value because source unavailable |
| 84 | + """ |
| 85 | + # Read source sensor state |
| 86 | + source_state = self.hass.states.get(self._source_entity) |
| 87 | + |
| 88 | + if not source_state: |
| 89 | + # Source entity doesn't exist (should never happen after setup) |
| 90 | + if self._last_source_state != "missing": |
| 91 | + self.logger.error( |
| 92 | + f"Source sensor {self._source_entity} not found! " |
| 93 | + f"Using cached value: {self._last_valid_value}%" |
| 94 | + ) |
| 95 | + self._last_source_state = "missing" |
| 96 | + return |
| 97 | + |
| 98 | + # Check if state is valid (not unknown/unavailable/None) |
| 99 | + if self._is_valid_state(source_state): |
| 100 | + # Valid state - parse and update cache |
| 101 | + try: |
| 102 | + new_value = float(source_state.state) |
| 103 | + |
| 104 | + # Validate SOC range (0-100%) |
| 105 | + if not (0 <= new_value <= 100): |
| 106 | + self.logger.warning( |
| 107 | + f"Source SOC value out of range: {new_value}% " |
| 108 | + f"(expected 0-100). Keeping cached value: {self._last_valid_value}%" |
| 109 | + ) |
| 110 | + return |
| 111 | + |
| 112 | + # Update cache (silent update - normal operation) |
| 113 | + await self._update_cache(new_value) |
| 114 | + |
| 115 | + # Reset invalid state tracking |
| 116 | + self._last_source_state = "valid" |
| 117 | + |
| 118 | + except (ValueError, TypeError) as e: |
| 119 | + # Failed to parse as float |
| 120 | + if self._last_source_state != "invalid_value": |
| 121 | + self.logger.warning( |
| 122 | + f"Failed to parse source SOC: {source_state.state} " |
| 123 | + f"(error: {e}). Using cached value: {self._last_valid_value}%" |
| 124 | + ) |
| 125 | + self._last_source_state = "invalid_value" |
| 126 | + else: |
| 127 | + # Invalid state (unknown/unavailable) - keep cache, log warning |
| 128 | + if self._last_source_state != source_state.state: |
| 129 | + # State changed to invalid - log warning |
| 130 | + self.logger.warning( |
| 131 | + f"{self.logger.ALERT} Using cached EV SOC: {self._last_valid_value}% " |
| 132 | + f"(source sensor unavailable: {source_state.state})" |
| 133 | + ) |
| 134 | + self._last_source_state = source_state.state |
| 135 | + |
| 136 | + def _is_valid_state(self, state) -> bool: |
| 137 | + """Check if state is valid (not unknown/unavailable/None).""" |
| 138 | + if not state: |
| 139 | + return False |
| 140 | + if state.state in [None, "unknown", "unavailable", "none"]: |
| 141 | + return False |
| 142 | + return True |
| 143 | + |
| 144 | + async def _update_cache(self, value: float): |
| 145 | + """Update cached sensor with new valid value (silent operation).""" |
| 146 | + self._last_valid_value = value |
| 147 | + self._last_valid_time = dt_util.now() |
| 148 | + |
| 149 | + # Calculate cache age for diagnostics |
| 150 | + cache_age_seconds = 0 # Just updated |
| 151 | + |
| 152 | + # Update cache sensor state |
| 153 | + self.hass.states.async_set( |
| 154 | + self._cache_entity, |
| 155 | + value, |
| 156 | + { |
| 157 | + "unit_of_measurement": "%", |
| 158 | + "source_entity": self._source_entity, |
| 159 | + "last_valid_update": self._last_valid_time.isoformat(), |
| 160 | + "is_cached": False, # Fresh value |
| 161 | + "cache_age_seconds": cache_age_seconds, |
| 162 | + }, |
| 163 | + ) |
| 164 | + |
| 165 | + async def async_remove(self): |
| 166 | + """Cleanup: cancel timer.""" |
| 167 | + if self._timer_unsub: |
| 168 | + self._timer_unsub() |
| 169 | + self._timer_unsub = None |
| 170 | + |
| 171 | + self.logger.info("EV SOC Monitor removed") |
0 commit comments