BOTTLING TESLA’S SOLAR: A SOLAR DASHBOARD WITH PYTHON
CHRISTOPHER PITSTICK
2025 PyCon US | PGH | 1
ABOUT ME: CHRISTOPHER PITSTICK
SELECT CASE rg%
CASE 0 TO 3
rg% = 0
CASE 4 TO 5
rg% = 1
CASE 11 TO 13
rg% = 7
CASE 12 TO 17
rg% = 8
CASE 18 TO 25
rg% = 9
CASE 26 TO 40
rg% = 10
CASE 41 TO 70
rg% = 11
CASE 71 TO 100
rg% = 12
END SELECT
2025 PyCon US | PGH | 2
DASHBOARD DEMONSTRATION
https://github.com/jasonacox/pypowerwall
https://github.com/jasonacox/Powerwall-Dashboard
2025 PyCon US | PGH | 3
A QUESTION FOR YOU ALL
2025 PyCon US | PGH | 4
SUNDAY, APRIL 27, 2025
Who LOVES perfect graphs?
2025 PyCon US | PGH | 5
This is not a perfect graph
2025 PyCon US | PGH | 6
But what if I don’t want to use my phone?
What if I want to see MORE data?
2025 PyCon US | PGH | 7
2025 PyCon US | PGH | 8
AND THAT’S WHERE PYTHON COMES IN!
2025 PyCon US | PGH | 9
1
2
2025 PyCon US | PGH | 10
2025 PyCon US | PGH | 11
START WITH CONFIGURATION
PW_HOST=192.168.91.1
# Gateway Password for TEDAPI Access Mode
PW_PASSWORD=password
PW_GW_PWD=
PW_EMAIL=email@example.com
PW_TIMEZONE=America/Los_Angeles
TZ=America/Los_Angeles
PW_DEBUG=no
PW_STYLE=grafana-dark
PW_DEBUG=no
TZ=America/New_York
# Inverter 1
PW_EMAIL_1=email@example.com
PW_PASSWORD_1=
PW_GW_PWD_1=password_1
PW_HOST_1=192.168.92.100
PW_PORT_1=8675
PW_TIMEZONE_1=America/New_York
PW_STYLE_1=solar
PW_NEG_SOLAR_1=yes
PW_CACHE_EXPIRE_1=30
PW_TIMEOUT_1=15
# Inverter 2
PW_EMAIL_2=email@example.com
PW_PASSWORD_2=
PW_GW_PWD_2=password_2
PW_HOST_2=192.168.92.101
PW_PORT_2=8685
PW_TIMEZONE_2=America/New_York
PW_STYLE_2=solar
PW_NEG_SOLAR_2=yes
PW_CACHE_EXPIRE_2=30
PW_TIMEOUT_2=15
suffixes_to_check = {"", "1"}
actual_configs = []
environment = [key.lower() for key in os.environ]
while suffixes_to_check:
current_suffix = suffixes_to_check.pop()
test_suffix = f"_{current_suffix}" if current_suffix.isnumeric() else current_suffix
if any(f"{config.value}{test_suffix}" in environment for config in CONFIG_TYPE):
actual_configs.append(test_suffix)
elif current_suffix.isnumeric() and int(current_suffix) > 1:
break
if current_suffix.isnumeric():
next_suffix = str(int(current_suffix) + 1)
if next_suffix not in suffixes_to_check:
suffixes_to_check.add(next_suffix)
return actual_configs
2025 PyCon US | PGH | 12
NEXT, QUERY INFORMATION IN PARALLEL
# Build powerwalls objects
for config in configs:
try:
pw_monitor = pypowerwall.Powerwall(
host=config[CONFIG_TYPE.PW_HOST],
password=config[CONFIG_TYPE.PW_PASSWORD],
…
)
pw_control = configure_pw_control(pw_monitor, config)
pws.append((pw_monitor, pw_control, config))
except Exception as e:
# Exception stuff
# Create the servers
for pw in pws:
powerwall_monitor = pw[0]
powerwall_control = pw[1]
config = pw[2]
server = threading.Thread(
target=run_server,
args=( # args from above )
)
servers.append(server)
# Start all server threads
for config, server in zip(configs, servers):
log.info(
f"pyPowerwall [{pypowerwall.version}] Proxy Server [{BUILD}] - {config[CONFIG_TYPE.PW_HTTP_TYPE]} Port {config[CONFIG_TYPE.PW_PORT]}{' - DEBUG' if SERVER_DEBUG else ''}"
)
log.info("pyPowerwall Proxy Started\n")
server.start()
# Wait for all server threads to finish
for server in servers:
server.join()
2025 PyCon US | PGH | 13
ACTUALLY START THE SERVERS!
def run_server(host, port, enable_https, configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall, pws:
List[Tuple[pypowerwall.Powerwall, pypowerwall.Powerwall, PROXY_CONFIG]]):
def handler_factory(*args, **kwargs):
return Handler(*args, configuration=configuration, pw=pw, pw_control=pw_control, proxy_stats=proxystats, all_pws=pws, **kwargs)
with ThreadingHTTPServer((host, port), handler_factory) as server:
try:
log.info(f"Starting server on {host}:{port}")
site_name = pw.site_name() or "Unknown"
# Misc error checking and logging goes here
server.serve_forever()
except (Exception, KeyboardInterrupt, SystemExit) as e:
# Handle exception
class Handler(BaseHTTPRequestHandler):
def init(self, request, client_address, server,
configuration: PROXY_CONFIG, pw: pypowerwall.Powerwall, pw_control: pypowerwall.Powerwall,
proxy_stats: PROXY_STATS, all_pws: List[Tuple[pypowerwall.Powerwall, pypowerwall.Powerwall, PROXY_CONFIG]]):
self.configuration = configuration
self.pw = pw
self.pw_control = pw_control
self.proxystats = proxy_stats
self.all_pws = all_pws
super().__init__(request, client_address, server)
def do_GET(self):
path = self.path
path_handlers = {
'/aggregates': self.handle_combined_aggregates,
'/api/meters/aggregates': self.handle_individual_gateway_aggregates,
'/vitals': self.handle_vitals,
…
}
if path in path_handlers:
result = path_handlers[path]()
2025 PyCon US | PGH | 14
BUT THERE IS ANOTHER PROBLEM
2025 PyCon US | PGH | 15
2025 PyCon US | PGH | 16
HOW DO I ACCESS BOTH SIMULTANEOUSLY WHEN THEY BOTH HAVE THE SAME IP ADDRESS?
192.168.91.1
192.168.91.1
2025 PyCon US | PGH | 17
THIS IS HOW – NETWORKING FTW!
sudo ip addr add 192.168.92.100/32 dev lo
sudo ip addr add 192.168.92.101/32 dev lo
# Route for wlan0
sudo ip route add 192.168.91.0/24 dev wlan1 table wlan1table
sudo ip route add default via 192.168.91.1 dev wlan1 table wlan1table
# Route for wlan1
sudo ip route add 192.168.91.0/24 dev wlan2 table wlan2table
sudo ip route add default via 192.168.91.1 dev wlan2 table wlan2table
sudo ip rule add from 192.168.92.100 table wlan1table
sudo ip rule add from 192.168.92.101 table wlan2table
sudo iptables -t nat -A OUTPUT -d 192.168.92.100 -j DNAT --to-destination 192.168.91.1
sudo iptables -t nat -A OUTPUT -d 192.168.92.101 -j DNAT --to-destination 192.168.91.1
sudo iptables -t nat -A POSTROUTING -s 192.168.92.100 -d 192.168.91.1 -j SNAT --to-source 192.168.91.124
sudo iptables -t nat -A POSTROUTING -s 192.168.92.101 -d 192.168.91.1 -j SNAT --to-source 192.168.91.174
2025 PyCon US | PGH | 18
THE PYTHON TRANSLATION
DESINATION_GATEWAY: Final[str] = str(IPv4Address('192.168.91.1'))
VIRTUAL_IP_PREAMBLE: Final[str] = "192.168.92."
BASE_TABLE_ID: Final[int] = 100
BASE_IP: Final[int] = 100
KNOWN_SSIDS: Final[List[str]] = ["TEG-1JG", "TEG-2N1"]
SSID_IP_MAPPING_OVERRIDE: Final[Dict[str, IPv4Address]] = {
ssid: IPv4Address(f"{VIRTUAL_IP_PREAMBLE}{BASE_IP + i}")
for i, ssid in enumerate(KNOWN_SSIDS)
}
class TEG_Wifi_Interface(TypedDict):
iface: str
table: int
ip: IPv4Address
virt_ip: IPv4Address
ssid: str
def setup_interfaces(interfaces: List[TEG_Wifi_Interface]):
# Helper to add iptables rule
def add_nat_rule(chain, dst=None, src=None,
to_dst=None, to_src=None):
rule = iptc.Rule()
if dst:
rule.dst = dst
if src:
rule.src = src
target = iptc.Target(rule, 'DNAT' if to_dst else 'SNAT')
if to_dst:
target.to_destination = str(to_dst)
if to_src:
target.to_source = str(to_src)
rule.target = target
table = iptc.Table(iptc.Table.NAT)
chain = iptc.Chain(table, chain)
chain.insert_rule(rule)
# IPRoute/pyroute2 setup
with IPRoute() as iproute, IPDB(nl=iproute) as ipdb:
lo = ipdb.interfaces['lo']
for interface in interfaces:
# Ex: sudo ip addr add 192.168.92.100/32 dev lo
virt_ip = interface['virt_ip']
print(f"Adding address: {virt_ip}")
lo.add_ip(virt_ip)
ipdb.commit()
interface_idx = iproute.link_lookup(ifname=interface['iface'])[0]
# Ex: sudo ip route add 192.168.91.0/24 dev wlan1 table wlan1table
try:
iproute.route('add', dst='192.168.91.0/24',
oif=interface_idx, table=interface['table'])
except Exception as e:
# Exception handler
# Ex: sudo ip route add default via 192.168.91.1 dev wlan1 table wlan1table
iproute.route('add', dst='default',
gateway=DESINATION_GATEWAY,
oif=interface_idx,
table=interface['table'])
# Ex: sudo ip rule add from 192.168.92.100 table wlan1table
iproute.rule('add', src=virt_ip, table=interface['table'])
# Ex: sudo iptables -t nat -A OUTPUT -d 192.168.92.100 -j DNAT
--to-destination 192.168.91.1
add_nat_rule('OUTPUT', dst=virt_ip, to_dst=DESINATION_GATEWAY)
# Ex: sudo iptables -t nat -A POSTROUTING -s 192.168.92.100 -d 192.168.91.1
-j SNAT --to-source 192.168.91.124
add_nat_rule('POSTROUTING', src=virt_ip,
dst=DESINATION_GATEWAY, to_src=interface['ip'])
2025 PyCon US | PGH | 19
BACK TO THE DASHBOARD
2025 PyCon US | PGH | 20
Assuming the system is repaired, how much did discovering this issue with pypowerwall/dashboard recover?
SHOW ME THE MONEY
Value | Unit |
~100 | kWh/month |
* | |
12 | months/year |
= | |
1.2 | mWh/year |
* | |
17 | cents/kWh |
= | |
~$204 | dollars/year |
* | |
10 | year payback period |
= | |
$2,040.00 | dollars/10 years |
/ | |
$3,800 | dollars/year usage |
| |
53.68% | percent of normal year |
2025 PyCon US | PGH | 21
WHAT’S NEXT?
Just keep swimmingcontributing! Bugs, features, linting, unit tests!
Merge branches to mainline.
Entrepreneurial play?��Business cat?
If you could have those branches merged by Monday, that would be great…
2025 PyCon US | PGH | 22
Final Exhortation – The Power of Python and Contributing to Open Source
2025 PyCon US | PGH | 23
QUESTIONS?
Solar installer? installer.pitstick.net
https://github.com/Nexarian
https://www.linkedin.com/in/christopher-pitstick-9abab33b/
SREC Affiliate Links
Homeowner? homeowner.pitstick.net
Do you know anyone at ?
🙏 Please put me in touch!��cmp@pitstick.net
2025 PyCon US | PGH | 24