128128 PyPowerwallFleetAPIInvalidPayload ,
129129)
130130
131- BUILD = "t88 "
131+ BUILD = "t89 "
132132ALLOWLIST = [
133133 "/api/status" ,
134134 "/api/site_info/site_name" ,
189189rsa_key_path = os .getenv ("PW_RSA_KEY_PATH" , None )
190190wifi_host = os .getenv ("PW_WIFI_HOST" , None )
191191neg_solar = os .getenv ("PW_NEG_SOLAR" , "yes" ).lower () == "yes"
192+ try :
193+ site_zero_threshold = int (os .getenv ("PW_SITE_ZERO_THRESHOLD" , "0" ))
194+ except (ValueError , TypeError ):
195+ print (f"WARNING: PW_SITE_ZERO_THRESHOLD must be an integer, defaulting to 0" )
196+ site_zero_threshold = 0
192197api_base_url = os .getenv (
193198 "PROXY_BASE_URL" , "/"
194199) # Prefix for public API calls, e.g. if you have everything behind a reverse proxy
259264 "PW_RSA_KEY_PATH" : rsa_key_path ,
260265 "PW_WIFI_HOST" : wifi_host ,
261266 "PW_NEG_SOLAR" : neg_solar ,
267+ "PW_SITE_ZERO_THRESHOLD" : site_zero_threshold ,
262268 "PW_SUPPRESS_NETWORK_ERRORS" : suppress_network_errors ,
263269 "PW_NETWORK_ERROR_RATE_LIMIT" : network_error_rate_limit ,
264270 "PW_FAIL_FAST" : fail_fast_mode ,
@@ -488,20 +494,20 @@ def get_performance_cached(cache_key):
488494 """
489495 Get cached endpoint response for performance optimization.
490496 Uses standard cache_expire TTL (typically 5 seconds).
491-
497+
492498 Args:
493499 cache_key: The cache key (e.g., '/csv/v2', '/json', '/freq', '/pod')
494-
500+
495501 Returns:
496502 Cached response string if available and fresh, None otherwise
497503 """
498504 with _performance_cache_lock :
499505 if cache_key not in _performance_cache :
500506 return None
501-
507+
502508 data , timestamp = _performance_cache [cache_key ]
503509 age = time .time () - timestamp
504-
510+
505511 # Use standard cache_expire (same as pypowerwall's internal cache)
506512 if age < cache_expire :
507513 log .debug (f"Performance cache hit for { cache_key } (age: { age :.2f} s)" )
@@ -514,7 +520,7 @@ def get_performance_cached(cache_key):
514520def cache_performance_response (cache_key , data ):
515521 """
516522 Cache endpoint response for performance optimization.
517-
523+
518524 Args:
519525 cache_key: The cache key (e.g., '/csv/v2', '/json', '/freq', '/pod')
520526 data: The response string to cache
@@ -527,10 +533,10 @@ def cache_performance_response(cache_key, data):
527533def performance_cached (cache_key ):
528534 """
529535 Decorator for performance caching of route handlers.
530-
536+
531537 Args:
532538 cache_key: The cache key to use (e.g., '/vitals', '/strings', '/freq')
533-
539+
534540 Returns:
535541 Decorator function that wraps route handlers with caching logic
536542 """
@@ -540,43 +546,43 @@ def wrapper(*args, **kwargs):
540546 cached_response = get_performance_cached (cache_key )
541547 if cached_response is not None :
542548 return cached_response
543-
549+
544550 # Cache miss - generate fresh data
545551 result = func (* args , ** kwargs )
546-
552+
547553 # Only cache non-None results
548554 if result is not None :
549555 cache_performance_response (cache_key , result )
550-
556+
551557 return result
552-
558+
553559 return wrapper
554560 return decorator
555561
556562
557563def cached_route_handler (cache_key , data_generator ):
558564 """
559565 Helper function for performance-cached route handling.
560-
566+
561567 Args:
562568 cache_key: The cache key to use for this route
563569 data_generator: Function that generates the response data
564-
570+
565571 Returns:
566572 Cached response if available, otherwise fresh data (and caches it)
567573 """
568574 # Try cache first
569575 cached_response = get_performance_cached (cache_key )
570576 if cached_response is not None :
571577 return cached_response
572-
578+
573579 # Cache miss - generate fresh data
574580 result = data_generator ()
575-
581+
576582 # Only cache non-None results
577583 if result is not None :
578584 cache_performance_response (cache_key , result )
579-
585+
580586 return result
581587
582588
@@ -1183,6 +1189,18 @@ def generate_aggregates():
11831189 except (json .JSONDecodeError , TypeError ):
11841190 aggregates = None
11851191
1192+ # Apply site zero threshold - suppress phantom grid noise
1193+ # Pass through None values - they indicate a data gap, not zero
1194+ if (
1195+ site_zero_threshold > 0
1196+ and aggregates
1197+ and "site" in aggregates
1198+ and "instant_power" in aggregates ["site" ]
1199+ and aggregates ["site" ]["instant_power" ] is not None
1200+ and abs (aggregates ["site" ]["instant_power" ]) <= site_zero_threshold
1201+ ):
1202+ aggregates ["site" ]["instant_power" ] = 0
1203+
11861204 if aggregates and not neg_solar and "solar" in aggregates :
11871205 solar = aggregates ["solar" ]
11881206 if solar and "instant_power" in solar and solar ["instant_power" ] < 0 :
@@ -1224,13 +1242,14 @@ def generate_aggregates():
12241242 # CSV Output - Grid,Home,Solar,Battery,Level
12251243 # CSV2 Output - Grid,Home,Solar,Battery,Level,GridStatus,Reserve
12261244 # Add ?headers to include CSV headers, e.g. http://localhost:8675/csv?headers
1245+ # None values are treated as 0 in CSV output (use JSON endpoints to see data gaps as nulls)
12271246 contenttype = "text/plain; charset=utf-8"
1228-
1247+
12291248 # Determine endpoint and whether to include headers
12301249 is_v2 = request_path .startswith ("/csv/v2" )
12311250 include_headers = "headers" in request_path
12321251 cache_key = f"/csv/v2{ '_headers' if include_headers else '' } " if is_v2 else f"/csv{ '_headers' if include_headers else '' } "
1233-
1252+
12341253 def generate_csv ():
12351254 # Optimization: Use single aggregates call for all power values
12361255 aggregates = safe_endpoint_call ("/aggregates" , pw .poll , "/api/meters/aggregates" , jsonformat = False )
@@ -1247,15 +1266,26 @@ def generate_csv():
12471266 # Shift energy from solar to load
12481267 home -= solar
12491268 solar = 0
1250-
1269+
1270+ # Apply site zero threshold - suppress phantom grid noise
1271+ # Pass through None values — they indicate a data gap, not zero
1272+ if site_zero_threshold > 0 and abs (grid ) <= site_zero_threshold :
1273+ grid = 0
1274+
1275+ # Convert None to 0 for output (None = data gap, output as 0)
1276+ grid = grid or 0
1277+ solar = solar or 0
1278+ battery = battery or 0
1279+ home = home or 0
1280+
12511281 # Get battery level - poll() handles caching internally
12521282 batterylevel = safe_pw_call (pw .level ) or 0
1253-
1283+
12541284 if is_v2 :
12551285 # Get grid status and reserve - these use cached data internally
12561286 gridstatus = 1 if safe_pw_call (pw .grid_status ) == "UP" else 0
12571287 reserve = safe_pw_call (pw .get_reserve ) or 0
1258-
1288+
12591289 # Build CSV response
12601290 if is_v2 :
12611291 result = ""
@@ -1284,7 +1314,7 @@ def generate_csv():
12841314 batterylevel ,
12851315 )
12861316 return result
1287-
1317+
12881318 message = cached_route_handler (cache_key , generate_csv )
12891319 elif request_path == "/vitals" :
12901320 # Vitals Data - JSON
@@ -1337,15 +1367,15 @@ def generate_csv():
13371367 proxystats ["mem_cache" ]["error_counts" ] = {
13381368 "entries" : len (_error_counts ),
13391369 "size_bytes" : sys .getsizeof (_error_counts ) + sum (
1340- sys .getsizeof (k ) + sys .getsizeof (v )
1370+ sys .getsizeof (k ) + sys .getsizeof (v )
13411371 for k , v in _error_counts .items ()
13421372 ),
13431373 }
13441374 proxystats ["mem_cache" ]["network_error_summary" ] = {
13451375 "entries" : len (_network_error_summary ),
13461376 "size_bytes" : sys .getsizeof (_network_error_summary ) + sum (
13471377 sys .getsizeof (k ) + sys .getsizeof (v ) + sum (
1348- sys .getsizeof (ek ) + sys .getsizeof (ev )
1378+ sys .getsizeof (ek ) + sys .getsizeof (ev )
13491379 for ek , ev in v .items ()
13501380 ) for k , v in _network_error_summary .items ()
13511381 ),
@@ -1374,7 +1404,7 @@ def generate_csv():
13741404 "entries" : len (_endpoint_stats ),
13751405 "size_bytes" : sys .getsizeof (_endpoint_stats ) + sum (
13761406 sys .getsizeof (k ) + sys .getsizeof (v ) + sum (
1377- sys .getsizeof (ek ) + sys .getsizeof (ev )
1407+ sys .getsizeof (ek ) + sys .getsizeof (ev )
13781408 for ek , ev in v .items ()
13791409 ) for k , v in _endpoint_stats .items ()
13801410 ),
@@ -1539,7 +1569,7 @@ def generate_temps_pw():
15391569 pwtemp [key ] = temps [i ]
15401570 idx = idx + 1
15411571 return json .dumps (pwtemp )
1542-
1572+
15431573 message = cached_route_handler ("/temps/pw" , generate_temps_pw )
15441574 elif request_path == "/alerts" :
15451575 # Alerts
@@ -1555,7 +1585,7 @@ def generate_alerts_pw():
15551585 for alert in alerts :
15561586 pwalerts [alert ] = 1
15571587 return json .dumps (pwalerts ) or json .dumps ({})
1558-
1588+
15591589 message = cached_route_handler ("/alerts/pw" , generate_alerts_pw )
15601590 elif request_path == "/freq" :
15611591 # Frequency, Current, Voltage and Grid Status
@@ -1601,7 +1631,7 @@ def generate_freq():
16011631 fcv [i ] = d [i ]
16021632 fcv ["grid_status" ] = safe_pw_call (pw .grid_status , "numeric" )
16031633 return json .dumps (fcv )
1604-
1634+
16051635 message = cached_route_handler ("/freq" , generate_freq )
16061636 elif request_path == "/pod" :
16071637 # Powerwall Battery Data
@@ -1720,7 +1750,7 @@ def generate_pod():
17201750 pod ["time_remaining_hours" ] = safe_pw_call (pw .get_time_remaining )
17211751 pod ["backup_reserve_percent" ] = safe_pw_call (pw .get_reserve )
17221752 return json .dumps (pod )
1723-
1753+
17241754 message = cached_route_handler ("/pod" , generate_pod )
17251755 elif request_path == "/json" :
17261756 # JSON - Grid,Home,Solar,Battery,Level,GridStatus,Reserve,TimeRemaining,FullEnergy,RemainingEnergy,Strings
@@ -1733,14 +1763,25 @@ def generate_json():
17331763 battery = aggregates .get ('battery' , {}).get ('instant_power' , 0 )
17341764 home = aggregates .get ('load' , {}).get ('instant_power' , 0 )
17351765 else :
1736- grid = solar = battery = home = 0
1737-
1766+ grid = solar = battery = home = None
1767+
17381768 # Apply negative solar correction if configured
1739- if not neg_solar and solar < 0 :
1769+ if not neg_solar and solar is not None and solar < 0 :
17401770 # Shift energy from solar to load
17411771 home -= solar
17421772 solar = 0
1743-
1773+
1774+ # Apply site zero threshold - suppress phantom grid noise
1775+ # Pass through None values — they indicate a data gap, not zero
1776+ if site_zero_threshold > 0 and grid is not None and abs (grid ) <= site_zero_threshold :
1777+ grid = 0
1778+
1779+ # Convert None to 0 for output (None = data gap, output as 0)
1780+ grid = grid or 0
1781+ solar = solar or 0
1782+ battery = battery or 0
1783+ home = home or 0
1784+
17441785 # Get remaining data
17451786 d = safe_pw_call (pw .system_status ) or {}
17461787 values = {
@@ -1757,7 +1798,7 @@ def generate_json():
17571798 "strings" : safe_pw_call (pw .strings , jsonformat = False ) or {},
17581799 }
17591800 return json .dumps (values )
1760-
1801+
17611802 message = cached_route_handler ("/json" , generate_json )
17621803 elif request_path == "/version" :
17631804 # Firmware Version
0 commit comments