Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
executable file
·2765 lines (2537 loc) · 126 KB
/
Copy pathserver.py
File metadata and controls
executable file
·2765 lines (2537 loc) · 126 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
#!/usr/bin/env python
# pyPowerWall Module - Proxy Server Tool
# -*- coding: utf-8 -*-
"""
Python module to interface with Tesla Solar Powerwall Gateway
Author: Jason A. Cox
For more information see https://github.com/jasonacox/pypowerwall
Project Status: Maintenance Mode
This proxy continues to receive critical/security fixes and remains a
drop-in replacement for existing deployments, but new feature development
has moved to pypowerwall-server (https://github.com/jasonacox/pypowerwall-server),
a full rewrite with multi-gateway support, orchestrated polling, and a
management console. New deployments, and anyone needing multi-gateway
support, should start there instead. See proxy/README.md and
https://github.com/jasonacox/pypowerwall/issues/254 for details.
Proxy Server Tool
This tool will proxy API calls to /api/meters/aggregates and
/api/system_status/soe - You can containerize it and run it as
an endpoint for tools like telegraf to pull metrics.
Local Powerwall Mode
The default mode for this proxy is to connect to a local Powerwall
to pull data. This works with the Tesla Energy Gateway (TEG) for
Powerwall 1, 2 and +. It will also support pulling /vitals and /strings
data if available.
Set: PW_HOST to Powerwall Address and PW_PASSWORD to use this mode.
Cloud Mode
An optional mode is to connect to the Tesla Cloud to pull data. This
requires that you have a Tesla Account and have registered your
Tesla Solar System or Powerwall with the Tesla App. It requires that
you run the setup 'python -m pypowerwall setup' process to create the
required API keys and tokens. This mode doesn't support /vitals or
/strings data.
Set: PW_EMAIL and leave PW_HOST blank to use this mode.
TEDAPI Mode
Full TEDAPI mode (PW_GW_PWD set, PW_PASSWORD blank) reads extended metrics
straight from the gateway. Two settings shape that connection:
- PW_TEDAPI_API_VERSION=V2024_06|V2026_06 — query/protobuf set (default
V2024_06; V2026_06 is the Tesla-signed GraphQL set)
- PW_TEDAPI_AUTH_MODE=basic|bearer — how to authenticate (default basic).
basic is HTTP Basic Auth to 192.168.91.1, only reachable over the
Gateway's Wi-Fi; bearer logs in via /api/login/Basic and also works
over the wired LAN IP. Use bearer together with
PW_TEDAPI_API_VERSION=V2026_06. Bearer works on solar-only
gateways but NOT Powerwall 2 or 3 (PW3 wired access = v1r mode).
The active values are reported in /stats and /health.
Control Mode
An optional mode is to enable control commands to set backup reserve
percentage and mode of the Powerwall. This requires that you set
and use the PW_CONTROL_SECRET environmental variable. This mode
is disabled by default and should be used with caution.
Set: PW_CONTROL_SECRET to enable this mode.
Error Handling
Global exception handling has been implemented for all pypowerwall
function calls to provide clean error logging instead of deep stack
traces. Connection errors, API errors, and unexpected exceptions are
caught and logged with descriptive messages while maintaining API
functionality through graceful error responses.
Weak WiFi / Network Error Handling
For environments with weak WiFi or unstable network connections, use:
- PW_SUPPRESS_NETWORK_ERRORS=yes to completely suppress individual
network error logs (summary reports every 5 minutes)
- PW_NETWORK_ERROR_RATE_LIMIT=N to limit network errors to N per
minute per function (default: 5)
- PW_FAIL_FAST=yes to return immediately when connection is degraded
instead of attempting new requests (reduces timeout delays)
- PW_GRACEFUL_DEGRADATION=yes to return cached data when fresh data
is unavailable (default: yes, improves telegraf reliability)
- PW_HEALTH_CHECK=yes to enable connection health monitoring and
automatic degraded mode detection (default: yes)
- PW_CACHE_TTL=N to set maximum age in seconds for cached data before
returning null instead of stale data (default: 30)
- Consider reducing PW_TIMEOUT to fail faster (e.g., PW_TIMEOUT=3)
TEDAPI SolarOnly Fallback Recovery (PW3/TEDAPI modes)
When TEDAPI drops mid-session (e.g. firmware 403 or route loss), the proxy
falls back to SolarOnly mode — solar data continues but battery/grid detail
is unavailable. The proxy now tracks this as a distinct `is_fallback_mode`
state (separate from `is_degraded`) and a background thread retries the
TEDAPI connection periodically with exponential backoff until data returns.
- PW_TEDAPI_RECOVERY=yes/no — enable/disable auto-recovery (default: yes)
- PW_TEDAPI_PROBE_INTERVAL=N — seconds between health probes (default: 30)
The fallback state is visible in /health and /stats under "fallback_mode".
Firmware Change Tracking
A background thread polls the gateway firmware version and logs it once
at startup plus a dated line whenever it changes, giving `docker logs`
a timestamped record of firmware updates (Powerwall-Dashboard#854).
- PW_FIRMWARE_CHECK_INTERVAL=N — seconds between firmware polls (default: 300)
Monitoring & Health Endpoints
- /health - returns connection health status and feature configuration
- /health/reset - resets health counters and clears cache
- /stats - includes connection health metrics when enabled
Data Freshness & Cache Behavior
The proxy prioritizes data freshness over availability. When fresh data
cannot be retrieved and cached data exceeds PW_CACHE_TTL seconds old,
endpoints return null instead of stale/zero values. This ensures
monitoring systems like telegraf can distinguish between actual zero
values and missing/stale data, preventing false alerts and misleading
metrics.
Telegraf Compatibility
Key endpoints return null when no fresh or reasonably recent cached
data is available, allowing telegraf to handle missing data appropriately:
- /aggregates returns null when no power data is available
- /soe returns null when battery level data is unavailable
- /vitals and /strings return null when device data is unavailable
- CSV endpoints continue to return zero values for backwards compatibility
"""
import datetime
import hmac
import html
import json
import logging
import os
import resource
import signal
import ssl
import sys
import time
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from typing import Optional
from urllib.parse import urlparse, parse_qs
import requests
import urllib3
# Robust import of transform helpers to support multiple invocation patterns:
# 1. python -m proxy.server (package-relative import works)
# 2. python proxy/server.py from project root (absolute package import works)
# 3. Executing from within the proxy directory (plain module import)
try: # Prefer relative when executed as a package module
from .transform import get_static, inject_js # type: ignore
except ImportError: # noqa: BLE001 - fall back to other strategies
try:
from proxy.transform import get_static, inject_js # type: ignore
except ImportError: # noqa: BLE001
from transform import get_static, inject_js # type: ignore # Last resort
import pypowerwall
from pypowerwall import parse_version
from pypowerwall.exceptions import (
PyPowerwallInvalidConfigurationParameter,
InvalidBatteryReserveLevelException,
)
from pypowerwall.tedapi.auth_mode import AuthMode
from pypowerwall.tedapi.exceptions import (
PyPowerwallTEDAPINoTeslaAuthFile,
PyPowerwallTEDAPITeslaNotConnected,
PyPowerwallTEDAPINotImplemented,
PyPowerwallTEDAPIInvalidPayload,
)
from pypowerwall.fleetapi.exceptions import (
PyPowerwallFleetAPINoTeslaAuthFile,
PyPowerwallFleetAPITeslaNotConnected,
PyPowerwallFleetAPINotImplemented,
PyPowerwallFleetAPIInvalidPayload,
)
BUILD = "t106"
ALLOWLIST = [
"/api/status",
"/api/site_info/site_name",
"/api/meters/site",
"/api/meters/solar",
"/api/sitemaster",
"/api/powerwalls",
"/api/customer/registration",
"/api/system_status",
"/api/system_status/grid_status",
"/api/system/update/status",
"/api/site_info",
"/api/system_status/grid_faults",
"/api/operation",
"/api/site_info/grid_codes",
"/api/solars",
"/api/solars/brands",
"/api/customer",
"/api/meters",
"/api/installer",
"/api/networks",
"/api/system/networks",
"/api/meters/readings",
"/api/synchrometer/ct_voltage_references",
"/api/troubleshooting/problems",
"/api/auth/toggle/supported",
"/api/solar_powerwall",
]
DISABLED = [
"/api/customer/registration",
]
# Maximum POST body size (bytes) accepted for /control commands
MAX_POST_BODY = 4096
web_root = os.path.join(os.path.dirname(__file__), "web")
# Configuration for Proxy - Check for environmental variables
# and always use those if available (required for Docker)
bind_address = os.getenv("PW_BIND_ADDRESS", "")
password = os.getenv("PW_PASSWORD", "")
email = os.getenv("PW_EMAIL", "email@example.com")
host = os.getenv("PW_HOST", "")
timezone = os.getenv("PW_TIMEZONE", "America/Los_Angeles")
debugmode = os.getenv("PW_DEBUG", "no").lower() == "yes"
cache_expire = int(os.getenv("PW_CACHE_EXPIRE", "5"))
browser_cache = int(os.getenv("PW_BROWSER_CACHE", "0"))
timeout = int(os.getenv("PW_TIMEOUT", "5"))
pool_maxsize = int(os.getenv("PW_POOL_MAXSIZE", "15"))
https_mode = os.getenv("PW_HTTPS", "no")
port = int(os.getenv("PW_PORT", "8675"))
style = os.getenv("PW_STYLE", "clear") + ".js"
siteid = os.getenv("PW_SITEID", None)
authpath = os.getenv("PW_AUTH_PATH", "")
authmode = os.getenv("PW_AUTH_MODE", "cookie")
cf = ".powerwall"
if authpath:
cf = os.path.join(authpath, ".powerwall")
cachefile = os.getenv("PW_CACHE_FILE", cf)
control_secret = os.getenv("PW_CONTROL_SECRET", "")
gw_pwd = os.getenv("PW_GW_PWD", None)
rsa_key_path = os.getenv("PW_RSA_KEY_PATH", None)
wifi_host = os.getenv("PW_WIFI_HOST", None)
tedapi_api_version = os.getenv("PW_TEDAPI_API_VERSION", "V2024_06")
# default= makes a typo in the env var a logged warning + basic fallback
# instead of a fatal "Powerwall Connection Error" and a restart loop.
tedapi_auth_mode = AuthMode.coerce(os.getenv("PW_TEDAPI_AUTH_MODE", "basic"),
default=AuthMode.BASIC)
neg_solar = os.getenv("PW_NEG_SOLAR", "yes").lower() == "yes"
try:
site_zero_threshold = int(os.getenv("PW_SITE_ZERO_THRESHOLD", "0"))
except (ValueError, TypeError):
print(f"WARNING: PW_SITE_ZERO_THRESHOLD must be an integer, defaulting to 0")
site_zero_threshold = 0
api_base_url = os.getenv(
"PROXY_BASE_URL", "/"
) # Prefix for public API calls, e.g. if you have everything behind a reverse proxy
# Network error handling configuration for weak WiFi scenarios
suppress_network_errors = os.getenv("PW_SUPPRESS_NETWORK_ERRORS", "no").lower() == "yes"
network_error_rate_limit = int(
os.getenv("PW_NETWORK_ERROR_RATE_LIMIT", "5")
) # errors per minute per function
# Additional robustness features for poor network conditions
fail_fast_mode = (
os.getenv("PW_FAIL_FAST", "no").lower() == "yes"
) # Return cached/empty data instead of retrying
graceful_degradation = (
os.getenv("PW_GRACEFUL_DEGRADATION", "yes").lower() == "yes"
) # Return partial data on errors
health_check_enabled = (
os.getenv("PW_HEALTH_CHECK", "yes").lower() == "yes"
) # Track connection health
degradation_cache_ttl_seconds = int(
os.getenv("PW_CACHE_TTL", "30")
) # Maximum age for cached data before returning None
tedapi_recovery_enabled = (
os.getenv("PW_TEDAPI_RECOVERY", "yes").lower() == "yes"
) # Auto-recover TEDAPI connection when proxy falls back to SolarOnly mode
try:
TEDAPI_PROBE_INTERVAL = max(5, int(os.getenv("PW_TEDAPI_PROBE_INTERVAL", "30")))
except (ValueError, TypeError):
TEDAPI_PROBE_INTERVAL = 30 # seconds between probes; default if env var is non-integer
TEDAPI_FALLBACK_THRESHOLD = 3 # consecutive probe failures before entering fallback mode
TEDAPI_RECOVERY_INITIAL_INTERVAL = 60 # initial recovery retry interval (seconds)
TEDAPI_RECOVERY_MAX_INTERVAL = 300 # cap on retry interval (seconds)
try:
FIRMWARE_CHECK_INTERVAL = max(
30, int(os.getenv("PW_FIRMWARE_CHECK_INTERVAL", "300"))
)
except (ValueError, TypeError):
FIRMWARE_CHECK_INTERVAL = 300 # seconds between firmware polls (Powerwall-Dashboard#854)
# Global Stats
proxystats = {
"pypowerwall": "%s Proxy %s" % (pypowerwall.version, BUILD),
"mode": "Unknown",
"gets": 0,
"posts": 0,
"errors": 0,
"timeout": 0,
"uri": {},
"ts": int(time.time()),
"start": int(time.time()),
"clear": int(time.time()),
"uptime": "",
"mem": 0,
"site_name": "",
"cloudmode": False,
"fleetapi": False,
"tedapi": False,
"pw3": False,
"tedapi_mode": "off",
"tedapi_api_version": "V2024_06",
"tedapi_auth_mode": str(AuthMode.BASIC),
"siteid": None,
"counter": 0,
"cf": cachefile,
"config": {
"PW_BIND_ADDRESS": bind_address,
"PW_PASSWORD": "*" * len(password) if password else None,
"PW_EMAIL": email,
"PW_HOST": host,
"PW_TIMEZONE": timezone,
"PW_DEBUG": debugmode,
"PW_CACHE_EXPIRE": cache_expire,
"PW_BROWSER_CACHE": browser_cache,
"PW_TIMEOUT": timeout,
"PW_POOL_MAXSIZE": pool_maxsize,
"PW_HTTPS": https_mode,
"PW_PORT": port,
"PW_STYLE": style,
"PW_SITEID": siteid,
"PW_AUTH_PATH": authpath,
"PW_AUTH_MODE": authmode,
"PW_CACHE_FILE": cachefile,
"PW_CONTROL_SECRET": "*" * len(control_secret) if control_secret else None,
"PW_GW_PWD": "*" * len(gw_pwd) if gw_pwd else None,
"PW_RSA_KEY_PATH": rsa_key_path,
"PW_WIFI_HOST": wifi_host,
"PW_TEDAPI_API_VERSION": tedapi_api_version,
"PW_TEDAPI_AUTH_MODE": str(tedapi_auth_mode),
"PW_NEG_SOLAR": neg_solar,
"PW_SITE_ZERO_THRESHOLD": site_zero_threshold,
"PW_SUPPRESS_NETWORK_ERRORS": suppress_network_errors,
"PW_NETWORK_ERROR_RATE_LIMIT": network_error_rate_limit,
"PW_FAIL_FAST": fail_fast_mode,
"PW_GRACEFUL_DEGRADATION": graceful_degradation,
"PW_HEALTH_CHECK": health_check_enabled,
"PW_CACHE_TTL": degradation_cache_ttl_seconds,
"PW_TEDAPI_RECOVERY": tedapi_recovery_enabled,
"PW_TEDAPI_PROBE_INTERVAL": TEDAPI_PROBE_INTERVAL,
"PW_FIRMWARE_CHECK_INTERVAL": FIRMWARE_CHECK_INTERVAL,
},
}
proxystats_lock = threading.RLock()
# Cap on distinct paths tracked in proxystats["uri"] (matches the 100-entry
# cap used by _endpoint_stats). Query strings are stripped before counting;
# new distinct paths beyond the cap are aggregated under "other".
URI_STATS_MAX = 100
if https_mode == "yes":
# run https mode with self-signed cert
cookiesuffix = "path=/;SameSite=None;Secure;"
httptype = "HTTPS"
elif https_mode == "http":
# run http mode but simulate https for proxy behind https proxy
cookiesuffix = "path=/;SameSite=None;Secure;"
httptype = "HTTP"
else:
# run in http mode
cookiesuffix = "path=/;"
httptype = "HTTP"
# Logging
log = logging.getLogger("proxy")
logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)
log.setLevel(logging.INFO)
if debugmode:
log.info(
"pyPowerwall [%s] Proxy Server [%s] - %s Port %d - DEBUG"
% (pypowerwall.version, BUILD, httptype, port)
)
pypowerwall.set_debug(True)
log.setLevel(logging.DEBUG)
else:
log.info(
"pyPowerwall [%s] Proxy Server [%s] - %s Port %d"
% (pypowerwall.version, BUILD, httptype, port)
)
log.info("pyPowerwall Proxy Started")
log.info(
"NOTICE: This proxy is in maintenance mode - new features are being built in "
"pypowerwall-server (https://github.com/jasonacox/pypowerwall-server). "
"See proxy/README.md for details."
)
# Log network error handling configuration
if suppress_network_errors:
log.info("Network error logging suppressed (PW_SUPPRESS_NETWORK_ERRORS=yes)")
else:
log.info(
f"Network error rate limiting: {network_error_rate_limit} errors/minute per function"
)
# Log additional robustness features
if fail_fast_mode:
log.info(
"Fail-fast mode enabled (PW_FAIL_FAST=yes) - degraded connections return immediately"
)
if graceful_degradation:
log.info(
f"Graceful degradation enabled (PW_GRACEFUL_DEGRADATION=yes) - cached data TTL: {degradation_cache_ttl_seconds}s"
)
if health_check_enabled:
log.info("Connection health monitoring enabled (PW_HEALTH_CHECK=yes)")
# Rate limiter for network error logging to prevent spam
_error_counts = {}
_error_counts_lock = threading.RLock()
_network_error_summary = {}
_last_summary_time = time.time()
def should_log_network_error(func_name, max_per_minute=5):
"""
Rate limit network error logging to prevent log spam.
Allow max_per_minute errors per function per minute.
"""
current_time = time.time()
minute_bucket = int(current_time // 60)
key = f"{func_name}_{minute_bucket}"
with _error_counts_lock:
_error_counts[key] = _error_counts.get(key, 0) + 1
# Clean up old entries (keep only current and previous minute)
current_keys = {
f"{func_name}_{minute_bucket}",
f"{func_name}_{minute_bucket-1}",
}
keys_to_remove = [
k
for k in _error_counts.keys()
if k not in current_keys and k.startswith(f"{func_name}_")
]
for k in keys_to_remove:
del _error_counts[k]
return _error_counts[key] <= max_per_minute
def track_network_error(func_name, error_type):
"""Track network errors for summary reporting."""
global _network_error_summary, _last_summary_time
with _error_counts_lock:
if func_name not in _network_error_summary:
_network_error_summary[func_name] = {}
if error_type not in _network_error_summary[func_name]:
_network_error_summary[func_name][error_type] = 0
_network_error_summary[func_name][error_type] += 1
# Log summary every 5 minutes if there are errors
current_time = time.time()
if current_time - _last_summary_time > 300: # 5 minutes
if _network_error_summary:
log.warning("Network Error Summary (last 5 minutes):")
for func, errors in _network_error_summary.items():
for error_type, count in errors.items():
log.warning(f" {func}: {count} {error_type} errors")
_network_error_summary.clear()
_last_summary_time = current_time
# Connection health tracking and caching for graceful degradation
_connection_health = {
"consecutive_failures": 0,
"last_success_time": time.time(),
"total_failures": 0,
"total_successes": 0,
"is_degraded": False,
}
_connection_health_lock = threading.RLock()
# SolarOnly fallback mode tracking (distinct from connection_health degradation)
# is_degraded = transient transport failures; is_fallback_mode = TEDAPI fell back to SolarOnly
_fallback_mode = {
"is_fallback_mode": False,
"fallback_since": None, # timestamp when fallback was entered
"recovery_attempts": 0,
"last_recovery_attempt": None,
"next_attempt_at": None, # timestamp the next recovery attempt is due
}
_fallback_mode_lock = threading.RLock()
_fallback_recovery_lock = threading.Lock() # serializes pw.connect() calls from recovery thread
# Cache for last known good responses (graceful degradation)
_last_good_responses = {}
_last_good_responses_lock = threading.RLock()
# Performance cache for frequently-hit endpoints (separate from degradation cache)
_performance_cache = {}
_performance_cache_lock = threading.RLock()
# Endpoint call tracking for success/failure statistics
_endpoint_stats = {}
_endpoint_stats_lock = threading.RLock()
# Health thresholds
HEALTH_FAILURE_THRESHOLD = 5 # consecutive failures before degraded mode
HEALTH_RECOVERY_THRESHOLD = 3 # consecutive successes to exit degraded mode
def update_connection_health(success=True):
"""Update connection health metrics and degraded mode status."""
global _connection_health
with _connection_health_lock:
if success:
_connection_health["consecutive_failures"] = 0
_connection_health["last_success_time"] = time.time()
_connection_health["total_successes"] += 1
# Check if we should exit degraded mode
if _connection_health["is_degraded"]:
if (
_connection_health["total_successes"] % HEALTH_RECOVERY_THRESHOLD
== 0
):
_connection_health["is_degraded"] = False
if not suppress_network_errors:
log.info("Connection health recovered - exiting degraded mode")
else:
_connection_health["consecutive_failures"] += 1
_connection_health["total_failures"] += 1
# Check if we should enter degraded mode
if not _connection_health["is_degraded"]:
if (
_connection_health["consecutive_failures"]
>= HEALTH_FAILURE_THRESHOLD
):
_connection_health["is_degraded"] = True
if not suppress_network_errors:
log.warning(
f"Connection health degraded after {HEALTH_FAILURE_THRESHOLD} consecutive failures - entering graceful degradation mode"
)
def enter_fallback_mode(reason="TEDAPI data unavailable"):
"""Signal that proxy has entered SolarOnly fallback mode (distinct from is_degraded)."""
with _fallback_mode_lock:
if not _fallback_mode["is_fallback_mode"]:
_fallback_mode["is_fallback_mode"] = True
_fallback_mode["fallback_since"] = time.time()
_fallback_mode["recovery_attempts"] = 0
_fallback_mode["last_recovery_attempt"] = None
# The recovery loop always sleeps TEDAPI_PROBE_INTERVAL before it can
# attempt, so the real due time is floored by the probe interval -
# without the max() this field under-reports whenever
# PW_TEDAPI_PROBE_INTERVAL is set above the 60s initial interval.
_fallback_mode["next_attempt_at"] = time.time() + max(
TEDAPI_RECOVERY_INITIAL_INTERVAL, TEDAPI_PROBE_INTERVAL)
log.warning(
f"Proxy entering SolarOnly fallback mode: {reason}. "
"Background recovery will retry periodically."
)
def exit_fallback_mode():
"""Signal that proxy has recovered from SolarOnly fallback mode."""
with _fallback_mode_lock:
if _fallback_mode["is_fallback_mode"]:
duration = time.time() - (_fallback_mode["fallback_since"] or time.time())
attempts = _fallback_mode["recovery_attempts"]
_fallback_mode["is_fallback_mode"] = False
_fallback_mode["fallback_since"] = None
_fallback_mode["recovery_attempts"] = 0
_fallback_mode["last_recovery_attempt"] = None
_fallback_mode["next_attempt_at"] = None
log.info(
f"Proxy recovered from SolarOnly fallback mode after "
f"{duration:.0f}s and {attempts} recovery attempt(s)."
)
# Firmware change tracking (Powerwall-Dashboard#854): keep a timestamped
# record of gateway firmware in the proxy logs — log it once at startup and
# again whenever it changes, so "when did my Powerwall update?" is answerable
# from `docker logs pypowerwall` without guesswork.
_firmware_lock = threading.Lock()
_firmware_state = {"version": None}
def track_firmware_version(version):
"""Record gateway firmware version; log first sighting and any change."""
if version is None:
return
# Coerce non-str (int/bytes) so versions aren't silently ignored
version = str(version)
# Sanitize external input — strip ASCII control chars and DEL (log
# forging), then collapse whitespace
version = "".join(ch for ch in version if ch >= " " and ch != "\x7f")
version = " ".join(version.split())
if not version:
return
with _firmware_lock:
previous = _firmware_state["version"]
if previous is None:
_firmware_state["version"] = version
log.info(f"Gateway firmware: {version}")
elif version != previous:
_firmware_state["version"] = version
log.info(f"Gateway firmware changed: {previous} -> {version}")
def _firmware_watchdog():
"""Background thread: poll gateway firmware and log changes (issue #854).
Polls immediately at startup, then every FIRMWARE_CHECK_INTERVAL seconds
(default 300s; pypowerwall caches responses so this is cheap). A None
return (gateway unreachable, cooldown) is skipped silently — no log noise.
Works in every mode: local, cloud, FleetAPI and TEDAPI.
"""
first = True
while True:
if not first:
time.sleep(FIRMWARE_CHECK_INTERVAL)
first = False
try:
# direct call — mirrors the TEDAPI probe; avoids safe_pw_call
# health side-effects from a background poller
track_firmware_version(pw.version())
except Exception as exc:
log.debug(f"Firmware poll failed: {exc}")
def _tedapi_probe_and_recover():
"""
Background thread: probe TEDAPI health and recover from SolarOnly fallback.
Runs only when TEDAPI mode is active. Polls pw.version() every TEDAPI_PROBE_INTERVAL
seconds to detect SolarOnly entry. After TEDAPI_FALLBACK_THRESHOLD consecutive None
results, enters fallback mode and starts attempting reconnect via pw.connect(retry=False).
On successful recovery, exits fallback mode and resets exponential backoff.
Stays in SolarOnly if recovery fails — proxy keeps serving available data.
Runtime-reconnect trade-off: pw.connect() swaps pw.client and transiently mutates
pw.mode/cloudmode/fleetapi while handler threads may be live. In practice this is
bounded — requests were already failing when recovery fires, the attribute swap is
atomic, and worst case a few in-flight requests hit a transient wrong-mode branch.
Hybrid-mode note: the gate is pw.tedapi, which includes hybrid mode (v1r + WiFi
fallback). In that topology pw.version() is served by the local API, so a WiFi
TEDAPI outage may not be detected by this probe. Monitoring is best-effort for
hybrid; pure TEDAPI mode (WiFi-only or v1r-only) gets full coverage.
429-cooldown interaction: during a gateway rate-limit cooldown (~5 min), pw.version()
returns None, which drives the probe into fallback mode and triggers reconnect
attempts. The 60→300s backoff bounds reconnect to roughly one attempt per cooldown
window, which is tolerable but does cut slightly against the "don't hammer a
rate-limited gateway" convention. A cooldown-aware probe is a follow-up item.
"""
consecutive_failures = 0
recovery_interval = TEDAPI_RECOVERY_INITIAL_INTERVAL
while True:
try:
time.sleep(TEDAPI_PROBE_INTERVAL)
with _fallback_mode_lock:
in_fallback = _fallback_mode["is_fallback_mode"]
# Only probe when in TEDAPI mode — but never let this gate block
# recovery once in fallback. A fully-failed pw.connect(retry=False)
# used to leave pw.tedapi=False (fixed in Powerwall.connect(), #366),
# and with the gate checked first the loop short-circuited here on
# every iteration: no further recovery attempt, no log line at any
# level, is_fallback_mode stuck True until restart. Checking
# in_fallback first makes that wedge structurally impossible even if
# some future path corrupts pw.tedapi again.
if not in_fallback and not getattr(pw, 'tedapi', None):
consecutive_failures = 0
continue
if not in_fallback:
# Healthy path: probe TEDAPI (treat exceptions as probe failures)
try:
version = pw.version() # direct call — don't want safe_pw_call health side-effects
except Exception as probe_exc:
log.debug(f"TEDAPI probe exception (counts as failure): {probe_exc}")
version = None
if version is not None:
consecutive_failures = 0
recovery_interval = TEDAPI_RECOVERY_INITIAL_INTERVAL
else:
consecutive_failures += 1
if consecutive_failures >= TEDAPI_FALLBACK_THRESHOLD:
enter_fallback_mode(
f"TEDAPI returned no data for {consecutive_failures} consecutive probes"
)
else:
# Fallback path: wait the backoff interval then attempt reconnect
extra_wait = recovery_interval - TEDAPI_PROBE_INTERVAL
if extra_wait > 0:
time.sleep(extra_wait)
# Re-check after the long backoff sleep: /health/reset may have cleared state.
with _fallback_mode_lock:
if not _fallback_mode["is_fallback_mode"]:
consecutive_failures = 0
recovery_interval = TEDAPI_RECOVERY_INITIAL_INTERVAL
continue
_fallback_mode["recovery_attempts"] += 1
_fallback_mode["last_recovery_attempt"] = time.time()
attempt_num = _fallback_mode["recovery_attempts"]
log.info(f"TEDAPI recovery attempt #{attempt_num} (interval={recovery_interval}s)...")
recovered = False
with _fallback_recovery_lock:
try:
if pw.connect(retry=False):
# Verify data flows after reconnect
version = pw.version()
if version is not None:
recovered = True
except Exception as exc:
log.warning(f"TEDAPI recovery attempt #{attempt_num} exception: {exc}")
if recovered:
exit_fallback_mode()
consecutive_failures = 0
recovery_interval = TEDAPI_RECOVERY_INITIAL_INTERVAL
else:
next_interval = min(recovery_interval * 2, TEDAPI_RECOVERY_MAX_INTERVAL)
# Elapsed time to the next attempt is probe_sleep +
# max(next_interval - probe_sleep, 0) = max(next_interval,
# TEDAPI_PROBE_INTERVAL) - clamp so next_attempt_at and the
# log line don't under-report with a large probe interval.
next_due = max(next_interval, TEDAPI_PROBE_INTERVAL)
with _fallback_mode_lock:
_fallback_mode["next_attempt_at"] = time.time() + next_due
log.warning(
f"TEDAPI recovery attempt #{attempt_num} failed — "
f"staying in SolarOnly, next retry in {next_due}s"
)
recovery_interval = next_interval
except (KeyboardInterrupt, SystemExit):
break
except Exception as exc:
log.debug(f"TEDAPI probe/recovery thread unexpected error: {exc}")
def get_cached_response(endpoint):
"""Get cached response for graceful degradation."""
if not graceful_degradation:
return None
with _last_good_responses_lock:
if endpoint in _last_good_responses:
cached_data, timestamp = _last_good_responses[endpoint]
age = time.time() - timestamp
if age < degradation_cache_ttl_seconds:
if debugmode:
log.debug(f"Using cached response for {endpoint} (age: {age:.1f}s)")
return cached_data
else:
# Cache expired - remove entry and return None
if debugmode:
log.debug(
f"Cache expired for {endpoint} (age: {age:.1f}s > {degradation_cache_ttl_seconds}s)"
)
del _last_good_responses[endpoint]
return None
def cache_response(endpoint, response):
"""Cache successful response for graceful degradation."""
if not graceful_degradation or response is None:
return
with _last_good_responses_lock:
_last_good_responses[endpoint] = (response, time.time())
# Limit cache size to prevent memory growth
if len(_last_good_responses) > 50:
# Remove oldest entries
oldest_key = min(
_last_good_responses.keys(), key=lambda k: _last_good_responses[k][1]
)
del _last_good_responses[oldest_key]
def get_performance_cached(cache_key):
"""
Get cached endpoint response for performance optimization.
Uses standard cache_expire TTL (typically 5 seconds).
Args:
cache_key: The cache key (e.g., '/csv/v2', '/json', '/freq', '/pod')
Returns:
Cached response string if available and fresh, None otherwise
"""
with _performance_cache_lock:
if cache_key not in _performance_cache:
return None
data, timestamp = _performance_cache[cache_key]
age = time.time() - timestamp
# Use standard cache_expire (same as pypowerwall's internal cache)
if age < cache_expire:
log.debug(f"Performance cache hit for {cache_key} (age: {age:.2f}s)")
return data
else:
log.debug(f"Performance cache expired for {cache_key} (age: {age:.2f}s)")
return None
def cache_performance_response(cache_key, data):
"""
Cache endpoint response for performance optimization.
Args:
cache_key: The cache key (e.g., '/csv/v2', '/json', '/freq', '/pod')
data: The response string to cache
"""
with _performance_cache_lock:
_performance_cache[cache_key] = (data, time.time())
log.debug(f"Cached performance response for {cache_key}")
def performance_cached(cache_key):
"""
Decorator for performance caching of route handlers.
Args:
cache_key: The cache key to use (e.g., '/vitals', '/strings', '/freq')
Returns:
Decorator function that wraps route handlers with caching logic
"""
def decorator(func):
def wrapper(*args, **kwargs):
# Try cache first
cached_response = get_performance_cached(cache_key)
if cached_response is not None:
return cached_response
# Cache miss - generate fresh data
result = func(*args, **kwargs)
# Only cache non-None results
if result is not None:
cache_performance_response(cache_key, result)
return result
return wrapper
return decorator
def cached_route_handler(cache_key, data_generator):
"""
Helper function for performance-cached route handling.
Args:
cache_key: The cache key to use for this route
data_generator: Function that generates the response data
Returns:
Cached response if available, otherwise fresh data (and caches it)
"""
# Try cache first
cached_response = get_performance_cached(cache_key)
if cached_response is not None:
return cached_response
# Cache miss - generate fresh data
result = data_generator()
# Only cache non-None results
if result is not None:
cache_performance_response(cache_key, result)
return result
def track_endpoint_call(endpoint, success=True):
"""Track endpoint call success/failure statistics."""
with _endpoint_stats_lock:
if endpoint not in _endpoint_stats:
_endpoint_stats[endpoint] = {
"total_calls": 0,
"successful_calls": 0,
"failed_calls": 0,
"last_success_time": None,
"last_failure_time": None,
}
_endpoint_stats[endpoint]["total_calls"] += 1
current_time = time.time()
if success:
_endpoint_stats[endpoint]["successful_calls"] += 1
_endpoint_stats[endpoint]["last_success_time"] = current_time
else:
_endpoint_stats[endpoint]["failed_calls"] += 1
_endpoint_stats[endpoint]["last_failure_time"] = current_time
# Limit endpoint stats size to prevent memory growth
if len(_endpoint_stats) > 100:
# Remove oldest entries (by last activity)
oldest_endpoint = min(
_endpoint_stats.keys(),
key=lambda k: max(
_endpoint_stats[k]["last_success_time"] or 0,
_endpoint_stats[k]["last_failure_time"] or 0,
),
)
del _endpoint_stats[oldest_endpoint]
# Global wrapper for pypowerwall function calls
def safe_pw_call(pw_func, *args, **kwargs):
"""
Safely call a pypowerwall function with global exception handling.
Returns None on any exception and logs a clean error message.
Optimized for weak WiFi connections with fast failure and concise logging.
Includes health tracking and graceful degradation support.
"""
global proxystats
def get_descriptive_name():
"""Build descriptive function name with arguments for better debugging."""
func_name = getattr(pw_func, "__name__", str(pw_func))
if func_name == "poll" and args:
# For poll() calls, include the URI endpoint being called
return f"{func_name}('{args[0]}')" if args[0] else func_name
elif args:
# For other functions, show first argument if it exists
first_arg = str(args[0])[:50] # Limit to 50 chars to keep logs readable
return f"{func_name}({first_arg})"
else:
return func_name
# In fail-fast mode with degraded connection, return None immediately
if fail_fast_mode and health_check_enabled:
with _connection_health_lock:
if _connection_health["is_degraded"]:
return None
try:
result = pw_func(*args, **kwargs)
# Only update health tracking on true, fresh connection success
if health_check_enabled and result is not None:
update_connection_health(success=True)
return result
except (
PyPowerwallInvalidConfigurationParameter,
InvalidBatteryReserveLevelException,
PyPowerwallTEDAPINoTeslaAuthFile,
PyPowerwallTEDAPITeslaNotConnected,
PyPowerwallTEDAPINotImplemented,
PyPowerwallTEDAPIInvalidPayload,
PyPowerwallFleetAPINoTeslaAuthFile,
PyPowerwallFleetAPITeslaNotConnected,
PyPowerwallFleetAPINotImplemented,
PyPowerwallFleetAPIInvalidPayload,
) as e:
descriptive_name = get_descriptive_name()
log.warning(f"Powerwall API Error in {descriptive_name}: {str(e)}")
with proxystats_lock:
proxystats["errors"] = proxystats["errors"] + 1
return None
except (
ConnectionError,
TimeoutError,
OSError,
requests.exceptions.RequestException,
requests.exceptions.Timeout,
requests.exceptions.ReadTimeout,
requests.exceptions.ConnectTimeout,
requests.exceptions.ConnectionError,
urllib3.exceptions.ReadTimeoutError,
urllib3.exceptions.ConnectTimeoutError,
urllib3.exceptions.TimeoutError,
urllib3.exceptions.MaxRetryError,
) as e:
func_name = getattr(pw_func, "__name__", str(pw_func))
descriptive_name = get_descriptive_name()
error_type = type(e).__name__
# Update health tracking on network failure
if health_check_enabled:
update_connection_health(success=False)
# Track error for summary reporting
track_network_error(func_name, error_type)