-
-
Notifications
You must be signed in to change notification settings - Fork 150
Expand file tree
/
Copy pathuserinterface.py
More file actions
1616 lines (1435 loc) · 70.7 KB
/
Copy pathuserinterface.py
File metadata and controls
1616 lines (1435 loc) · 70.7 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
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init
"""Configuration management and Home Assistant event handling.
Mixin class for loading, validating, and synchronising configuration settings
between PredBat and Home Assistant. Handles entity creation for switches,
input_numbers, and select inputs, and routes HA events (state changes,
service calls) to the appropriate handlers.
"""
import os
from datetime import timedelta
from utils import get_override_time_from_string, mask_secret_args
import json
import yaml
import re
import copy
from const import (
TIME_FORMAT,
PREDBAT_MODE_OPTIONS,
PREDBAT_MODE_MONITOR,
)
from config import APPS_SCHEMA, CONFIG_API_OVERRIDE
from predbat import THIS_VERSION
DEBUG_EXCLUDE_LIST = [
"ha_interface",
"components",
"prediction",
"logfile",
"predheat",
"inverters",
"run_list",
"threads",
"EVENT_LISTEN_LIST",
"local_tz",
"CONFIG_ITEMS",
"config_index",
"comparison",
"plugin_system",
"ge_url_cache",
"github_url_cache",
"octopus_url_cache",
"secrets",
]
class UserInterface:
"""Configuration management and HA event handling mixin.
Loads, validates, and synchronises configuration settings between
PredBat and Home Assistant. Creates entities for switches, input_numbers,
and selects. Routes HA events to appropriate handlers.
"""
def call_notify(self, message):
"""
Sync wrapper for call_notify
"""
for device in self.notify_devices:
self.call_service_wrapper("notify/" + device, message=message)
return True
def call_service_wrapper_stub2(self, service, message):
"""
Stub for 2 arg service wrapper
"""
return self.call_service_wrapper(service, message=message)
async def async_call_notify(self, message):
"""
Send HA notifications
"""
for device in self.notify_devices:
await self.run_in_executor(self.call_service_wrapper_stub2, "notify/" + device, message)
return True
def resolve_arg(self, arg, value, default=None, indirect=True, combine=False, attribute=None, index=None, extra_args=None, quiet=False, required_unit=None):
"""
Resolve argument templates and state instances
"""
if isinstance(value, list) and (index is not None):
if index < len(value):
value = value[index]
else:
if not quiet:
self.log("Warn: Out of range index {} within item {} value {}".format(index, arg, value))
value = None
index = None
if index and value is not None:
self.log("Warn: item {} is incorrectly setup, element {} has value {}".format(arg, index, value))
# If we have a list of items get each and add them up or return them as a list
if isinstance(value, list):
if combine:
final = 0
for item in value:
got = self.resolve_arg(arg, item, default=default, indirect=True)
try:
final += float(got)
except (ValueError, TypeError):
if not quiet:
self.log("Warn: Return bad value {} from {} arg {}".format(got, item, arg))
self.record_status("Warn: Return bad value {} from {} arg {}".format(got, item, arg), had_errors=True)
return final
else:
final = []
for item in value:
item = self.resolve_arg(arg, item, default=default, indirect=indirect)
if isinstance(item, list):
final += item
else:
final.append(item)
return final
# Resolve templated data
for repeat in range(2):
if isinstance(value, str) and "{" in value:
try:
if extra_args:
# Remove duplicates or format will fail
arg_hash = {}
arg_hash.update(self.args)
arg_hash.update(extra_args)
value = value.format(**arg_hash)
else:
value = value.format(**self.args)
except KeyError:
if not quiet:
self.log("Warn: can not resolve {} value {}".format(arg, value))
value = default
# Resolve join list by name
if isinstance(value, str) and value.startswith("+[") and value.endswith("]"):
value = self.get_arg(value[2:-1], default=default, indirect=indirect, combine=False, attribute=attribute, index=index)
# Resolve indirect instance
if indirect and isinstance(value, str) and "." in value:
if "$" in value:
value, attribute = value.split("$")
if attribute:
value = self.get_state_wrapper(entity_id=value, default=default, attribute=attribute, required_unit=required_unit)
else:
value = self.get_state_wrapper(entity_id=value, default=default, required_unit=required_unit)
return value
def set_arg(self, arg, value, index=None):
"""
Argument setter that can use HA state as well as fixed values
Parameters:
arg (str): The argument name to set.
value: The value to set for the argument, or None to delete it
Returns:
None
"""
if value is None:
if arg in self.args:
del self.args[arg]
else:
if index is not None:
if arg not in self.args:
self.args[arg] = []
while len(self.args[arg]) <= index:
self.args[arg].append(None)
self.args[arg][index] = value
else:
self.args[arg] = value
def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=None, index=None, domain=None, can_override=True, required_unit=None):
"""
Argument getter that can use HA state as well as fixed values
"""
value = None
if can_override:
can_override = CONFIG_API_OVERRIDE.get(arg, False)
if can_override:
overrides = self.get_manual_api(arg)
if isinstance(default, list):
value = self.get_arg(arg, default=default, indirect=indirect, combine=combine, attribute=attribute, index=index, domain=domain, can_override=False)
is_dict_list = self.is_multi_instance_override(arg)
for override in overrides:
# dict_list index only dedupes at write time, it has no output position (#4405)
if is_dict_list:
value.append(override.get("value", None))
self.log("Note: API Overridden arg {} value {} appended".format(arg, value))
continue
override_index = override.get("index", 0)
if override_index is None:
override_index = 0
for idx in range(max(len(value), override_index + 1)):
if override_index == idx:
if len(value) <= idx:
# Extend length of value list to match index
value.extend([default] * (idx - len(value) + 1))
org_value = value[idx]
value[idx] = override.get("value", None)
if isinstance(org_value, float):
try:
value[idx] = float(value[idx])
except (ValueError, TypeError):
self.log("Warn: Return bad float value {} from {} override using default {}".format(value[idx], arg, default))
self.record_status("Warn: Return bad float value {} from arg override {}".format(value[idx], arg), had_errors=True)
value[idx] = default
elif isinstance(org_value, int) and not isinstance(org_value, bool):
try:
value[idx] = int(float(value[idx]))
except (ValueError, TypeError):
self.log("Warn: Return bad int value {} from {} override using default {}".format(value[idx], arg, default))
self.record_status("Warn: Return bad int value {} from arg override {}".format(value[idx], arg), had_errors=True)
value[idx] = default
elif isinstance(org_value, bool) and isinstance(value[idx], str):
# Convert to Boolean
if value[idx].lower() in ["on", "true", "yes", "enabled", "enable", "connected"]:
value[idx] = True
else:
value[idx] = False
self.log("Note: API Overridden arg {} value {} index {}".format(arg, value, idx))
if index:
if index < len(value):
value = value[index]
else:
self.log("Warn: Out of range index {} within item {} value {}".format(index, arg, value))
value = None
elif overrides:
for override in overrides:
override_index = override.get("index", 0)
if override_index is None:
override_index = 0
if override_index == index:
value = override.get("value", value)
self.log("Note: API Overridden arg {} value {}".format(arg, value))
break
# Get From HA config (not for domain specific which are apps.yaml options only)
if value is None and not domain:
value, default = self.get_ha_config(arg, default)
# Resolve locally if no HA config
if value is None:
if (arg not in self.args) and (default is not None) and (index is not None):
# Allow default to apply to all indices if there is not config item set
index = None
if domain:
value = self.args.get(domain, {}).get(arg, default)
else:
value = self.args.get(arg, default)
value = self.resolve_arg(arg, value, default=default, indirect=indirect, combine=combine, attribute=attribute, index=index, required_unit=required_unit)
if isinstance(default, float):
# Convert to float?
if value is None:
# Nothing resolved - not configured, or an out-of-range index on a per-inverter list
# (resolve_arg has already logged "Out of range index ..."). That is exactly what the
# caller's default is for, so apply it quietly rather than reporting an error: flagging
# it pins the warning on the status sensor and ends every run as "Read-Only with Errors"
# for a gap the caller already handles. A value that is present but unparseable is a
# genuine fault and is still reported below.
value = default
else:
try:
value = float(value)
except (ValueError, TypeError):
self.log("Warn: Return bad float value {} from {} using default {}".format(value, arg, default))
self.record_status("Warn: Return bad float value {} from {}".format(value, arg), had_errors=True)
value = default
elif isinstance(default, int) and not isinstance(default, bool):
# Convert to int?
if value is None:
# See the float case above - a missing value is what the default is for, not an error
value = default
else:
try:
value = int(float(value))
except (ValueError, TypeError):
self.log("Warn: Return bad int value {} from {} using default {}".format(value, arg, default))
self.record_status("Warn: Return bad int value {} from {}".format(value, arg), had_errors=True)
value = default
elif isinstance(default, bool) and isinstance(value, str):
# Convert to Boolean
if value.lower() in ["on", "true", "yes", "enabled", "enable", "connected"]:
value = True
else:
value = False
elif isinstance(default, list):
# Convert to list?
if not isinstance(value, list):
value = [value]
return value
async def select_event(self, event, data, kwargs):
"""
Catch HA Input select updates
Parameters:
- event: The event triggered by the input select.
- data: The data associated with the event.
- kwargs: Additional keyword arguments.
Returns:
None
Description:
This method is used to handle Home Assistant input select updates.
It extracts the necessary information from the data and performs different actions based on the selected option.
The actions include calling update service, saving and restoring settings, performing manual selection, and exposing configuration.
After performing the actions, it triggers an update by setting update_pending flag to True and plan_valid flag to False.
"""
service_data = data.get("service_data", {})
value = service_data.get("option", None)
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for entity_id in entities:
await self.components.select_event(entity_id, value)
for item in self.CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
entity = item["entity"]
self.log("select_event: {}, {} = {}".format(item["name"], entity, value))
if item["name"] == "update":
self.log("Calling update service for {}".format(value))
await self.async_download_predbat_version(value)
elif item["name"] == "saverestore":
if value == "save current":
await self.async_update_save_restore_list()
await self.async_save_settings_yaml()
elif value == "restore default":
await self.async_restore_settings_yaml(None)
else:
await self.async_restore_settings_yaml(value)
elif item.get("manual") or item.get("manual_rate"):
await self.async_manual_select(item["name"], value)
elif item.get("api"):
await self.async_api_select(item["name"], value)
else:
if item.get("value", None) != value:
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
async def number_event(self, event, data, kwargs):
"""
Catch HA Input number updates
This method is called when there is an update to a Home Assistant input number entity.
It extracts the value and entity ID from the event data and processes it accordingly.
If the entity ID matches any of the entities specified in the CONFIG_ITEMS list,
it logs the entity and value, exposes the configuration item, and updates the pending plan.
Args:
event (str): The event name.
data (dict): The event data.
kwargs (dict): Additional keyword arguments.
Returns:
None
"""
service_data = data.get("service_data", {})
value = service_data.get("value", None)
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for entity_id in entities:
await self.components.number_event(entity_id, value)
for item in self.CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
entity = item["entity"]
if item.get("value", None) != value:
self.log("number_event: {} = {}".format(entity, value))
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
async def watch_event(self, entity, attribute, old, new, kwargs):
"""
Catch HA state changes for watched entities
"""
self.log("Watched event: {} = {} will trigger re-plan".format(entity, new))
self.update_pending = True
self.plan_valid = False
async def switch_event(self, event, data, kwargs):
"""
Catch HA Switch toggle
This method is called when a Home Assistant switch is toggled. It handles the logic for updating the state of the switch
and triggering any necessary actions based on the switch state.
Parameters:
- event (str): The event triggered by the switch toggle.
- data (dict): Additional data associated with the event.
- kwargs (dict): Additional keyword arguments.
Returns:
- None
"""
service = data.get("service", None)
service_data = data.get("service_data", {})
entities = service_data.get("entity_id", [])
# Can be a string or an array
if isinstance(entities, str):
entities = [entities]
for entity_id in entities:
await self.components.switch_event(entity_id, service)
for item in self.CONFIG_ITEMS:
if ("entity" in item) and (item["entity"] in entities):
value = item["value"]
entity = item["entity"]
if service == "turn_on":
value = True
elif service == "turn_off":
value = False
elif service == "toggle" and isinstance(value, bool):
value = not value
# Check if value has changed
if item.get("value", None) != value:
self.log("switch_event: {} = {}".format(entity, value))
await self.async_expose_config(item["name"], value, event=True)
self.update_pending = True
self.plan_valid = False
def get_ha_config(self, name, default):
"""
Get Home assistant config value, use default if not set
Parameters:
name (str): The name of the config value to retrieve.
default: The default value to use if the config value is not set.
Returns:
value: The value of the config if it is set, otherwise the default value.
default: The default value passed as an argument.
"""
item = self.config_index.get(name)
if item and item["name"] == name:
enabled = self.user_config_item_enabled(item)
if enabled:
value = item.get("value", None)
else:
value = None
if default is None:
default = item.get("default", None)
if item.get("type") == "input_number" and isinstance(default, int) and not isinstance(default, bool):
# This default is not just a fallback for a missing value - get_arg() (the only
# caller that reaches here with default=None) applies a further type coercion to
# whatever value this function returns, keyed on the *type* of this default,
# regardless of whether that returned value is this default or the item's real
# configured value. So an int default doesn't just risk supplying an int when
# unset - it forces every read of this item back to an int even when the user has
# genuinely configured a fractional one, via get_arg's int(float(value)) (#4296:
# metric_battery_cycle's real, present, correctly-resolved 0.5 was still coerced
# to 0 downstream, purely because its default happened to be the int 0). Normalise
# here, at the source, so it can't matter which literal a future item's "default"
# happens to be written as.
step = item.get("step", 1)
if isinstance(step, float) and step != int(step):
default = float(default)
if value is None:
value = default
return value, default
return None, default
def convert_currency_unit(self, unit):
"""
Convert a config item unit string (using the default £/p symbols) into the
user's configured currency symbols so displayed units match the rates.
"""
if not unit:
return unit
major = self.currency_symbols[0] if self.currency_symbols and len(self.currency_symbols) > 0 else "£"
minor = self.currency_symbols[1] if self.currency_symbols and len(self.currency_symbols) > 1 else "p"
unit = unit.replace("£", "%%CURR_MAJOR%%").replace("p", minor).replace("%%CURR_MAJOR%%", major)
return unit
async def async_expose_config(self, name, value, quiet=True, event=False, force=False, in_progress=False):
return await self.run_in_executor(self.expose_config, name, value, quiet, event, force, in_progress)
def expose_config(self, name, value, quiet=True, event=False, force=False, in_progress=False, force_ha=False):
"""
Share the config with HA
"""
item = self.config_index.get(name, None)
if item:
enabled = self.user_config_item_enabled(item)
if not enabled:
item["value"] = None
else:
entity = item.get("entity")
has_changed = ((item.get("value", None) is None) or (value != item.get("value", None))) or force
if entity and (has_changed or force_ha):
if has_changed and item.get("reset_inverter", False):
self.inverter_needs_reset = True
self.log("Set reset inverter true due to reset_inverter on item {}".format(name))
if has_changed and item.get("reset_inverter_force", False):
self.inverter_needs_reset = True
self.log("Set reset inverter true due to reset_inverter_force on item {}".format(name))
if event and item.get("value", None) is not None:
self.inverter_needs_reset_force = name
self.log("Set reset inverter force true due to reset_inverter_force on item {}".format(name))
item["value"] = value
if item["type"] == "input_number":
"""INPUT_NUMBER"""
icon = item.get("icon", "mdi:numeric")
unit = self.convert_currency_unit(item["unit"])
self.set_state_wrapper(
entity_id=entity,
state=value,
attributes={
"friendly_name": item["friendly_name"],
"min": item["min"],
"max": item["max"],
"step": item["step"],
"unit_of_measurement": unit,
"icon": icon,
},
)
elif item["type"] == "switch":
"""SWITCH"""
icon = item.get("icon", "mdi:light-switch")
self.set_state_wrapper(entity_id=entity, state=("on" if value else "off"), attributes={"friendly_name": item["friendly_name"], "icon": icon})
elif item["type"] == "select":
"""SELECT"""
icon = item.get("icon", "mdi:format-list-bulleted")
if value is None:
value = item.get("default", "")
options = item["options"]
if value not in options:
options.append(value)
old_state = self.get_state_wrapper(entity_id=entity)
if old_state and old_state != value:
self.set_state_wrapper(entity_id=entity, state=old_state, attributes={"friendly_name": item["friendly_name"], "options": options, "icon": icon})
self.set_state_wrapper(entity_id=entity, state=value, attributes={"friendly_name": item["friendly_name"], "options": options, "icon": icon})
elif item["type"] == "update":
"""UPDATE"""
summary = self.releases.get("latest_body", "")
latest = self.releases.get("latest", "check HACS")
state = "off"
if item["installed_version"] != latest:
state = "on"
self.set_state_wrapper(
entity_id=entity,
state=state,
attributes={
"friendly_name": item["friendly_name"],
"title": item["title"],
"in_progress": in_progress,
"auto_update": True,
"installed_version": item["installed_version"],
"latest_version": latest,
"entity_picture": item["entity_picture"],
"release_url": item["release_url"],
"release_summary": summary,
"skipped_version": None,
"supported_features": 1,
},
)
def user_config_item_enabled(self, item):
"""
Check if user config item is enable
"""
enable = item.get("enable", None)
enable_condition = item.get("enable_condition", None)
enabled = True
if enable:
enabled_value = self.get_arg(enable, default=False)
citem = self.config_index.get(enable, None)
if enable_condition:
# Evaluate the condition in python
try:
# Use eval to evaluate the condition
enabled = eval(enable_condition, {"__builtins__": None}, {enable: enabled_value})
except Exception as e:
self.log("Warn: Enable to evaluate enable condition for item {} - '{}': {}".format(item, enable_condition, e))
enabled = False
return enabled
else:
enabled = True if enabled_value else False
return enabled
async def async_update_save_restore_list(self):
return await self.run_in_executor(self.update_save_restore_list)
def update_save_restore_list(self):
"""
Update list of current Predbat settings
"""
global PREDBAT_SAVE_RESTORE
self.save_restore_dir = self.config_root + "/predbat_save"
if not os.path.exists(self.save_restore_dir):
os.mkdir(self.save_restore_dir)
PREDBAT_SAVE_RESTORE = ["save current", "restore default"]
for root, dirs, files in os.walk(self.save_restore_dir):
for name in files:
filepath = os.path.join(root, name)
if filepath.endswith(".yaml") and not name.startswith("."):
PREDBAT_SAVE_RESTORE.append(name)
item = self.config_index.get("saverestore", None)
item["options"] = PREDBAT_SAVE_RESTORE
self.expose_config("saverestore", None)
async def async_restore_settings_yaml(self, filename):
"""
Restore settings from YAML file
"""
self.save_restore_dir = self.config_root + "/predbat_save"
# Create full hierarchical version of filepath to write to the logfile
filepath_p = self.config_root_p + "/predbat_save"
if filename != "previous.yaml":
await self.async_save_settings_yaml("previous.yaml")
if not filename:
self.log("Restore settings to default")
for item in self.CONFIG_ITEMS:
if (item["value"] != item.get("default", None)) and item.get("restore", True):
self.log("Restore setting: {} = {} (was {})".format(item["name"], item["default"], item["value"]))
await self.async_expose_config(item["name"], item["default"], event=True)
if self.get_arg("set_system_notify"):
await self.async_call_notify("Predbat settings restored from default")
else:
filepath = os.path.join(self.save_restore_dir, filename)
if os.path.exists(filepath):
filepath_p = filepath_p + "/" + filename
self.log("Restore settings from {}".format(filepath_p))
with open(filepath, "r") as file:
settings = yaml.safe_load(file)
for item in settings:
current = self.config_index.get(item["name"], None)
if current and (current["value"] != item["value"]) and current.get("restore", True):
self.log("Restore setting: {} = {} (was {})".format(item["name"], item["value"], current["value"]))
await self.async_expose_config(item["name"], item["value"], event=True)
if self.get_arg("set_system_notify"):
await self.async_call_notify("Predbat settings restored from {}".format(filename))
await self.async_expose_config("saverestore", None)
def load_current_config(self):
"""
Load the current configuration from a json file
"""
if self.ha_interface.db_primary:
# No need to save/restore config from a file if we are using the database
return
filepath = self.config_root + "/predbat_config.json"
if os.path.exists(filepath):
with open(filepath, "r") as file:
try:
settings = json.load(file)
except json.JSONDecodeError:
self.log("Warn: Failed to load Predbat settings from {}".format(filepath))
return
for name in settings:
current = self.config_index.get(name, None)
if not current:
for item in self.CONFIG_ITEMS:
if item.get("oldname", "") == name:
self.log("Restore setting from old name {} to new name {}".format(name, item["name"]))
current = item
if current:
item_value = settings[name]
if current.get("value", None) != item_value:
# self.log("Restore saved setting: {} = {} (was {})".format(name, item_value, current.get("value", None)))
current["value"] = item_value
def save_current_config(self):
"""
Saves the currently defined configuration to a json file
"""
if self.ha_interface.db_primary:
# No need to save/restore config from a file if we are using the database
return
filepath = self.config_root + "/predbat_config.json"
# Create full hierarchical version of filepath to write to the logfile
filepath_p = self.config_root_p + "/predbat_config.json"
save_array = {}
for item in self.CONFIG_ITEMS:
if item.get("save", True):
if item.get("value", None) is not None:
save_array[item["name"]] = item["value"]
with open(filepath, "w") as file:
json.dump(save_array, file)
self.log("Saved current settings to {}".format(filepath_p))
async def async_save_settings_yaml(self, filename=None):
"""
Save current Predbat settings
"""
self.save_restore_dir = self.config_root + "/predbat_save"
filepath_p = self.config_root_p + "/predbat_save"
if not filename:
filename = self.now_utc.strftime("%y_%m_%d_%H_%M_%S")
filename += ".yaml"
filepath = os.path.join(self.save_restore_dir, filename)
filepath_p = filepath_p + "/" + filename
with open(filepath, "w") as file:
yaml.dump(self.CONFIG_ITEMS, file)
self.log("Saved Predbat settings to {}".format(filepath_p))
if self.get_arg("set_system_notify"):
await self.async_call_notify("Predbat settings saved to {}".format(filename))
def read_debug_yaml(self, filename):
"""
Read debug yaml - used for debugging scenarios not for the main code
"""
debug = {}
if os.path.exists(filename):
with open(filename, "r") as file:
debug = yaml.unsafe_load(file)
else:
self.log("Warn: Debug file {} not found".format(filename))
return
for key in debug:
if key not in ["CONFIG_ITEMS", "inverters"]:
self.__dict__[key] = copy.deepcopy(debug[key])
if key == "inverters":
new_inverters = []
for inverter in debug[key]:
inverter_obj = copy.deepcopy(self.inverters[0])
for key in inverter:
inverter_obj.__dict__[key] = copy.deepcopy(inverter[key])
new_inverters.append(inverter_obj)
self.inverters = new_inverters
# Handle old-format octopus_slots (flat list of dicts) vs new format (list-of-lists per car)
if isinstance(self.octopus_slots, list) and self.octopus_slots and isinstance(self.octopus_slots[0], dict):
self.octopus_slots = [self.octopus_slots] + [[] for _ in range(7)]
for item in debug["CONFIG_ITEMS"]:
current = self.config_index.get(item["name"], None)
if current:
# print("Restore setting: {} = {} (was {})".format(item["name"], item["value"], current["value"]))
if current.get("value", None) != item.get("value", None):
current["value"] = item.get("value", None)
self.log("Restored debug settings - minutes now {}".format(self.minutes_now))
def create_debug_yaml(self, write_file=True):
"""
Write out a debug info yaml
"""
time_now = self.now_utc.strftime("%H_%M_%S")
basename = "/debug/predbat_debug_{}.yaml".format(time_now)
filename = self.config_root + basename
# Create full hierarchical version of filepath to write to the logfile
filename_p = self.config_root_p + basename
os.makedirs(os.path.dirname(filename), exist_ok=True)
debug = {}
# Store all predbat member variables into debug
for key in self.__dict__:
if not key.startswith("__") and not callable(getattr(self, key)):
if (key.startswith("db")) or ("_key" in key) or key in DEBUG_EXCLUDE_LIST:
pass
else:
if key == "args":
debug[key] = mask_secret_args(self.__dict__[key])
else:
debug[key] = self.__dict__[key]
inverters_debug = []
for inverter in self.inverters:
inverter_debug = {}
for key in inverter.__dict__:
if not key.startswith("__") and not callable(getattr(inverter, key)):
if key.startswith("base"):
pass
else:
inverter_debug[key] = inverter.__dict__[key]
inverters_debug.append(inverter_debug)
debug["inverters"] = inverters_debug
debug["CONFIG_ITEMS"] = copy.deepcopy(self.CONFIG_ITEMS)
if write_file:
with open(filename, "w") as file:
yaml.dump(debug, file)
self.log("Wrote debug yaml to {}".format(filename_p))
else:
# Return the debug yaml as a string
return yaml.dump(debug)
def create_entity_list(self):
"""
Create the standard entity list
"""
text = ""
text += "# Predbat Dashboard - {}\n".format(THIS_VERSION)
text += "type: entities\n"
text += "Title: Predbat\n"
text += "entities:\n"
enable_list = [None]
for item in self.CONFIG_ITEMS:
enable = item.get("enable", None)
if enable and enable not in enable_list:
enable_list.append(enable)
for try_enable in enable_list:
for item in self.CONFIG_ITEMS:
entity = item.get("entity", None)
enable = item.get("enable", None)
if entity and enable == try_enable and self.user_config_item_enabled(item):
text += " - entity: " + entity + "\n"
for entity in self.dashboard_index:
text += " - entity: " + entity + "\n"
# Find path
basename = "/predbat_dashboard.yaml"
filename = self.config_root + basename
# Create full hierarchical version of filepath to write to the logfile
filename_p = self.config_root_p + basename
# Write
han = open(filename, "w")
if han:
self.log("Creating predbat dashboard at {}".format(filename_p))
han.write(text)
han.close()
else:
self.log("Failed to write predbat dashboard to {}".format(filename_p))
def load_previous_value_from_ha(self, entity, attribute=None):
"""
Load HA value either from state or from history if there is any
"""
if attribute:
ha_value = self.get_state_wrapper(entity, attribute=attribute)
if ha_value is not None and ha_value not in ("unavailable", "unknown"):
return ha_value
else:
ha_value = self.get_state_wrapper(entity)
if ha_value is not None and ha_value not in ("unavailable", "unknown"):
return ha_value
# Try history if no current state
history = self.get_history_wrapper(entity_id=entity, required=False)
if history:
history = history[0]
if history:
if attribute:
ha_value = history[-1].get("attributes", {}).get(attribute, None)
else:
ha_value = history[-1].get("state", None)
return ha_value
async def trigger_watch_list(self, entity_id, attribute, old, new):
"""
Trigger a watch event for an entity
"""
for entity in self.watch_list:
if entity_id == entity:
await self.watch_event(entity, attribute, old, new, None)
async def trigger_callback(self, service_data):
"""
Trigger a callback for a service via HA Interface
Returns True if a matching listener was found and run, False otherwise - callers
(e.g. HAInterface.call_service()'s loopback branch) use this as the success signal
for the same True/success, False/None-failure contract the websocket branch provides,
since loopback mode only ever simulates the entity-control services in EVENT_LISTEN_LIST,
not arbitrary third-party integration services.
"""
for item in self.EVENT_LISTEN_LIST:
if item["domain"] == service_data.get("domain", "") and item["service"] == service_data.get("service", ""):
# self.log("Trigger callback for {} {}".format(item["domain"], item["service"]))
await item["callback"](item["service"], service_data, None)
return True
return False
def define_service_list(self):
self.SERVICE_REGISTER_LIST = [
{"domain": "input_number", "service": "set_value"},
{"domain": "input_number", "service": "increment"},
{"domain": "input_number", "service": "decrement"},
{"domain": "switch", "service": "turn_on"},
{"domain": "switch", "service": "turn_off"},
{"domain": "switch", "service": "toggle"},
{"domain": "select", "service": "select_option"},
{"domain": "select", "service": "select_first"},
{"domain": "select", "service": "select_last"},
{"domain": "select", "service": "select_next"},
{"domain": "select", "service": "select_previous"},
]
self.EVENT_LISTEN_LIST = [
{"domain": "switch", "service": "turn_on", "callback": self.switch_event},
{"domain": "switch", "service": "turn_off", "callback": self.switch_event},
{"domain": "switch", "service": "toggle", "callback": self.switch_event},
{"domain": "input_number", "service": "set_value", "callback": self.number_event},
{"domain": "input_number", "service": "increment", "callback": self.number_event},
{"domain": "input_number", "service": "decrement", "callback": self.number_event},
{"domain": "number", "service": "set_value", "callback": self.number_event},
{"domain": "number", "service": "increment", "callback": self.number_event},
{"domain": "number", "service": "decrement", "callback": self.number_event},
{"domain": "select", "service": "select_option", "callback": self.select_event},
{"domain": "select", "service": "select_first", "callback": self.select_event},
{"domain": "select", "service": "select_last", "callback": self.select_event},
{"domain": "select", "service": "select_next", "callback": self.select_event},
{"domain": "select", "service": "select_previous", "callback": self.select_event},
{"domain": "update", "service": "install", "callback": self.update_event},
{"domain": "update", "service": "skip", "callback": self.update_event},
]
def is_new_install(self):
"""
Determine whether this is a genuinely new install, used to set sensible defaults
(e.g. mode defaults to Monitor rather than Control charge & discharge).
"""
current_status = self.load_previous_value_from_ha(self.prefix + ".status")
if current_status:
return False
# HA's live state and history can both come back empty for a moment right after an
# abrupt restart, before HA's own state store has fully warmed back up. A single
# failed predbat.status read isn't enough evidence of a fresh install on its own -
# predbat_config.json only exists once Predbat has actually saved a config before,
# so its presence is a persistent, restart-proof signal that this is a real install,
# not a new one (see #4397/#4396 root cause, and #3259/#3306 for the resulting
# spurious config resets this was letting through).
config_path = os.path.join(self.config_root or "", "predbat_config.json")
if not self.ha_interface.db_primary and os.path.isfile(config_path):
self.log("predbat.status unavailable but predbat_config.json exists - not treating this as a new install")
return False
self.log("New install detected")
return True
def load_user_config(self, quiet=True, register=False, load_config=False):
"""
Load config from HA
"""
self.config_index = {}
self.log("Refreshing Predbat configuration")
# New install, used to set default of expert mode
new_install = self.is_new_install()
# Build config index
for item in self.CONFIG_ITEMS:
name = item["name"]
self.config_index[name] = item
if name == "mode" and new_install:
item["default"] = PREDBAT_MODE_OPTIONS[PREDBAT_MODE_MONITOR]
if name in self.args:
# If the item is in args, use it as the default
item["default"] = self.args[name]
# Load current config from JSON file when explicitly requested via load_config=True.
# This is done on the very first startup call (before HA state is read) so that
# JSON-saved values populate item["value"] and take priority over transient HA states.
# During HA restart, entities can return "unavailable"/"unknown", and without this
# pre-load those transient states would overwrite the saved JSON via save_current_config().
# Subsequent periodic calls omit load_config so in-memory values updated by HA events
# are not overwritten by the (potentially stale) JSON.
if load_config:
self.log("Loading current config")
self.load_current_config()