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