-
-
Notifications
You must be signed in to change notification settings - Fork 150
Expand file tree
/
Copy pathweb.py
More file actions
5809 lines (5029 loc) · 280 KB
/
Copy pathweb.py
File metadata and controls
5809 lines (5029 loc) · 280 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
#
# This code creates a web server and serves up the Predbat web pages
"""Built-in web dashboard server.
Provides the PredBat web interface using aiohttp, serving dashboard pages,
configuration editors, entity browsers, plan visualisations, and REST API
endpoints. Includes 50+ HTTP routes for monitoring and control.
"""
from aiohttp import web
import asyncio
import os
import os.path
import sys
import re
from datetime import datetime, timedelta
import json
import shutil
import html as html_module
import urllib.parse
import traceback
import threading
import io
from io import StringIO
import hashlib
import copy
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
from web_helper import (
get_header_html,
get_plan_css,
get_plan_renderer_js,
get_editor_js,
get_editor_css,
get_log_css,
get_charts_css,
get_apps_css,
get_html_config_css,
get_apps_js,
get_components_css,
get_entity_modal_css,
get_component_edit_modal_css,
get_entity_modal_js,
get_component_edit_modal_js,
get_logfile_js,
get_entity_toggle_js,
get_entity_control_css,
get_entity_css,
get_entity_js,
get_refresh_inverter_js,
get_restart_button_js,
get_browse_css,
get_entity_detailed_row_js,
get_internals_css,
get_internals_js,
get_dashboard_css,
get_dashboard_collapsible_js,
)
from utils import calc_percent_limit, str2time, dp0, dp2, dp4, format_time_ago, get_override_time_from_string, history_attribute, prune_today, mask_secret_args
from const import TIME_FORMAT, TIME_FORMAT_DAILY, TIME_FORMAT_HA
from predbat import THIS_VERSION_DISPLAY
from component_base import ComponentBase
from config import APPS_SCHEMA
from web_annual import AnnualPage
from web_metrics_dashboard import get_metrics_dashboard_css, get_metrics_dashboard_body
from predbat_metrics import metrics_handler, metrics_json_handler, metrics, PROMETHEUS_AVAILABLE
from marginal import MARGINAL_EXTRA_KWH_LEVEL_NAMES, MARGINAL_EXTRA_KWH_LEVELS, MARGINAL_TIME_OFFSETS
ROOT_YAML_KEY = "pred_bat"
def state_as_of_slots(records, slots):
"""
Resolve each slot to the state in effect at it - the most recent record at or before the slot.
records must be a list of (timestamp, value) ordered oldest first. Returns
{slot: (value, changed, prev_value)} where value is "-" for slots preceding the first record and
changed marks a slot whose value differs from the one shown at the previous slot.
"""
filled = {}
last_value = None
previous = None
index = 0
for slot in sorted(slots):
while index < len(records) and records[index][0] <= slot:
last_value = records[index][1]
index += 1
value = last_value if last_value is not None else "-"
filled[slot] = (value, value != "-" and value != previous, previous)
previous = value
return filled
def build_entity_history_table_data(entity_selections, entity_data_fetch):
"""
Resolve the /entity history table's 30-minute rows and their 5-minute detail slots.
entity_selections: list of {"entity_id": ..., "attribute": ...} (attribute may be None for state)
entity_data_fetch: dict of entity_id -> history as returned by get_history_with_now(), i.e. [[record, ...]]
Every slot reports the state as of its own timestamp. Summarising a window by the last sample
taken inside it instead let a momentary blip stand for the whole window - one stray "Lost"
record at 21:57 made the entire 21:30 row read "Lost" - and that value then carried forward
into every later slot that had no sample of its own, turning a blip into hours of downtime.
Returns (entity_filled_30min, entity_filled_5min, sorted_timestamps_30min, all_display_slots_5min):
entity_filled_30min / entity_filled_5min: one dict per selection, {slot: (value, changed, prev_value)}
sorted_timestamps_30min: the 30-min row timestamps, newest first
all_display_slots_5min: the 5-min slots covering each 30-min row's own window (offsets 0 to +25)
"""
entity_records = []
all_timestamps_30min = set()
for selection in entity_selections:
entity_id = selection["entity_id"]
attribute = selection["attribute"]
history = entity_data_fetch[entity_id]
records = []
if history and len(history) >= 1:
history = history[0]
if history:
for item in history:
if "last_updated" not in item:
continue
try:
last_updated_stamp = str2time(item["last_updated"])
except (ValueError, TypeError):
continue
# Get state or attribute value
if attribute:
state = item.get("attributes", {}).get(attribute, None)
else:
state = item.get("state", None)
if state is None:
state = "None"
records.append((last_updated_stamp, state))
# A record makes the window it landed in a row, so activity is always on screen
minutes = last_updated_stamp.hour * 60 + last_updated_stamp.minute
rounded_minutes_30 = (minutes // 30) * 30
all_timestamps_30min.add(last_updated_stamp.replace(minute=rounded_minutes_30 % 60, hour=rounded_minutes_30 // 60, second=0, microsecond=0))
# str2time is only reliable for ordering once parsed - the raw strings mix UTC history with
# the local-time "now" record get_history_with_now() appends
records.sort(key=lambda record: record[0])
entity_records.append(records)
# Sort timestamps in reverse chronological order
sorted_timestamps_30min = sorted(all_timestamps_30min, reverse=True)
# Detail slots are the 5-min marks INSIDE each row's own half hour, so expanding a row explains
# that row rather than describing the preceding half hour
all_display_slots_5min = set()
for ts_30 in sorted_timestamps_30min:
for offset in range(0, 30, 5):
all_display_slots_5min.add(ts_30 + timedelta(minutes=offset))
entity_filled_30min = []
entity_filled_5min = []
for records in entity_records:
entity_filled_30min.append(state_as_of_slots(records, sorted_timestamps_30min))
entity_filled_5min.append(state_as_of_slots(records, all_display_slots_5min))
return entity_filled_30min, entity_filled_5min, sorted_timestamps_30min, all_display_slots_5min
def is_data_numerical(history, attribute=None):
"""
Check if history data is numerical (supports both state and attribute checking)
Returns True if at least 10% of values are numeric or boolean
"""
count_nums = 0
count_total = 0
if history and len(history) >= 1:
for item in history[0]:
if attribute:
# Check attribute value
attr_value = item.get("attributes", {}).get(attribute, None)
if attr_value is None:
continue
value = str(attr_value)
else:
# Check state value
value = item.get("state", None)
if value is None:
continue
value = str(value)
if value.lower() in ["on", "off", "true", "false"]:
count_nums += 1
else:
try:
float(value)
count_nums += 1
except (ValueError, TypeError):
pass
count_total += 1
if count_total > 0 and (count_nums / count_total) >= 0.1:
return True
elif count_total == 0:
return True
return False
def split_entities_for_charting(entities, entity_data_fetch):
"""
Fetch each entity's history and split a unit group into numeric vs non-numeric entries.
Deciding numeric-vs-timeline per entity (rather than once for the whole group, from
whichever entity happened to be processed last) means a numeric entity doesn't end up
silently rendered as a broken timeline chart just because another entity sharing the same
unit group is non-numerical.
entities: list of {"id": entity_id, "friendly_name": ..., "attribute": ...}
entity_data_fetch: dict of entity_id -> history as returned by get_history_with_now()
Returns (numeric_entries, timeline_entries), each a list of
{"name": display_name, "friendly_name": ..., "entity_id": ..., "data": history_chart}.
"""
numeric_entries = []
timeline_entries = []
for entity_info in entities:
entity_id = entity_info["id"]
friendly_name = entity_info["friendly_name"]
attribute = entity_info.get("attribute")
history = entity_data_fetch.get(entity_id)
is_numerical = is_data_numerical(history, attribute=attribute)
if attribute:
history_chart = history_attribute(history, state_key=attribute, attributes=True, is_numerical=is_numerical)
display_name = f"{friendly_name} ({attribute})"
else:
history_chart = history_attribute(history, is_numerical=is_numerical)
display_name = friendly_name
if not history_chart:
continue
entry = {"name": display_name, "friendly_name": friendly_name, "entity_id": entity_id, "data": history_chart}
(numeric_entries if is_numerical else timeline_entries).append(entry)
return numeric_entries, timeline_entries
def resolve_group_unit_and_name(entity_id, dashboard_values, live_unit=None, live_friendly_name=None):
"""
Resolve the unit_of_measurement/friendly_name to group and label an entity by for the
/entity charts.
Prefers Predbat's own dashboard_values cache, falling back to a caller-supplied live HA
lookup (mirroring html_get_entity_text's fallback) for entities Predbat doesn't track
itself - e.g. inverter control entities that are selectable on this page but were never
published via dashboard_item(), which otherwise silently grouped every such entity into
"(no unit)" regardless of their real HA unit.
live_unit/live_friendly_name should only be looked up by the caller when entity_id isn't
in dashboard_values, since that's the only case they're used.
"""
attributes = dashboard_values.get(entity_id, {}).get("attributes", {})
if entity_id in dashboard_values:
unit = attributes.get("unit_of_measurement") or ""
friendly_name = attributes.get("friendly_name") or ""
else:
unit = live_unit or ""
friendly_name = live_friendly_name or ""
return unit or "(no unit)", friendly_name or entity_id
class WebInterface(ComponentBase):
"""Built-in web dashboard server using aiohttp.
Serves the PredBat dashboard with 50+ HTTP routes for monitoring,
configuration, entity browsing, plan visualisation, and REST API
endpoints. Supports plugin endpoint registration.
"""
def initialize(self, web_port):
self.default_page = "./dash"
self.web_port = web_port
self.default_log = "warnings"
# Plugin registration system
self.registered_endpoints = []
self.annual_page = AnnualPage(self)
def register_endpoint(self, path, handler, method="GET"):
"""
Register a new endpoint with the web interface
Args:
path (str): URL path for the endpoint (e.g., '/metrics')
handler (callable): Async handler function
method (str): HTTP method ('GET', 'POST', etc.)
"""
self.registered_endpoints.append({"path": path, "handler": handler, "method": method.upper()})
self.log(f"Registered endpoint: {method.upper()} {path}")
def subtract_daily(self, hist1, hist2):
"""
Subtract the values in hist2 from hist1
"""
results = {}
for key in hist1:
if key in hist2:
results[key] = hist1[key] - hist2[key]
else:
results[key] = hist1[key]
return results
def history_daily_at_hour(self, history_raw, hour_limit=3):
"""
For each calendar day (local time), return the last recorded entity state
whose local hour falls in [0, hour_limit). This captures the result of
the overnight compare run (which completes around 1am) without being
influenced by any later manual re-runs during the day.
Returns {YYYY-MM-DD: float} with values in the raw unit of the entity state.
"""
results = {}
if not isinstance(history_raw, list) or not history_raw:
return results
history = history_raw[0] if isinstance(history_raw[0], list) else history_raw
for item in history:
last_updated = item.get("last_updated")
state = item.get("state")
if not last_updated or state in (None, "unavailable", "unknown"):
continue
try:
state = float(state)
except (ValueError, TypeError):
continue
try:
ts = str2time(last_updated).astimezone()
except (ValueError, TypeError):
continue
if ts.hour >= hour_limit:
continue
day_str = ts.strftime(TIME_FORMAT_DAILY)
# Keep the latest record within the overnight window for this day
results[day_str] = state
return results
def average_cost_window(self, daily_pence, days):
"""
Return the mean daily cost (in pence) and the number of days with data
over the last `days` calendar days, excluding today.
Returns (None, 0) when there are no data points in the window.
"""
today = self.now_utc.astimezone().date()
total = 0.0
count = 0
for d in range(1, days + 1):
day_str = (today - timedelta(days=d)).strftime(TIME_FORMAT_DAILY)
if day_str in daily_pence:
total += daily_pence[day_str]
count += 1
return ((total / count), count) if count else (None, 0)
def rolling_7d_average(self, daily_pence):
"""
For every date that has a data point, compute the 7-day trailing average
(the mean of that day and up to 6 preceding days that have data).
Returns {YYYY-MM-DD: float} in the same unit as daily_pence (pence),
only for days where at least one data point exists in the window.
"""
if not daily_pence:
return {}
from datetime import date as date_cls
# Sort dates so we can iterate chronologically
sorted_days = sorted(daily_pence.keys())
results = {}
for day_str in sorted_days:
try:
anchor = date_cls.fromisoformat(day_str)
except (ValueError, TypeError):
continue
total = 0.0
count = 0
for d in range(7):
candidate = (anchor - timedelta(days=d)).strftime(TIME_FORMAT_DAILY)
if candidate in daily_pence:
total += daily_pence[candidate]
count += 1
if count:
results[day_str] = dp2(total / count)
return results
def _register_annual_routes(self, app):
"""Register the Annual tab's routes on ``app``.
Split out from start() so a test can register these onto a bare aiohttp
Application and assert they exist, without booting a real TCP listener -
the constructor for that Application performs no network I/O of its own.
"""
app.router.add_get("/annual", self.annual_page.html_annual)
app.router.add_post("/annual", self.annual_page.html_annual_post)
app.router.add_post("/annual_reset", self.annual_page.html_annual_reset)
app.router.add_post("/annual_array", self.annual_page.html_annual_array)
app.router.add_post("/annual_delete", self.annual_page.html_annual_delete)
app.router.add_get("/annual_cost_preview", self.annual_page.html_annual_cost_preview)
app.router.add_post("/annual_run", self.annual_page.html_annual_run)
app.router.add_get("/annual_status", self.annual_page.html_annual_status)
app.router.add_post("/annual_cancel", self.annual_page.html_annual_cancel)
app.router.add_get("/annual_download", self.annual_page.html_annual_download)
app.router.add_get("/annual_plan", self.annual_page.html_annual_plan)
app.router.add_get("/annual_view", self.annual_page.html_annual_view)
app.router.add_get("/annual_compare", self.annual_page.html_annual_compare)
async def start(self):
# Start the web server
app = web.Application()
app.router.add_get("/", self.html_default)
app.router.add_get("/plan", self.html_plan)
app.router.add_get("/log", self.html_log)
app.router.add_get("/apps", self.html_apps)
app.router.add_post("/apps", self.html_apps_post)
app.router.add_get("/charts", self.html_charts)
app.router.add_get("/config", self.html_config)
app.router.add_get("/entity", self.html_entity)
app.router.add_post("/entity", self.html_entity_post)
app.router.add_post("/config", self.html_config_post)
app.router.add_get("/dash", self.html_dash)
app.router.add_post("/dash", self.html_dash_post)
app.router.add_get("/dash_content", self.html_dash_content)
app.router.add_get("/components", self.html_components)
app.router.add_get("/component_entities", self.html_component_entities)
app.router.add_post("/component_restart", self.html_component_restart)
app.router.add_get("/component_config", self.html_component_config)
app.router.add_post("/component_config_save", self.html_component_config_save)
app.router.add_get("/debug_yaml", self.html_debug_yaml)
app.router.add_get("/debug_log", self.html_debug_log)
app.router.add_get("/debug_apps", self.html_debug_apps)
app.router.add_get("/debug_apps_live", self.html_debug_apps_live)
app.router.add_get("/debug_plan", self.html_debug_plan)
app.router.add_get("/compare", self.html_compare)
app.router.add_post("/compare", self.html_compare_post)
self._register_annual_routes(app)
app.router.add_get("/apps_editor", self.html_apps_editor)
app.router.add_post("/apps_editor", self.html_apps_editor_post)
app.router.add_get("/apps_editor_checksum", self.html_apps_editor_checksum)
app.router.add_post("/plan_override", self.html_plan_override)
app.router.add_post("/rate_override", self.html_rate_override)
app.router.add_post("/restart", self.html_restart)
app.router.add_post("/inverter_refresh", self.html_inverter_refresh)
app.router.add_get("/api/state", self.html_api_get_state)
app.router.add_get("/api/ping", self.html_api_ping)
app.router.add_post("/api/state", self.html_api_post_state)
app.router.add_post("/api/service", self.html_api_post_service)
app.router.add_get("/api/plan_data", self.html_api_plan_data)
app.router.add_get("/api/log", self.html_api_get_log)
app.router.add_get("/api/entities", self.html_api_get_entities)
app.router.add_post("/api/login", self.html_api_login)
app.router.add_get("/browse", self.html_browse)
app.router.add_get("/download", self.html_download_file)
app.router.add_get("/images/{filename}", self.html_logo_image)
app.router.add_get("/internals", self.html_internals)
app.router.add_get("/api/internals", self.html_api_internals)
app.router.add_get("/api/internals/download", self.html_api_internals_download)
app.router.add_get("/api/status", self.html_api_get_status)
app.router.add_get("/metrics", metrics_handler)
app.router.add_get("/metrics/json", metrics_json_handler)
app.router.add_get("/metrics_dashboard", self.html_metrics_dashboard)
# Notify plugin system that web interface is ready
if hasattr(self.base, "plugin_system") and self.base.plugin_system:
self.base.plugin_system.call_hooks("on_web_start")
# Register any dynamically registered endpoints
for endpoint in self.registered_endpoints:
if endpoint["method"] == "GET":
app.router.add_get(endpoint["path"], endpoint["handler"])
elif endpoint["method"] == "POST":
app.router.add_post(endpoint["path"], endpoint["handler"])
# Add more methods as needed
self.log(f"Added registered endpoint: {endpoint['method']} {endpoint['path']}")
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", self.web_port)
await site.start()
print("Web interface started")
self.api_started = True
count = 0
while not self.api_stop:
await asyncio.sleep(1)
if count % 60 == 0:
self.update_success_timestamp()
count += 1
# Otherwise a restart mid-run leaves the annual engine's child process
# orphaned - burning a CPU core for up to several minutes with nothing left
# tracking it - while the fresh AnnualPage created on the next start() reports
# idle and would happily let a second run be started alongside it.
await self.annual_page.job.cancel()
await runner.cleanup()
self.api_started = False
print("Web interface stopped")
def get_attributes_html(self, entity, from_db=False):
"""
Return the attributes of an entity as HTML
"""
text = ""
attributes = {}
if from_db:
history = self.get_history_wrapper(entity, 1, required=False)
if history and len(history) >= 1:
history = history[0]
if history:
attributes = history[0].get("attributes", {})
else:
attributes = self.base.dashboard_values.get(entity, {}).get("attributes", {})
if not attributes:
return ""
text += "<table>"
for key in attributes:
if key in ["icon", "device_class", "state_class", "unit_of_measurement", "friendly_name"]:
continue
value = attributes[key]
full_value = str(value)[:16384] # Limit to 16k
full_value = full_value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'")
if len(str(value)) > 128:
display_value = str(full_value)[:128] + " ... "
# Escape HTML entities for tooltip
text += '<tr><td>{}</td><td title="{}">{}</td></tr>'.format(key, full_value, display_value)
else:
# Also escape HTML entities for short values
text += "<tr><td>{}</td><td>{}</td></tr>".format(key, full_value)
text += "</table>"
return text
def icon2html(self, icon):
if icon:
icon = '<span class="mdi mdi-{}"></span>'.format(icon.replace("mdi:", ""))
return icon
def get_power_flow_diagram(self):
"""
Generate a graphical power flow diagram showing energy movement between grid, battery, PV, and house load
Each component (grid, battery, PV, House) will be represented with a circle
arrows will run between the PV and Load, the Battery and Load and the House and Grid
The energy the house consumes is called the Load Power
The energy the PV generates is called the PV Power
The energy the battery charges or discharges is called the Battery Power
The energy the grid imports or exports is called the Grid Power
The house will be a circle in the middle the PV will be top left, the battery bottom left and the grid bottom right
"""
# Get power values
grid_power = self.base.grid_power
battery_power = self.base.battery_power
pv_power = self.base.pv_power
load_power = self.base.load_power
# Determine flow directions
grid_importing = grid_power <= -10 # Grid is importing power (negative value)
grid_exporting = grid_power >= 10 # Grid is exporting power (positive value)
battery_charging = battery_power >= 10 # Battery is charging (positive value)
battery_discharging = battery_power <= -10 # Battery is discharging (negative value)
pv_generating = pv_power > 0 # PV is generating power
html = ""
html += """
<div style="text-align: left; margin: 0px;">
<svg width="600" height="400" viewBox="0 0 600 400" xmlns="http://www.w3.org/2000/svg">
<!-- Grid Circle -->
<circle cx="450" cy="300" r="50" fill="#4CAF50" />
<text x="450" y="300" text-anchor="middle" dy=".3em" fill="#fff">Grid</text>
<!-- Battery Circle -->
<circle cx="150" cy="300" r="50" fill="#FF9800" />
<text x="150" y="300" text-anchor="middle" dy=".3em" fill="#fff">Battery</text>
<!-- PV Circle -->
<circle cx="150" cy="100" r="50" fill="#2196F3" />
<text x="150" y="100" text-anchor="middle" dy=".3em" fill="#fff">PV</text>
<!-- House Circle -->
<circle cx="300" cy="200" r="50" fill="#9C27B0" />
<text x="300" y="190" text-anchor="middle" dy=".3em" fill="#fff">House</text>
<text x="300" y="215" text-anchor="middle" dy=".3em" fill="#fff">{} W</text>
<!-- Define animation paths -->
<defs>
<!-- PV to House path -->
<path id="pv-house-path" d="M200,100 L250,150" stroke="transparent" fill="none" />
<!-- House to PV path -->
<path id="house-pv-path" d="M250,150 L200,100" stroke="transparent" fill="none" />
<!-- Battery to House path -->
<path id="battery-house-path" d="M200,300 L250,250" stroke="transparent" fill="none" />
<!-- House to Battery path -->
<path id="house-battery-path" d="M265,235 L215,275" stroke="transparent" fill="none" />
<!-- Grid to House path -->
<path id="grid-house-path" d="M410,290 L355,240" stroke="transparent" fill="none" />
<!-- House to Grid path -->
<path id="house-grid-path" d="M340,230 L390,270" stroke="transparent" fill="none" />
</defs>
""".format(
dp0(load_power)
)
# Draw arrows and labels
if pv_generating:
# Calculate animation speed based on power flow - faster for higher power
pv_speed = max(0.5, min(3.0, 2.0 - (abs(pv_power) / 3000)))
html += """
<!-- PV to House Arrow -->
<line x1="200" y1="100" x2="250" y2="150" stroke="#2196F3" stroke-width="2" marker-end="url(#pv-arrow)" />
<text x="250" y="120" text-anchor="middle" fill="#2196F3">{} W</text>
<!-- Moving dots for PV to House -->
<circle r="4" fill="#2196F3" opacity="0.8">
<animateMotion dur="{}s" repeatCount="indefinite" path="M200,100 L250,150" />
</circle>
<circle r="3" fill="#2196F3" opacity="0.6">
<animateMotion dur="{}s" repeatCount="indefinite" begin="0.5s" path="M200,100 L250,150" />
</circle>
<circle r="2" fill="#2196F3" opacity="0.4">
<animateMotion dur="{}s" repeatCount="indefinite" begin="1.0s" path="M200,100 L250,150" />
</circle>
""".format(
dp0(pv_power), pv_speed, pv_speed, pv_speed
)
else:
# Make the PV to House line dashed if not generating
html += """
<!-- PV to House Arrow (dashed) -->
<line x1="200" y1="100" x2="250" y2="150" stroke="#2196F3" stroke-width="2" stroke-dasharray="5,5" marker-end="url(#pv-arrow)" />
<text x="250" y="120" text-anchor="middle" fill="#2196F3">{} W</text>
<!-- No moving dot when PV is not generating -->
""".format(
dp0(pv_power)
)
if battery_charging:
# Calculate animation speed based on power flow - faster for higher power
battery_speed = max(0.5, min(3.0, 2.0 - (abs(battery_power) / 3000)))
html += """
<!-- Battery to House Arrow -->
<line x1="200" y1="300" x2="250" y2="250" stroke="#FF9800" stroke-width="2" marker-end="url(#battery-arrow)" />
<text x="260" y="280" text-anchor="middle" fill="#FF9800">{} W</text>
<!-- Moving dots for Battery to House -->
<circle r="4" fill="#FF9800" opacity="0.8">
<animateMotion dur="{}s" repeatCount="indefinite" path="M200,300 L250,250" />
</circle>
<circle r="3" fill="#FF9800" opacity="0.6">
<animateMotion dur="{}s" repeatCount="indefinite" begin="0.5s" path="M200,300 L250,250" />
</circle>
<circle r="2" fill="#FF9800" opacity="0.4">
<animateMotion dur="{}s" repeatCount="indefinite" begin="1.0s" path="M200,300 L250,250" />
</circle>
""".format(
dp0(battery_power), battery_speed, battery_speed, battery_speed
)
else:
# Calculate animation speed based on power flow - faster for higher power
battery_speed = max(0.5, min(3.0, 2.0 - (abs(battery_power) / 3000)))
html += """
<!-- House to Battery Arrow -->
<line x1="265" y1="235" x2="215" y2="275" stroke="#FF9800" stroke-width="2" marker-end="url(#battery-arrow)" />
<text x="260" y="280" text-anchor="middle" fill="#FF9800">{} W</text>
<!-- Moving dots for House to Battery -->
<circle r="4" fill="#FF9800" opacity="0.8">
<animateMotion dur="{}s" repeatCount="indefinite" path="M265,235 L215,275" />
</circle>
<circle r="3" fill="#FF9800" opacity="0.6">
<animateMotion dur="{}s" repeatCount="indefinite" begin="0.5s" path="M265,235 L215,275" />
</circle>
<circle r="2" fill="#FF9800" opacity="0.4">
<animateMotion dur="{}s" repeatCount="indefinite" begin="1.0s" path="M265,235 L215,275" />
</circle>
""".format(
dp0(battery_power), battery_speed, battery_speed, battery_speed
)
if grid_importing:
# Calculate animation speed based on power flow - faster for higher power
grid_speed = max(0.5, min(3.0, 2.0 - (abs(grid_power) / 3000)))
html += """
<!-- Grid to House Arrow -->
<line x1="410" y1="290" x2="355" y2="240" stroke="#4CAF50" stroke-width="2" marker-end="url(#grid-arrow)" />
<text x="350" y="280" text-anchor="middle" fill="#4CAF50">{} W</text>
<!-- Moving dots for Grid to House -->
<circle r="4" fill="#4CAF50" opacity="0.8">
<animateMotion dur="{}s" repeatCount="indefinite" path="M410,290 L355,240" />
</circle>
<circle r="3" fill="#4CAF50" opacity="0.6">
<animateMotion dur="{}s" repeatCount="indefinite" begin="0.5s" path="M410,290 L355,240" />
</circle>
<circle r="2" fill="#4CAF50" opacity="0.4">
<animateMotion dur="{}s" repeatCount="indefinite" begin="1.0s" path="M410,290 L355,240" />
</circle>
""".format(
dp0(grid_power), grid_speed, grid_speed, grid_speed
)
else:
# Calculate animation speed based on power flow - faster for higher power
grid_speed = max(0.5, min(3.0, 2.0 - (abs(grid_power) / 3000)))
html += """
<!-- House to Grid Arrow -->
<line x1="340" y1="230" x2="390" y2="270" stroke="#4CAF50" stroke-width="2" marker-end="url(#grid-arrow)" />
<text x="340" y="280" text-anchor="middle" fill="#4CAF50">{} W</text>
<!-- Moving dots for House to Grid -->
<circle r="4" fill="#4CAF50" opacity="0.8">
<animateMotion dur="{}s" repeatCount="indefinite" path="M340,230 L390,270" />
</circle>
<circle r="3" fill="#4CAF50" opacity="0.6">
<animateMotion dur="{}s" repeatCount="indefinite" begin="0.5s" path="M340,230 L390,270" />
</circle>
<circle r="2" fill="#4CAF50" opacity="0.4">
<animateMotion dur="{}s" repeatCount="indefinite" begin="1.0s" path="M340,230 L390,270" />
</circle>
""".format(
dp0(grid_power), grid_speed, grid_speed, grid_speed
)
html += """
<!-- Arrowhead Marker -->
<defs>
<marker id="pv-arrow" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2196F3"/>
</marker>
<marker id="battery-arrow" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#FF9800"/>
</marker>
<marker id="grid-arrow" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#4CAF50"/>
</marker>
</defs>
</svg>
</div>
<script>
// Ensure the animations work correctly in both light and dark modes
function adjustFlowAnimations() {
const isDarkMode = document.body.classList.contains('dark-mode');
// Could add additional animation adjustments here if needed for dark mode
// This function is called when the page loads and can be extended for other special effects
}
// Run when page loads
adjustFlowAnimations();
</script>
"""
return html
def get_status_html(self, version):
text = ""
if not self.base.dashboard_index:
text += "<h2>Loading please wait...</h2>"
return text
debug_enable, ignore = self.get_ha_config("debug_enable", None)
read_only, ignore = self.get_ha_config("set_read_only", None)
mode, ignore = self.get_ha_config("mode", None)
# Create a two-column layout for Status and Debug tables
text += '<div style="display: flex; gap: 5px; margin-bottom: 20px; max-width: 800px;">\n'
# Left column - Status table
text += '<div style="flex: 1;">\n'
text += "<h2>Status</h2>\n"
text += "<table>\n"
try:
is_running = self.base.is_running()
except Exception as e:
self.log("Error checking if Predbat is running: {}".format(e))
is_running = False
status_entity = self.prefix + ".status"
last_updated = self.get_state_wrapper(status_entity, attribute="last_updated", default=None)
if last_updated:
try:
last_updated = str2time(last_updated).replace(tzinfo=None, microsecond=0)
except (ValueError, TypeError) as e:
self.log("Warn: Failed to parse last_updated time {}: {}".format(last_updated, e))
status = self.get_state_wrapper(status_entity, default="Unknown")
detail = self.get_state_wrapper(status_entity, attribute="detail", default="")
debug = self.get_state_wrapper(status_entity, attribute="debug", default="")
status_full = status + " " + detail
debug_escaped = str(debug).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
debug_title = ' title="{}"'.format(debug_escaped) if debug else ""
if status and (("Warn:" in status) or ("Error:" in status)):
text += "<tr><td>Status</td><td bgcolor=#ff7777{}>{}</td></tr>\n".format(debug_title, status_full)
elif not is_running:
text += "<tr><td colspan='2' bgcolor='#ff7777'{}>{} (unhealthy)</td></tr>\n".format(debug_title, status_full)
else:
text += "<tr><td>Status</td><td{}>{}</td></tr>\n".format(debug_title, status_full)
text += "<tr><td>Last Updated</td><td>{}</td></tr>\n".format(last_updated)
last_started = self.get_state_wrapper(self.prefix + ".last_started", default=None)
if last_started:
try:
last_started = str2time(last_started).replace(tzinfo=None)
except (ValueError, TypeError) as e:
self.log("Warn: Failed to parse last_started time {}: {}".format(last_started, e))
text += "<tr><td>Last Started</td><td>{}</td></tr>\n".format(last_started)
text += "<tr><td>Version</td><td>{}</td></tr>\n".format(version)
# Editable Mode field
text += "<tr><td>Mode</td><td>"
text += f'<form style="display: inline;" method="post" action="./dash">'
text += f'<select name="mode" class="dashboard-select" onchange="this.form.submit()">'
for option in self.base.config_index.get("mode", {}).get("options", []):
selected = "selected" if option == mode else ""
text += f'<option value="{option}" {selected}>{option}</option>'
text += "</select></form></td></tr>\n"
text += "<tr><td>SoC</td><td>{}</td></tr>\n".format(self.get_battery_status_icon())
# Editable Debug Enable field
text += "<tr><td>Debug Enable</td><td>"
text += f'<form style="display: inline;" method="post" action="./dash">'
toggle_class = "toggle-switch active" if debug_enable else "toggle-switch"
text += f'<button class="{toggle_class}" type="button" onclick="toggleSwitch(this, \'debug_enable\')"></button>'
text += "</form></td></tr>\n"
# Editable Set Read Only field
text += "<tr><td>Set Read Only</td><td>"
text += f'<form style="display: inline;" method="post" action="./dash">'
toggle_class = "toggle-switch active" if read_only else "toggle-switch"
text += f'<button class="{toggle_class}" type="button" onclick="toggleSwitch(this, \'set_read_only\')"></button>'
text += "</form></td></tr>\n"
# Editable Predbat Active field
predbat_active, ignore = self.get_ha_config("active", None)
text += "<tr><td>Predbat Active</td><td>"
text += f'<form style="display: inline;" method="post" action="./dash">'
toggle_class = "toggle-switch active" if predbat_active else "toggle-switch"
text += f'<button class="{toggle_class}" type="button" onclick="toggleSwitch(this, \'active\')" title="On during calculations, off otherwise"></button>'
text += "</form></td></tr>\n"
if self.arg_errors:
count_errors = len(self.arg_errors)
text += "<tr><td>Config</td><td bgcolor=#ff7777>apps.yaml has {} errors</td></tr>\n".format(count_errors)
else:
text += "<tr><td>Config</td><td>OK</td></tr>\n"
text += "</table>\n"
text += "</div>\n"
# Right column - Debug table
text += '<div style="flex: 1;">\n'
text += "<h2>Debug</h2>\n"
text += "<table>\n"
text += "<tr><td>Download</td><td><a href='javascript:void(0)' onclick='downloadLiveApps()'>apps.yaml (live)</a> | <a href='./debug_apps'>apps.yaml (file)</a></td></tr>\n"
text += "<tr><td>Create</td><td><a href='./debug_yaml'>predbat_debug.yaml</a></td></tr>\n"
text += "<tr><td>Download</td><td><a href='./debug_log'>predbat.log</a></td></tr>\n"
text += "<tr><td>Download</td><td><a href='./debug_plan'>predbat_plan.html</a></td></tr>\n"
text += "<tr><td>Restart</td><td><button onclick='restartPredbat()' style='background-color: #ff4444; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold;'>Restart Predbat</button></td></tr>\n"
text += "</table>\n"
text += "</div>\n"
# Close the two-column layout
text += "</div>\n"
# Add power flow diagram
text += "<h2>Power Flow</h2>\n"
text += """<div style="margin-bottom: 8px; display: flex; align-items: center; gap: 10px;">
<button id="inverterRefreshBtn" onclick="refreshInverterData()" style="background-color: #2196F3; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold;">Refresh</button>
<span id="inverterRefreshStatus" style="font-size: 13px; color: #666;"></span>
</div>
"""
text += get_refresh_inverter_js()
text += self.get_power_flow_diagram()
# Text description of the plan
text += "<h2>Plan textual description</h2>\n"
text += "<table>\n"
text_plan = self.get_state_wrapper(entity_id=self.prefix + ".plan_html", attribute="text", default="No plan available")
text += "<tr><td>{}</td></tr>\n".format(text_plan)
text += "</table>\n"
# Form the app list
app_list = ["predbat"]
for entity_id in self.base.dashboard_index_app.keys():
app = self.base.dashboard_index_app[entity_id]
if app not in app_list:
app_list.append(app)
# Add expand/collapse all button
text += '<div style="margin: 20px 0;">\n'
text += '<button id="expandAllBtn" class="expand-all-button" onclick="toggleAllSections()">Expand All</button>\n'
text += "</div>\n"
# Display per app
for app in app_list:
section_id = f"section-{app}"
# Build entity list first to get count
if app == "predbat":
entity_list = self.base.dashboard_index
else:
entity_list = []
for entity_id in self.base.dashboard_index_app.keys():
if self.base.dashboard_index_app[entity_id] == app:
entity_list.append(entity_id)
entity_count = len(entity_list)
entity_word = "entity" if entity_count == 1 else "entities"
text += f'<div class="dashboard-section">\n'
text += f'<h2 class="dashboard-section-header" onclick="toggleDashboardSection(\'{section_id}\')">\n'
text += f'<span class="expand-icon" id="icon-{section_id}">+</span> {app[0].upper() + app[1:]} Entities ({entity_count} {entity_word})\n'
text += "</h2>\n"
text += f'<div id="{section_id}" class="dashboard-section-content collapsed">\n'
text += "<table>\n"
text += "<tr><th></th><th>Name</th><th>Entity</th><th>State</th><th>Attributes</th></tr>\n"
for entity in entity_list:
text += self.html_get_entity_text(entity)
text += "</table>\n"
text += "</div>\n"
text += "</div>\n"
return text
def html_get_entity_text(self, entity):
text = ""
if entity in self.base.dashboard_values:
state = self.base.dashboard_values.get(entity, {}).get("state", None)
attributes = self.base.dashboard_values.get(entity, {}).get("attributes", {})
unit_of_measurement = attributes.get("unit_of_measurement", "")
icon = self.icon2html(attributes.get("icon", ""))
if unit_of_measurement is None:
unit_of_measurement = ""
friendly_name = attributes.get("friendly_name", "")
if state is None:
state = "None"
text += '<tr><td> {} </td><td> <a href="./entity?entity_id={}"> {} </a></td><td>{}</td><td>{} {}</td><td>{}</td></tr>\n'.format(icon, entity, friendly_name, entity, state, unit_of_measurement, self.get_attributes_html(entity))
else:
state = self.get_state_wrapper(entity_id=entity)
unit_of_measurement = self.get_state_wrapper(entity_id=entity, attribute="unit_of_measurement")
friendly_name = self.get_state_wrapper(entity_id=entity, attribute="friendly_name")
text += '<tr><td> {} </td><td> <a href="./entity?entity_id={}"> {} </a></td><td>{}</td><td>{} {}</td><td>{}</td></tr>\n'.format("", entity, friendly_name, entity, state, unit_of_measurement, self.get_attributes_html(entity, from_db=True))
return text
def get_entity_list_data(self):
"""
Generate entity list data for the entity selector
"""
# Get app list
app_list = ["predbat"]
for entity_id in self.base.dashboard_index_app.keys():
app = self.base.dashboard_index_app[entity_id]
if app not in app_list:
app_list.append(app)
entity_data_list = []
for app in app_list:
if app == "predbat":
entity_list = self.base.dashboard_index if hasattr(self.base, "dashboard_index") and self.base.dashboard_index else []
else:
entity_list = []
if hasattr(self.base, "dashboard_index_app") and self.base.dashboard_index_app:
for entity_id in self.base.dashboard_index_app.keys():
if self.base.dashboard_index_app[entity_id] == app:
entity_list.append(entity_id)
for entity_id in entity_list:
if hasattr(self.base, "dashboard_values") and self.base.dashboard_values:
attributes = self.base.dashboard_values.get(entity_id, {}).get("attributes", {})
entity_friendly_name = attributes.get("friendly_name", entity_id)