Skip to content

Commit ea9ff36

Browse files
author
Antonio ...
committed
feat: Cloud sensor reliability layer with automatic caching (v1.4.0)
MAJOR FEATURE: EV SOC Monitor - Automatic Cache for Unreliable Cloud Sensors Problem Solved: Cloud-based car SOC sensors frequently show "unknown" or "unavailable" states, causing Priority Balancer, Night Charge, and Solar Surplus to fail calculations or use incorrect default values. Solution Implemented: New cache sensor pattern with dedicated monitoring service that polls cloud sensor every 5 seconds and maintains last known good value during outages. New Components: - EVSOCMonitor: Dedicated monitoring service (5-second polling) - EVSCCachedEVSOCSensor: Reliable cache sensor with state persistence - Phase 2.5 integration in setup lifecycle Architecture: Cloud Sensor (unreliable) → Monitor (5s polling) → Cache Sensor (reliable) → All Components Key Features: ✅ 5-second polling of cloud sensor (fixed interval) ✅ Silent updates when cloud sensor provides valid values ✅ Maintains last valid value when cloud sensor unavailable ✅ Warning logs ONLY when using cached value due to source unavailability ✅ RestoreEntity pattern for state persistence across HA restarts ✅ Automatic fallback to direct cloud sensor if cache unavailable ✅ Zero user configuration required (fully automatic) Technical Implementation: 1. NEW FILE: ev_soc_monitor.py (~175 lines) - EVSOCMonitor class with async_track_time_interval timer - Validates cloud sensor state (rejects None, "unknown", "unavailable") - Updates cache only on valid values (0-100% range check) - Change detection prevents log spam (logs only on state transitions) 2. MODIFIED: sensor.py - Added EVSCCachedEVSOCSensor class (~65 lines) - RestoreEntity for state persistence - Attributes: source_entity, last_valid_update, is_cached, cache_age_seconds - Total sensors: 6 → 7 3. MODIFIED: priority_balancer.py - Split _soc_car into _soc_car_source (cloud) and _soc_car (cache) - Cache sensor discovery in async_setup() - Automatic fallback to source if cache unavailable - All SOC reads now use cached sensor 4. MODIFIED: __init__.py - Added Phase 2.5: EV SOC Monitor setup (between ChargerController and AutomationCoordinator) - Monitor cleanup in async_unload_entry() - Reference stored in hass.data for lifecycle management 5. MODIFIED: night_smart_charge.py - Updated diagnostic label to show cached sensor (cosmetic) 6. MODIFIED: const.py, manifest.json - VERSION updated to "1.4.0" - Added HELPER_CACHED_EV_SOC_SUFFIX constant - Added EV_SOC_MONITOR_INTERVAL = 5 constant Logging Behavior: - Silent operation when cloud sensor provides valid values (normal operation) - WARNING only when using cached value because cloud sensor unavailable - Change detection prevents log spam during prolonged outages - Example: "⚠️ Using cached EV SOC: 65% (source sensor unavailable: unknown)" Benefits: ✅ Eliminates calculation failures from unreliable cloud sensors ✅ Priority Balancer always has valid SOC data for decisions ✅ Night Charge no longer skips charging due to "unknown" EV SOC ✅ Solar Surplus battery support logic more reliable ✅ No user intervention required during cloud sensor outages ✅ Seamless recovery when cloud sensor becomes available again Backward Compatibility: ✅ Fully backward compatible - works automatically after HA restart ✅ Graceful fallback to direct cloud sensor if cache unavailable ✅ No breaking changes to existing configurations ✅ No user action required (automatic migration) Migration Notes: After upgrading to v1.4.0 and restarting Home Assistant: 1. Cache sensor automatically created: sensor.evsc_cached_ev_soc 2. Monitor starts polling cloud sensor every 5 seconds 3. Priority Balancer automatically discovers and uses cache sensor 4. Check logs for: "✅ Using cached EV SOC sensor: sensor.evsc_cached_ev_soc" Testing Recommendations: 1. Monitor logs during first 5 minutes after restart 2. Verify cache sensor appears in Developer Tools → States 3. Test cloud sensor outage: check cache maintains last valid value 4. Verify Priority Balancer calculations continue during outage 5. Check warning logs only appear when using cached value Files Changed: - custom_components/ev_smart_charger/ev_soc_monitor.py (NEW - 172 lines) - custom_components/ev_smart_charger/sensor.py (added EVSCCachedEVSOCSensor) - custom_components/ev_smart_charger/__init__.py (Phase 2.5 integration) - custom_components/ev_smart_charger/priority_balancer.py (cache discovery) - custom_components/ev_smart_charger/night_smart_charge.py (diagnostic label) - custom_components/ev_smart_charger/const.py (version + constants) - custom_components/ev_smart_charger/manifest.json (version) Upgrade Priority: 🟢 RECOMMENDED - Significantly improves reliability for users with cloud-based car integrations
1 parent d8df032 commit ea9ff36

7 files changed

Lines changed: 293 additions & 5 deletions

File tree

custom_components/ev_smart_charger/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .const import DOMAIN, PLATFORMS, VERSION
1111
from .automation_coordinator import AutomationCoordinator
1212
from .charger_controller import ChargerController
13+
from .ev_soc_monitor import EVSOCMonitor
1314
from .priority_balancer import PriorityBalancer
1415
from .night_smart_charge import NightSmartCharge
1516
from .automations import SmartChargerBlocker
@@ -56,6 +57,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5657
_LOGGER.exception("Charger Controller setup error details:")
5758
return False
5859

60+
# ========== PHASE 2.5: CREATE EV SOC MONITOR (Cache Reliability Layer) ==========
61+
_LOGGER.info("⏳ Phase 2.5: Creating EV SOC Monitor (cache reliability)")
62+
ev_soc_monitor = EVSOCMonitor(hass, entry.entry_id, entry.data)
63+
try:
64+
await ev_soc_monitor.async_setup()
65+
_LOGGER.info("✅ EV SOC Monitor setup complete")
66+
except Exception as e:
67+
_LOGGER.error(f"❌ Failed to set up EV SOC Monitor: {e}")
68+
_LOGGER.exception("EV SOC Monitor setup error details:")
69+
return False
70+
5971
# ========== PHASE 3: CREATE AUTOMATION COORDINATOR ==========
6072
_LOGGER.info("🔧 Phase 3: Creating Automation Coordinator")
6173
coordinator = AutomationCoordinator(hass, entry.entry_id)
@@ -132,6 +144,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
132144
hass.data[DOMAIN][entry.entry_id] = {
133145
"config": entry.data,
134146
"charger_controller": charger_controller,
147+
"ev_soc_monitor": ev_soc_monitor,
135148
"coordinator": coordinator,
136149
"priority_balancer": priority_balancer,
137150
"night_smart_charge": night_smart_charge,
@@ -191,6 +204,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
191204
_LOGGER.info("🗑️ Removing Log Manager")
192205
await log_manager.async_remove()
193206

207+
ev_soc_monitor = entry_data.get("ev_soc_monitor")
208+
if ev_soc_monitor:
209+
_LOGGER.info("🗑️ Removing EV SOC Monitor")
210+
await ev_soc_monitor.async_remove()
211+
194212
charger_controller = entry_data.get("charger_controller")
195213
if charger_controller:
196214
_LOGGER.info("🗑️ Removing Charger Controller")

custom_components/ev_smart_charger/const.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# ========== INTEGRATION METADATA ==========
44
DOMAIN = "ev_smart_charger"
5-
VERSION = "1.3.26"
5+
VERSION = "1.4.0"
66
DEFAULT_NAME = "EV Smart Charger"
77

88
# ========== PLATFORMS ==========
@@ -133,6 +133,7 @@
133133
HELPER_LOG_FILE_PATH_SUFFIX = "evsc_log_file_path" # v1.3.25
134134
HELPER_TODAY_EV_TARGET_SUFFIX = "evsc_today_ev_target" # v1.3.26
135135
HELPER_TODAY_HOME_TARGET_SUFFIX = "evsc_today_home_target" # v1.3.26
136+
HELPER_CACHED_EV_SOC_SUFFIX = "evsc_cached_ev_soc" # v1.4.0
136137

137138
# ========== DEFAULT VALUES - SOLAR SURPLUS ==========
138139
DEFAULT_CHECK_INTERVAL = 1 # minutes
@@ -192,3 +193,6 @@
192193
# ========== FILE LOGGING SETTINGS (v1.3.25) ==========
193194
FILE_LOG_MAX_SIZE_MB = 10 # 10MB per log file
194195
FILE_LOG_BACKUP_COUNT = 5 # Keep 5 backup files (50MB total)
196+
197+
# ========== EV SOC MONITOR SETTINGS (v1.4.0) ==========
198+
EV_SOC_MONITOR_INTERVAL = 5 # seconds - polling frequency for cloud sensor reliability
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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")

custom_components/ev_smart_charger/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"domain": "ev_smart_charger",
33
"name": "EV Smart Charger",
4-
"version": "1.3.26",
4+
"version": "1.4.0",
55
"documentation": "https://github.com/antbald/ha-ev-smart-charger",
66
"issue_tracker": "https://github.com/antbald/ha-ev-smart-charger/issues",
77
"codeowners": ["@antbald"],

custom_components/ev_smart_charger/night_smart_charge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ async def _evaluate_and_charge(self) -> None:
382382

383383
critical_sensors = {
384384
"Charger Status": self._charger_status,
385-
"EV SOC": self._soc_car,
385+
"EV SOC (cached)": self.priority_balancer._soc_car, # v1.4.0 - show cached sensor
386386
f"EV Target ({today.capitalize()})": ev_target_entity,
387387
}
388388

custom_components/ev_smart_charger/priority_balancer.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
HELPER_PRIORITY_BALANCER_ENABLED_SUFFIX,
1414
HELPER_TODAY_EV_TARGET_SUFFIX,
1515
HELPER_TODAY_HOME_TARGET_SUFFIX,
16+
HELPER_CACHED_EV_SOC_SUFFIX,
1617
PRIORITY_EV,
1718
PRIORITY_HOME,
1819
PRIORITY_EV_FREE,
@@ -37,7 +38,8 @@ def __init__(self, hass: HomeAssistant, entry_id: str, config: dict):
3738
self.logger = EVSCLogger("PRIORITY BALANCER")
3839

3940
# User-mapped entities
40-
self._soc_car = config.get(CONF_SOC_CAR)
41+
self._soc_car_source = config.get(CONF_SOC_CAR) # Cloud sensor (original)
42+
self._soc_car = None # Cached sensor (discovered in async_setup) - v1.4.0
4143
self._soc_home = config.get(CONF_SOC_HOME)
4244

4345
# Helper entities (discovered in async_setup)
@@ -88,6 +90,23 @@ async def async_setup(self):
8890
self.hass, home_suffix
8991
)
9092

93+
# Discover cached EV SOC sensor (v1.4.0)
94+
self._soc_car = entity_helper.find_by_suffix(
95+
self.hass, HELPER_CACHED_EV_SOC_SUFFIX
96+
)
97+
98+
if not self._soc_car:
99+
self.logger.warning(
100+
f"Cached EV SOC sensor not found - falling back to direct source. "
101+
f"This should only happen on first setup before restart."
102+
)
103+
self._soc_car = self._soc_car_source # Fallback to cloud sensor
104+
else:
105+
self.logger.info(
106+
f"✅ Using cached EV SOC sensor: {self._soc_car} "
107+
f"(source: {self._soc_car_source})"
108+
)
109+
91110
# Discover today's target sensors (v1.3.26)
92111
self._today_ev_target_sensor = entity_helper.find_by_suffix(
93112
self.hass, HELPER_TODAY_EV_TARGET_SUFFIX

custom_components/ev_smart_charger/sensor.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from homeassistant.components.sensor import SensorEntity
99
from homeassistant.helpers.restore_state import RestoreEntity
1010

11-
from .const import DOMAIN, VERSION
11+
from .const import DOMAIN, VERSION, CONF_SOC_CAR
1212

1313
_LOGGER = logging.getLogger(__name__)
1414

@@ -83,6 +83,18 @@ async def async_setup_entry(
8383
)
8484
)
8585

86+
# Create Cached EV SOC Sensor (v1.4.0)
87+
entities.append(
88+
EVSCCachedEVSOCSensor(
89+
hass,
90+
entry.entry_id,
91+
entry.data.get(CONF_SOC_CAR), # Source cloud sensor
92+
"evsc_cached_ev_soc",
93+
"EVSC Cached EV SOC",
94+
"mdi:car-battery",
95+
)
96+
)
97+
8698
async_add_entities(entities)
8799
_LOGGER.info(f"✅ Created {len(entities)} EVSC sensors")
88100

@@ -403,3 +415,67 @@ async def async_added_to_hass(self) -> None:
403415
self._attr_native_value = None
404416
if last_state.attributes:
405417
self._attr_extra_state_attributes = dict(last_state.attributes)
418+
419+
420+
class EVSCCachedEVSOCSensor(SensorEntity, RestoreEntity):
421+
"""EVSC Cached EV SOC Sensor - reliable cache for cloud-based EV SOC sensor (v1.4.0)."""
422+
423+
_attr_should_poll = False
424+
425+
def __init__(
426+
self,
427+
hass: HomeAssistant,
428+
entry_id: str,
429+
source_entity: str,
430+
suffix: str,
431+
name: str,
432+
icon: str,
433+
) -> None:
434+
"""Initialize the cached sensor."""
435+
self._hass = hass
436+
self._entry_id = entry_id
437+
self._source_entity = source_entity
438+
self._attr_unique_id = f"{DOMAIN}_{entry_id}_{suffix}"
439+
self._attr_name = name
440+
self._attr_icon = icon
441+
self._attr_native_value = None
442+
self._attr_native_unit_of_measurement = "%"
443+
self._attr_extra_state_attributes = {
444+
"source_entity": source_entity,
445+
"last_valid_update": None,
446+
"is_cached": False,
447+
}
448+
# Set explicit entity_id to match pattern
449+
self.entity_id = f"sensor.{DOMAIN}_{entry_id}_{suffix}"
450+
451+
@property
452+
def device_info(self):
453+
"""Return device info to group all entities under one device."""
454+
return {
455+
"identifiers": {(DOMAIN, self._entry_id)},
456+
"name": "EV Smart Charger",
457+
"manufacturer": "antbald",
458+
"model": "EV Smart Charger",
459+
"sw_version": VERSION,
460+
}
461+
462+
@property
463+
def extra_state_attributes(self) -> dict:
464+
"""Return the state attributes."""
465+
return self._attr_extra_state_attributes
466+
467+
async def async_added_to_hass(self) -> None:
468+
"""Restore last state."""
469+
await super().async_added_to_hass()
470+
_LOGGER.info(f"✅ Cached EV SOC sensor registered: {self.entity_id} (unique_id: {self.unique_id})")
471+
_LOGGER.info(f" 🔗 Source sensor: {self._source_entity}")
472+
473+
if (last_state := await self.async_get_last_state()) is not None:
474+
try:
475+
self._attr_native_value = float(last_state.state) if last_state.state not in [None, "unknown", "unavailable"] else None
476+
_LOGGER.info(f" 🔄 Restored cached SOC: {self._attr_native_value}%")
477+
except (ValueError, TypeError):
478+
self._attr_native_value = None
479+
_LOGGER.warning(f" ⚠️ Failed to restore cached SOC from: {last_state.state}")
480+
if last_state.attributes:
481+
self._attr_extra_state_attributes = dict(last_state.attributes)

0 commit comments

Comments
 (0)