1 of 24

BOTTLING TESLA’S SOLAR: A SOLAR DASHBOARD WITH PYTHON

CHRISTOPHER PITSTICK

2025 PyCon US | PGH | 1

2 of 24

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

3 of 24

DASHBOARD DEMONSTRATION

https://github.com/jasonacox/pypowerwall

https://github.com/jasonacox/Powerwall-Dashboard

2025 PyCon US | PGH | 3

4 of 24

A QUESTION FOR YOU ALL

2025 PyCon US | PGH | 4

5 of 24

SUNDAY, APRIL 27, 2025

Who LOVES perfect graphs?

2025 PyCon US | PGH | 5

6 of 24

This is not a perfect graph

2025 PyCon US | PGH | 6

7 of 24

But what if I don’t want to use my phone?

What if I want to see MORE data?

2025 PyCon US | PGH | 7

8 of 24

2025 PyCon US | PGH | 8

9 of 24

AND THAT’S WHERE PYTHON COMES IN!

2025 PyCon US | PGH | 9

10 of 24

1

2

2025 PyCon US | PGH | 10

11 of 24

2025 PyCon US | PGH | 11

12 of 24

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

13 of 24

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

14 of 24

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

15 of 24

BUT THERE IS ANOTHER PROBLEM

2025 PyCon US | PGH | 15

16 of 24

2025 PyCon US | PGH | 16

17 of 24

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

18 of 24

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

19 of 24

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

20 of 24

BACK TO THE DASHBOARD

2025 PyCon US | PGH | 20

21 of 24

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

22 of 24

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

23 of 24

Final Exhortation – The Power of Python and Contributing to Open Source

2025 PyCon US | PGH | 23

24 of 24

QUESTIONS?

Solar installer? installer.pitstick.net

https://github.com/Nexarian

https://www.linkedin.com/in/christopher-pitstick-9abab33b/

SREC Affiliate Links

Do you know anyone at ?

🙏 Please put me in touch!��cmp@pitstick.net

2025 PyCon US | PGH | 24