Skip to content

Commit

Permalink
Add initial version of gotado package
Browse files Browse the repository at this point in the history
  • Loading branch information
gonzolino committed Apr 13, 2021
1 parent 16b8b4c commit 267e9ba
Show file tree
Hide file tree
Showing 12 changed files with 1,458 additions and 0 deletions.
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
GOSWAGGER_IMAGE=quay.io/goswagger/swagger
GOSWAGGER_VERSION=v0.25.0
SWAGGERCMD=docker run --rm -v $(HOME):$(HOME) -w $(CURDIR) $(GOSWAGGER_IMAGE):$(GOSWAGGER_VERSION)
SWAGGER_SPEC_FILE=tado-api.yaml

$(SWAGGER_SPEC_FILE):
# Use swagger spec from openHAB
curl -o $(SWAGGER_SPEC_FILE) https://raw.githubusercontent.com/openhab/openhab-addons/2.5.x/bundles/org.openhab.binding.tado/src/main/api/tado-api.yaml

.PHONY: test
test:
go test .

.PHONY: generate
generate: $(SWAGGER_SPEC_FILE)
go generate ./...
71 changes: 71 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package gotado

import (
"context"
"fmt"
"io"
"net/http"
"time"

oauth2int "github.com/gonzolino/gotado/internal/oauth2"
"golang.org/x/oauth2"
)

const (
authURL = "https://auth.tado.com/oauth/authorize"
tokenURL = "https://auth.tado.com/oauth/token"
)

// Client to access the tado° API
type Client struct {
// ClientID specifies the client ID to use for authentication
ClientID string
// ClientSecret specifies the client secret to use for authentication
ClientSecret string

http *http.Client
}

// NewClient creates a new tado° client
func NewClient(clientID, clientSecret string) *Client {
return &Client{
ClientID: clientID,
ClientSecret: clientSecret,
http: http.DefaultClient,
}
}

// WithTimeout configures the tado° object with the given timeout for HTTP requests.
func (c *Client) WithTimeout(timeout time.Duration) *Client {
c.http.Timeout = timeout
return c
}

// WithCredentials sets the given credentials and scopes for the tado° API
func (c *Client) WithCredentials(ctx context.Context, username, password string) (*Client, error) {
config := oauth2int.NewConfig(c.ClientID, c.ClientSecret, authURL, tokenURL, []string{"home.user"})

httpContext := context.WithValue(ctx, oauth2.HTTPClient, c.http)
token, err := config.PasswordCredentialsToken(httpContext, username, password)
if err != nil {
return nil, fmt.Errorf("invalid credentials: %w", err)
}
authClient := config.Client(httpContext, token)

c.http = authClient

return c, nil
}

// Request performs an HTTP request to the tado° API
func (c *Client) Request(method, url string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("unable to create tado° API request: %w", err)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("unable to talk to tado° API: %w", err)
}
return resp, nil
}
55 changes: 55 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package gotado

import (
"context"
"fmt"
"net/url"
"testing"
"time"

"github.com/golang/mock/gomock"
oauth2int "github.com/gonzolino/gotado/internal/oauth2"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)

func TestWithTimeout(t *testing.T) {
client := NewClient("test", "test")

assert.Zero(t, client.http.Timeout)

client.WithTimeout(1)
assert.Equal(t, time.Duration(1), client.http.Timeout)
}

func TestWithCredentials(t *testing.T) {
ctrl, ctx := gomock.WithContext(context.Background(), t)

config := oauth2.Config{}
mockConfig := oauth2int.NewMockConfigInterface(ctrl)
oauth2int.NewConfig = func(clientID, clientSecret, authURL, tokenURL string, scopes []string) oauth2int.ConfigInterface {
return mockConfig
}
forbiddenError := &url.Error{}

token := &oauth2.Token{
AccessToken: "access_token",
TokenType: "token_type",
RefreshToken: "refresh_token",
Expiry: time.Now(),
}

client := NewClient("test", "test")
httpCtx := context.WithValue(ctx, oauth2.HTTPClient, client.http)

mockConfig.EXPECT().PasswordCredentialsToken(gomock.AssignableToTypeOf(httpCtx), "username", "password").Return(token, nil)
mockConfig.EXPECT().Client(httpCtx, token).Return(config.Client(httpCtx, token))

_, err := client.WithCredentials(ctx, "username", "password")
assert.NoError(t, err)

mockConfig.EXPECT().PasswordCredentialsToken(gomock.AssignableToTypeOf(httpCtx), "username", gomock.Not("password")).Return(nil, forbiddenError)

_, err = client.WithCredentials(ctx, "username", "wrong")
assert.Exactly(t, fmt.Errorf("invalid credentials: %w", forbiddenError), err)
}
78 changes: 78 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"context"
"fmt"
"os"
"time"

"github.com/gonzolino/gotado"
)

const (
clientID = "tado-web-app"
clientSecret = "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc"
)

func main() {
username, ok := os.LookupEnv("TADO_USERNAME")
if !ok {
fmt.Fprintf(os.Stderr, "Variable TADO_USERNAME not set\n")
os.Exit(1)
}
password, ok := os.LookupEnv("TADO_PASSWORD")
if !ok {
fmt.Fprintf(os.Stderr, "Variable TADO_PASSWORD not set\n")
os.Exit(1)
}

ctx := context.Background()

client := gotado.NewClient(clientID, clientSecret).WithTimeout(5 * time.Second)
client, err := client.WithCredentials(ctx, username, password)
if err != nil {
fmt.Fprintf(os.Stderr, "Authentication failed: %v\n", err)
os.Exit(1)
}

user, err := gotado.GetMe(client)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get user info: %v\n", err)
os.Exit(1)
}
fmt.Printf("Email: %s\nUsername: %s\nName: %s\n", user.Email, user.Username, user.Name)
for _, userHome := range user.Homes {
fmt.Printf("Home ID: %d\nHome Name: %s\n", userHome.ID, userHome.Name)
home, err := gotado.GetHome(client, &userHome)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get user home info: %v\n", err)
os.Exit(1)
}
fmt.Printf("Address:\n%s\n%s %s\n", *home.Address.AddressLine1, *home.Address.ZipCode, *home.Address.City)

zones, err := gotado.GetZones(client, &userHome)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get user home zones: %v\n", err)
os.Exit(1)
}
for _, zone := range zones {
fmt.Printf("Zone ID: %d\nZone Name: %s\n", zone.ID, zone.Name)
zoneState, err := gotado.GetZoneState(client, &userHome, zone)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get zone state: %v", err)
os.Exit(1)
}
fmt.Printf("Zone State Mode: %s\n", zoneState.TadoMode)
if zoneState.OpenWindow != nil {
fmt.Printf("Open window detected at %s", zoneState.OpenWindow.DetectedTime)
}
if zone.Name == "Bad" {
if err := gotado.SetWindowOpen(client, &userHome, zone); err != nil {
fmt.Fprintf(os.Stderr, "Failed to close window: %v", err)
} else {
fmt.Printf("Opened window in %s", zone.Name)
}
}
}
}
}
47 changes: 47 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package gotado

import (
"encoding/json"
"fmt"
"net/http"
"strings"
)

type apiErrors struct {
Errors []apiError `json:"errors"`
}

func (es *apiErrors) Error() string {
errs := make([]string, len(es.Errors))
for i, e := range es.Errors {
errs[i] = e.Error()
}
return strings.Join(errs, ", ")
}

type apiError struct {
Code string `json:"code"`
Title string `json:"title"`
}

func (e *apiError) Error() string {
return fmt.Sprintf("%s: %s", strings.Title(e.Code), e.Title)
}

func isError(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
var errs apiErrors
if err := json.NewDecoder(resp.Body).Decode(&errs); err != nil {
return fmt.Errorf("unable to decode API error: %w", err)
}

if len(errs.Errors) == 1 {
return &errs.Errors[0]
} else if len(errs.Errors) == 0 {
return fmt.Errorf("API returned empty error")
} else {
return &errs
}
}
18 changes: 18 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module github.com/gonzolino/gotado

go 1.15

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/mock v1.4.4
github.com/golang/protobuf v1.4.3 // indirect
github.com/google/go-cmp v0.5.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/stretchr/testify v1.6.1
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b // indirect
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect
)
Loading

0 comments on commit 267e9ba

Please sign in to comment.