Skip to content

Commit dacab2e

Browse files
authored
Merge pull request #298 from jasonacox-sam/feature/site-zero-threshold
feat: Add PW_SITE_ZERO_THRESHOLD to suppress phantom grid noise
2 parents ae21cd5 + 093ecac commit dacab2e

2 files changed

Lines changed: 84 additions & 37 deletions

File tree

RELEASE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
# RELEASE NOTES
22

3-
## v0.15.7 - v1r Owner API Login Fix
4-
3+
## v0.15.7 - Grid Noise Suppression and v1r Owner API Login Fix
4+
5+
* Feat: Add `PW_SITE_ZERO_THRESHOLD` environment variable to suppress phantom grid noise readings
6+
* When set to a positive integer value (in watts), site power readings with absolute value at or below the threshold are reported as 0
7+
* Applies to `/api/meters/aggregates` site power, `/csv`/`/csv/v2` grid power, and `/json` grid power endpoints
8+
* Useful for off-grid and night-time scenarios where sensor noise causes small non-zero grid readings (e.g. 5–15W phantom draw)
9+
* Default is `0` (disabled — no suppression)
10+
* Proxy build t89
511
* Fix: v1r Owner API registration (`python -m pypowerwall setup -v1r` → option 1) now uses the native `tesla_auth` WebView PKCE flow instead of the broken `teslapy` browser redirect. The `tesla://` custom URL scheme callback is intercepted by the WebView, eliminating the "missing_code" login failure (#300, reported in discussion #299)
612
* Fix: Cached token lookup in `owner_api_login()` now selects the account matching the requested `email` argument instead of always using the first entry in `.pypowerwall.auth`
713
* Bump library version to `0.15.7`

proxy/server.py

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
PyPowerwallFleetAPIInvalidPayload,
129129
)
130130

131-
BUILD = "t88"
131+
BUILD = "t89"
132132
ALLOWLIST = [
133133
"/api/status",
134134
"/api/site_info/site_name",
@@ -189,6 +189,11 @@
189189
rsa_key_path = os.getenv("PW_RSA_KEY_PATH", None)
190190
wifi_host = os.getenv("PW_WIFI_HOST", None)
191191
neg_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
192197
api_base_url = os.getenv(
193198
"PROXY_BASE_URL", "/"
194199
) # Prefix for public API calls, e.g. if you have everything behind a reverse proxy
@@ -259,6 +264,7 @@
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):
514520
def 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):
527533
def 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

557563
def 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

Comments
 (0)