From 9c6d0ebe4e7321b147eaeb288e42a2ef81ad53e4 Mon Sep 17 00:00:00 2001 From: firstthumb Date: Sun, 16 May 2021 15:36:23 +0300 Subject: [PATCH] Initial commit --- .github/workflows/tests.yml | 62 ++++ .gitignore | 22 ++ LICENSE.md | 21 ++ README.md | 74 +++++ example/lights/get-lights/main.go | 18 ++ go.mod | 12 + go.sum | 33 ++ pkg/client.go | 157 ++++++++++ pkg/hue_test.go | 38 +++ pkg/light.go | 188 +++++++++++ pkg/light_accessors.go | 8 + pkg/light_model.go | 74 +++++ pkg/light_test.go | 210 +++++++++++++ pkg/testdata/Light_Get.json | 70 +++++ pkg/testdata/Light_GetAll.json | 499 ++++++++++++++++++++++++++++++ pkg/testdata/Light_GetNew.json | 6 + pkg/testdata/Light_Rename.json | 1 + pkg/testdata/Light_Search.json | 7 + pkg/testdata/Light_SetState.json | 4 + pkg/user.go | 27 ++ 20 files changed, 1531 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 example/lights/get-lights/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/client.go create mode 100644 pkg/hue_test.go create mode 100644 pkg/light.go create mode 100644 pkg/light_accessors.go create mode 100644 pkg/light_model.go create mode 100644 pkg/light_test.go create mode 100644 pkg/testdata/Light_Get.json create mode 100644 pkg/testdata/Light_GetAll.json create mode 100644 pkg/testdata/Light_GetNew.json create mode 100644 pkg/testdata/Light_Rename.json create mode 100644 pkg/testdata/Light_Search.json create mode 100644 pkg/testdata/Light_SetState.json create mode 100644 pkg/user.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f9996d0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +on: + push: + branches: + - master + pull_request: + branches: + - master + +name: tests +env: + GO111MODULE: on + +jobs: + test: + strategy: + matrix: + go-version: [1.16.x] + platform: [ubuntu-latest] + include: + - go-version: 1.x + platform: windows-latest + + - go-version: 1.x + platform: ubuntu-latest + update-coverage: true + runs-on: ${{ matrix.platform }} + + steps: + - name: Cancel previous + uses: styfle/cancel-workflow-action@89f242ee29e10c53a841bfe71cc0ce7b2f065abc #0.9.0 + with: + access_token: ${{ github.token }} + + - uses: actions/setup-go@v1 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v2 + + - name: Cache go modules + uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: ${{ runner.os }}-go- + + - name: Run go fmt + if: runner.os != 'Windows' + run: diff -u <(echo -n) <(gofmt -d -s .) + + - name: Ensure go generate produces a zero diff + shell: bash + run: go generate -x ./... && git diff --exit-code; code=$?; git checkout -- .; (exit $code) + + - name: Run go vet + run: go vet ./... + + - name: Run go test + run: go test -v -race -coverprofile coverage.txt -covermode atomic ./... + + - name: Upload coverage to Codecov + if: ${{ matrix.update-coverage }} + uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d27f6ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f6b4e18 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba3077f --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +

go-hue

+

*** Work In Progress ***

+ +

+ + + + GitHub code size in bytes + GitHub go.mod Go version + + + + + GitHub closed pull requests + + + GitHub pull requests + + + GitHub issues + + + GitHub contributors + + + License: BSD + +

+ +> go-hue is a Go client library for accessing the [Philips Hue API](https://developers.meethue.com/develop/hue-api/) + +## Install + +```sh +go get github.com/firstthumb/go-hue +``` + +## Authentication + +Philips Hue uses local authorization. First you need to create user. + +## Usage + +Import the package into your project. + +```Go +import "github.com/firstthumb/go-hue" +``` + +Use existing user and access Hue services. For example: + +```Go +client := hue.NewClient("", "") +lights, resp, err := client.Light.GetAll(context.Background()) +``` + +## Coverage + +Currently the following services are supported: + +- [x] [Lights API](https://developers.meethue.com/develop/hue-api/lights-api/) + - [x] Get all lights + - [x] Get new lights + - [x] Search for new lights + - [x] Get light attributes and state + - [x] Set light attributes (rename) + - [x] Set light state + - [x] Delete lights +- [x] [Groups API](https://developers.meethue.com/develop/hue-api/groupds-api/) + - [ ] Get all groups + +## Show your support + +Give a ⭐️ if this project helped you! \ No newline at end of file diff --git a/example/lights/get-lights/main.go b/example/lights/get-lights/main.go new file mode 100644 index 0000000..87ad2d3 --- /dev/null +++ b/example/lights/get-lights/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/firstthumb/go-hue" +) + +func main() { + host := "" + token := "" + client := hue.NewClient(nil, host, token) + result, _, _ := client.Light.GetAll(context.Background()) + lights, _ := json.Marshal(result) + fmt.Println(string(lights)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..92b6729 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/firstthumb/go-hue/v1 + +go 1.16 + +require ( + github.com/bombsimon/logrusr v1.1.0 + github.com/go-logr/logr v0.4.0 + github.com/google/go-cmp v0.5.5 + github.com/sirupsen/logrus v1.8.1 + github.com/stretchr/testify v1.4.0 + github.com/thoas/go-funk v0.8.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..97764b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/bombsimon/logrusr v1.1.0 h1:Y03FI4Z/Shyrc9jF26vuaUbnPxC5NMJnTtJA/3Lihq8= +github.com/bombsimon/logrusr v1.1.0/go.mod h1:Jq0nHtvxabKE5EMwAAdgTaz7dfWE8C4i11NOltxGQpc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc= +github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/thoas/go-funk v0.8.0 h1:JP9tKSvnpFVclYgDM0Is7FD9M4fhPvqA0s0BsXmzSRQ= +github.com/thoas/go-funk v0.8.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/client.go b/pkg/client.go new file mode 100644 index 0000000..21838ff --- /dev/null +++ b/pkg/client.go @@ -0,0 +1,157 @@ +package hue + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/bombsimon/logrusr" + "github.com/go-logr/logr" + "github.com/sirupsen/logrus" +) + +const ( + defaultBasePath = "api/" + userAgent = "gohue" +) + +type Response struct { + *http.Response +} + +type Client struct { + client *http.Client + + BaseURL *url.URL + + UserAgent string + + Username string + + Verbose bool + + common service + + logger logr.Logger + + User *UserService + Light *LightService +} + +type service struct { + client *Client +} + +func NewClient(httpClient *http.Client, host, username string) *Client { + if httpClient == nil { + httpClient = &http.Client{} + } + + baseURL, _ := url.Parse(fmt.Sprintf("http://%s/%s", host, defaultBasePath)) + + c := &Client{client: httpClient, BaseURL: baseURL, UserAgent: userAgent, Username: username} + c.logger = logrusr.NewLogger(logrus.New()) + c.common.client = c + c.Verbose = true + c.User = (*UserService)(&c.common) + c.Light = (*LightService)(&c.common) + + return c +} + +func (c *Client) NewRequest(method, url string, payload interface{}) (*http.Request, error) { + if !strings.HasSuffix(c.BaseURL.Path, "/") { + return nil, fmt.Errorf("BaseURL must have a trailing slash, but %q does not", c.BaseURL) + } + u, err := c.BaseURL.Parse(url) + if err != nil { + return nil, err + } + + var buf io.ReadWriter + if payload != nil { + buf = &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(payload) + if err != nil { + return nil, err + } + } + + req, err := http.NewRequest(method, u.String(), buf) + if err != nil { + return nil, err + } + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } + return req, nil +} + +func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*Response, error) { + if ctx == nil { + return nil, errors.New("context must be non-nil") + } + req = req.WithContext(ctx) + + body, _ := httputil.DumpRequest(req, true) + c.logger.Info(fmt.Sprintf("%s", string(body))) + + resp, err := c.client.Do(req) + if err != nil { + return &Response{Response: resp}, err + } + defer resp.Body.Close() + + body, _ = httputil.DumpResponse(resp, true) + c.logger.Info(fmt.Sprintf("%s", string(body))) + + switch v := v.(type) { + case nil: + case io.Writer: + _, err = io.Copy(v, resp.Body) + default: + decErr := json.NewDecoder(resp.Body).Decode(v) + if decErr == io.EOF { + decErr = nil // ignore EOF errors caused by empty response body + } + if decErr != nil { + err = decErr + } + } + return &Response{Response: resp}, err +} + +type ApiResponse struct { + Success map[string]interface{} `json:"success,omitempty"` + Error *ApiError `json:"error,omitempty"` +} + +type ApiError struct { + Type int `json:"type"` + Address string `json:"address"` + Description string `json:"description"` +} + +func Bool(v bool) *bool { return &v } + +func Int(v int) *int { return &v } + +func UInt8(v uint8) *uint8 { return &v } + +func Int64(v int64) *int64 { return &v } + +func String(v string) *string { return &v } diff --git a/pkg/hue_test.go b/pkg/hue_test.go new file mode 100644 index 0000000..d5f2f91 --- /dev/null +++ b/pkg/hue_test.go @@ -0,0 +1,38 @@ +package hue + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +const ( + baseURLPath = "/api" +) + +func setup() (client *Client, mux *http.ServeMux, serverURL string, teardown func()) { + mux = http.NewServeMux() + + apiHandler := http.NewServeMux() + apiHandler.Handle(baseURLPath+"/", http.StripPrefix(baseURLPath, mux)) + apiHandler.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + + http.Error(w, "Client.BaseURL path prefix is not preserved in the request URL.", http.StatusInternalServerError) + }) + + server := httptest.NewServer(apiHandler) + + client = NewClient(nil, "localhost", "username") + url, _ := url.Parse(server.URL + baseURLPath + "/") + client.BaseURL = url + + return client, mux, server.URL, server.Close +} + +func testMethod(t *testing.T, r *http.Request, want string) { + t.Helper() + if got := r.Method; got != want { + t.Errorf("Request method: %v, want %v", got, want) + } +} diff --git a/pkg/light.go b/pkg/light.go new file mode 100644 index 0000000..8c0240b --- /dev/null +++ b/pkg/light.go @@ -0,0 +1,188 @@ +package hue + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + + funk "github.com/thoas/go-funk" +) + +// Service +type LightService service + +// Request +type RenameRequest struct { + Name string `json:"name"` +} + +type SetStateRequest struct { + On *bool `json:"on,omitempty"` + Bri *uint8 `json:"bri,omitempty"` + Hue *uint16 `json:"hue,omitempty"` + Sat *uint8 `json:"sat,omitempty"` + XY *[]float32 `json:"xy,omitempty"` + CT *uint16 `json:"ct,omitempty"` + Alert *string `json:"alert,omitempty"` + Effect *string `json:"effect,omitempty"` + TransitionTime *uint16 `json:"transitiontime,omitempty"` + BriInc *uint8 `json:"bri_inc,omitempty"` + SatInc *uint8 `json:"sat_inc,omitempty"` + HueInc *uint16 `json:"hue_inc,omitempty"` + CTInc *uint16 `json:"ct_inc,omitempty"` + XYInc *[]float32 `json:"xy_inc,omitempty"` +} + +func path(params ...string) string { + if len(params) == 1 { + return fmt.Sprintf("%v/lights", params[0]) + } else if len(params) == 2 { + return fmt.Sprintf("%v/lights/%v", params[0], params[1]) + } + + return fmt.Sprintf("%v/lights/%v/%v", params[0], params[1], params[2]) +} + +func (s *LightService) GetAll(ctx context.Context) ([]*Light, *Response, error) { + req, err := s.client.NewRequest(http.MethodGet, path(s.client.Username), nil) + if err != nil { + return nil, nil, err + } + + parsed := new(map[string]*Light) + resp, err := s.client.Do(ctx, req, parsed) + if err != nil { + return nil, resp, err + } + + for i, l := range *parsed { + id, _ := strconv.Atoi(i) + l.ID = &id + } + + result := funk.Values(*parsed).([]*Light) + sort.Slice(result, func(i, j int) bool { + return *result[i].ID < *result[j].ID + }) + + return result, resp, nil +} + +func (s *LightService) Get(ctx context.Context, id string) (*Light, *Response, error) { + req, err := s.client.NewRequest(http.MethodGet, path(s.client.Username, id), nil) + if err != nil { + return nil, nil, err + } + + parsed := new(Light) + resp, err := s.client.Do(ctx, req, parsed) + if err != nil { + return nil, resp, err + } + + return parsed, resp, nil +} + +func (s *LightService) GetNew(ctx context.Context) ([]*Light, *Response, error) { + req, err := s.client.NewRequest(http.MethodGet, path(s.client.Username, "new"), nil) + if err != nil { + return nil, nil, err + } + + parsed := new(map[string]interface{}) + resp, err := s.client.Do(ctx, req, parsed) + if err != nil { + return nil, resp, err + } + + lights := []*Light{} + for i, l := range *parsed { + if i == "lastscan" { + // Skip lastscan + } else { + light := &Light{} + id, _ := strconv.Atoi(i) + light.ID = &id + light.Name = String(l.(map[string]interface{})["name"].(string)) + lights = append(lights, light) + } + } + + return lights, resp, nil +} + +func (s *LightService) Search(ctx context.Context) (bool, *Response, error) { + req, err := s.client.NewRequest(http.MethodPost, path(s.client.Username), nil) + if err != nil { + return false, nil, err + } + + apiResponses := new([]*ApiResponse) + resp, err := s.client.Do(ctx, req, apiResponses) + if err != nil { + return false, resp, err + } + + if len(*apiResponses) > 0 && len((*apiResponses)[0].Success) != 0 { + if s.client.Verbose { + s.client.logger.Info("%v", (*apiResponses)[0].Success["/lights"]) + } + return true, resp, nil + } + + return false, resp, nil +} + +func (s *LightService) Rename(ctx context.Context, id, name string) (bool, *Response, error) { + payload := &RenameRequest{name} + req, err := s.client.NewRequest(http.MethodPut, path(s.client.Username, id), payload) + if err != nil { + return false, nil, err + } + + apiResponses := new([]*ApiResponse) + resp, err := s.client.Do(ctx, req, apiResponses) + if err != nil { + return false, resp, err + } + + if len(*apiResponses) > 0 && len((*apiResponses)[0].Success) != 0 { + if s.client.Verbose { + s.client.logger.Info("%v", (*apiResponses)[0].Success[fmt.Sprintf("/lights/%s/name", id)]) + } + return true, resp, nil + } + + return false, resp, nil +} + +func (s *LightService) SetState(ctx context.Context, id string, payload *SetStateRequest) ([]*ApiResponse, *Response, error) { + req, err := s.client.NewRequest(http.MethodPut, path(s.client.Username, id, "state"), payload) + if err != nil { + return nil, nil, err + } + + apiResponses := new([]*ApiResponse) + resp, err := s.client.Do(ctx, req, apiResponses) + if err != nil { + return nil, resp, err + } + + return *apiResponses, resp, nil +} + +func (s *LightService) Delete(ctx context.Context, id string) (bool, *Response, error) { + req, err := s.client.NewRequest(http.MethodDelete, path(s.client.Username, id), nil) + if err != nil { + return false, nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return false, resp, err + } + + return true, resp, nil +} diff --git a/pkg/light_accessors.go b/pkg/light_accessors.go new file mode 100644 index 0000000..235b004 --- /dev/null +++ b/pkg/light_accessors.go @@ -0,0 +1,8 @@ +package hue + +func (l *Light) GetName() string { + if l == nil || l.Name == nil { + return "" + } + return *l.Name +} diff --git a/pkg/light_model.go b/pkg/light_model.go new file mode 100644 index 0000000..4f7fdff --- /dev/null +++ b/pkg/light_model.go @@ -0,0 +1,74 @@ +package hue + +type Light struct { + ID *int + State *State `json:"state,omitempty"` + SWUpdate *SWUpdate `json:"swupdate,omitempty"` + Type *string `json:"type,omitempty"` + Name *string `json:"name"` + ModelID *string `json:"modelid,omitempty"` + ManufacturerName *string `json:"manufacturername,omitempty"` + ProductName *string `json:"productname,omitempty"` + Capabilities *Capabilities `json:"capabilities,omitempty"` + Config *Config `json:"config,omitempty"` + UniqueId *string `json:"uniqueid,omitempty"` + SWVersion *string `json:"swversion,omitempty"` + SWConfigId *string `json:"swconfigid,omitempty"` + ProductId *string `json:"productid,omitempty"` +} + +type Config struct { + Archetype *string `json:"archetype"` + Function *string `json:"function"` + Direction *string `json:"direction"` + Startup *Startup `json:"startup"` +} + +type Startup struct { + Mode *string `json:"mode"` + Configured *bool `json:"configured"` +} + +type Capabilities struct { + Certified *bool `json:"certified"` + Control *Control `json:"control"` + Streaming *Streaming `json:"streaming"` +} + +type Streaming struct { + Renderer *bool `json:"renderer"` + Proxy *bool `json:"proxy"` +} + +type Control struct { + Mindimlevel *int `json:"mindimlevel"` + Maxlumen *int `json:"maxlumen"` + Colorgamuttype *string `json:"colorgamuttype"` + Colorgamut [][]float64 `json:"colorgamut"` + Ct *Ct `json:"ct"` +} + +type Ct struct { + Min *int `json:"min"` + Max *int `json:"max"` +} + +type SWUpdate struct { + State *string `json:"state"` + Lastinstall *string `json:"lastinstall"` +} + +type State struct { + On *bool `json:"on"` + Hue *uint16 `json:"hue,omitempty"` + Effect *string `json:"effect,omitempty"` + Bri *uint8 `json:"bri,omitempty"` + Sat *uint8 `json:"sat,omitempty"` + CT *uint16 `json:"ct,omitempty"` + XY []float32 `json:"xy,omitempty"` + Alert *string `json:"alert,omitempty"` + TransitionTime *uint16 `json:"transitiontime,omitempty"` + Reachable *bool `json:"reachable,omitempty"` + ColorMode *string `json:"colormode,omitempty"` + Mode *string `json:"mode,omitempty"` +} diff --git a/pkg/light_test.go b/pkg/light_test.go new file mode 100644 index 0000000..1a0dba8 --- /dev/null +++ b/pkg/light_test.go @@ -0,0 +1,210 @@ +package hue + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "sort" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + funk "github.com/thoas/go-funk" +) + +func TestLightService_GetAll(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_GetAll.json") + mux.HandleFunc("/username/lights", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + lights, _, err := client.Light.GetAll(ctx) + if err != nil { + t.Errorf("Lights.GetAll returned error: %v", err) + } + var result map[string]*Light + json.Unmarshal(bytes, &result) + + for i, l := range result { + id, _ := strconv.Atoi(i) + l.ID = &id + } + + want := funk.Values(result).([]*Light) + sort.Slice(want, func(i, j int) bool { + return *want[i].ID < *want[j].ID + }) + + if !reflect.DeepEqual(lights, want) { + t.Errorf("Lights.GetAll returned %+v, want %+v", lights, want) + } +} + +func TestLightService_Get(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_Get.json") + mux.HandleFunc("/username/lights/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + light, _, err := client.Light.Get(ctx, "1") + if err != nil { + t.Errorf("Light.Get returned error: %v", err) + } + want := &Light{} + json.Unmarshal(bytes, want) + + if !reflect.DeepEqual(light, want) { + t.Errorf("Light.Get returned %+v, want %+v", light, want) + } +} + +func TestLightService_GetNew(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_GetNew.json") + mux.HandleFunc("/username/lights/new", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + lights, _, err := client.Light.GetNew(ctx) + if err != nil { + t.Errorf("Light.GetNew returned error: %v", err) + } + + want := []*Light{ + { + ID: Int(8), + Name: String("new lamb"), + }, + } + + if !reflect.DeepEqual(lights, want) { + t.Errorf("Light.GetNew returned %+v, want %+v", lights, want) + } +} + +func TestLightService_Search(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_Search.json") + mux.HandleFunc("/username/lights", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + got, _, err := client.Light.Search(ctx) + if err != nil { + t.Errorf("Light.Search returned error: %v", err) + } + + want := true + + if !reflect.DeepEqual(got, want) { + t.Errorf("Light.Search returned %+v, want %+v", got, want) + } +} + +func TestLightService_Rename(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_Rename.json") + mux.HandleFunc("/username/lights/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + got, _, err := client.Light.Rename(ctx, "1", "new_name") + if err != nil { + t.Errorf("Light.Rename returned error: %v", err) + } + + want := true + + if !reflect.DeepEqual(got, want) { + t.Errorf("Light.Rename returned %+v, want %+v", got, want) + } +} + +func TestLightService_SetState(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_SetState.json") + mux.HandleFunc("/username/lights/1/state", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "PUT") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + got, _, err := client.Light.SetState(ctx, "1", &SetStateRequest{On: Bool(false), Bri: UInt8(200)}) + if err != nil { + t.Errorf("Light.SetState returned error: %v", err) + } + + want := []*ApiResponse{ + { + Success: map[string]interface{}{ + "/lights/1/state/on": false, + }, + }, + { + Success: map[string]interface{}{ + "/lights/1/state/bri": float64(200), + }, + }, + } + + if !cmp.Equal(got, want) { + t.Errorf("Light.SetState returned %+v, want %+v", got, want) + } +} + +func TestLightService_Delete(t *testing.T) { + client, mux, _, teardown := setup() + defer teardown() + + bytes, _ := ioutil.ReadFile("testdata/Light_SetState.json") + mux.HandleFunc("/username/lights/1/state", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, string(bytes)) + }) + + ctx := context.Background() + got, _, err := client.Light.Delete(ctx, "1") + if err != nil { + t.Errorf("Light.Delete returned error: %v", err) + } + + want := true + + if !cmp.Equal(got, want) { + t.Errorf("Light.Delete returned %+v, want %+v", got, want) + } +} diff --git a/pkg/testdata/Light_Get.json b/pkg/testdata/Light_Get.json new file mode 100644 index 0000000..1bf9fab --- /dev/null +++ b/pkg/testdata/Light_Get.json @@ -0,0 +1,70 @@ +{ + "state": { + "on": false, + "bri": 254, + "hue": 41440, + "sat": 75, + "effect": "none", + "xy": [ + 0.3146, + 0.3303 + ], + "ct": 156, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:50" + }, + "type": "Extended color light", + "name": "Lamp1", + "modelid": "LCT010", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue color lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 1000, + "maxlumen": 806, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "sultanbulb", + "function": "mixed", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:88:01:04:7f:1a:23-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "292E579A", + "productid": "Philips-LCT010-1-A19ECLv4" +} \ No newline at end of file diff --git a/pkg/testdata/Light_GetAll.json b/pkg/testdata/Light_GetAll.json new file mode 100644 index 0000000..3591cda --- /dev/null +++ b/pkg/testdata/Light_GetAll.json @@ -0,0 +1,499 @@ +{ + "1": { + "state": { + "on": false, + "bri": 254, + "hue": 41440, + "sat": 75, + "effect": "none", + "xy": [ + 0.3146, + 0.3303 + ], + "ct": 156, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:50" + }, + "type": "Extended color light", + "name": "Lamp1", + "modelid": "LCT010", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue color lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 1000, + "maxlumen": 806, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "sultanbulb", + "function": "mixed", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:89:01:01:8f:0a:23-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "292E579B", + "productid": "Philips-LCT010-1-A19ECLv4" + }, + "2": { + "state": { + "on": false, + "bri": 137, + "hue": 8402, + "sat": 140, + "effect": "none", + "xy": [ + 0.4575, + 0.4099 + ], + "ct": 366, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:55" + }, + "type": "Extended color light", + "name": "Lamp2", + "modelid": "LCT010", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue color lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 1000, + "maxlumen": 806, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "sultanbulb", + "function": "mixed", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:89:02:02:77:c2:b3-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "292E579B", + "productid": "Philips-LCT010-1-A19ECLv4" + }, + "3": { + "state": { + "on": false, + "bri": 234, + "hue": 6515, + "sat": 254, + "effect": "none", + "xy": [ + 0.5588, + 0.4080 + ], + "ct": 500, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:35" + }, + "type": "Extended color light", + "name": "Lamp3", + "modelid": "LCT010", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue color lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 1000, + "maxlumen": 806, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "sultanbulb", + "function": "mixed", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:27:88:01:03:7d:b4:18-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "292E579B", + "productid": "Philips-LCT010-1-A19ECLv4" + }, + "4": { + "state": { + "on": false, + "bri": 254, + "hue": 39743, + "sat": 110, + "effect": "none", + "xy": [ + 0.3125, + 0.3302 + ], + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-01-04T06:46:02" + }, + "type": "Color light", + "name": "Lamp4", + "modelid": "LLC010", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue iris", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 10000, + "maxlumen": 210, + "colorgamuttype": "A", + "colorgamut": [ + [ + 0.7040, + 0.2960 + ], + [ + 0.2151, + 0.7106 + ], + [ + 0.1380, + 0.0800 + ] + ] + }, + "streaming": { + "renderer": true, + "proxy": false + } + }, + "config": { + "archetype": "hueiris", + "function": "decorative", + "direction": "upwards", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:88:02:13:32:77:a3-0b", + "swversion": "5.127.1.26581" + }, + "5": { + "state": { + "on": false, + "bri": 254, + "hue": 8597, + "sat": 121, + "effect": "none", + "xy": [ + 0.4452, + 0.4068 + ], + "ct": 343, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:46" + }, + "type": "Extended color light", + "name": "Lamp5", + "modelid": "LCT024", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue play", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 100, + "maxlumen": 540, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "hueplay", + "function": "decorative", + "direction": "upwards", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:88:02:01:93:4c:95-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "949259E6", + "productid": "3241-3127-7871-LS00" + }, + "6": { + "state": { + "on": false, + "bri": 254, + "hue": 8597, + "sat": 121, + "effect": "none", + "xy": [ + 0.4452, + 0.4068 + ], + "ct": 343, + "alert": "select", + "colormode": "xy", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2020-03-10T21:44:41" + }, + "type": "Extended color light", + "name": "Lamp6", + "modelid": "LCT024", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue play", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 100, + "maxlumen": 540, + "colorgamuttype": "C", + "colorgamut": [ + [ + 0.6915, + 0.3083 + ], + [ + 0.1700, + 0.7000 + ], + [ + 0.1532, + 0.0475 + ] + ], + "ct": { + "min": 153, + "max": 500 + } + }, + "streaming": { + "renderer": true, + "proxy": true + } + }, + "config": { + "archetype": "hueplay", + "function": "decorative", + "direction": "upwards", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:27:88:01:16:93:52:2d-0b", + "swversion": "1.50.2_r30933", + "swconfigid": "949259E6", + "productid": "3241-3127-7871-LS00" + }, + "7": { + "state": { + "on": false, + "bri": 254, + "alert": "select", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2021-01-26T12:31:50" + }, + "type": "Dimmable light", + "name": "Lamp7", + "modelid": "LWA001", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue white lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 5000, + "maxlumen": 800 + }, + "streaming": { + "renderer": false, + "proxy": false + } + }, + "config": { + "archetype": "sultanbulb", + "function": "functional", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:88:01:08:b7:4a:36-0b", + "swversion": "1.76.10", + "swconfigid": "F48BD383", + "productid": "Philips-LWA001-1-A19DLv5" + }, + "8": { + "state": { + "on": false, + "bri": 254, + "alert": "select", + "mode": "homeautomation", + "reachable": true + }, + "swupdate": { + "state": "noupdates", + "lastinstall": "2021-01-26T12:31:48" + }, + "type": "Dimmable light", + "name": "Lamp8", + "modelid": "LWA001", + "manufacturername": "Signify Netherlands B.V.", + "productname": "Hue white lamp", + "capabilities": { + "certified": true, + "control": { + "mindimlevel": 5000, + "maxlumen": 800 + }, + "streaming": { + "renderer": false, + "proxy": false + } + }, + "config": { + "archetype": "sultanbulb", + "function": "functional", + "direction": "omnidirectional", + "startup": { + "mode": "safety", + "configured": true + } + }, + "uniqueid": "00:17:88:02:08:bf:29:a8-0b", + "swversion": "1.76.10", + "swconfigid": "F48BD383", + "productid": "Philips-LWA001-1-A19DLv5" + } +} \ No newline at end of file diff --git a/pkg/testdata/Light_GetNew.json b/pkg/testdata/Light_GetNew.json new file mode 100644 index 0000000..dc9f7b4 --- /dev/null +++ b/pkg/testdata/Light_GetNew.json @@ -0,0 +1,6 @@ +{ + "lastscan": "none", + "8": { + "name": "new lamb" + } +} \ No newline at end of file diff --git a/pkg/testdata/Light_Rename.json b/pkg/testdata/Light_Rename.json new file mode 100644 index 0000000..22b60ac --- /dev/null +++ b/pkg/testdata/Light_Rename.json @@ -0,0 +1 @@ +[{"success":{"/lights/1/name":"new_name"}}] \ No newline at end of file diff --git a/pkg/testdata/Light_Search.json b/pkg/testdata/Light_Search.json new file mode 100644 index 0000000..dada254 --- /dev/null +++ b/pkg/testdata/Light_Search.json @@ -0,0 +1,7 @@ +[ + { + "success": { + "/lights": "Searching for new devices" + } + } +] \ No newline at end of file diff --git a/pkg/testdata/Light_SetState.json b/pkg/testdata/Light_SetState.json new file mode 100644 index 0000000..dd40f16 --- /dev/null +++ b/pkg/testdata/Light_SetState.json @@ -0,0 +1,4 @@ +[ + {"success":{"/lights/1/state/on":false}}, + {"success":{"/lights/1/state/bri":200}} +] \ No newline at end of file diff --git a/pkg/user.go b/pkg/user.go new file mode 100644 index 0000000..803d80c --- /dev/null +++ b/pkg/user.go @@ -0,0 +1,27 @@ +package hue + +import ( + "context" + "net/http" +) + +type UserService service + +type User struct { + Lights map[string]Light `json:"lights,omitempty"` +} + +func (s *UserService) Login(ctx context.Context, username string) (*User, *Response, error) { + req, err := s.client.NewRequest(http.MethodGet, username, nil) + if err != nil { + return nil, nil, err + } + + uResp := new(User) + resp, err := s.client.Do(ctx, req, uResp) + if err != nil { + return nil, resp, err + } + + return uResp, resp, nil +}