Repository navigation
Expand file tree
/
Copy pathconfig_flow.py
More file actions
3982 lines (3740 loc) · 188 KB
/
Copy pathconfig_flow.py
File metadata and controls
3982 lines (3740 loc) · 188 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
"""Config flow for Solar Energy Management integration."""
from __future__ import annotations
import logging
from typing import Any, Dict
import voluptuous as vol
from homeassistant.helpers import selector
from homeassistant.core import callback
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from .consts.devices import CONTACT_VALUE_SERVICES, SG_READY_CONTACT_DOMAINS
from .const import (
DOMAIN,
DEFAULT_PHASE_GUARD_TOPOLOGY,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_BATTERY_PRIORITY_SOC,
DEFAULT_BATTERY_BUFFER_SOC,
DEFAULT_BATTERY_AUTO_START_SOC,
DEFAULT_MIN_SOLAR_POWER,
DEFAULT_DAILY_EV_TARGET,
DEFAULT_BATTERY_ASSIST_MAX_POWER,
DEFAULT_BATTERY_ASSIST_MIN_SURPLUS,
DEFAULT_BATTERY_CAPACITY_KWH,
DEFAULT_BATTERY_DISCHARGE_PROTECTION_ENABLED,
DEFAULT_BATTERY_MAX_DISCHARGE_POWER,
DEFAULT_PREFER_HARDWARE_ENERGY,
DEFAULT_ENERGY_SOURCE_AUTO,
DEFAULT_TARGET_PEAK_LIMIT,
DEFAULT_WARNING_PEAK_LEVEL,
DEFAULT_EMERGENCY_PEAK_LEVEL,
DEFAULT_LOAD_MANAGEMENT_ENABLED,
DEFAULT_OBSERVER_MODE,
MIN_PEAK_LIMIT_KW,
MAX_PEAK_LIMIT_KW,
PEAK_LIMIT_STEP_KW,
DEFAULT_PEAK_LIMIT_UNLIMITED,
)
from .consts.bounds import bounds_selector
from .coordinator.ev_taper_detector import resolve_charge_efficiency
from .coordinator.units import energy_state_to_kwh, normalize_unit
from .ha_energy_reader import read_energy_dashboard_config, EnergyDashboardConfig
from .hardware_detection import (
HardwareDetector,
discover_ev_charger_from_registry,
discover_inverter_from_registry,
)
_LOGGER = logging.getLogger(__name__)
# #735 — ``ev_charger_efficiency`` is stored as the fraction the taper
# detector books with (0.5–1.0), but a percentage is what a charger's
# datasheet quotes and what the user is thinking in. The dialog is the only
# place the two units meet, so the conversion lives here and nowhere else.
def _efficiency_as_percent(stored: Any) -> int:
"""Stored fraction → the whole percent the dialog shows.
Runs the value through the detector's resolver first, so a figure the
booking is already ignoring (a hand-edited ``92``, a stray ``3.0``) is
displayed as the default rather than as a number outside the box's own
range — which HA refuses to submit, wedging the whole dialog.
Round-trips exactly for anything the form itself wrote (whole percent →
multiples of 0.01). A hand-edited 0.785 shows as 78 % and saves back as
0.78: the field's resolution is one point, so sub-point precision cannot
survive a visit to the dialog. Bounded and one-way.
"""
return round(resolve_charge_efficiency(stored) * 100)
def _efficiency_from_percent(percent: Any) -> float:
"""The dialog's whole percent → the fraction the detector reads.
Re-validating after the divide is not belt-and-braces: the step is also
reachable from a raw ``config_entries/options/flow`` POST, which is not
bound by the selector's 50–100 range.
"""
try:
fraction = float(percent) / 100.0
except (TypeError, ValueError):
return resolve_charge_efficiency(None)
return resolve_charge_efficiency(fraction)
def _clearable_keys(schema: Any) -> set[str]:
"""The fields a form lets the user leave EMPTY.
``vol.Optional(key, description={"suggested_value": …})`` with no
``default`` is exactly HA's "may be absent": the marker only *suggests*
a value, so an emptied field comes back as a MISSING key rather than an
empty string. An Optional that carries a ``default`` is always filled in
by validation, so it is never ambiguous and never clearable.
"""
inner = getattr(schema, "schema", None)
if not isinstance(inner, dict):
return set()
return {
str(marker.schema)
for marker in inner
if isinstance(marker, vol.Optional)
and getattr(marker, "default", vol.UNDEFINED) is vol.UNDEFINED
}
def _parse_service_data(raw) -> dict | None:
"""#801: service data is stored as a JSON string in the flow.
Returns the dict, {} for empty, None for invalid — the caller turns
None into a form error instead of shipping an unusable payload.
"""
if isinstance(raw, dict):
return raw
text = str(raw or "").strip()
if not text:
return {}
try:
import json
parsed = json.loads(text)
except Exception: # noqa: BLE001 — invalid JSON is the error case
return None
return parsed if isinstance(parsed, dict) else None
def _contact_values_missing(get) -> bool:
"""#801: a contact pointing at a VALUE entity (text/number/select) needs
BOTH an ON and an OFF value.
Without them SEM has nothing to write, so the contact would sit in the
config looking configured and never move the pump — the silent
half-configuration the #437 relay rule already refuses for relays.
"""
for idx in (1, 2):
eid = str(get(f"heat_pump_relay{idx}_entity") or "").strip()
if not eid or eid.split(".", 1)[0] not in CONTACT_VALUE_SERVICES:
continue
on = str(get(f"heat_pump_relay{idx}_on_value") or "").strip()
off = str(get(f"heat_pump_relay{idx}_off_value") or "").strip()
if not on or not off:
return True
return False
def _draft_list(flow: Any, key: str) -> list:
"""Working copy of a list-valued option — draft first, saved second (#990).
``flow._data`` is the DRAFT this dialog is building; ``entry.options`` is
what is already saved. Resolving the two with ``draft or saved`` cannot
tell *"the flow has not touched the list"* from *"the user just emptied
it"* — an empty list is falsy, so both fall the same way and the saved
copy wins. That collapses the one state removal exists to produce: pop
the LAST additional heat pump and the very next read hands the row
straight back (@RienduPre, #990 — Remove was accepted and the pump stayed
on the menu; adding afterwards then resurrected it as a phantom sibling).
Key PRESENCE is the only thing that separates the two, so that is what
this tests. Same distinction as ``_merge_form_input``'s cleared-field
rule one level up: silence and emptiness are different answers.
The ROWS are copied too, not just the list. ``entry.options`` is a
read-only mapping at the top level and wide open one level down, so a
step that merges a form into ``rows[0]`` was writing through to the
stored dict — and ``full_config = {**entry.data, **entry.options}``
shares those same row objects with the LIVE coordinator. Editing page
one and then closing the dialog changed the running charger with no
save. Every caller re-stores the list into ``flow._data``, so nobody
needs the identity.
"""
raw = flow._data[key] if key in flow._data else flow.config_entry.options.get(key)
return [dict(row) if isinstance(row, dict) else row for row in (raw or [])]
def _suggest_discovered(saved: dict, discovered: dict, key: str):
"""Offer a detected entity only where the user never answered (#990).
``saved.get(k) or discovered.get(k)`` is ``_draft_list``'s bug on a
scalar. A cleared optional field is stored as an explicit ``None``
(``_merge_form_input``, #690) — so on the one install that deliberately
DELETED the auto-detected sensor, the ``or`` reads that deletion as
"nobody said", re-suggests the entity, HA pre-fills the field with it,
and the next Submit silently re-adopts what the user just took out.
Presence of the key is the difference between silence and an answer.
"""
if key in saved:
return saved[key] or None
return discovered.get(key) or None
def _merge_form_input(flow: Any, target: dict, user_input: dict) -> None:
"""Merge a submitted form into ``target``, honouring CLEARED fields.
``target.update(user_input)`` — what every step used to do — cannot tell
"the user left this alone" from "the user emptied it", because HA drops
an emptied optional field out of ``user_input`` altogether. The result
is a config key that can be re-pointed but never taken back: a wrong
auto-detected ``ev_start_stop_entity`` (#627's symptom), a mis-picked
``phase_guard_*`` sensor, a heat-pump relay on the wrong switch. The
only recorded cure was deleting the integration.
The missing input is *which fields this page offered* — and HA keeps it:
``flow.cur_step`` is the form it just showed, schema included. So the
page itself decides what may be cleared, and no hand-written key list
can drift away from the fields on screen.
A cleared field is written as ``None``, not deleted. The runtime config
is ``{**entry.data, **entry.options}`` and the options flow replaces
options wholesale (#690), so deleting the key merely un-covers whatever
initial setup wrote into ``entry.data`` and the cleared value returns.
``None`` is also the house spelling of "not set" — the v8→v9 migration
seeds ``vehicle_min_current: None`` — and every consumer of these keys
reads them for truth or with a falsy fallback.
With no shown form to consult (a direct handler call), this degrades to
a plain update: nothing to clear against.
"""
cur_step = getattr(flow, "cur_step", None) or {}
for key in _clearable_keys(cur_step.get("data_schema")):
if key not in user_input:
target[key] = None
target.update(user_input)
# Every per-charger key that names an ENTITY, i.e. that says something about
# the physical box rather than about the car or about how to talk to it.
# Service names are deliberately absent: two KEBAs both answer to
# ``keba.set_current``, so a service is not an identity. Pinned against
# ``hardware_detection`` by tests/test_ev_charger_post_install_surface.py, so
# a brand that starts reporting a new entity key cannot quietly slip out of
# the comparison below.
_CHARGER_ENTITY_KEYS = frozenset({
"ev_charge_mode_entity",
"ev_charger_service_entity_id",
"ev_charging_power_sensor",
"ev_charging_sensor",
"ev_connected_sensor",
"ev_current_control_entity",
"ev_current_sensor",
"ev_session_energy_sensor",
"ev_start_stop_entity",
"ev_total_energy_sensor",
})
def _charger_entities(charger: Any) -> set[str]:
"""The entities a charger config points at — its fingerprint."""
if not isinstance(charger, dict):
return set()
return {str(v) for k, v in charger.items() if k in _CHARGER_ENTITY_KEYS and v}
def _charger_already_installed(discovery: dict, installed: Any) -> bool:
"""Is this discovery a box the user already has?
Compared by ENTITY OVERLAP. The dedup this replaces compared
``_device_id`` — a key ``hardware_detection`` puts on a *discovery* and
that nothing ever writes onto the *stored* charger. So the set of
already-known ids was always empty, every discovery always looked new,
and the values offered for charger #2 were charger #1's own config: its
sensors and its ``ev_charger_service``. Accept the suggestions and SEM
drives the first box twice while the second one never moves.
Entities are the right key because they are what actually gets stored,
for every charger — including charger #0, which the initial setup flow
creates and which can never carry a ``_device_id``, and including one
that was configured by hand.
"""
mine = _charger_entities(discovery)
if not mine:
return False
return any(mine & _charger_entities(c) for c in (installed or []))
#: (#848) Per-spec STABLE keys — an integration's ``translation_key`` /
#: ``unique_id`` suffix never localises and survives renames, unlike the
#: entity_id the glob fallback below matches. Grounded live on huawei_solar
#: (a German install: ``sensor.batterien_akkukapazitat`` carries
#: ``tk=storage_rated_capacity``).
_SPEC_REGISTRY_KEYS: Dict[str, tuple] = {
"battery_capacity_kwh": (
"storage_rated_capacity", "battery_capacity", "usable_capacity",
"rated_capacity",
),
"system_size_kwp": ("rated_power", "nominal_power"),
"battery_max_discharge_power": (
"storage_maximum_discharging_power", "max_discharge_power",
"max_discharging_power",
),
}
#: (#915) role in the mined roster -> the spec this flow fills from it. Only
#: read-side specs: a spec is a NUMBER SEM reads once, never a control it
#: writes, so a wrong guess costs a wrong capacity figure the user can see
#: and correct — not a wrong register write.
_ROSTER_SPEC_ROLES: Dict[str, str] = {
"battery_capacity_kwh": "battery_capacity_spec",
"system_size_kwp": "system_size_spec",
"battery_max_discharge_power": "battery_discharge_limit",
}
def _spec_exact_only(spec: str, platform: str) -> tuple:
"""Which of this spec's keys may be matched by translation_key ONLY."""
role = _ROSTER_SPEC_ROLES.get(spec)
if not role or not platform:
return ()
try:
from .hardware_detection import roster_role_vocab
return tuple(roster_role_vocab(platform, role).get("exact_only", ()))
except Exception: # noqa: BLE001
return ()
def _spec_matches(entry, spec: str, platform: str):
"""Does this registry entry carry one of ``spec``'s keys? The single
matcher in ``hardware_detection``, with the roster's exact_only set —
including for keys that were hand-written before the roster existed."""
try:
from .hardware_detection import _entry_matches_declared
return _entry_matches_declared(
entry, _spec_keys(spec, platform), _spec_exact_only(spec, platform))
except Exception: # noqa: BLE001
return None
def _spec_keys(spec: str, platform: str) -> tuple:
"""The entity keys that identify ``spec`` on ``platform``.
Hand-written keys FIRST and always — they were harvested from real
installs and one of them (Huawei's ``storage_rated_capacity``) has since
been renamed upstream to ``rated_ess_capacity``. Mined keys are ADDITIVE
ALIASES appended after: a published vocabulary is the current truth, a
harvested one is the truth some user's box still runs. Replacing rather
than appending would have broken that install.
"""
keys = tuple(_SPEC_REGISTRY_KEYS.get(spec, ()))
role = _ROSTER_SPEC_ROLES.get(spec)
if not role or not platform:
return keys
try:
from .hardware_detection import roster_role_vocab
vocab = roster_role_vocab(platform, role)
except Exception: # noqa: BLE001 — a prior is never load-bearing
return keys
# (06.09 audit) an exact_only mined key is a suffix of a longer key the
# same brand declares; matched by unique_id suffix it picks the wrong
# entity, so it is not an alias this suffix-matching consumer may use
exact_only = set(vocab.get("exact_only", ()))
return keys + tuple(k for k in vocab["keys"]
if k not in keys and k not in exact_only)
def _suggest_select_from_roster(hass: HomeAssistant, platform: str,
role: str) -> str | None:
"""(#915) A select entity identified by the options its own integration
declares, for every mined brand.
``_suggest_select_with_options`` already does this — it is how #827 finds
the Deye work-mode selector and #845 the Huawei one, "whatever the
integration named the entity". Both vocabularies are hand-written, which
means the trick only ever worked for two brands. The roster supplies the
other three hundred; the matcher, its three-label guard and its live
intersection are unchanged.
"""
if not platform:
return None
try:
from .consts import integration_roster as _r
body = ((getattr(_r, "ROLE_VOCAB", {}) or {}).get(platform) or {}).get(role)
except Exception: # noqa: BLE001
return None
labels = tuple(body.get("options", ())) if body else ()
if len(labels) < 3:
return None
return _suggest_select_with_options(hass, list(labels))
def _spec_from_registry(hass: HomeAssistant, registry=None) -> Dict[str, str]:
"""(#848) entity_id per spec, found by translation_key / unique_id
suffix in the entity registry — the language-proof half of the ladder.
Returns only the entity ids; the caller reads and converts values."""
from homeassistant.helpers import entity_registry as _er
if registry is None:
try:
registry = _er.async_get(hass)
except Exception: # noqa: BLE001 — glob fallback stands alone
return {}
found: Dict[str, str] = {}
try:
entries = list(registry.entities.values())
except Exception: # noqa: BLE001 — un-loaded/mocked registry: globs stand alone
return {}
for e in entries:
if getattr(e, "disabled_by", None):
continue
eid = str(e.entity_id)
if not eid.startswith(("sensor.", "number.")):
continue
for spec in _SPEC_REGISTRY_KEYS:
if spec in found:
continue
# (#915) hand-written keys plus this integration's own declared
# aliases — see _spec_keys for why they are additive.
# (07.09 re-audit) THE one matcher — this had its own copy,
# which honoured no exact_only at all: a HAND-WRITTEN spec key
# the roster later marks exact_only (Marstek's
# `max_discharge_power`, a suffix of `system_max_discharge_power`)
# still matched the wrong entity by unique_id, whichever the
# registry yielded first.
if _spec_matches(e, spec, str(getattr(e, "platform", ""))):
found[spec] = eid
return found
def _detect_hardware_specs(hass: HomeAssistant, registry=None) -> Dict[str, float]:
"""Auto-detect battery capacity, system size, and max discharge from hardware.
(#848) Registry-first: stable ``translation_key``/``unique_id`` matches
win; the entity_id globs below remain as the last-resort fallback for
integrations without stable keys. Returns only the values it found.
"""
detected: Dict[str, float] = {}
# ── the registry ladder rung ─────────────────────────────────────
for spec, eid in _spec_from_registry(hass, registry=registry).items():
state = hass.states.get(eid)
if state is None:
continue
try:
val = float(state.state)
except (TypeError, ValueError):
continue
if val <= 0:
continue
if spec == "battery_capacity_kwh":
labelled = bool(normalize_unit(state))
val = energy_state_to_kwh(state, default=val)
if not labelled and val > 500:
val = val / 1000
detected[spec] = round(val, 1)
elif spec == "system_size_kwp":
if val > 100: # W → kWp
detected[spec] = round(val / 1000, 1)
else: # discharge power, W
detected["battery_max_discharge_power"] = round(val, 0)
detected["battery_assist_max_power"] = round(val, 0)
# Battery capacity (Wh or kWh)
capacity_patterns = [
"sensor.*akkukapazitat*", # Huawei (Wh)
"sensor.*battery_capacity*", # Generic (kWh or Wh)
"sensor.*usable_capacity*", # SolarEdge, generic
"sensor.*rated_capacity*", # BYD, generic
]
for pattern in capacity_patterns:
import fnmatch
for state in hass.states.async_all("sensor"):
if fnmatch.fnmatch(state.entity_id, pattern):
try:
val = float(state.state)
if val > 0:
# #641 — the unit half goes through units.py (so MWh
# converts too). The ``> 500`` magnitude heuristic
# applies ONLY to an UNLABELLED counter, which is the
# case it was written for: applying it after a unit
# conversion would divide a labelled 600 kWh bank twice.
labelled = bool(normalize_unit(state))
val = energy_state_to_kwh(state, default=val)
if not labelled and val > 500:
val = val / 1000 # unlabelled Wh → kWh
detected["battery_capacity_kwh"] = round(val, 1)
break
except (ValueError, TypeError):
continue
if "battery_capacity_kwh" in detected:
break
# Inverter rated power (W → kWp)
power_patterns = [
"sensor.*nennleistung*", # Huawei (W)
"sensor.*rated_power*", # Generic (W)
"sensor.*nominal_power*", # SolarEdge (W)
"sensor.*max_power*", # Generic
]
for pattern in power_patterns:
import fnmatch
for state in hass.states.async_all("sensor"):
if fnmatch.fnmatch(state.entity_id, pattern) and "inverter" in state.entity_id.lower():
try:
val = float(state.state)
if val > 100: # Must be in W
detected["system_size_kwp"] = round(val / 1000, 1)
break
except (ValueError, TypeError):
continue
if "system_size_kwp" in detected:
break
# Battery max discharge power (W)
discharge_patterns = [
"number.*maximale_entladeleistung*", # Huawei
"number.*max_discharge*", # Generic
"sensor.*max_discharge_power*", # SolarEdge, generic
]
for pattern in discharge_patterns:
import fnmatch
for state in hass.states.async_all(["number", "sensor"]):
if fnmatch.fnmatch(state.entity_id, pattern):
try:
val = float(state.state)
if val > 0:
detected["battery_max_discharge_power"] = round(val, 0)
detected["battery_assist_max_power"] = round(val, 0)
break
except (ValueError, TypeError):
continue
if "battery_max_discharge_power" in detected:
break
if detected:
_LOGGER.info("Hardware auto-detected: %s", detected)
return detected
class SolarEnergyManagementConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Solar Energy Management."""
# v4 (#255): per-charger settings seeded from globals (async_migrate_entry).
# v5 (#277 Phase A): per-charger charge_mode derived from legacy toggles.
# v6 (#277 Phase B): re-derive charge_mode for the pv+tariff combinations
# Phase A's derivation silently dropped.
# v7 (#277 Phase C): drop the dead ev_charging_mode key (charge_mode is
# authoritative now; the legacy select + 3 legacy switches are gone).
# v8 (#359): flip tariff_classification_mode static→percentile when
# tariff_mode == "dynamic".
# v9 (#440 ADR 0010 #3): add ``vehicle_min_current`` to each
# ``ev_chargers`` entry (default None = use the loadpoint
# ``ev_min_current``). Optional per-car handshake-floor override.
# v10 (#441): rename per-charger ``ev_night_initial_current`` to
# ``initial_current`` (decouples from the misleading "night"
# prefix — the value is the session-start ramp current, applied
# whenever a session begins). Display: "Vehicle Start Amps".
# v17 (#758): write ``energy_plan_actuation`` down explicitly on upgrade
# and announce it once. The v1.8 plan drives hardware; the default
# is on, and a default nobody was told about is not consent.
VERSION = 18
# v18.2 (#876): fill the Energy-Dashboard COUNTER keys into entries that
# predate the config-flow merge at ``_async_step_energy_source``.
# A minor bump, because it only ADDS keys — but it has to be declared
# here or HA never calls async_migrate_entry at all: an entry at 18.1
# already matches a handler that declares no MINOR_VERSION, so the
# migration would sit in the file and never run. PROD sat at 18.1 for
# nine months with the backfill dead behind one missing key.
MINOR_VERSION = 2
@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize the config flow."""
self._data = {}
self._errors = {}
self._energy_dashboard_config: EnergyDashboardConfig | None = None
self._detector = None
async def async_step_integration_discovery(
self, discovery_info: dict[str, Any]
) -> FlowResult:
"""Handle integration discovery (#44).
Triggered when a supported inverter integration (huawei_solar,
solaredge, fronius, goodwe, enphase_envoy, sma, growatt_server,
solis, powerwall, kostal_plenticore, solax, victron) is loaded.
Suggests SEM setup if Energy Dashboard is configured.
"""
await self.async_set_unique_id(DOMAIN)
# Reload on rediscovery to pick up updated version/device info
# (quality scale: discovery-update-info)
self._abort_if_unique_id_configured(reload_on_update=True)
# (#915) Discovery used to stand down unless the Energy Dashboard
# was already complete — so the one moment SEM KNOWS a supported
# inverter just appeared was also the moment it said nothing. The
# user step now handles an empty dashboard by asking the box, so
# discovery can simply offer the install.
return await self.async_step_user()
async def async_step_sources(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""(#915) Name the three sensors SEM reads, without the Energy
Dashboard.
Reached only when the dashboard cannot answer. Every field is
pre-filled from what this box already runs — the integration's own
declared entity names first, the plain shape of a power sensor
second — so the common case is *confirm*, not *hunt through four
hundred entities*. Solar and grid are required because SEM cannot
compute a surplus without them; battery is optional, since plenty of
installs have none.
Nothing here is bound silently: the flow shows the proposal, the
user accepts or replaces it, and the sign convention is still
detected from live values rather than assumed.
"""
errors: dict[str, str] = {}
proposals = {}
try:
from .hardware_detection import propose_energy_sources
proposals = propose_energy_sources(self.hass)
except Exception: # noqa: BLE001 — a proposal never breaks an install
proposals = {}
if user_input is not None:
solar = (user_input.get("solar_power_sensor") or "").strip()
grid = (user_input.get("grid_import_power_sensor") or "").strip()
soc = (user_input.get("battery_soc_sensor") or "").strip()
# (#915) A combined grid sensor is not something every brand
# HAS. Growatt (pattern E), Anker's official integration and
# Senec publish import and export as two positive sensors and
# nothing else — demanding a combined one would stop exactly the
# installs this step exists to rescue. Either answer is enough,
# and the pair must be whole: half of it reads as a meter that
# only ever imports.
split_in = (user_input.get("grid_import_power_entity") or "").strip()
split_out = (user_input.get("grid_export_power_entity") or "").strip()
if not solar:
errors["solar_power_sensor"] = "required"
if split_in or split_out:
if not split_in:
errors["grid_import_power_entity"] = "required"
if not split_out:
errors["grid_export_power_entity"] = "required"
elif not grid:
errors["grid_import_power_sensor"] = "required"
if not errors:
battery = (user_input.get("battery_power_sensor") or "").strip()
# Both key sets, deliberately. ``solar_production_sensor`` /
# ``grid_power_sensor`` are what SensorReader's LEGACY path
# consumes, and that is the path this install takes — the
# Energy Dashboard could not answer, so nothing feeds
# ``set_energy_dashboard_config`` and the reader falls back
# to these. The dashboard-shaped names are written too
# because the rest of the integration (flags, diagnostics,
# the Config card's pickers) reads those. Writing only the
# dashboard-shaped ones installed cleanly and then read 0 W
# from a 4.2 kW inverter — caught on the .46 rig.
self._data.update({
"solar_production_sensor": solar,
"grid_power_sensor": grid,
"solar_power_sensor": solar,
"grid_import_power_sensor": grid,
"battery_power_sensor": battery,
"battery_soc_sensor": soc,
"has_solar": True,
"has_grid": True,
"has_battery": bool(battery),
"has_ev": False,
# (#915) Recorded so a support thread can tell a manual
# install from a dashboard-derived one at a glance.
"sources_from": "manual",
})
if split_in and split_out:
# The keys SensorReader reads for a declared pair, on
# BOTH of its paths since #915 — this install is on the
# legacy one by definition.
self._data["grid_import_power_entity"] = split_in
self._data["grid_export_power_entity"] = split_out
self._data["observer_mode"] = user_input.get(
"observer_mode", DEFAULT_OBSERVER_MODE)
self._data["vacation_mode"] = False
self._data["energy_plan_actuation"] = True
return await self.async_step_hardware()
def _sug(key: str) -> dict:
hit = proposals.get(key)
return {"suggested_value": hit["entity"]} if hit else {}
found_lines = []
for key, label in (("solar_power_sensor", "Solar"),
("grid_import_power_sensor", "Grid"),
("grid_import_power_entity", "Grid import"),
("grid_export_power_entity", "Grid export"),
("battery_power_sensor", "Battery"),
("battery_soc_sensor", "Battery charge")):
hit = proposals.get(key)
if hit:
found_lines.append(
f" • {label}: `{hit['entity']}` — {hit['why']}")
summary = ("\n".join(found_lines) if found_lines
else " • nothing recognised — pick the sensors yourself")
return self.async_show_form(
step_id="sources",
data_schema=vol.Schema({
vol.Required(
"solar_power_sensor", description=_sug("solar_power_sensor"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="power")),
vol.Optional(
"grid_import_power_sensor",
description=_sug("grid_import_power_sensor"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="power")),
# …or the two-sided pair, for meters that have no combined
# reading. Both positive; SEM computes export − import.
vol.Optional(
"grid_import_power_entity",
description=_sug("grid_import_power_entity"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="power")),
vol.Optional(
"grid_export_power_entity",
description=_sug("grid_export_power_entity"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="power")),
vol.Optional(
"battery_power_sensor",
description=_sug("battery_power_sensor"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="power")),
vol.Optional(
"battery_soc_sensor",
description=_sug("battery_soc_sensor"),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor",
device_class="battery")),
vol.Optional(
"observer_mode", default=DEFAULT_OBSERVER_MODE,
): selector.BooleanSelector(),
}),
description_placeholders={"summary": summary,
"url": "/config/energy"},
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step - check Energy Dashboard configuration."""
errors: dict[str, str] = {}
# Read Energy Dashboard configuration
self._energy_dashboard_config = await read_energy_dashboard_config(self.hass)
# (#915) The Energy Dashboard is SEM's first anchor, not its only
# one. It used to be both: a missing or half-filled dashboard ended
# the install with "go configure a different page and start again" —
# the hardest wall in SEM's onboarding, and one that asks the user to
# map ENERGY counters when SEM steers on POWER. When it cannot
# answer, ask the box instead: which energy integrations are
# installed, and what does each call the three sensors SEM needs.
if (self._energy_dashboard_config is None
or not self._energy_dashboard_config.is_minimally_configured()):
return await self.async_step_sources()
# Energy Dashboard is configured - show summary and continue.
# Slim install (v1.7.1-beta.11+, #442): route directly to
# ``async_step_hardware`` and skip the EV charger step entirely.
# The EV step's fields stay available in the OptionsFlow for
# power users and via the new dashboard Configuration tab for
# everyone else — fresh installs no longer need to lie about
# their EV setup just to get past the install.
if user_input is not None:
# Store Energy Dashboard sensor config + the observer_mode toggle
self._data.update(self._energy_dashboard_config.to_dict())
self._data["observer_mode"] = user_input.get(
"observer_mode", DEFAULT_OBSERVER_MODE)
# (#777) Record ALL persisted-switch defaults explicitly at
# install: a key present in entry data means "this install
# chose", so a dead install's restore-store ghost can never
# speak for a fresh one. Only true legacy upgrades (no key
# anywhere) keep the restore-state fallback.
self._data["vacation_mode"] = False
self._data["energy_plan_actuation"] = True
return await self.async_step_hardware()
# Show Energy Dashboard summary — list every sensor SEM picked up so the
# user can verify the auto-detection at a glance.
cfg = self._energy_dashboard_config
summary_lines: list[str] = []
def _add(category: str, fields: list[tuple[str, str | None]]) -> None:
present = [(label, eid) for label, eid in fields if eid]
if not present:
return
summary_lines.append(f"**{category}**")
for label, eid in present:
summary_lines.append(f" • {label}: `{eid}`")
summary_lines.append("")
if cfg.has_solar:
if len(cfg.solar_power_list) > 1:
fields = [(f"Power ({i+1})", p) for i, p in enumerate(cfg.solar_power_list)]
fields += [(f"Energy ({i+1})", e) for i, e in enumerate(cfg.solar_energy_list)]
_add(f"Solar ({len(cfg.solar_power_list)} inverters)", fields)
else:
_add("Solar", [
("Power", cfg.solar_power),
("Energy", cfg.solar_energy),
])
if cfg.has_grid:
if len(cfg.grid_import_energy_list) > 1:
fields = [(f"Import energy ({i+1})", e) for i, e in enumerate(cfg.grid_import_energy_list)]
fields += [(f"Export energy ({i+1})", e) for i, e in enumerate(cfg.grid_export_energy_list)]
fields.insert(0, ("Power", cfg.grid_import_power))
_add(f"Grid ({len(cfg.grid_import_energy_list)} tariffs)", fields)
else:
_add("Grid", [
("Power", cfg.grid_import_power),
("Import energy", cfg.grid_import_energy),
("Export energy", cfg.grid_export_energy),
])
if cfg.has_battery:
if len(cfg.battery_power_list) > 1:
fields = [(f"Power ({i+1})", p) for i, p in enumerate(cfg.battery_power_list)]
fields += [(f"Charge energy ({i+1})", e) for i, e in enumerate(cfg.battery_charge_energy_list)]
fields += [(f"Discharge energy ({i+1})", e) for i, e in enumerate(cfg.battery_discharge_energy_list)]
_add(f"Battery ({len(cfg.battery_power_list)} units)", fields)
else:
_add("Battery", [
("Power", cfg.battery_power),
("Charge energy", cfg.battery_charge_energy),
("Discharge energy", cfg.battery_discharge_energy),
])
if cfg.has_ev:
_add("EV", [
("Power", cfg.ev_power),
("Energy", cfg.ev_energy),
])
# Trim trailing blank line for tidy rendering
if summary_lines and summary_lines[-1] == "":
summary_lines.pop()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
# Single safety toggle, defaulted ON: a new install shows
# what it WOULD do and commands nothing until its owner
# turns this off. Reads the constant rather than repeating
# it — the literal here and the constant elsewhere are how
# the two drifted before.
vol.Optional(
"observer_mode",
default=DEFAULT_OBSERVER_MODE,
): selector.BooleanSelector(),
}),
description_placeholders={
"summary": "\n".join(summary_lines)
},
errors=errors
)
async def async_step_ev_charger(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the EV charger control configuration step."""
errors: dict[str, str] = {}
# Initialize hardware detector for EV control entity detection
if self._detector is None:
self._detector = HardwareDetector(self.hass)
if user_input is not None:
# Validate EV charger entities
validation_errors = self._detector.validate_ev_configuration(user_input)
if validation_errors:
errors.update(validation_errors)
_LOGGER.error(f"EV validation failed: {validation_errors}")
# Validate optional entity IDs exist in HA and have usable state (#32)
for entity_key in (
"ev_charger_service_entity_id",
"ev_current_sensor",
"ev_total_energy_sensor",
):
entity_id = user_input.get(entity_key, "")
if entity_id:
state = self.hass.states.get(entity_id)
if not state:
errors[entity_key] = "entity_not_found"
elif state.state in ("unknown", "unavailable"):
_LOGGER.warning(
"Entity %s exists but has state '%s' — may cause issues",
entity_id, state.state,
)
if not errors:
# Store EV charger entities and continue to the hardware step
_merge_form_input(self, self._data, user_input)
return await self.async_step_hardware()
# Primary: integration-aware registry discovery (KEBA, Easee, go-eCharger, Wallbox).
# This filters by entity registry platform and device_class, so it never matches
# unrelated devices like generic smart plugs.
suggestions = discover_ev_charger_from_registry(self.hass)
# Fallback: pattern-based detection only fills keys the registry didn't already set,
# so a stray generic match can never override a confident registry match.
pattern_suggestions = self._detector.get_suggested_ev_defaults() if self._detector else {}
for key, value in pattern_suggestions.items():
if value and not suggestions.get(key):
suggestions[key] = value
# Pre-fill from Energy Dashboard if available
if self._energy_dashboard_config and self._energy_dashboard_config.has_ev:
if self._energy_dashboard_config.ev_power and not suggestions.get("ev_charging_power_sensor"):
suggestions["ev_charging_power_sensor"] = self._energy_dashboard_config.ev_power
if self._energy_dashboard_config.ev_energy and not suggestions.get("ev_total_energy_sensor"):
suggestions["ev_total_energy_sensor"] = self._energy_dashboard_config.ev_energy
# Helper for optional EntitySelector fields: HA rejects default="" because
# an empty string is neither a valid entity_id nor None. Use suggested_value
# via the field description so the prefill is shown without becoming a
# hard default.
def _opt_entity_default(key: str):
v = suggestions.get(key)
return v if v is not None else None
return self.async_show_form(
step_id="ev_charger",
data_schema=vol.Schema({
# EV Charger Control Entities (Required for solar optimization)
# Accept both binary_sensor and sensor for Easee/GoodWe (#68)
vol.Required(
"ev_connected_sensor",
default=suggestions.get("ev_connected_sensor", ""),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor", "sensor"])
),
vol.Required(
"ev_charging_sensor",
default=suggestions.get("ev_charging_sensor", ""),
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor", "sensor"])
),
vol.Required(
"ev_charging_power_sensor",
default=suggestions.get("ev_charging_power_sensor", ""),
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain="sensor",
device_class="power"
)
),
# EV Charger Control — pick ONE of the two paths below:
# • Number entity (Wallbox, go-eCharger, Heidelberg, OpenWB, Ohme, V2C, …)
# • Service call (KEBA, Easee, Zaptec, OCPP, …)
vol.Optional(
"ev_current_control_entity",
description={"suggested_value": _opt_entity_default("ev_current_control_entity")},
): selector.EntitySelector(
selector.EntitySelectorConfig(domain="number")
),
vol.Optional(
"ev_charger_service",
default=suggestions.get("ev_charger_service", ""),
): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT
)
),
vol.Optional(
"ev_charger_service_entity_id",
description={"suggested_value": _opt_entity_default("ev_charger_service_entity_id")},
): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor", "sensor", "switch"])
),
# Optional readback sensors
vol.Optional(
"ev_current_sensor",
description={"suggested_value": _opt_entity_default("ev_current_sensor")},
): selector.EntitySelector(
selector.EntitySelectorConfig(
domain="sensor",
device_class="current"
)
),
vol.Optional(
"ev_total_energy_sensor",
description={"suggested_value": _opt_entity_default("ev_total_energy_sensor")},
): selector.EntitySelector(
selector.EntitySelectorConfig(