-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy path__init__.py
More file actions
2128 lines (1899 loc) · 95.6 KB
/
__init__.py
File metadata and controls
2128 lines (1899 loc) · 95.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Solar Energy Management Integration.
This integration provides comprehensive solar energy management with:
- Real-time energy flow monitoring and optimization
- EV charging control with solar priority
- Battery management and discharge protection
- Peak load management and demand control
- Energy dashboard integration
- Sankey flow visualization
Best Practices Implementation:
- Async-first with proper error handling
- Graceful degradation for optional features
- Non-blocking initialization for better startup performance
- Service registry checks to prevent conflicts
- Comprehensive logging and diagnostics
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform, EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, issue_registry as ir
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator.sensor_reader import GRID_TRIGGER_HINTS
from .coordinator import SEMCoordinator
_LOGGER = logging.getLogger(__name__)
def _content_hash_cache_bust(card_root: str, base_url: str, version: str) -> str:
"""Compute ``{version}-{sha1(content)[:8]}`` for a dashboard card asset.
Used by every Lovelace-resource registration path so the ``?v=`` query
follows file content. A bare timestamp (the previous behaviour) stayed
constant across rsync deploys and let browsers serve a stale cached
``sem-localize.js`` even after the on-disk file was updated — surfacing
as raw translation keys on the EV charge-mode selector (#301).
``base_url`` is the ``/local/custom_components/.../card/<path>`` URL;
the trailing relative path is resolved against ``card_root`` (the
deployed copy under ``/config/www/...``). Falls back to a bare
``version`` if the file can't be read so a transient disk error still
busts the cache once and never deregisters a resource.
"""
import hashlib
rel = base_url.split("/dashboard/card/", 1)[-1]
try:
with open(os.path.join(card_root, rel), "rb") as f:
return f"{version}-{hashlib.sha1(f.read()).hexdigest()[:8]}"
except OSError:
return version
class _SEMYAMLModeSkip(Exception):
"""Sentinel: bail out of the Lovelace resource registration block
when the user is running YAML-mode Lovelace (#283). YAML-mode
resources are read-only; the user has to add SEM's bundle to
``configuration.yaml`` themselves. Logged with the exact URLs above
the raise; the outer ``except`` clause swallows this quietly so it
doesn't get reported as a generic "could not register" warning."""
type SEMConfigEntry = ConfigEntry[SEMCoordinator]
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
Platform.NUMBER,
Platform.BINARY_SENSOR,
Platform.SELECT,
Platform.TIME,
Platform.BUTTON,
]
async def async_migrate_entry(hass: HomeAssistant, entry: SEMConfigEntry) -> bool:
"""Migrate old config entry data to current schema.
Migrations:
- v1 → v2 (#98): `battery_priority_soc` semantics changed from
legacy 3-zone "battery target before EV" (default 80) to 4-zone
"Zone 1 floor: below this all solar → battery, EV blocked"
(default 30). Existing entries that still carry the legacy 80%
get remapped down to 30% so the 4-zone strategy actually leaves
Zone 1 on a normally-charged battery.
"""
_LOGGER.info(
"Migrating SEM config entry from version %s.%s",
entry.version, entry.minor_version
)
# Accumulators threaded across all migration steps. Each step starts
# from these (not from ``entry.options`` / ``entry.data``) so a
# multi-version upgrade (e.g. v3 → v5 in one call) doesn't lose the
# earlier step's mutations. Pre-#277 each step read ``entry.options``
# afresh; in real HA ``async_update_entry`` mutates the entry and the
# next read picks up the change, but in tests (and in any harness
# mocking the entry) the second step then overwrote the first. The
# explicit accumulator makes the chain deterministic on both paths.
accumulated_data = {**entry.data}
accumulated_options = {**entry.options}
if entry.version < 2:
try:
from .consts.core import (
DEFAULT_BATTERY_BUFFER_SOC,
DEFAULT_BATTERY_AUTO_START_SOC,
DEFAULT_BATTERY_ASSIST_FLOOR_SOC,
)
new_data = {**accumulated_data}
new_options = {**accumulated_options}
legacy_priority = max(
new_options.get("battery_priority_soc") if new_options.get("battery_priority_soc") is not None else 0,
new_data.get("battery_priority_soc") if new_data.get("battery_priority_soc") is not None else 0,
)
# Anything ≥ 50 is the legacy 3-zone meaning — remap.
if legacy_priority >= 50:
_LOGGER.warning(
"Migrating battery_priority_soc %s → 30 (4-zone semantics, see #98)",
legacy_priority,
)
new_data["battery_priority_soc"] = 30
new_options.pop("battery_priority_soc", None)
# Seed any 4-zone keys missing or null on legacy entries so the
# number entities boot with sensible state.
for key, default in (
("battery_buffer_soc", DEFAULT_BATTERY_BUFFER_SOC),
("battery_auto_start_soc", DEFAULT_BATTERY_AUTO_START_SOC),
("battery_assist_floor_soc", DEFAULT_BATTERY_ASSIST_FLOOR_SOC),
):
if new_data.get(key) is None:
new_data[key] = default
hass.config_entries.async_update_entry(
entry,
data=new_data,
options=new_options,
version=2,
minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s failed — keeping original config: %s",
entry.version, e,
)
return False
if entry.version < 3:
try:
# v2 → v3: Wrap flat ev_* keys into ev_chargers list for multi-charger support
new_data = {**accumulated_data}
new_options = {**accumulated_options}
full = {**new_data, **new_options}
# Only migrate if flat EV keys exist and ev_chargers doesn't
if full.get("ev_charging_power_sensor") and "ev_chargers" not in full:
_EV_FLAT_KEYS = [
"ev_connected_sensor", "ev_charging_sensor",
"ev_charging_power_sensor", "ev_charger_service",
"ev_charger_service_entity_id", "ev_current_control_entity",
"ev_current_sensor", "ev_total_energy_sensor",
"ev_session_energy_sensor", "ev_service_param_name",
"ev_service_device_id", "ev_start_stop_entity",
"ev_charge_mode_entity", "ev_charge_mode_start",
"ev_charge_mode_stop", "ev_start_service",
"ev_start_service_data", "ev_stop_service",
"ev_stop_service_data", "ev_charger_needs_cycle",
"ev_surplus_priority", "ev_load_priority",
]
charger_0 = {"id": "ev_charger", "name": "EV Charger"}
for k in _EV_FLAT_KEYS:
val = new_options.get(k) or new_data.get(k)
if val is not None:
charger_0[k] = val
new_options["ev_chargers"] = [charger_0]
_LOGGER.info(
"Migrated flat EV config to ev_chargers list (1 charger)"
)
hass.config_entries.async_update_entry(
entry,
data=new_data,
options=new_options,
version=3,
minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s to v3 failed — keeping original config: %s",
entry.version, e,
)
return False
if entry.version < 4:
try:
# v3 → v4 (#255): per-charger entities are becoming the source of truth, so
# the duplicate GLOBAL EV settings will be removed. Seed each charger's
# per-charger value from the matching global where it's unset, so removing the
# global later never silently resets a user's configured value. Behaviour-
# neutral today (per-charger already falls back to the global at runtime).
_SEED_KEYS = (
"daily_ev_target", "daily_ev_target_max",
"ev_target_soc", "ev_target_soc_max",
"ev_min_current", "ev_night_initial_current",
"ev_kwh_per_100km", "ev_target_type",
# #255 Phase 4 — also converted to per-charger
"ev_charging_mode", "ev_phases",
# #246 Phase 2 — per-charger charge-by deadline
"ev_target_time",
)
new_data = {**accumulated_data}
new_options = {**accumulated_options}
full = {**new_data, **new_options}
chargers = new_options.get("ev_chargers", new_data.get("ev_chargers"))
if isinstance(chargers, list):
seeded = []
for c in chargers:
c = dict(c) if isinstance(c, dict) else c
if isinstance(c, dict):
for key in _SEED_KEYS:
gval = full.get(key)
# ev_target_type carries a legacy alias (ev_target_mode, #235)
if key == "ev_target_type" and gval is None:
gval = full.get("ev_target_mode")
if c.get(key) is None and gval is not None:
c[key] = gval
seeded.append(c)
new_options["ev_chargers"] = seeded
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options,
version=4, minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s to v4 failed — keeping original config: %s",
entry.version, e,
)
return False
if entry.version < 5:
try:
# v4 → v5 (#277 Phase A): seed per-charger ``charge_mode`` from
# the existing toggle state. The new selector is the consolidated
# user-intent layer that will replace the four-toggle UX in
# Phase B; here we just derive the equivalent named mode so the
# selector reflects the user's actual current behaviour on first
# boot post-upgrade. Legacy toggles are kept unchanged — they
# remain authoritative for the strategy machine until Phase B
# makes ``charge_mode`` the source of truth.
new_data = {**accumulated_data}
new_options = {**accumulated_options}
full = {**new_data, **new_options}
chargers = new_options.get("ev_chargers", new_data.get("ev_chargers"))
if isinstance(chargers, list):
seeded = []
for c in chargers:
c = dict(c) if isinstance(c, dict) else c
if isinstance(c, dict) and c.get("charge_mode") is None:
c["charge_mode"] = _derive_charge_mode(
c, full, hass,
)
_LOGGER.info(
"Charger %s: derived charge_mode=%s from legacy toggles",
c.get("id", "ev_charger"), c["charge_mode"],
)
seeded.append(c)
new_options["ev_chargers"] = seeded
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options,
version=5, minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s to v5 failed — keeping original config: %s",
entry.version, e,
)
return False
if entry.version < 6:
try:
# v5 → v6 (#277 Phase B fix-up): narrow re-derivation for the
# pv/auto + tariff_on combinations that Phase A's derivation
# silently dropped (was: ``if mode == "auto" and tariff``,
# which missed the legacy ``mode=pv`` group). Phase B fixed
# the derivation in both ``_derive_charge_mode`` and
# ``effective_charge_mode_for`` to also map
# ``pv/self_consumption + tariff_on`` → ``solar_plus_cheap``;
# this step propagates that fix to chargers that already
# ran through Phase A's v4→v5 with the buggy derivation.
#
# Condition is unambiguous: stored mode is the catch-all
# ``min_plus_solar``, legacy mode is one of the pv-family,
# and the per-charger tariff switch is currently ON. That
# combination can only be produced by Phase A's missing
# tariff branch — a user who explicitly picked
# ``min_plus_solar`` from the new selector AFTER Phase A
# would also satisfy the condition, but their UI experience
# is improved by the fix: the selector label finally matches
# the tariff intent they expressed.
new_data = {**accumulated_data}
new_options = {**accumulated_options}
full = {**new_data, **new_options}
chargers = new_options.get("ev_chargers", new_data.get("ev_chargers"))
if isinstance(chargers, list):
fixed = []
for c in chargers:
c = dict(c) if isinstance(c, dict) else c
if (
isinstance(c, dict)
and c.get("charge_mode") == "min_plus_solar"
):
legacy_mode = (
c.get("ev_charging_mode")
or full.get("ev_charging_mode")
or "pv"
)
cid = c.get("id", "ev_charger")
tariff_on = hass.states.is_state(
f"switch.sem_charger_{cid}_tariff_optimized", "on",
)
if (
tariff_on
and legacy_mode in ("pv", "auto", "self_consumption")
):
c["charge_mode"] = "solar_plus_cheap"
_LOGGER.info(
"Charger %s: corrected charge_mode "
"min_plus_solar → solar_plus_cheap "
"(tariff intent preserved)",
cid,
)
fixed.append(c)
new_options["ev_chargers"] = fixed
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options,
version=6, minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s to v6 failed — keeping original config: %s",
entry.version, e,
)
return False
if entry.version < 7:
try:
# v6 → v7 (#277 Phase C): drop the now-dead legacy
# ``ev_charging_mode`` per-charger key. Phase C made the
# named ``charge_mode`` the authoritative input to both
# the strategy machine and ``_tariff_optimized_for``; the
# legacy mode string is no longer read anywhere. Removing
# it from the persisted config prevents stale values from
# leaking back into the UI (the ``select.sem_charger_<id>
# _ev_charging_mode`` entity is also retired in Phase C —
# the entity registry's stale-cleanup in ``select.py``
# purges the orphans).
#
# The corresponding legacy switches were removed from
# ``switch.py`` in Phase C; the registry's stale-cleanup
# in that file removes the per-charger night/smart/tariff
# switch entries the same way.
new_data = {**accumulated_data}
new_options = {**accumulated_options}
chargers = new_options.get("ev_chargers", new_data.get("ev_chargers"))
if isinstance(chargers, list):
cleaned = []
for c in chargers:
c = dict(c) if isinstance(c, dict) else c
if isinstance(c, dict) and "ev_charging_mode" in c:
removed_value = c.pop("ev_charging_mode")
_LOGGER.info(
"Charger %s: dropped dead config key "
"ev_charging_mode=%s (Phase C — charge_mode "
"is authoritative)",
c.get("id", "ev_charger"), removed_value,
)
cleaned.append(c)
new_options["ev_chargers"] = cleaned
# Top-level ``ev_charging_mode`` (legacy global default) is
# also gone now — nothing reads it. Same removal logic; the
# top level only had it via #255 seeding, never the source
# of truth post-v4.
if "ev_charging_mode" in new_options:
new_options.pop("ev_charging_mode")
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options,
version=7, minor_version=1,
)
accumulated_data, accumulated_options = new_data, new_options
except Exception as e:
_LOGGER.error(
"Migration from v%s to v7 failed — keeping original config: %s",
entry.version, e,
)
return False
_LOGGER.info("Migration to version %s.%s done", entry.version, entry.minor_version)
return True
def _derive_charge_mode(
charger_cfg: dict,
full_config: dict,
hass: HomeAssistant,
) -> str:
"""Derive a Charge mode (#277) from the legacy four-toggle state.
Decision tree (matches ``docs/plans/2026-05-30_ev_charge_mode_consolidation.md``):
ev_charging_mode == "now" → always_max
ev_charging_mode == "off" → off
ev_charging_mode == "auto" + tariff → solar_plus_cheap
ev_charging_mode in (pv, auto) + no night → solar_only
otherwise → min_plus_solar (catch-all default)
Reads the per-charger switch state where available (the canonical
location since #255) and falls back to the global / config dict
for legacy installs. The two switches we consult are
``switch.sem_charger_<id>_night_charging`` and
``switch.sem_charger_<id>_tariff_optimized``. If a switch hasn't
been created yet (cold migration before the platform sets up),
we default to ON for night (the factory default) and OFF for
tariff (the factory default).
"""
cid = charger_cfg.get("id", "ev_charger")
# ev_charging_mode is canonical per-charger as of v4 (#255).
mode = (
charger_cfg.get("ev_charging_mode")
or full_config.get("ev_charging_mode")
or "pv"
)
# Night switch — per-charger canonical, fall back to global, then
# to the factory default (ON).
night_eid = f"switch.sem_charger_{cid}_night_charging"
if hass.states.get(night_eid) is not None:
night = hass.states.is_state(night_eid, "on")
elif hass.states.get("switch.sem_night_charging") is not None:
night = hass.states.is_state("switch.sem_night_charging", "on")
else:
night = True # factory default
# Tariff switch — per-charger only; the global was never created.
# Default OFF when missing (the factory default).
tariff_eid = f"switch.sem_charger_{cid}_tariff_optimized"
if hass.states.get(tariff_eid) is not None:
tariff = hass.states.is_state(tariff_eid, "on")
else:
tariff = False
if mode == "now":
return "always_max"
if mode == "off":
return "off"
# Tariff-on expresses cheap-hour intent regardless of which legacy
# mode the user picked (auto / pv / self_consumption all preserve
# it). Migrating those users to solar_only would silently lose
# their tariff preference.
if mode in ("pv", "auto", "self_consumption") and tariff:
return "solar_plus_cheap"
if mode in ("pv", "auto", "self_consumption") and not night:
return "solar_only"
# Catch-all — covers minpv (which always pulls Min from grid, so
# never maps to solar_only regardless of night flag) and any
# unrecognised mode value.
from .consts.ev_charge_modes import DEFAULT_EV_CHARGE_MODE
return DEFAULT_EV_CHARGE_MODE
def _migrate_limit_surplus_to_max(hass: HomeAssistant, entry: SEMConfigEntry) -> None:
"""Fold the removed ev_limit_surplus switch (#235) into the Max ceiling (#245).
Users who had limit-surplus ON get Max set to their current target so surplus
still stops there; the legacy key is then dropped. Idempotent — only acts while
the key is present. Default-OFF users (the norm) get no change: Max stays unset
→ full → "charge freely from sun", exactly as before.
"""
opts = {**entry.options}
data = entry.data
changed = False
# Global scope (the switch persisted to options, but read data too for safety).
# `entry.data` is read-only here, so a key living only in data can't be removed;
# skip it once Max is already populated to avoid re-running (and log spam) forever.
if "ev_limit_surplus" in opts or (
"ev_limit_surplus" in data
and opts.get("daily_ev_target_max") is None
and opts.get("ev_target_soc_max") is None
):
if bool(opts.get("ev_limit_surplus", data.get("ev_limit_surplus"))):
cur_kwh = opts.get("daily_ev_target", data.get("daily_ev_target"))
if cur_kwh is not None and opts.get("daily_ev_target_max") is None:
opts["daily_ev_target_max"] = cur_kwh
cur_soc = opts.get("ev_target_soc", data.get("ev_target_soc"))
if cur_soc is not None and opts.get("ev_target_soc_max") is None:
opts["ev_target_soc_max"] = cur_soc
opts.pop("ev_limit_surplus", None)
changed = True
# Per-charger scope.
chargers = opts.get("ev_chargers")
if isinstance(chargers, list):
new_chargers = []
per_changed = False
for c in chargers:
if isinstance(c, dict) and "ev_limit_surplus" in c:
c = dict(c)
if bool(c.pop("ev_limit_surplus")):
if c.get("daily_ev_target") is not None and c.get("daily_ev_target_max") is None:
c["daily_ev_target_max"] = c["daily_ev_target"]
if c.get("ev_target_soc") is not None and c.get("ev_target_soc_max") is None:
c["ev_target_soc_max"] = c["ev_target_soc"]
per_changed = True
new_chargers.append(c)
if per_changed:
opts["ev_chargers"] = new_chargers
changed = True
if changed:
hass.config_entries.async_update_entry(entry, options=opts)
_LOGGER.info("Folded ev_limit_surplus into the Max charge ceiling (#245)")
async def async_setup_entry(hass: HomeAssistant, entry: SEMConfigEntry) -> bool:
"""Set up Solar Energy Management from a config entry.
This follows Home Assistant best practices:
1. Fast initialization - non-blocking operations deferred
2. Proper error handling with ConfigEntryNotReady
3. Graceful degradation for optional features
4. Service registry checks to prevent conflicts
"""
_LOGGER.info(
"Starting Solar Energy Management setup (entry_id: %s, version: %s)",
entry.entry_id,
entry.version
)
# Initialize domain data storage (kept for backward compatibility with services)
hass.data.setdefault(DOMAIN, {})
# Fold the removed ev_limit_surplus switch (#235) into the Max ceiling (#245).
# Idempotent; only acts while the legacy key is present.
_migrate_limit_surplus_to_max(hass, entry)
# Merge entry.data and entry.options for complete configuration
full_config = {**entry.data, **entry.options}
_LOGGER.debug("Configuration keys: %s", list(full_config.keys()))
# Create coordinator with error handling
try:
coordinator = SEMCoordinator(hass, full_config)
coordinator.config_entry = entry
_LOGGER.debug("SEMCoordinator created successfully")
except Exception as err:
_LOGGER.error("Failed to create coordinator: %s", err, exc_info=True)
raise ConfigEntryNotReady(f"Coordinator creation failed: {err}") from err
# Try to initialize from HA Energy Dashboard (HA 2025.12+)
# This reads sensor configuration from the Energy Dashboard instead of manual config
_LOGGER.info("Attempting to read sensors from HA Energy Dashboard...")
try:
result = await coordinator.async_initialize_energy_dashboard()
if result:
_LOGGER.info("Successfully using sensors from HA Energy Dashboard")
else:
_LOGGER.info("Energy Dashboard not available or incomplete, using legacy sensor config")
except Exception as err:
_LOGGER.warning("Failed to read Energy Dashboard, using legacy config: %s", err, exc_info=True)
# Fetch initial data - this is critical for setup
_LOGGER.debug("Fetching initial data from coordinator")
try:
await coordinator.async_config_entry_first_refresh()
_LOGGER.info("Initial data fetch successful")
except Exception as err:
_LOGGER.error(
"Failed to fetch initial data. This may indicate missing sensors or "
"connectivity issues: %s",
err,
exc_info=True
)
raise ConfigEntryNotReady(
f"Could not fetch initial data. Check that all required sensors exist: {err}"
) from err
# Store coordinator in runtime_data (quality scale: runtime-data)
entry.runtime_data = coordinator
# Also store in hass.data for backward compatibility with platform setup
hass.data[DOMAIN][entry.entry_id] = coordinator
# Create repair issue if EV charger is not configured (quality scale: repair-issues)
if not full_config.get("ev_connected_sensor") and not full_config.get("ev_charging_power_sensor"):
ir.async_create_issue(
hass,
DOMAIN,
"ev_charger_not_configured",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="ev_charger_not_configured",
)
else:
ir.async_delete_issue(hass, DOMAIN, "ev_charger_not_configured")
# Initialize load management (optional feature - don't fail setup if it fails)
try:
await coordinator.async_initialize_load_management(entry)
_LOGGER.info("Load management initialized successfully")
# Initialize unified device registry (reads Energy Dashboard, syncs to both systems)
try:
from .device_registry import UnifiedDeviceRegistry
from .load_device_discovery import LoadDeviceDiscovery
discovery = LoadDeviceDiscovery(hass)
registry = UnifiedDeviceRegistry(
hass, coordinator._surplus_controller, coordinator._load_manager, discovery
)
await registry.async_initialize()
coordinator._device_registry = registry
# Tell load manager to skip its own discovery — registry owns the device list
if coordinator._load_manager:
coordinator._load_manager._unified_registry_active = True
_LOGGER.info("Unified device registry initialized with %d devices", len(registry.devices))
except Exception as err:
_LOGGER.warning("Unified device registry init failed (non-critical): %s", err)
coordinator._device_registry = None
# Register EV charger(s) as CurrentControlDevice for unified control
# Solar mode: SurplusController manages by priority
# Night mode: coordinator manages directly with grid headroom budget
#
# Multi-charger support (#112): ev_chargers list in config
# Backward compat: flat ev_* keys wrapped into list by v2→v3 migration
# Build charger config list from config + auto-discovery
ev_chargers_config = list(full_config.get("ev_chargers") or [])
# Auto-discover if no chargers configured
if not ev_chargers_config:
ev_auto = {}
if coordinator._device_registry:
ev_auto = coordinator._device_registry.discover_ev_charger()
if ev_auto:
_LOGGER.info("Auto-discovered EV charger config: %s", list(ev_auto.keys()))
ev_auto["id"] = "ev_charger"
ev_auto["name"] = "EV Charger"
ev_chargers_config = [ev_auto]
# Persist discovered config
new_options = dict(entry.options)
new_options["ev_chargers"] = ev_chargers_config
hass.config_entries.async_update_entry(entry, options=new_options)
full_config["ev_chargers"] = ev_chargers_config
# Fallback: check flat keys (pre-migration installs)
if not ev_chargers_config:
ev_power = full_config.get("ev_charging_power_sensor")
ev_svc = full_config.get("ev_charger_service")
ev_ctl = full_config.get("ev_current_control_entity")
if ev_power and (ev_svc or ev_ctl):
ev_chargers_config = [{
"id": "ev_charger", "name": "EV Charger",
**{k: full_config[k] for k in full_config
if k.startswith("ev_") and full_config[k] is not None},
}]
# Register each charger
from .devices.base import CurrentControlDevice
coordinator._ev_devices = {}
for idx, charger_cfg in enumerate(ev_chargers_config):
charger_id = charger_cfg.get("id", f"ev_charger_{idx}")
charger_name = charger_cfg.get("name", f"EV Charger {idx + 1}")
# Resolve config: charger-specific keys, fall back to global config
def _cfg(key, default=None):
v = charger_cfg.get(key)
if v is not None:
return v
v = full_config.get(key)
return v if v is not None else default
ev_power_entity = _cfg("ev_charging_power_sensor")
ev_charger_service = _cfg("ev_charger_service")
ev_service_entity = _cfg("ev_charger_service_entity_id")
ev_current_entity = _cfg("ev_current_control_entity")
ev_priority = int(_cfg("ev_surplus_priority", _cfg("ev_load_priority", 3 + idx)))
# Also auto-fill sensor reader config from first charger
if idx == 0:
for key in ("ev_connected_sensor", "ev_charging_sensor", "ev_total_energy_sensor"):
if not full_config.get(key) and charger_cfg.get(key):
full_config[key] = charger_cfg[key]
if not ev_power_entity or not (ev_charger_service or ev_current_entity):
_LOGGER.debug("Charger %s missing power sensor or control method, skipping", charger_id)
continue
ev_device = CurrentControlDevice(
hass=hass,
device_id=charger_id,
name=charger_name,
priority=ev_priority,
min_current=float(_cfg("ev_min_current", 6)),
max_current=float(_cfg("max_charging_current", 32)),
phases=int(_cfg("ev_phases", 3)),
voltage=230.0,
power_entity_id=ev_power_entity,
charger_service=ev_charger_service,
charger_service_entity_id=ev_service_entity,
current_entity_id=ev_current_entity,
)
ev_device.needs_pilot_cycle = _cfg("ev_charger_needs_cycle", False)
# Per-integration charger profile (#82)
if _cfg("ev_service_param_name"):
ev_device.service_param_name = _cfg("ev_service_param_name")
if _cfg("ev_service_device_id"):
ev_device.service_device_id = _cfg("ev_service_device_id")
if _cfg("ev_start_stop_entity"):
ev_device.start_stop_entity = _cfg("ev_start_stop_entity")
if _cfg("ev_charge_mode_entity"):
ev_device.charge_mode_entity = _cfg("ev_charge_mode_entity")
ev_device.charge_mode_start = _cfg("ev_charge_mode_start")
ev_device.charge_mode_stop = _cfg("ev_charge_mode_stop")
if _cfg("ev_start_service"):
ev_device.start_service = _cfg("ev_start_service")
ev_device.start_service_data = json.loads(_cfg("ev_start_service_data", "{}"))
if _cfg("ev_stop_service"):
ev_device.stop_service = _cfg("ev_stop_service")
ev_device.stop_service_data = json.loads(_cfg("ev_stop_service_data", "{}"))
coordinator._surplus_controller.register_device(ev_device)
coordinator._ev_devices[charger_id] = ev_device
ev_device.managed_externally = True
_LOGGER.info(
"EV charger '%s' registered as CurrentControlDevice "
"(priority %d, max %dA, service: %s)",
charger_name, ev_priority,
int(ev_device.max_current),
ev_charger_service or ev_current_entity,
)
# Also register in load management for peak shedding
if coordinator._load_manager:
await coordinator._load_manager.register_ev_charger(
current_control_entity=ev_current_entity,
power_entity=ev_power_entity,
priority=ev_priority,
is_critical=False,
charger_service=ev_charger_service,
)
# Backward compat: _ev_device points to primary (first) charger
if coordinator._ev_devices:
coordinator._ev_device = next(iter(coordinator._ev_devices.values()))
_LOGGER.info(
"Registered %d EV charger(s). Primary: %s",
len(coordinator._ev_devices),
coordinator._ev_device.name,
)
else:
_LOGGER.debug("EV charger not configured (no power sensor or control method)")
# Register heat pump SG-Ready controller if configured
hp_relay1 = full_config.get("heat_pump_relay1_entity")
hp_relay2 = full_config.get("heat_pump_relay2_entity")
if hp_relay1 and hp_relay2:
from .devices.heat_pump_controller import HeatPumpController
hp_device = HeatPumpController(
hass=hass,
device_id="heat_pump",
name=full_config.get("heat_pump_name", "Heat Pump"),
rated_power=float(full_config.get("heat_pump_rated_power", 2000)),
priority=int(full_config.get("heat_pump_priority", 4)),
relay1_entity_id=hp_relay1,
relay2_entity_id=hp_relay2,
climate_entity_id=full_config.get("heat_pump_climate_entity"),
power_entity_id=full_config.get("heat_pump_power_sensor"),
temperature_entity_id=full_config.get("heat_pump_temperature_sensor"),
boost_offset=float(full_config.get("heat_pump_boost_offset", 2.0)),
max_setpoint=float(full_config.get("heat_pump_max_setpoint", 55.0)),
force_on_threshold=float(full_config.get("heat_pump_force_on_threshold", 5000)),
)
coordinator._surplus_controller.register_device(hp_device)
_LOGGER.info(
"Heat pump registered as SG-Ready device "
"(priority %d, relay1=%s, relay2=%s)",
hp_device.priority, hp_relay1, hp_relay2,
)
else:
_LOGGER.debug("Heat pump not configured (no relay entities)")
except Exception as err:
_LOGGER.warning(
"Load management initialization failed (non-critical): %s. "
"Load management features will be unavailable.",
err
)
# Setup platforms (critical - must succeed)
try:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_LOGGER.info("Platforms setup completed: %s", PLATFORMS)
except Exception as err:
_LOGGER.error("Failed to setup platforms: %s", err, exc_info=True)
# Cleanup coordinator data
hass.data[DOMAIN].pop(entry.entry_id, None)
raise ConfigEntryNotReady(f"Platform setup failed: {err}") from err
# Register services (with duplicate check)
try:
await _async_register_services(hass, coordinator)
await _async_register_phase_services(hass, coordinator)
_LOGGER.debug("Services registered successfully")
except Exception as err:
_LOGGER.warning(
"Service registration failed (non-critical): %s. "
"Services may not be available.",
err
)
# Register frontend resources (optional - don't fail setup)
try:
await _async_register_frontend_resources(hass)
_LOGGER.debug("Frontend resources registered successfully")
except Exception as err:
_LOGGER.warning(
"Frontend resource registration failed (non-critical): %s. "
"Custom cards may not be available.",
err
)
# Auto-install card JS files to /config/www/ on startup (#55)
# Only runs if dashboard was previously generated. On HACS updates,
# this ensures new cards are available after restart without manual action.
try:
await _async_install_card_assets(hass, entry)
except Exception as err:
_LOGGER.debug("Card asset installation skipped: %s", err)
# Register options update listener
entry.async_on_unload(entry.add_update_listener(async_update_options))
# Schedule post-startup tasks (non-blocking)
_schedule_post_startup_tasks(hass, entry, full_config, coordinator)
# One-shot: if the user opted in during the install flow, generate the
# SEM dashboard right after first setup. The dashboard service schedules
# an HA restart 5s after success, so we set a marker in entry.options
# *before* calling the service. The reload triggered by setting the
# marker will see it on the second setup_entry pass and skip; the same
# marker survives the HA restart and prevents a third regeneration.
install_flag = entry.data.get("generate_dashboard_on_install")
already_generated = entry.options.get("_install_dashboard_generated", False)
if install_flag and not already_generated:
_LOGGER.info(
"Install flow opted in to dashboard generation — scheduling one-shot"
)
async def _run_once_install_dashboard(_now=None) -> None:
try:
await hass.services.async_call(
DOMAIN, "generate_dashboard", {}, blocking=True
)
hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
"_install_dashboard_generated": True,
},
)
except Exception as gen_err:
_LOGGER.error(
"Post-install dashboard generation failed: %s", gen_err
)
from homeassistant.helpers.event import async_call_later as _acl
_acl(hass, 2, _run_once_install_dashboard)
_LOGGER.info("Solar Energy Management integration setup completed successfully")
return True
def _schedule_post_startup_tasks(
hass: HomeAssistant,
entry: ConfigEntry,
full_config: Dict[str, Any],
coordinator: SEMCoordinator
) -> None:
"""Schedule non-critical tasks to run after Home Assistant has started.
This prevents blocking the startup process while still ensuring
these tasks run when the system is ready.
"""
from homeassistant.helpers.event import async_track_state_added_domain
@callback
def _async_post_startup_init(event) -> None:
"""Force a fresh split-grid discovery once HA has finished starting.
Catches the case where another integration's energy sensor was not yet
in the registry during async_config_entry_first_refresh(), leaving the
cache pinned on an any-device pick (issue #166).
"""
_LOGGER.debug("Running post-startup initialization tasks")
reader = getattr(coordinator, "_sensor_reader", None)
if reader is not None:
reader.invalidate_split_grid_cache()
hass.async_create_task(coordinator.async_request_refresh())
@callback
def _on_new_sensor(event) -> None:
"""Re-run split-grid discovery when a grid-shaped sensor appears.
Triggered for entity additions (old_state is None). Pre-filters by
substring so the typical firehose of new temperature/humidity/etc.
sensors does not schedule a coordinator refresh. The authoritative
pattern check still happens inside _discover_split_grid_power.
"""
reader = getattr(coordinator, "_sensor_reader", None)
if reader is None:
return
# Only relevant for split-grid setups. Combined-grid users (with
# ed.grid_import_power set) never enter discovery, so skip them.
if not getattr(reader, "_uses_split_grid", False):
return
disc = getattr(reader, "_split_grid_discovery", None)
if disc is None or disc.get("confidence") == "same-device":
return # already locked in, nothing to upgrade
eid = event.data.get("entity_id", "")
if not any(hint in eid for hint in GRID_TRIGGER_HINTS):
return
_LOGGER.info(
"New grid-shaped sensor %s appeared — re-running split-grid discovery",
eid,
)
reader.invalidate_split_grid_cache()
hass.async_create_task(coordinator.async_request_refresh())
# Schedule tasks to run when Home Assistant is fully started
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_post_startup_init)
# React live to new sensor entities from other integrations (e.g. DSMR loading
# after SEM's first refresh). Cheap: only fires on entity creation, not state
# changes. See plan for issue #166.
entry.async_on_unload(
async_track_state_added_domain(hass, "sensor", _on_new_sensor)
)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update.
Skips reload when the change came from a number/switch entity (runtime
config tweak): those updates already mirrored the value into the
coordinator's in-memory config, so a full reload (which destroys all
entities for ~1 s) is wasteful.
The skip is keyed to the *exact* options payload the entity persisted
(``_skip_options_reload`` holds that snapshot), and is consumed once. A
bare boolean used to leak — a stale flag from an earlier stepper could
swallow a later options-FLOW save (e.g. ``vehicle_soc_entity``), which then
only took effect after a full restart (#245 review #1). Comparing against
the snapshot makes a flow change (different options) always reload.
"""
coordinator = entry.runtime_data if hasattr(entry, "runtime_data") else None
snapshot = getattr(coordinator, "_skip_options_reload", None) if coordinator else None
if coordinator is not None:
coordinator._skip_options_reload = None # always consume — no leak
if isinstance(snapshot, dict) and dict(entry.options) == snapshot:
_LOGGER.debug("Options update from runtime tweak — skipping reload")
return
_LOGGER.info("Config options updated, reloading integration")
await hass.config_entries.async_reload(entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant,
entry: SEMConfigEntry,