Skip to content

Commit 433f350

Browse files
author
Antonio ...
committed
feat: Centralized mobile notification system (v1.3.0)
This release introduces a comprehensive mobile notification system that sends push notifications to users' mobile devices for key events. ## New Features ### 📱 Mobile Notification System - **MobileNotificationService**: Centralized service for sending mobile notifications - **Configuration Step**: Added notifications step in onboarding wizard - **Granular Control**: 3 switches to enable/disable notifications per feature: - `evsc_notify_smart_blocker_enabled` - Smart Blocker notifications (default ON) - `evsc_notify_priority_balancer_enabled` - Priority Balancer notifications (default ON) - `evsc_notify_night_charge_enabled` - Night Charge notifications (default ON) ### 🔔 Notification Types 1. **Smart Blocker** - Charging interrupted outside window - Emoji-rich Italian message with timestamp - Includes blocking reason 2. **Priority Balancer** - Priority changes (EV ↔ Home ↔ EV_Free) - Shows current and target SOC for both EV and Home battery - Explains reason for priority change 3. **Night Smart Charge** - Charging started (battery/grid mode) - Distinguishes between battery and grid mode - Includes PV forecast and charging amperage - Explains reason for mode selection ### 📋 Technical Details - **Title**: All notifications use fixed title "BORGO" - **Tags**: Grouped by feature (evsc_smart_blocker, evsc_priority_change, evsc_night_charge) - **Priority Levels**: High for Smart Blocker, normal for others - **Icon**: All use mdi:ev-station - **Service Discovery**: Runtime discovery of mobile_app_* notify services - **Backward Compatibility**: Graceful handling for existing installations without notification config ## Files Modified/Created ### NEW - `utils/mobile_notification_service.py` (212 lines) - Centralized notification service ### MODIFIED - `const.py` - Added CONF_NOTIFY_SERVICES and notification switch suffixes - `config_flow.py` - Added step 5 (notifications) to both config and options flow - `switch.py` - Added 3 notification control switches - `strings.json` - Added Italian translations for notifications step - `automations.py` - Integrated mobile notifications in Smart Blocker - `priority_balancer.py` - Added priority change detection and notifications - `night_smart_charge.py` - Added notifications when charging starts (battery/grid mode) - `__init__.py` - Updated version to 1.3.0 - `manifest.json` - Updated version to 1.3.0 ## Integration Architecture - Config flow passes `CONF_NOTIFY_SERVICES` list to all components - Each component initializes MobileNotificationService with notify services list - Notifications respect enabled/disabled switches via entity registry lookup - Default behavior: All notification switches ON for new installations ## Testing Recommendations 1. Complete onboarding with mobile device selection 2. Verify 3 notification switches created and enabled by default 3. Test Smart Blocker notification when charging blocked 4. Test Priority Balancer notification on priority changes 5. Test Night Charge notifications for both battery and grid modes 6. Verify notifications arrive on selected mobile devices 7. Test disabling individual notification switches 🚀 Generated with Claude Code
1 parent c41c8c4 commit 433f350

10 files changed

Lines changed: 455 additions & 20 deletions

File tree

‎custom_components/ev_smart_charger/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
2323
hass.data.setdefault(DOMAIN, {})
2424

2525
_LOGGER.info("=" * 64)
26-
_LOGGER.info("🚗 EV Smart Charger v1.2.1 - Starting setup")
26+
_LOGGER.info("🚗 EV Smart Charger v1.3.0 - Starting setup")
2727
_LOGGER.info("=" * 64)
2828

2929
# ========== PHASE 1: SETUP PLATFORMS (Helper Entities) ==========
@@ -120,7 +120,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
120120
entry.async_on_unload(entry.add_update_listener(_reload_on_update))
121121

122122
_LOGGER.info("=" * 64)
123-
_LOGGER.info("✅ EV Smart Charger v1.2.1 - Setup completed successfully!")
123+
_LOGGER.info("✅ EV Smart Charger v1.3.0 - Setup completed successfully!")
124124
_LOGGER.info("=" * 64)
125125

126126
return True

‎custom_components/ev_smart_charger/automations.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DOMAIN,
1313
CONF_EV_CHARGER_SWITCH,
1414
CONF_EV_CHARGER_STATUS,
15+
CONF_NOTIFY_SERVICES,
1516
CHARGER_STATUS_CHARGING,
1617
HELPER_NIGHT_CHARGE_TIME_SUFFIX,
1718
SMART_BLOCKER_ENFORCEMENT_TIMEOUT,
@@ -24,6 +25,7 @@
2425
from .utils.state_helper import get_state
2526
from .utils.entity_registry_service import EntityRegistryService
2627
from .utils.notification_service import NotificationService
28+
from .utils.mobile_notification_service import MobileNotificationService
2729
from .utils.astral_time_service import AstralTimeService
2830
from .utils.time_parsing_service import TimeParsingService
2931

@@ -62,6 +64,9 @@ def __init__(
6264
self.logger = EVSCLogger("SMART BLOCKER")
6365
self._registry_service = EntityRegistryService(hass, entry_id)
6466
self._notification_service = NotificationService(hass)
67+
self._mobile_notifier = MobileNotificationService(
68+
hass, config.get(CONF_NOTIFY_SERVICES, []), entry_id
69+
)
6570
self._astral_service = AstralTimeService(hass)
6671

6772
# Listeners
@@ -516,6 +521,9 @@ async def _block_charging(self, reason: str) -> None:
516521
}
517522
)
518523

524+
# Send mobile notification
525+
await self._mobile_notifier.send_smart_blocker_notification(reason)
526+
519527
self.logger.separator()
520528

521529
except Exception as e:

‎custom_components/ev_smart_charger/config_flow.py‎

Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
CONF_HOME_CONSUMPTION,
1818
CONF_GRID_IMPORT,
1919
CONF_PV_FORECAST,
20+
CONF_NOTIFY_SERVICES,
2021
)
2122

2223
class EVSCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -117,10 +118,9 @@ async def async_step_pv_forecast(self, user_input: dict[str, Any] | None = None)
117118
errors = {}
118119

119120
if user_input is not None:
120-
# Merge all data and create entry
121-
data = {**self.init_info, **self.charger_info, **self.sensor_info, **user_input}
122-
title = self.init_info.get(CONF_NAME, DEFAULT_NAME)
123-
return self.async_create_entry(title=title, data=data)
121+
# Store PV forecast info and move to notifications step
122+
self.pv_forecast_info = user_input
123+
return await self.async_step_notifications()
124124

125125
schema = vol.Schema({
126126
vol.Optional(CONF_PV_FORECAST): selector.EntitySelector(
@@ -134,10 +134,59 @@ async def async_step_pv_forecast(self, user_input: dict[str, Any] | None = None)
134134
errors=errors,
135135
description_placeholders={
136136
"step": "4",
137-
"total_steps": "4"
137+
"total_steps": "5"
138138
}
139139
)
140140

141+
async def async_step_notifications(self, user_input: dict[str, Any] | None = None):
142+
"""Handle mobile notification services selection step."""
143+
errors = {}
144+
145+
if user_input is not None:
146+
# Merge all data and create entry
147+
data = {
148+
**self.init_info,
149+
**self.charger_info,
150+
**self.sensor_info,
151+
**self.pv_forecast_info,
152+
**user_input
153+
}
154+
title = self.init_info.get(CONF_NAME, DEFAULT_NAME)
155+
return self.async_create_entry(title=title, data=data)
156+
157+
# Discover available mobile notify services
158+
notify_services = self._get_mobile_notify_services()
159+
160+
schema = vol.Schema({
161+
vol.Optional(CONF_NOTIFY_SERVICES, default=[]): selector.SelectSelector(
162+
selector.SelectSelectorConfig(
163+
options=notify_services,
164+
multiple=True,
165+
mode=selector.SelectSelectorMode.DROPDOWN
166+
)
167+
),
168+
})
169+
170+
return self.async_show_form(
171+
step_id="notifications",
172+
data_schema=schema,
173+
errors=errors,
174+
description_placeholders={
175+
"step": "5",
176+
"total_steps": "5"
177+
}
178+
)
179+
180+
def _get_mobile_notify_services(self) -> list[str]:
181+
"""Get list of available mobile_app notify services."""
182+
notify_services = self.hass.services.async_services().get("notify", {})
183+
mobile_services = [
184+
service
185+
for service in notify_services.keys()
186+
if service.startswith("mobile_app_")
187+
]
188+
return mobile_services if mobile_services else []
189+
141190
@staticmethod
142191
@callback
143192
def async_get_options_flow(config_entry):
@@ -247,12 +296,9 @@ async def async_step_sensors(self, user_input: dict[str, Any] | None = None):
247296
async def async_step_pv_forecast(self, user_input: dict[str, Any] | None = None):
248297
"""Manage PV forecast entity options (optional)."""
249298
if user_input is not None:
250-
# Merge all data and update entry
251-
data = {**self.config_entry.data, **self.charger_info, **self.sensor_info, **user_input}
252-
self.hass.config_entries.async_update_entry(
253-
self.config_entry, data=data
254-
)
255-
return self.async_create_entry(title="", data={})
299+
# Store PV forecast info and move to notifications step
300+
self.pv_forecast_info = user_input
301+
return await self.async_step_notifications()
256302

257303
# Get current values
258304
current_data = self.config_entry.data
@@ -271,6 +317,58 @@ async def async_step_pv_forecast(self, user_input: dict[str, Any] | None = None)
271317
data_schema=schema,
272318
description_placeholders={
273319
"step": "3",
274-
"total_steps": "3"
320+
"total_steps": "4"
321+
}
322+
)
323+
324+
async def async_step_notifications(self, user_input: dict[str, Any] | None = None):
325+
"""Manage mobile notification services options."""
326+
if user_input is not None:
327+
# Merge all data and update entry
328+
data = {
329+
**self.config_entry.data,
330+
**self.charger_info,
331+
**self.sensor_info,
332+
**self.pv_forecast_info,
333+
**user_input
334+
}
335+
self.hass.config_entries.async_update_entry(
336+
self.config_entry, data=data
337+
)
338+
return self.async_create_entry(title="", data={})
339+
340+
# Get current values
341+
current_data = self.config_entry.data
342+
notify_services = self._get_mobile_notify_services()
343+
344+
schema = vol.Schema({
345+
vol.Optional(
346+
CONF_NOTIFY_SERVICES,
347+
default=current_data.get(CONF_NOTIFY_SERVICES, [])
348+
): selector.SelectSelector(
349+
selector.SelectSelectorConfig(
350+
options=notify_services,
351+
multiple=True,
352+
mode=selector.SelectSelectorMode.DROPDOWN
353+
)
354+
),
355+
})
356+
357+
return self.async_show_form(
358+
step_id="notifications",
359+
data_schema=schema,
360+
description_placeholders={
361+
"step": "4",
362+
"total_steps": "4"
275363
}
276364
)
365+
366+
def _get_mobile_notify_services(self) -> list[str]:
367+
"""Get list of available mobile_app notify services."""
368+
notify_services = self.hass.services.async_services().get("notify", {})
369+
mobile_services = [
370+
service
371+
for service in notify_services.keys()
372+
if service.startswith("mobile_app_")
373+
]
374+
return mobile_services if mobile_services else []

‎custom_components/ev_smart_charger/const.py‎

Lines changed: 9 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.2.1"
5+
VERSION = "1.3.0"
66
DEFAULT_NAME = "EV Smart Charger"
77

88
# ========== PLATFORMS ==========
@@ -59,6 +59,9 @@
5959
CONF_GRID_IMPORT = "grid_import"
6060
CONF_PV_FORECAST = "pv_forecast"
6161

62+
# Mobile Notifications
63+
CONF_NOTIFY_SERVICES = "notify_services"
64+
6265
# ========== HELPER ENTITY SUFFIXES ==========
6366

6467
# Switches
@@ -68,6 +71,11 @@
6871
HELPER_PRIORITY_BALANCER_ENABLED_SUFFIX = "evsc_priority_balancer_enabled"
6972
HELPER_NIGHT_CHARGE_ENABLED_SUFFIX = "evsc_night_smart_charge_enabled"
7073

74+
# Notification Switches
75+
HELPER_NOTIFY_SMART_BLOCKER_SUFFIX = "evsc_notify_smart_blocker_enabled"
76+
HELPER_NOTIFY_PRIORITY_BALANCER_SUFFIX = "evsc_notify_priority_balancer_enabled"
77+
HELPER_NOTIFY_NIGHT_CHARGE_SUFFIX = "evsc_notify_night_charge_enabled"
78+
7179
# Numbers - Solar Surplus
7280
HELPER_CHECK_INTERVAL_SUFFIX = "evsc_check_interval"
7381
HELPER_GRID_IMPORT_THRESHOLD_SUFFIX = "evsc_grid_import_threshold"

‎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.2.1",
4+
"version": "1.3.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: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CONF_EV_CHARGER_STATUS,
1111
CONF_SOC_HOME,
1212
CONF_PV_FORECAST,
13+
CONF_NOTIFY_SERVICES,
1314
CHARGER_STATUS_FREE,
1415
NIGHT_CHARGE_MODE_BATTERY,
1516
NIGHT_CHARGE_MODE_GRID,
@@ -22,6 +23,7 @@
2223
)
2324
from .utils.logging_helper import EVSCLogger
2425
from .utils import entity_helper, state_helper
26+
from .utils.mobile_notification_service import MobileNotificationService
2527
from .utils.astral_time_service import AstralTimeService
2628
from .utils.time_parsing_service import TimeParsingService
2729

@@ -54,6 +56,9 @@ def __init__(
5456
self.charger_controller = charger_controller
5557
self.logger = EVSCLogger("NIGHT SMART CHARGE")
5658
self._astral_service = AstralTimeService(hass)
59+
self._mobile_notifier = MobileNotificationService(
60+
hass, config.get(CONF_NOTIFY_SERVICES, []), entry_id
61+
)
5762

5863
# User-configured entities
5964
self._charger_status = config.get(CONF_EV_CHARGER_STATUS)
@@ -327,25 +332,26 @@ async def _evaluate_and_charge(self) -> None:
327332
"BATTERY MODE",
328333
f"Good solar forecast ({pv_forecast} kWh >= {threshold} kWh)"
329334
)
330-
await self._start_battery_charge()
335+
await self._start_battery_charge(pv_forecast)
331336
else:
332337
self.logger.decision(
333338
"Charging mode",
334339
"GRID MODE",
335340
f"Low/no solar forecast ({pv_forecast} kWh < {threshold} kWh)"
336341
)
337-
await self._start_grid_charge()
342+
await self._start_grid_charge(pv_forecast)
338343

339344
# ========== BATTERY CHARGE MODE ==========
340345

341-
async def _start_battery_charge(self) -> None:
346+
async def _start_battery_charge(self, pv_forecast: float) -> None:
342347
"""Start charging using home battery at configured amperage with continuous monitoring."""
343348
self.logger.separator()
344349
self.logger.start(f"{self.logger.BATTERY} Battery charge mode")
345350

346351
amperage = self._get_night_charge_amperage()
347352
home_min_soc = self._get_home_battery_min_soc()
348353
ev_target = self.priority_balancer.get_ev_target_for_today()
354+
threshold = self._get_solar_threshold()
349355

350356
self.logger.info(f" Charger amperage: {amperage}A")
351357
self.logger.info(f" EV target SOC: {ev_target}%")
@@ -358,6 +364,15 @@ async def _start_battery_charge(self) -> None:
358364
self._night_charge_active = True
359365
self._active_mode = NIGHT_CHARGE_MODE_BATTERY
360366

367+
# Send mobile notification
368+
reason = f"Previsione solare sufficiente ({pv_forecast:.1f} kWh >= {threshold} kWh)"
369+
await self._mobile_notifier.send_night_charge_notification(
370+
mode=NIGHT_CHARGE_MODE_BATTERY,
371+
reason=reason,
372+
amperage=amperage,
373+
forecast=pv_forecast
374+
)
375+
361376
# Start continuous battery monitoring (every 1 minute)
362377
if self._battery_monitor_unsub:
363378
self._battery_monitor_unsub() # Cancel existing monitor if any
@@ -425,13 +440,14 @@ async def _async_monitor_battery_charge(self, now) -> None:
425440

426441
# ========== GRID CHARGE MODE ==========
427442

428-
async def _start_grid_charge(self) -> None:
443+
async def _start_grid_charge(self, pv_forecast: float) -> None:
429444
"""Start charging from grid at configured amperage."""
430445
self.logger.separator()
431446
self.logger.start(f"{self.logger.GRID} Grid charge mode")
432447

433448
amperage = self._get_night_charge_amperage()
434449
ev_target = self.priority_balancer.get_ev_target_for_today()
450+
threshold = self._get_solar_threshold()
435451

436452
self.logger.info(f" Charger amperage: {amperage}A")
437453
self.logger.info(f" EV target SOC: {ev_target}%")
@@ -443,6 +459,15 @@ async def _start_grid_charge(self) -> None:
443459
self._night_charge_active = True
444460
self._active_mode = NIGHT_CHARGE_MODE_GRID
445461

462+
# Send mobile notification
463+
reason = f"Previsione solare insufficiente ({pv_forecast:.1f} kWh < {threshold} kWh)"
464+
await self._mobile_notifier.send_night_charge_notification(
465+
mode=NIGHT_CHARGE_MODE_GRID,
466+
reason=reason,
467+
amperage=amperage,
468+
forecast=pv_forecast
469+
)
470+
446471
self.logger.success("Grid charge started successfully")
447472
self.logger.info(f"Will charge until EV reaches target SOC ({ev_target}%)")
448473
self.logger.info("Grid import detection is disabled for night charging")

0 commit comments

Comments
 (0)