Skip to content

Commit 6b75dc7

Browse files
authored
Email update (#17)
* Email styling: stronger headings, add Grid Imported Today, README screenshot * Add Zappi data to startup email
1 parent 5ce966a commit 6b75dc7

8 files changed

Lines changed: 56 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,8 @@ On a good solar day you expect: a tall stacked bar, a Green forecast strip, a lo
985985

986986
## Email Notifications
987987

988+
![Example mode-change notification email](docs/images/email_example.png)
989+
988990
When the scheduler issues a mode-change command, it sends an email notification with:
989991

990992
- success or failure status

‎docs/images/email_example.png‎

118 KB
Loading

‎integrations/zappi_interaction.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,28 @@ async def get_daily_totals(self, target_date: date) -> dict[str, Any] | None:
9898
"""
9999
records = await self._client.get_daily_history(target_date)
100100
if not records:
101-
return {"total_kwh": 0.0, "diverted_kwh": 0.0, "boosted_kwh": 0.0}
101+
return {"total_kwh": 0.0, "diverted_kwh": 0.0, "boosted_kwh": 0.0, "grid_imported_kwh": 0.0}
102102

103103
# myenergi history API returns energy values in joules.
104104
# Divide by 3,600,000 to convert to kWh.
105105
# h1d = solar energy diverted to EV; h1b = grid energy boosted to EV.
106-
# imp = total site grid import — NOT EV-specific, excluded here.
106+
# imp = total site grid import (whole-house, not EV-specific).
107107
total_div_j = 0
108108
total_boost_j = 0
109+
total_import_j = 0
109110
for record in records:
110111
total_div_j += int(record.get("h1d", 0) or 0)
111112
total_boost_j += int(record.get("h1b", 0) or 0)
113+
total_import_j += int(record.get("imp", 0) or 0)
112114

113115
diverted_kwh = total_div_j / 3_600_000.0
114116
boosted_kwh = total_boost_j / 3_600_000.0
115117
total_kwh = diverted_kwh + boosted_kwh
118+
grid_imported_kwh = total_import_j / 3_600_000.0
116119

117120
return {
118121
"total_kwh": round(total_kwh, 3),
119122
"diverted_kwh": round(diverted_kwh, 3),
120123
"boosted_kwh": round(boosted_kwh, 3),
124+
"grid_imported_kwh": round(grid_imported_kwh, 3),
121125
}

‎main.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,19 @@ async def create_scheduler_interaction(mode_names: dict[int, str]) -> SigenInter
171171
exc,
172172
)
173173
startup_mode_raw, startup_soc, startup_solar_today_kwh = await log_current_mode_on_startup(sigen, mode_names)
174+
startup_zappi_status = None
175+
startup_zappi_daily = None
176+
try:
177+
from integrations.zappi_auth import get_zappi_interaction
178+
from zoneinfo import ZoneInfo
179+
from config.settings import LOCAL_TIMEZONE
180+
zappi = get_zappi_interaction()
181+
if zappi is not None:
182+
startup_zappi_status = await zappi.get_live_status()
183+
local_today = datetime.now(ZoneInfo(LOCAL_TIMEZONE)).date()
184+
startup_zappi_daily = await zappi.get_daily_totals(local_today)
185+
except Exception as exc:
186+
logger.warning("[SCHEDULER] Could not fetch Zappi data for startup email: %s", exc)
174187
await notify_startup_email(
175188
current_mode_raw=startup_mode_raw,
176189
battery_soc=startup_soc,
@@ -179,6 +192,8 @@ async def create_scheduler_interaction(mode_names: dict[int, str]) -> SigenInter
179192
mode_names=mode_names,
180193
event_time_utc=datetime.now(timezone.utc),
181194
logger=logger,
195+
zappi_status=startup_zappi_status,
196+
zappi_daily=startup_zappi_daily,
182197
)
183198
return sigen
184199
except Exception as e:

‎notifications/email_notifications.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _build_today_forecast_email_sections(
111111
'<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;'
112112
'border:1px solid #e4ebf3;border-radius:10px;">'
113113
'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;'
114-
'color:#5b6b82;margin-bottom:6px;">Today\'s Solar Forecast</div>'
114+
'color:#143a52;font-weight:700;margin-bottom:6px;">Today\'s Solar Forecast</div>'
115115
'<div style="font-size:12px;color:#5b6b82;">Unavailable</div>'
116116
'</div>'
117117
)
@@ -164,7 +164,7 @@ def _build_today_forecast_email_sections(
164164
'<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;'
165165
'border:1px solid #e4ebf3;border-radius:10px;">'
166166
'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;'
167-
'color:#5b6b82;margin-bottom:6px;">Today\'s Solar Forecast</div>'
167+
'color:#143a52;font-weight:700;margin-bottom:6px;">Today\'s Solar Forecast</div>'
168168
f'<table role="presentation" style="width:100%;border-collapse:collapse;">{rows_html}</table>'
169169
'</div>'
170170
)

‎notifications/notification_email_helpers.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ async def notify_startup_email(
7373
mode_names: dict[int, str],
7474
event_time_utc: datetime,
7575
logger: logging.Logger,
76+
zappi_status: dict[str, Any] | None = None,
77+
zappi_daily: dict[str, Any] | None = None,
7678
) -> None:
7779
"""Send a startup email with current mode, SOC, and recent transition summary.
7880
@@ -84,6 +86,8 @@ async def notify_startup_email(
8486
mode_names: Mapping from mode value to human-readable mode label.
8587
event_time_utc: Startup timestamp in UTC.
8688
logger: Logger instance used for status/error output.
89+
zappi_status: Most recent Zappi live-status snapshot, or None when unavailable.
90+
zappi_daily: Today's Zappi daily charge totals, or None when unavailable.
8791
"""
8892
sender = _get_email_sender_instance()
8993
if sender is None:
@@ -110,6 +114,7 @@ async def notify_startup_email(
110114
f"SOC {soc_text} • Solar Today {today_solar_text}"
111115
)
112116
forecast_text, forecast_html = _build_today_forecast_email_sections(today_period_forecast)
117+
zappi_text, zappi_html = _build_zappi_email_sections(zappi_status, zappi_daily)
113118

114119
now_local = event_time_utc.astimezone(LOCAL_TZ)
115120
previous_2230 = (now_local - timedelta(days=1)).replace(
@@ -143,7 +148,7 @@ async def notify_startup_email(
143148
timeline_text = "\n".join(timeline_lines)
144149
timeline_html = (
145150
f'<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">'
146-
f'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:6px;">Transitions since 10:30 PM</div>'
151+
f'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:6px;">Transitions since 10:30 PM</div>'
147152
f'<table role="presentation" style="width:100%;border-collapse:collapse;">{timeline_rows}</table>'
148153
f'</div>'
149154
)
@@ -163,7 +168,8 @@ async def notify_startup_email(
163168
f"Battery SOC: {soc_text}\n\n"
164169
f"Solar Produced Today: {today_solar_text}\n\n"
165170
f"{forecast_text}\n"
166-
"Transitions Since 10:30 PM\n"
171+
+ (f"{zappi_text}\n" if zappi_text else "")
172+
+ "Transitions Since 10:30 PM\n"
167173
"---------------------------\n"
168174
f"{timeline_text}\n"
169175
)
@@ -185,27 +191,28 @@ async def notify_startup_email(
185191
<tr>
186192
<td style="width:50%;padding:0 6px 0 0;vertical-align:top;">
187193
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
188-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Current Mode</div>
194+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Current Mode</div>
189195
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(current_mode_label)}</div>
190196
</div>
191197
</td>
192198
<td style="width:50%;padding:0 0 0 6px;vertical-align:top;">
193199
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
194-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Battery SOC</div>
200+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Battery SOC</div>
195201
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(soc_text)}</div>
196202
</div>
197203
</td>
198204
</tr>
199205
<tr>
200206
<td style="padding:12px 6px 0 0;vertical-align:top;" colspan="2">
201207
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
202-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Solar Produced Today</div>
208+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Solar Produced Today</div>
203209
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(today_solar_text)}</div>
204210
</div>
205211
</td>
206212
</tr>
207213
</table>
208214
{forecast_html}
215+
{zappi_html}
209216
{timeline_html}
210217
</div>
211218
</div>
@@ -271,10 +278,13 @@ def _build_zappi_email_sections(
271278
total_kwh = zappi_daily.get("total_kwh", 0.0)
272279
diverted_kwh = zappi_daily.get("diverted_kwh", 0.0)
273280
boosted_kwh = zappi_daily.get("boosted_kwh", 0.0)
281+
grid_imported_kwh = zappi_daily.get("grid_imported_kwh")
274282
rows_text += [
275283
f"Today's Total to EV: {total_kwh:.2f} kWh "
276284
f"(Solar: {diverted_kwh:.2f} kWh | Grid: {boosted_kwh:.2f} kWh)",
277285
]
286+
if grid_imported_kwh is not None:
287+
rows_text.append(f"Grid Imported Today: {grid_imported_kwh:.2f} kWh")
278288
rows_html += (
279289
f'<tr><td style="padding:4px 8px 4px 0;font-size:12px;color:#5b6b82;white-space:nowrap;">Today — Total to EV</td>'
280290
f'<td style="padding:4px 0;font-size:12px;font-weight:600;color:#172033;">{total_kwh:.2f} kWh</td></tr>'
@@ -283,6 +293,11 @@ def _build_zappi_email_sections(
283293
f'<tr><td style="padding:4px 8px 4px 0;font-size:12px;color:#5b6b82;white-space:nowrap;">&nbsp;&nbsp;Grid boost</td>'
284294
f'<td style="padding:4px 0;font-size:12px;color:#172033;">{boosted_kwh:.2f} kWh</td></tr>'
285295
)
296+
if grid_imported_kwh is not None:
297+
rows_html += (
298+
f'<tr><td style="padding:4px 8px 4px 0;font-size:12px;color:#5b6b82;white-space:nowrap;">Grid Imported Today</td>'
299+
f'<td style="padding:4px 0;font-size:12px;font-weight:600;color:#172033;">{grid_imported_kwh:.2f} kWh</td></tr>'
300+
)
286301

287302
plain_text = (
288303
"EV Charger (Zappi)\n"
@@ -294,7 +309,7 @@ def _build_zappi_email_sections(
294309
'<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;'
295310
'border:1px solid #e4ebf3;border-radius:10px;">'
296311
'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;'
297-
'color:#5b6b82;margin-bottom:6px;">EV Charger (Zappi)</div>'
312+
'color:#143a52;font-weight:700;margin-bottom:6px;">EV Charger (Zappi)</div>'
298313
'<table role="presentation" style="width:100%;border-collapse:collapse;">'
299314
+ rows_html
300315
+ '</table>'
@@ -445,7 +460,7 @@ async def notify_mode_change_email(
445460
)
446461
timeline_section = (
447462
f'<div style="margin-top:12px;padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">'
448-
f'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:6px;">'
463+
f'<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:6px;">'
449464
f'Transitions since 10:30 PM</div>'
450465
f'<table role="presentation" style="width:100%;border-collapse:collapse;">{timeline_rows}</table>'
451466
f'</div>'
@@ -470,21 +485,21 @@ async def notify_mode_change_email(
470485
<tr>
471486
<td style="width:50%;padding:0 6px 0 0;vertical-align:top;">
472487
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
473-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Time</div>
488+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Time</div>
474489
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(local_time)}</div>
475490
</div>
476491
</td>
477492
<td style="width:50%;padding:0 0 0 6px;vertical-align:top;">
478493
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
479-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Battery SOC</div>
494+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Battery SOC</div>
480495
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(soc_text)}</div>
481496
</div>
482497
</td>
483498
</tr>
484499
<tr>
485500
<td style="padding:12px 6px 0 0;vertical-align:top;" colspan="2">
486501
<div style="padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;">
487-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:3px;">Solar Produced Today</div>
502+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:3px;">Solar Produced Today</div>
488503
<div style="font-size:14px;font-weight:700;color:#172033;">{escape(today_solar_text)}</div>
489504
</div>
490505
</td>
@@ -493,7 +508,7 @@ async def notify_mode_change_email(
493508
{forecast_html}
494509
{zappi_html}
495510
<div style="margin-bottom:12px;padding:10px 12px;background:#f8fafc;border:1px solid #e4ebf3;border-radius:10px;font-size:13px;line-height:1.5;color:#26354d;">
496-
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#5b6b82;margin-bottom:4px;">Reason</div>
511+
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.07em;color:#143a52;font-weight:700;margin-bottom:4px;">Reason</div>
497512
{escape(reason)}
498513
</div>
499514
<details style="margin-bottom:0;">

‎scripts/test_mode_change_email.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
1515

1616
import main
17+
import logic.mode_change as _mode_change_module
1718
from logic.mode_change import apply_mode_change
1819

1920
_script_logger = logging.getLogger("test_mode_change_email")
@@ -80,11 +81,11 @@ async def run_test(mode: int, period: str, reason: str, soc: float) -> bool:
8081
True when simulated apply_mode_change completed successfully.
8182
"""
8283
mode_names = {value: name for name, value in main.SIGEN_MODES.items()}
83-
previous_full_sim = main.FULL_SIMULATION_MODE
84+
previous_full_sim = _mode_change_module.FULL_SIMULATION_MODE
8485

8586
try:
8687
# Force simulation path so the test does not send inverter commands.
87-
main.FULL_SIMULATION_MODE = True
88+
_mode_change_module.FULL_SIMULATION_MODE = True
8889
return await apply_mode_change(
8990
sigen=None,
9091
mode=mode,
@@ -95,7 +96,7 @@ async def run_test(mode: int, period: str, reason: str, soc: float) -> bool:
9596
logger=_script_logger,
9697
)
9798
finally:
98-
main.FULL_SIMULATION_MODE = previous_full_sim
99+
_mode_change_module.FULL_SIMULATION_MODE = previous_full_sim
99100

100101

101102
def main_cli() -> None:

‎tests/test_zappi_integration.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ async def test_get_daily_totals_empty_history() -> None:
193193
client.get_daily_history = AsyncMock(return_value=[])
194194
interaction = ZappiInteraction(client)
195195
result = await interaction.get_daily_totals(date(2026, 4, 15))
196-
assert result == {"total_kwh": 0.0, "diverted_kwh": 0.0, "boosted_kwh": 0.0}
196+
assert result == {"total_kwh": 0.0, "diverted_kwh": 0.0, "boosted_kwh": 0.0, "grid_imported_kwh": 0.0}
197197

198198

199199
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)