diff --git a/client.go b/client.go new file mode 100644 index 0000000..07a1173 --- /dev/null +++ b/client.go @@ -0,0 +1,335 @@ +package lifx + +import ( + //"crypto/tls" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +const defaultUserAgent = "lifx-go" + +type ( + Client struct { + accessToken string + userAgent string + Client *http.Client + } + + Result struct { + Id string `json:"id"` + Label string `json:"label"` + Status Status `json:"status"` + } + + Error struct { + Field string `json:"field"` + Message []string `json:"message"` + } + + Warning struct { + Warning string `json:"warning"` + } + + RateLimit struct { + Limit int + Remaining int + Reset time.Time + } + + Response struct { + StatusCode int + Header http.Header + Body io.ReadCloser + RateLimit RateLimit + } + + LifxResponse struct { + Error string `json:"error"` + Errors []Error `json:"errors"` + Warnings []Warning `json:"warnings"` + Results []Result `json:"results"` + } +) + +var errorMap = map[int]error{ + http.StatusNotFound: errors.New("Selector did not match any lights"), + http.StatusUnauthorized: errors.New("Bad access token"), + http.StatusForbidden: errors.New("Bad OAuth scope"), + http.StatusUnprocessableEntity: errors.New("Missing or malformed parameters"), + http.StatusUpgradeRequired: errors.New("HTTP was used to make the request instead of HTTPS. Repeat the request using HTTPS instead"), + http.StatusTooManyRequests: errors.New("The request exceeded a rate limit"), + http.StatusInternalServerError: errors.New("Something went wrong on LIFX's end"), + http.StatusBadGateway: errors.New("Something went wrong on LIFX's end"), + http.StatusServiceUnavailable: errors.New("Something went wrong on LIFX's end"), + 523: errors.New("Something went wrong on LIFX's end"), +} + +func NewClient(accessToken string, options ...func(*Client)) *Client { + var c *Client + tr := &http.Transport{ + //TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + } + + c = &Client{ + accessToken: accessToken, + Client: &http.Client{Transport: tr}, + } + + for _, option := range options { + option(c) + } + + return c +} + +func WithUserAgent(userAgent string) func(*Client) { + return func(c *Client) { + c.userAgent = userAgent + } +} + +func NewClientWithUserAgent(accessToken string, userAgent string) *Client { + tr := &http.Transport{ + //TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), + } + return &Client{ + accessToken: accessToken, + userAgent: userAgent, + Client: &http.Client{Transport: tr}, + } +} + +func NewResponse(r *http.Response) (*Response, error) { + resp := Response{ + StatusCode: r.StatusCode, + Header: r.Header, + Body: r.Body, + } + + if t := r.Header.Get("X-RateLimit-Limit"); t != "" { + if n, err := strconv.ParseInt(t, 10, 32); err == nil { + resp.RateLimit.Limit = int(n) + } else { + return nil, err + } + } + + if t := r.Header.Get("X-RateLimit-Remaining"); t != "" { + if n, err := strconv.ParseInt(t, 10, 32); err == nil { + resp.RateLimit.Remaining = int(n) + } else { + return nil, err + } + } + + if t := r.Header.Get("X-RateLimit-Reset"); t != "" { + if n, err := strconv.ParseInt(t, 10, 32); err == nil { + resp.RateLimit.Reset = time.Unix(n, 0) + } else { + return nil, err + } + } + + return &resp, nil +} + +func (r *Response) IsError() bool { + return r.StatusCode > 299 +} + +func (r *Response) GetLifxError() (err error) { + var ( + s *LifxResponse + ) + if err = json.NewDecoder(r.Body).Decode(&s); err != nil { + return nil + } + return errors.New(s.Error) +} + +func (c *Client) NewRequest(method, url string, body io.Reader) (req *http.Request, err error) { + req, err = http.NewRequest(method, url, body) + if err != nil { + return + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.accessToken)) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("User-Agent", c.userAgent) + return +} + +func (c *Client) setState(selector string, state State) (*Response, error) { + var ( + err error + j []byte + req *http.Request + r *http.Response + resp *Response + ) + + if j, err = json.Marshal(state); err != nil { + return nil, err + } + + if req, err = c.NewRequest("PUT", EndpointState(selector), bytes.NewBuffer(j)); err != nil { + return nil, err + } + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) setStates(selector string, states States) (*Response, error) { + var ( + err error + j []byte + req *http.Request + r *http.Response + resp *Response + ) + + if j, err = json.Marshal(states); err != nil { + return nil, err + } + + if req, err = c.NewRequest("PUT", EndpointStates(), bytes.NewBuffer(j)); err != nil { + return nil, err + } + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) toggle(selector string, duration float64) (*Response, error) { + var ( + err error + j []byte + req *http.Request + r *http.Response + resp *Response + ) + + if j, err = json.Marshal(&Toggle{Duration: duration}); err != nil { + return nil, err + } + + if req, err = c.NewRequest("POST", EndpointToggle(selector), bytes.NewBuffer(j)); err != nil { + return nil, err + } + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) validateColor(color Color) (*Response, error) { + var ( + err error + req *http.Request + r *http.Response + resp *Response + q url.Values + ) + + if req, err = c.NewRequest("GET", EndpointColor(), nil); err != nil { + return nil, err + } + + q = req.URL.Query() + q.Set("string", color.ColorString()) + req.URL.RawQuery = q.Encode() + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) listLights(selector string) (*Response, error) { + var ( + err error + req *http.Request + r *http.Response + resp *Response + ) + + if req, err = c.NewRequest("GET", EndpointListLights(selector), nil); err != nil { + return nil, err + } + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} + +func (c *Client) stateDelta(selector string, delta StateDelta) (*Response, error) { + var ( + err error + j []byte + req *http.Request + r *http.Response + resp *Response + ) + + if j, err = json.Marshal(delta); err != nil { + return nil, err + } + + if req, err = c.NewRequest("POST", EndpointStateDelta(selector), bytes.NewBuffer(j)); err != nil { + return nil, err + } + + if r, err = c.Client.Do(req); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/color.go b/color.go new file mode 100644 index 0000000..7028677 --- /dev/null +++ b/color.go @@ -0,0 +1,224 @@ +package lifx + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +type ( + Color interface { + ColorString() string + } +) + +type ( + RGBColor struct { + R, G, B uint8 + } + + HSBKColor struct { + H *float32 `json:"hue"` + S *float32 `json:"saturation"` + B *float32 `json:"brightness"` + K *int16 `json:"kelvin"` + } + + NamedColor string +) + +const ( + KelvinCandlelight = 1500 + KelvinSunset = 2000 + KelvinUltraWarm = 2500 + KelvinIncandescent = 2700 + KelvinWarm = 3000 + KelvinCool = 4000 + KelvinCoolDaylight = 4500 + KelvinSoftDaylight = 5000 + KelvinDaylight = 5600 + KelvinNoonDaylight = 6000 + KelvinBrightDaylight = 6500 + KelvinCloudDaylight = 7000 + KelvinBlueDaylight = 7500 + KelvinBlueOvercast = 8000 + KelvinBlueIce = 9000 + + HueWhite = 0 + HueRed = 0 + HueOrange = 36 + HueYellow = 60 + HueGreen = 120 + HueCyan = 180 + HueBlue = 250 + HuePurple = 280 + HuePink = 325 +) + +var ( + DefaultWhites = map[string]int{ + "candlelight": KelvinCandlelight, + "sunset": KelvinSunset, + "ultrawarm": KelvinUltraWarm, + "incandescent": KelvinIncandescent, + "warm": KelvinWarm, + "cool": KelvinCool, + "cooldaylight": KelvinCoolDaylight, + "softdaylight": KelvinSoftDaylight, + "daylight": KelvinDaylight, + "noondaylight": KelvinNoonDaylight, + "brightdaylight": KelvinBrightDaylight, + "clouddaylight": KelvinCloudDaylight, + "bluedaylight": KelvinBlueDaylight, + "blueovercast": KelvinBlueOvercast, + "blueice": KelvinBlueIce, + } +) + +func NewRGBColor(r, g, b uint8) (RGBColor, error) { + var c RGBColor + if (r < 0 || r > 255) && (g < 0 || r > 255) && (b < 0 || b > 255) { + return c, errors.New("values must be between 0-255") + } + + return RGBColor{R: r, G: g, B: b}, nil +} + +func NewHSColor(h, s float32) (HSBKColor, error) { + var c HSBKColor + + if h < 0 || h > 360 { + return c, errors.New("hue must be between 0.0-360.0") + } + if s < 0 || s > 1 { + return c, errors.New("saturation must be between 0.0-1.0") + } + + c = HSBKColor{ + H: Float32Ptr(h), + S: Float32Ptr(s), + } + + return c, nil +} + +func NewHSBColor(h, s, b float32) (HSBKColor, error) { + var c HSBKColor + + if h < 0 || h > 360 { + return c, errors.New("hue must be between 0.0-360.0") + } + if s < 0 || s > 1 { + return c, errors.New("saturation must be between 0.0-1.0") + } + if b < 0 || b > 1 { + return c, errors.New("brightness must be between 0.0-1.0") + } + + c = HSBKColor{ + H: Float32Ptr(h), + S: Float32Ptr(s), + B: Float32Ptr(b), + } + + return c, nil +} + +func NewRed() (HSBKColor, error) { return NewHSColor(HueRed, 1) } +func NewOrange() (HSBKColor, error) { return NewHSColor(HueOrange, 1) } +func NewYellow() (HSBKColor, error) { return NewHSColor(HueYellow, 1) } +func NewGreen() (HSBKColor, error) { return NewHSColor(HueGreen, 1) } +func NewCyan() (HSBKColor, error) { return NewHSColor(HueCyan, 1) } +func NewPurple() (HSBKColor, error) { return NewHSColor(HuePurple, 1) } +func NewPink() (HSBKColor, error) { return NewHSColor(HuePink, 1) } + +func NewWhite(k int16) (HSBKColor, error) { + var c HSBKColor + + if k < 1500 || k > 9000 { + return c, errors.New("kelvin must be between 1500-9000") + } + + c = HSBKColor{ + H: Float32Ptr(HueWhite), + S: Float32Ptr(0.0), + K: Int16Ptr(k), + } + + return c, nil +} + +func NewWhiteString(s string) (HSBKColor, error) { + k, ok := DefaultWhites[s] + + if !ok { + return HSBKColor{}, fmt.Errorf("'%s' is not a valid default white", s) + } + + return NewWhite(int16(k)) +} + +func (c RGBColor) ColorString() string { + return fmt.Sprintf("rgb:%d,%d,%d", c.R, c.G, c.B) +} + +func (c RGBColor) Hex() string { + return fmt.Sprintf("#%x%x%x", c.R, c.G, c.B) +} + +func (c HSBKColor) ColorString() string { + var s []string + if c.H != nil { + s = append(s, fmt.Sprintf("hue:%g", *c.H)) + } + if c.S != nil { + s = append(s, fmt.Sprintf("saturation:%g", *c.S)) + } + if c.B != nil { + s = append(s, fmt.Sprintf("brightness:%g", *c.B)) + } + if c.K != nil { + s = append(s, fmt.Sprintf("kelvin:%d", *c.K)) + } + return strings.Join(s, " ") +} + +func (c HSBKColor) MarshalText() ([]byte, error) { + return []byte(c.ColorString()), nil +} + +func (c RGBColor) MarshalText() ([]byte, error) { + return []byte(c.ColorString()), nil +} + +func (c NamedColor) ColorString() string { + return string(c) +} + +func (c *Client) ValidateColor(color Color) (Color, error) { + var ( + err error + s *HSBKColor + r *http.Response + resp *Response + ) + + if resp, err = c.validateColor(color); err != nil { + return nil, err + } + + resp, err = NewResponse(r) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} diff --git a/endpoints.go b/endpoints.go new file mode 100644 index 0000000..6db0c5b --- /dev/null +++ b/endpoints.go @@ -0,0 +1,35 @@ +package lifx + +import ( + "fmt" + "net/url" + "path" +) + +func BuildURL(rawurl, rawpath string) string { + u, _ := url.Parse(rawurl) + u.Path = path.Join(u.Path, rawpath) + return u.String() +} + +var ( + Endpoint = "https://api.lifx.com/v1" + EndpointState = func(selector string) string { + return BuildURL(Endpoint, fmt.Sprintf("/lights/%s/state", selector)) + } + EndpointStateDelta = func(selector string) string { + return BuildURL(Endpoint, fmt.Sprintf("/lights/%s/state/delta", selector)) + } + EndpointListLights = func(selector string) string { + return BuildURL(Endpoint, fmt.Sprintf("/lights/%s", selector)) + } + EndpointStates = func() string { + return BuildURL(Endpoint, "/lights/states") + } + EndpointColor = func() string { + return BuildURL(Endpoint, "/color") + } + EndpointToggle = func(selector string) string { + return BuildURL(Endpoint, fmt.Sprintf("/lights/%s/toggle", selector)) + } +) diff --git a/lights.go b/lights.go new file mode 100644 index 0000000..6137cd2 --- /dev/null +++ b/lights.go @@ -0,0 +1,221 @@ +package lifx + +import ( + //"crypto/tls" + "encoding/json" + "net/http" + "time" +) + +const ( + OK Status = "ok" + TimedOut Status = "timed_out" + Offline Status = "offline" +) + +type ( + Status string + + Selector struct { + Id string `json:"id"` + Name string `json:"name"` + } + + Product struct { + Name string `json:"name"` + Identifier string `json:"identifier"` + Company string `json:"company"` + Capabilities Capabilities `json:"capabilities"` + } + + Capabilities struct { + HasColor bool `json:"has_color"` + HasVariableColorTemp bool `json:"has_variable_color_temp"` + HasIR bool `json:"has_ir"` + HasChain bool `json:"has_chain"` + HasMultizone bool `json:"has_multizone"` + MinKelvin float64 `json:"min_kelvin"` + MaxKelvin float64 `json:"max_kelvin"` + } + + Light struct { + Id string `json:"id"` + UUID string `json:"uuid"` + Label string `json:"label"` + Connected bool `json:"connected"` + Power string `json:"power"` + Color HSBKColor `json:"color"` + Brightness float64 `json:"brightness"` + Effect string `json:"effect"` + Group Selector `json:"group"` + Location Selector `json:"location"` + Product Product `json:"product"` + LastSeen time.Time `json:"last_seen"` + SecondsLastSeen float64 `json:"seconds_last_seen"` + } + + State struct { + Power string `json:"power,omitempty"` + Color Color `json:"color,omitempty"` + Brightness float64 `json:"brightness,omitempty"` + Duration float64 `json:"duration,omitempty"` + Infrared float64 `json:"infrared,omitempty"` + Fast bool `json:"fast,omitempty"` + } + + StateDelta struct { + Power *string `json:"power,omitempty"` + Duration *float64 `json:"duration,omitempty"` + Infrared *float64 `json:"infrared,omitempty"` + Hue *float64 `json:"hue,omitempty"` + Saturation *float64 `json:"saturation,omitempty"` + Brightness *float64 `json:"brightness,omitempty"` + Kelvin *int `json:"kelvin,omitempty"` + } + + StateWithSelector struct { + State + Selector string `json:"selector"` + } + + States struct { + States []StateWithSelector `json:"states,omitempty"` + Defaults State `json:"defaults,omitempty"` + } + + Toggle struct { + Duration float64 `json:"duration,omitempty"` + } +) + +func (c *Client) SetState(selector string, state State) (*LifxResponse, error) { + var ( + err error + s *LifxResponse + resp *Response + ) + + if resp, err = c.setState(selector, state); err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.IsError() { + return nil, resp.GetLifxError() + } + + if state.Fast && resp.StatusCode == http.StatusAccepted { + return nil, nil + } + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} + +func (c *Client) FastSetState(selector string, state State) (*LifxResponse, error) { + state.Fast = true + return c.SetState(selector, state) +} + +func (c *Client) SetStates(selector string, states States) (*LifxResponse, error) { + var ( + err error + s *LifxResponse + resp *Response + ) + + if resp, err = c.setStates(selector, states); err != nil { + return nil, err + } + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} + +func (c *Client) StateDelta(selector string, delta StateDelta) (*LifxResponse, error) { + var ( + err error + s *LifxResponse + resp *Response + ) + + if resp, err = c.stateDelta(selector, delta); err != nil { + return nil, err + } + defer resp.Body.Close() + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} + +func (c *Client) Toggle(selector string, duration float64) (*LifxResponse, error) { + var ( + err error + s *LifxResponse + resp *Response + ) + + if resp, err = c.toggle(selector, duration); err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.IsError() { + return nil, resp.GetLifxError() + } + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} + +func (c *Client) ListLights(selector string) ([]Light, error) { + var ( + err error + s []Light + resp *Response + ) + + if resp, err = c.listLights(selector); err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, resp.GetLifxError() + } + + if err = json.NewDecoder(resp.Body).Decode(&s); err != nil { + return nil, err + } + + return s, nil +} + +func (c *Client) PowerOff(selector string) (*LifxResponse, error) { + return c.SetState(selector, State{Power: "off"}) +} + +func (c *Client) FastPowerOff(selector string) { + c.SetState(selector, State{Power: "off", Fast: true}) +} + +func (c *Client) PowerOn(selector string) (*LifxResponse, error) { + return c.SetState(selector, State{Power: "on"}) +} + +func (c *Client) FastPowerOn(selector string) { + c.SetState(selector, State{Power: "on", Fast: true}) +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..c0c3b18 --- /dev/null +++ b/util.go @@ -0,0 +1,7 @@ +package lifx + +func StringPtr(v string) *string { return &v } +func Float64Ptr(v float64) *float64 { return &v } +func Float32Ptr(v float32) *float32 { return &v } +func IntPtr(v int) *int { return &v } +func Int16Ptr(v int16) *int16 { return &v }