Skip to content

Commit

Permalink
Support multiple paths to a single Tesla Gateway
Browse files Browse the repository at this point in the history
This adds support to have multiple IP/Ports to calling a single Tesla Gateway, such as if the Gateway is connected to your LAN while the machine running the proxy is also connected to its Wifi network. Will return the first successful response from the gateay, defaulting to the first error otherwise.
  • Loading branch information
mgb committed Jan 27, 2023
1 parent 8f45963 commit 980fe1d
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 30 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tesla-powerwall-proxy
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ RestartSec=5
[Install]
WantedBy=multi-user.target
```

## Multiple Powerwall hostnames

If your Powerwall is connected to your home network as well as your machine is connected to the Powerwall's wifi network, you can have the proxy attempt both (or more) hostnames to try and retrieve the data. This is useful in situations where the Powerwall may disconnect from your home network but its Wifi network is still working. To do this, add the IP address of the Powerwall as another `-h` in your configuration.

```
ExecStart=/home/pi/go/bin/tesla-powerwall-proxy -h 192.168.91.1 -h 192.168.0.7 -u [email protected] -p PASSWORD -l=:8043
```
52 changes: 44 additions & 8 deletions cmd/tesla-powerwall-proxy/tesla-powerwall-proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,73 @@ package main

import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"

"github.com/mgb/tesla-powerwall-local/pkg/httpsproxy"
"github.com/mgb/tesla-powerwall-local/pkg/tesla"
"github.com/spf13/pflag"
)

func main() {
host := pflag.StringP("host", "h", "", "the hostname(:port) of the Tesla Gateway")
hosts := pflag.StringArrayP("host", "h", nil, "the hostname(:port) of the Tesla Gateway (can have multiple)")
username := pflag.StringP("username", "u", "", "email address for login")
password := pflag.StringP("password", "p", "", "password for login")
listen := pflag.StringP("listen", "l", "localhost:8043", "http server address")
loginTimeout := pflag.DurationP("login-timeout", "t", 2*time.Minute, "timeout for logging in")
force := pflag.BoolP("force", "f", false, "force service online, even if hosts or passwords are wrong")
pflag.Parse()

if *host == "" || *username == "" || *password == "" {
log.Fatal("host, username, and password flags are required")
if err := validateParameters(*hosts, *username, *password); err != nil {
log.Fatal(err)
}

g := tesla.NewGateway(*host, *username, *password, *loginTimeout)
err := g.Login(context.Background())
if err != nil {
log.Fatalf("failed to login to the server (check your username/password): %s", err.Error())
var successes int
var handlers httpsproxy.Handlers
for _, host := range *hosts {
g := tesla.NewGateway(host, *username, *password, *loginTimeout)
err := g.Login(context.Background())
if err != nil {
log.Printf("[%s] failed to login to the server (check your username/password): %s", host, err.Error())
} else {
successes++
}

handlers = append(handlers, g)
}

// Require at least one successful login. Others can fail (that path maybe offline for now).
if !*force && successes == 0 {
log.Fatalf("unable to login to any of the hosts: %s", strings.Join(*hosts, ", "))
}

http.Handle("/api/", g)
http.Handle("/api/", handlers)
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `Please see <a href="https://github.com/vloschiavo/powerwall2">README.md</a> for API usage. Sample: <a href="/api/meters/aggregates">/api/meters/aggregates</a> and <a href="/api/system_status/soe">/api/system_status/soe</a>`)
})
log.Fatal(http.ListenAndServe(*listen, nil))
}

func validateParameters(hosts []string, username string, password string) error {
if len(hosts) == 0 {
return errors.New("host is required")
}
for _, h := range hosts {
if h == "" {
return errors.New("host cannot be empty")
}
}
if username == "" {
return errors.New("username is required")
}
if password == "" {
return errors.New("password is required")
}

return nil
}
15 changes: 10 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
module github.com/mgb/tesla-powerwall-local

go 1.15
go 1.17

require (
github.com/avast/retry-go v3.0.0+incompatible
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.5.9
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20210119194325-5f4716e94777
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
github.com/stretchr/testify v1.8.1
golang.org/x/net v0.5.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
40 changes: 34 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,49 @@ github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevB
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
77 changes: 77 additions & 0 deletions pkg/httpsproxy/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package httpsproxy

import (
"bytes"
"net/http"
"sync"
)

type Handlers []http.Handler

func (h Handlers) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
ch := make(chan bufferedResponseWriter)
for _, handler := range h {
handler := handler
wg.Add(1)
go func() {
defer wg.Done()

var b bufferedResponseWriter
handler.ServeHTTP(&b, r)
ch <- b
}()
}
go func() {
wg.Wait()
close(ch)
}()

var badResponse *bufferedResponseWriter
for b := range ch {
if b.statusCode != 0 && b.statusCode != http.StatusOK {
if badResponse != nil {
// We only care about the first real failure, ignore the rest
continue
}
if b.statusCode == http.StatusRequestTimeout {
continue
}

// shadow b so it doesn't get overwritten
b := b
badResponse = &b
continue
}

// First successful call gets returned, other calls will now get canceled.
w.Write(b.response.Bytes())
return
}

if badResponse == nil {
w.WriteHeader(http.StatusExpectationFailed)
return
}

w.WriteHeader(badResponse.statusCode)
w.Write(badResponse.response.Bytes())
}

// bufferedResponseWriter is a helper struct to buffer the response from the handler
type bufferedResponseWriter struct {
response bytes.Buffer
statusCode int
}

func (b *bufferedResponseWriter) Header() http.Header {
return make(http.Header)
}

func (b *bufferedResponseWriter) WriteHeader(statusCode int) {
b.statusCode = statusCode
}

func (b *bufferedResponseWriter) Write(p []byte) (int, error) {
return b.response.Write(p)
}
136 changes: 136 additions & 0 deletions pkg/httpsproxy/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package httpsproxy

import (
"net/http"
"testing"
"time"

"github.com/google/go-cmp/cmp"
)

func TestHandlers_ServeHTTP(t *testing.T) {
tests := []struct {
name string
h Handlers

wantStatus int
wantBody string
}{
{
name: "empty",
h: Handlers{},

wantStatus: http.StatusExpectationFailed,
},
{
name: "one handler, success",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
}),
},

wantBody: "hello",
},
{
name: "one handler, failure",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}),
},

wantStatus: http.StatusTeapot,
},
{
name: "two handlers, get fastest success",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.Write([]byte("world"))
}),
},

wantBody: "hello",
},
{
name: "two handlers, first failure",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.Write([]byte("world"))
}),
},

wantBody: "world",
},
{
name: "two handlers, both failure, get fastest failure",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.WriteHeader(http.StatusBadRequest)
}),
},

wantStatus: http.StatusTeapot,
},
{
name: "two handlers, ignore failed with timeout, return slow success",
h: Handlers{
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusRequestTimeout)
}),
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
w.Write([]byte("world"))
}),
},

wantBody: "world",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b bufferedResponseWriter
var r http.Request

tt.h.ServeHTTP(&b, &r)

if b.statusCode != tt.wantStatus {
t.Errorf("statusCode = %v, want %v", b.statusCode, tt.wantStatus)
}
if diff := cmp.Diff(b.response.String(), tt.wantBody); diff != "" {
t.Errorf("response body mismatch (-want +got):\n%s", diff)
}
})
}
}

func TestBufferedResponseWriter(t *testing.T) {
var b bufferedResponseWriter

header := b.Header()
if header == nil {
t.Errorf("header is nil")
}

b.WriteHeader(http.StatusTeapot)
if b.statusCode != http.StatusTeapot {
t.Errorf("statusCode = %v, want %v", b.statusCode, http.StatusTeapot)
}

b.Write([]byte("hello"))
if b.response.String() != "hello" {
t.Errorf("response = %v, want %v", b.response.String(), "hello")
}
}
Loading

0 comments on commit 980fe1d

Please sign in to comment.