diff --git a/app/api/devices.go b/app/api/devices.go index afba0c6..e77e98b 100644 --- a/app/api/devices.go +++ b/app/api/devices.go @@ -4,6 +4,7 @@ import ( "context" "git.aiterp.net/lucifer/new-server/app/config" "git.aiterp.net/lucifer/new-server/app/services/publisher" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" "log" @@ -305,7 +306,7 @@ func Devices(r gin.IRoutes) { forgettableDriver, ok := driver.(models.ForgettableDriver) if !ok { - return nil, models.ErrCannotForget + return nil, lerrors.ErrCannotForget } err = forgettableDriver.ForgetDevice(ctxOf(c), bridge, *device) if err != nil { diff --git a/app/api/presets.go b/app/api/presets.go index 2b443da..156f3e4 100644 --- a/app/api/presets.go +++ b/app/api/presets.go @@ -2,6 +2,7 @@ package api import ( "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "github.com/gin-gonic/gin" ) @@ -25,13 +26,13 @@ func ColorPresets(r gin.IRoutes) { return nil, err } - newColor, err := models.ParseColorValue(body.ColorString) + newColor, err := color.Parse(body.ColorString) if err != nil { return nil, err } preset := models.ColorPreset{ - Name: body.Name, + Name: body.Name, Value: newColor, } @@ -64,7 +65,7 @@ func ColorPresets(r gin.IRoutes) { } if body.ColorString != nil { - newColor, err := models.ParseColorValue(*body.ColorString) + newColor, err := color.Parse(*body.ColorString) if err != nil { return nil, err } diff --git a/app/api/util.go b/app/api/util.go index 5cdece7..3b8feed 100644 --- a/app/api/util.go +++ b/app/api/util.go @@ -3,25 +3,25 @@ package api import ( "context" "encoding/json" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "github.com/gin-gonic/gin" "strconv" ) var errorMap = map[error]int{ - models.ErrNotFound: 404, - models.ErrInvalidName: 400, - models.ErrBadInput: 400, - models.ErrBadColor: 400, - models.ErrInternal: 500, - models.ErrUnknownColorFormat: 400, + lerrors.ErrNotFound: 404, + lerrors.ErrInvalidName: 400, + lerrors.ErrBadInput: 400, + lerrors.ErrBadColor: 400, + lerrors.ErrInternal: 500, + lerrors.ErrUnknownColorFormat: 400, - models.ErrSceneInvalidInterval: 400, - models.ErrSceneNoRoles: 400, - models.ErrSceneRoleNoStates: 400, - models.ErrSceneRoleUnsupportedOrdering: 422, - models.ErrSceneRoleUnknownEffect: 422, - models.ErrSceneRoleUnknownPowerMode: 422, + lerrors.ErrSceneInvalidInterval: 400, + lerrors.ErrSceneNoRoles: 400, + lerrors.ErrSceneRoleNoStates: 400, + lerrors.ErrSceneRoleUnsupportedOrdering: 422, + lerrors.ErrSceneRoleUnknownEffect: 422, + lerrors.ErrSceneRoleUnknownPowerMode: 422, } type response struct { @@ -66,7 +66,7 @@ func intParam(c *gin.Context, key string) int { func parseBody(c *gin.Context, target interface{}) error { err := json.NewDecoder(c.Request.Body).Decode(target) if err != nil { - return models.ErrBadInput + return lerrors.ErrBadInput } return nil diff --git a/cmd/bridgetest/main.go b/cmd/bridgetest/main.go index 9670c4d..7b49e6e 100644 --- a/cmd/bridgetest/main.go +++ b/cmd/bridgetest/main.go @@ -7,6 +7,7 @@ import ( "flag" "fmt" "git.aiterp.net/lucifer/new-server/app/config" + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "log" "os" @@ -124,7 +125,7 @@ func main() { continue } - color, err := models.ParseColorValue(tokens[2]) + color, err := color.Parse(tokens[2]) if err != nil { _, _ = fmt.Fprintln(os.Stderr, "Invalid color:", err) continue diff --git a/cmd/xy/main.go b/cmd/xy/main.go index a164ee8..b254361 100644 --- a/cmd/xy/main.go +++ b/cmd/xy/main.go @@ -1,49 +1,15 @@ package main import ( - "context" - "encoding/json" - "fmt" - "git.aiterp.net/lucifer/new-server/internal/drivers/hue2" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/color" "log" ) func main() { - client := hue2.NewClient("10.80.8.8", "o2XKGgmVUGNBghYFdLUCVuinOTMxFH4pHV9PuTbU") - bridge := hue2.NewBridge(client) - - err := bridge.RefreshAll(context.Background()) - if err != nil { - log.Fatalln(err) - } - j, _ := json.Marshal(bridge.GenerateDevices()) - fmt.Println(string(j)) - - ch := make(chan models.Event) - go func() { - for event := range ch { - log.Println("EVENT", event.Name, event.Payload) - } - }() - - for i, dev := range bridge.GenerateDevices() { - device := dev - switch device.InternalID { - case "6d5a45b0-ec69-4417-8588-717358b05086": - c, _ := models.ParseColorValue("xy:0.22,0.18") - device.State.Color = c - device.State.Intensity = 0.3 - case "a71128f4-5295-4ae4-9fbc-5541abc8739b": - c, _ := models.ParseColorValue("k:6500") - device.State.Color = c - device.State.Intensity = 0.2 - } - - device.ID = i + 1 - bridge.Update(device) - } - - err = bridge.Run(context.Background(), ch) - log.Println(err) + cv, _ := color.Parse("xy:0.1944,0.0942") + hs, _ := cv.ToHS() + log.Println(cv.String()) + log.Println(hs.String()) + xy, _ := hs.ToXY() + log.Println(xy.String()) } diff --git a/internal/color/color.go b/internal/color/color.go new file mode 100644 index 0000000..3cdd8f4 --- /dev/null +++ b/internal/color/color.go @@ -0,0 +1,369 @@ +package color + +import ( + "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" + "github.com/lucasb-eyer/go-colorful" + "strconv" + "strings" +) + +type Color struct { + RGB *RGB `json:"rgb,omitempty"` + HS *HueSat `json:"hs,omitempty"` + K *int `json:"k,omitempty"` + XY *XY `json:"xy,omitempty"` +} + +func (col *Color) IsHueSat() bool { + return col.HS != nil +} + +func (col *Color) IsHueSatKelvin() bool { + return col.HS != nil && col.K != nil +} + +func (col *Color) IsKelvin() bool { + return col.K != nil +} + +func (col *Color) IsEmpty() bool { + return *col == Color{} +} + +func (col *Color) SetK(k int) { + *col = Color{K: &k} +} + +func (col *Color) SetXY(xy XY) { + *col = Color{XY: &xy} +} + +// ToRGB tries to copy the color to an RGB color. If it's already RGB, it will be plainly copied, but HS +// will be returned. If ok is false, then no copying has occurred and cv2 will be blank. +func (col *Color) ToRGB() (col2 Color, ok bool) { + if col.RGB != nil { + rgb := *col.RGB + col2 = Color{RGB: &rgb} + ok = true + } else if col.HS != nil { + rgb := col.HS.ToRGB() + col2 = Color{RGB: &rgb} + ok = true + } else if col.XY != nil { + rgb := col.XY.ToRGB() + col2 = Color{RGB: &rgb} + ok = true + } + + return +} + +func (col *Color) ToHS() (col2 Color, ok bool) { + if col.HS != nil { + hs := *col.HS + col2 = Color{HS: &hs} + ok = true + } else if col.RGB != nil { + hs := col.RGB.ToHS() + col2 = Color{HS: &hs} + ok = true + } else if col.XY != nil { + hs := col.XY.ToHS() + col2 = Color{HS: &hs} + ok = true + } + + return +} + +func (col *Color) ToHSK() (col2 Color, ok bool) { + k := 4000 + + if col.HS != nil { + hs := *col.HS + col2 = Color{HS: &hs} + + if col.K != nil { + k = *col.K + } + col2.K = &k + + ok = true + } else if col.RGB != nil { + hs := col.RGB.ToHS() + col2 = Color{HS: &hs} + col2.K = &k + ok = true + } else if col.XY != nil { + hs := col.XY.ToHS() + col2 = Color{HS: &hs} + col2.K = &k + ok = true + } else if col.K != nil { + k = *col.K + col2.HS = &HueSat{Hue: 0, Sat: 0} + col2.K = &k + ok = true + } + + return +} + +// ToXY tries to copy the color to an XY color. +func (col *Color) ToXY() (col2 Color, ok bool) { + if col.XY != nil { + xy := *col.XY + col2 = Color{XY: &xy} + ok = true + } else if col.HS != nil { + xy := col.HS.ToXY() + col2 = Color{XY: &xy} + ok = true + } else if col.RGB != nil { + xy := col.RGB.ToXY() + col2 = Color{XY: &xy} + ok = true + } + + return +} + +func (col *Color) Interpolate(other Color, fac float64) Color { + // Special case for kelvin values. + if col.IsKelvin() && other.IsKelvin() { + k1 := *col.K + k2 := *col.K + k3 := k1 + int(float64(k2-k1)*fac) + return Color{K: &k3} + } + + // Get the colorful values. + cvCF := col.colorful() + otherCF := other.colorful() + + // Blend and normalize + blended := cvCF.BlendLuv(otherCF, fac) + blendedHue, blendedSat, _ := blended.Hsv() + blendedHs := HueSat{Hue: blendedHue, Sat: blendedSat} + + // Convert to the first's type + switch col.Kind() { + case "rgb": + rgb := blendedHs.ToRGB() + return Color{RGB: &rgb} + case "xy": + xy := blendedHs.ToXY() + return Color{XY: &xy} + default: + return Color{HS: &blendedHs} + } +} + +func (col *Color) Kind() string { + switch { + case col.RGB != nil: + return "rgb" + case col.XY != nil: + return "xy" + case col.HS != nil && col.K != nil: + return "hsk" + case col.HS != nil: + return "hs" + case col.K != nil: + return "k" + default: + return "" + } +} + +func (col *Color) String() string { + switch { + case col.RGB != nil: + return fmt.Sprintf("rgb:%.3f,%.3f,%.3f", col.RGB.Red, col.RGB.Green, col.RGB.Blue) + case col.XY != nil: + return fmt.Sprintf("xy:%.4f,%.4f", col.XY.X, col.XY.Y) + case col.HS != nil && col.K != nil: + return fmt.Sprintf("hsk:%.4f,%.3f,%d", col.HS.Hue, col.HS.Sat, *col.K) + case col.HS != nil: + return fmt.Sprintf("hs:%.4f,%.3f", col.HS.Hue, col.HS.Sat) + case col.K != nil: + return fmt.Sprintf("k:%d", *col.K) + default: + return "" + } +} + +func (col *Color) colorful() colorful.Color { + switch { + case col.HS != nil: + return colorful.Hsv(col.HS.Hue, col.HS.Sat, 1) + case col.RGB != nil: + return colorful.Color{R: col.RGB.Red, G: col.RGB.Green, B: col.RGB.Blue} + case col.XY != nil: + return colorful.Xyy(col.XY.X, col.XY.Y, 0.5) + default: + return colorful.Color{R: 255, B: 255, G: 255} + } +} + +func Parse(raw string) (col Color, err error) { + if raw == "" { + return + } + + tokens := strings.SplitN(raw, ":", 2) + if len(tokens) != 2 { + err = lerrors.ErrBadInput + return + } + + switch tokens[0] { + case "kelvin", "k": + { + parsedPart, err := strconv.Atoi(tokens[1]) + if err != nil { + err = lerrors.ErrBadInput + break + } + + col.K = &parsedPart + } + + case "xy": + { + parts := strings.Split(tokens[1], ",") + if len(parts) < 2 { + err = lerrors.ErrUnknownColorFormat + return + } + + x, err1 := strconv.ParseFloat(parts[0], 64) + y, err2 := strconv.ParseFloat(parts[1], 64) + if err1 != nil || err2 != nil { + err = lerrors.ErrBadInput + break + } + + col.XY = &XY{x, y} + } + + case "hs": + { + parts := strings.Split(tokens[1], ",") + if len(parts) < 2 { + err = lerrors.ErrUnknownColorFormat + return + } + + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) + if err1 != nil || err2 != nil { + err = lerrors.ErrBadInput + break + } + + col.HS = &HueSat{Hue: part1, Sat: part2} + } + + case "hsk": + { + parts := strings.Split(tokens[1], ",") + if len(parts) < 3 { + err = lerrors.ErrUnknownColorFormat + return + } + + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) + part3, err3 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil || err3 != nil { + err = lerrors.ErrBadInput + break + } + + col.HS = &HueSat{Hue: part1, Sat: part2} + col.K = &part3 + } + + case "rgb": + { + if strings.HasPrefix(tokens[1], "#") { + hex := tokens[1][1:] + if !validHex(hex) { + err = lerrors.ErrBadInput + break + } + + if len(hex) == 6 { + col.RGB = &RGB{ + Red: float64(hex2num(hex[0:2])) / 255.0, + Green: float64(hex2num(hex[2:4])) / 255.0, + Blue: float64(hex2num(hex[4:6])) / 255.0, + } + } else if len(hex) == 3 { + col.RGB = &RGB{ + Red: float64(hex2digit(hex[0])) / 15.0, + Green: float64(hex2digit(hex[1])) / 15.0, + Blue: float64(hex2digit(hex[2])) / 15.0, + } + } else { + err = lerrors.ErrUnknownColorFormat + return + } + } else { + parts := strings.Split(tokens[1], ",") + if len(parts) < 3 { + err = lerrors.ErrUnknownColorFormat + return + } + + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) + part3, err3 := strconv.ParseFloat(parts[2], 64) + if err1 != nil || err2 != nil || err3 != nil { + err = lerrors.ErrBadInput + break + } + + col.RGB = &RGB{Red: part1, Green: part2, Blue: part3} + } + + normalizedRGB := col.RGB.ToHS().ToRGB() + col.RGB = &normalizedRGB + } + + default: + err = lerrors.ErrUnknownColorFormat + } + + return +} + +func validHex(h string) bool { + for _, ch := range h { + if !((ch >= 'a' && ch <= 'f') || (ch >= '0' || ch <= '9')) { + return false + } + } + + return true +} + +func hex2num(s string) int { + v := 0 + for _, h := range s { + v *= 16 + v += hex2digit(byte(h)) + } + + return v +} + +func hex2digit(h byte) int { + if h >= 'a' && h <= 'f' { + return 10 + int(h-'a') + } else { + return int(h - '0') + } +} diff --git a/internal/color/hs.go b/internal/color/hs.go new file mode 100644 index 0000000..be97786 --- /dev/null +++ b/internal/color/hs.go @@ -0,0 +1,17 @@ +package color + +import "github.com/lucasb-eyer/go-colorful" + +type HueSat struct { + Hue float64 `json:"hue"` + Sat float64 `json:"sat"` +} + +func (hs HueSat) ToXY() XY { + return hs.ToRGB().ToXY() +} + +func (hs HueSat) ToRGB() RGB { + c := colorful.Hsv(hs.Hue, hs.Sat, 1) + return RGB{Red: c.R, Green: c.G, Blue: c.B} +} diff --git a/internal/color/rgb.go b/internal/color/rgb.go new file mode 100644 index 0000000..5eb9878 --- /dev/null +++ b/internal/color/rgb.go @@ -0,0 +1,29 @@ +package color + +import "github.com/lucasb-eyer/go-colorful" + +type RGB struct { + Red float64 `json:"red"` + Green float64 `json:"green"` + Blue float64 `json:"blue"` +} + +func (rgb RGB) AtIntensity(intensity float64) RGB { + hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv() + hsv2 := colorful.Hsv(hue, sat, intensity) + return RGB{Red: hsv2.R, Green: hsv2.G, Blue: hsv2.B} +} + +func (rgb RGB) ToHS() HueSat { + hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv() + return HueSat{Hue: hue, Sat: sat} +} + +func (rgb RGB) ToXY() XY { + x, y, z := (colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}).Xyz() + + return XY{ + X: x / (x + y + z), + Y: y / (x + y + z), + } +} diff --git a/models/colorxy.go b/internal/color/xy.go similarity index 65% rename from models/colorxy.go rename to internal/color/xy.go index 9b69d5d..d2665d0 100644 --- a/models/colorxy.go +++ b/internal/color/xy.go @@ -1,4 +1,4 @@ -package models +package color import ( "github.com/lucasb-eyer/go-colorful" @@ -8,17 +8,17 @@ import ( const eps = 0.0001 const epsSquare = eps * eps -type ColorGamut struct { - Red ColorXY `json:"red"` - Green ColorXY `json:"green"` - Blue ColorXY `json:"blue"` +type Gamut struct { + Red XY `json:"red"` + Green XY `json:"green"` + Blue XY `json:"blue"` } -func (cg *ColorGamut) side(x1, y1, x2, y2, x, y float64) float64 { +func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 { return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1) } -func (cg *ColorGamut) naiveContains(color ColorXY) bool { +func (cg *Gamut) naiveContains(color XY) bool { x, y := color.X, color.Y x1, y1 := cg.Red.X, cg.Red.Y x2, y2 := cg.Green.X, cg.Green.Y @@ -31,7 +31,7 @@ func (cg *ColorGamut) naiveContains(color ColorXY) bool { return checkSide1 && checkSide2 && checkSide3 } -func (cg *ColorGamut) getBounds() (xMin, xMax, yMin, yMax float64) { +func (cg *Gamut) getBounds() (xMin, xMax, yMin, yMax float64) { x1, y1 := cg.Red.X, cg.Red.Y x2, y2 := cg.Green.X, cg.Green.Y x3, y3 := cg.Blue.X, cg.Blue.Y @@ -44,14 +44,14 @@ func (cg *ColorGamut) getBounds() (xMin, xMax, yMin, yMax float64) { return } -func (cg *ColorGamut) isInBounds(color ColorXY) bool { +func (cg *Gamut) isInBounds(color XY) bool { x, y := color.X, color.Y xMin, xMax, yMin, yMax := cg.getBounds() return !(x < xMin || xMax < x || y < yMin || yMax < y) } -func (cg *ColorGamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) float64 { +func (cg *Gamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) float64 { sqLength1 := (x2-x1)*(x2-x1) + (y2-y1)*(y2-y1) dotProduct := ((x-x1)*(x2-x1) + (y-y1)*(y2-y1)) / sqLength1 if dotProduct < 0 { @@ -64,7 +64,7 @@ func (cg *ColorGamut) distanceSquarePointToSegment(x1, y1, x2, y2, x, y float64) } } -func (cg *ColorGamut) atTheEdge(color ColorXY) bool { +func (cg *Gamut) atTheEdge(color XY) bool { x, y := color.X, color.Y x1, y1 := cg.Red.X, cg.Red.Y x2, y2 := cg.Green.X, cg.Green.Y @@ -83,7 +83,7 @@ func (cg *ColorGamut) atTheEdge(color ColorXY) bool { return false } -func (cg *ColorGamut) Contains(color ColorXY) bool { +func (cg *Gamut) Contains(color XY) bool { if cg == nil { return true } @@ -91,18 +91,18 @@ func (cg *ColorGamut) Contains(color ColorXY) bool { return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color)) } -func (cg *ColorGamut) Conform(color ColorXY) ColorXY { +func (cg *Gamut) Conform(color XY) XY { if cg.Contains(color) { return color } - var best *ColorXY + var best *XY xMin, xMax, yMin, yMax := cg.getBounds() for x := xMin; x < xMax; x += 0.001 { for y := yMin; y < yMax; y += 0.001 { - color2 := ColorXY{X: x, Y: y} + color2 := XY{X: x, Y: y} if cg.Contains(color2) { if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) { @@ -129,7 +129,7 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY { for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 { for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 { - color2 := ColorXY{X: x, Y: y} + color2 := XY{X: x, Y: y} if cg.atTheEdge(color2) { if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) { @@ -141,7 +141,7 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY { for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 { for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 { - color2 := ColorXY{X: x, Y: y} + color2 := XY{X: x, Y: y} if cg.atTheEdge(color2) { if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) { @@ -154,51 +154,29 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY { return *best } -type ColorXY struct { +type XY struct { X float64 `json:"x"` Y float64 `json:"y"` } -func (xy ColorXY) DistanceTo(other ColorXY) float64 { - return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2)) +func (xy XY) ToRGB() RGB { + h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv() + c := colorful.Hsv(h, s, 1) + return RGB{Red: c.R, Green: c.G, Blue: c.B} } -func (xy ColorXY) Round() ColorXY { - return ColorXY{ - X: math.Round(xy.X*10000) / 10000, - Y: math.Round(xy.Y*10000) / 10000, - } -} - -func hsToXY(hue, sat float64) ColorXY { - c := colorful.Hsv(hue, sat, 1) - red255, green255, blue255 := c.RGB255() - red := float64(red255) / 255.0 - green := float64(green255) / 255.0 - blue := float64(blue255) / 255.0 - - return rgbToXY(red, green, blue) +func (xy XY) ToHS() HueSat { + h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv() + return HueSat{Hue: h, Sat: s} } -func rgbToXY(red float64, green float64, blue float64) ColorXY { - x := red*0.649926 + green*0.103455 + blue*0.197109 - y := red*0.234327 + green*0.743075 + blue*0.022598 - z := green*0.053077 + blue*1.035763 - - return ColorXY{ - X: x / (x + y + z), - Y: y / (x + y + z), - } +func (xy XY) DistanceTo(other XY) float64 { + return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2)) } -func screenRGBToXY(red, green, blue float64) ColorXY { - for _, component := range []*float64{&red, &green, &blue} { - if *component > 0.04045 { - *component = math.Pow((*component+0.055)/(1.055), 2.4) - } else { - *component /= 12.92 - } +func (xy XY) Round() XY { + return XY{ + X: math.Round(xy.X*10000) / 10000, + Y: math.Round(xy.Y*10000) / 10000, } - - return rgbToXY(red, green, blue) } diff --git a/internal/drivers/hue/bridge.go b/internal/drivers/hue/bridge.go index 0385e0a..5b53cc3 100644 --- a/internal/drivers/hue/bridge.go +++ b/internal/drivers/hue/bridge.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "golang.org/x/sync/errgroup" "io" @@ -159,7 +160,7 @@ func (b *Bridge) ForgetDevice(ctx context.Context, device models.Device) error { } b.mu.Unlock() if !found { - return models.ErrNotFound + return lerrors.ErrNotFound } // Delete light from bridge @@ -237,7 +238,7 @@ func (b *Bridge) getToken(ctx context.Context) (string, error) { return "", errLinkButtonNotPressed } if result[0].Success == nil { - return "", models.ErrUnexpectedResponse + return "", lerrors.ErrUnexpectedResponse } return result[0].Success.Username, nil diff --git a/internal/drivers/hue/driver.go b/internal/drivers/hue/driver.go index 2f1df8e..4da272f 100644 --- a/internal/drivers/hue/driver.go +++ b/internal/drivers/hue/driver.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "log" "net/http" @@ -22,7 +23,7 @@ type Driver struct { func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun bool) ([]models.Bridge, error) { if address == "" { if !dryRun { - return nil, models.ErrAddressOnlyDryRunnable + return nil, lerrors.ErrAddressOnlyDryRunnable } res, err := http.Get("https://discovery.meethue.com") diff --git a/internal/drivers/hue/state.go b/internal/drivers/hue/state.go index 3d3a46f..58aa82d 100644 --- a/internal/drivers/hue/state.go +++ b/internal/drivers/hue/state.go @@ -40,7 +40,7 @@ func (s *hueLightState) Update(state models.DeviceState) { if state.Power { input.On = ptrBool(true) if state.Color.IsKelvin() { - input.CT = ptrInt(1000000 / state.Color.Kelvin) + input.CT = ptrInt(1000000 / *state.Color.K) if *input.CT < s.info.Capabilities.Control.CT.Min { *input.CT = s.info.Capabilities.Control.CT.Min } @@ -51,13 +51,13 @@ func (s *hueLightState) Update(state models.DeviceState) { if s.input.CT == nil || *s.input.CT != *input.CT { s.stale = true } - } else { - input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536) + } else if color, ok := state.Color.ToHS(); ok { + input.Hue = ptrInt(int(color.HS.Hue*(65536/360)) % 65536) if s.input.Hue == nil || *s.input.Hue != *input.Hue { s.stale = true } - input.Sat = ptrInt(int(state.Color.Saturation * 255)) + input.Sat = ptrInt(int(color.HS.Sat * 255)) if *input.Sat > 254 { *input.Sat = 254 } diff --git a/internal/drivers/hue2/bridge.go b/internal/drivers/hue2/bridge.go index 8cf425d..e14edef 100644 --- a/internal/drivers/hue2/bridge.go +++ b/internal/drivers/hue2/bridge.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "golang.org/x/sync/errgroup" "log" @@ -269,7 +270,7 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { lightsOut := light.Power != nil && !device.State.Power if !lightsOut { if light.ColorTemperature != nil && device.State.Color.IsKelvin() { - mirek := 1000000 / device.State.Color.Kelvin + mirek := 1000000 / *device.State.Color.K if mirek < light.ColorTemperature.MirekSchema.MirekMinimum { mirek = light.ColorTemperature.MirekSchema.MirekMinimum } @@ -280,8 +281,8 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { update.Mirek = &mirek changed = true } - } else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil { - xy = light.Color.Gamut.Conform(xy).Round() + } else if xyColor, ok := device.State.Color.ToXY(); ok && light.Color != nil { + xy := light.Color.Gamut.Conform(*xyColor.XY).Round() if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) { update.ColorXY = &xy changed = true @@ -419,9 +420,7 @@ func (b *Bridge) GenerateDevices() []models.Device { } if light.ColorTemperature != nil { if light.ColorTemperature.Mirek != nil { - device.State.Color = models.ColorValue{ - Kelvin: int(1000000 / *light.ColorTemperature.Mirek), - } + device.State.Color.SetK(1000000 / *light.ColorTemperature.Mirek) } device.Capabilities = append(device.Capabilities, models.DCColorKelvin) device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum @@ -429,7 +428,7 @@ func (b *Bridge) GenerateDevices() []models.Device { } if light.Color != nil { if device.State.Color.IsEmpty() { - device.State.Color = models.ColorValue{ + device.State.Color = color.Color{ XY: &light.Color.XY, } } @@ -530,9 +529,11 @@ func (b *Bridge) applyPatches(patches []ResourceData) { resCopy.Color = &cp resCopy.Color.XY = patch.Color.XY - cp2 := *resCopy.ColorTemperature - resCopy.ColorTemperature = &cp2 - resCopy.ColorTemperature.Mirek = nil + if resCopy.ColorTemperature != nil { + cp2 := *resCopy.ColorTemperature + resCopy.ColorTemperature = &cp2 + resCopy.ColorTemperature.Mirek = nil + } } if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil { cp := *resCopy.ColorTemperature diff --git a/internal/drivers/hue2/client.go b/internal/drivers/hue2/client.go index a97cfa7..e666d41 100644 --- a/internal/drivers/hue2/client.go +++ b/internal/drivers/hue2/client.go @@ -8,7 +8,7 @@ import ( "encoding/json" "errors" "fmt" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "io" "log" "net" @@ -52,7 +52,7 @@ func (c *Client) Register(ctx context.Context) (string, error) { } } if result[0].Success == nil { - return "", models.ErrUnexpectedResponse + return "", lerrors.ErrUnexpectedResponse } c.token = result[0].Success.Username diff --git a/internal/drivers/hue2/data.go b/internal/drivers/hue2/data.go index 7f7134b..e8bd6d6 100644 --- a/internal/drivers/hue2/data.go +++ b/internal/drivers/hue2/data.go @@ -4,7 +4,7 @@ import ( "encoding/json" "encoding/xml" "fmt" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/color" "strings" "time" ) @@ -152,9 +152,9 @@ type LightDimming struct { } type LightColor struct { - Gamut models.ColorGamut `json:"gamut"` - GamutType string `json:"gamut_type"` - XY models.ColorXY `json:"xy"` + Gamut color.Gamut `json:"gamut"` + GamutType string `json:"gamut_type"` + XY color.XY `json:"xy"` } type LightCT struct { @@ -182,7 +182,7 @@ type LightAlert struct { type ResourceUpdate struct { Name *string Power *bool - ColorXY *models.ColorXY + ColorXY *color.XY Brightness *float64 Mirek *int TransitionDuration *time.Duration diff --git a/internal/drivers/hue2/driver.go b/internal/drivers/hue2/driver.go index d176ce5..e509967 100644 --- a/internal/drivers/hue2/driver.go +++ b/internal/drivers/hue2/driver.go @@ -5,6 +5,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "net/http" "sync" @@ -19,7 +20,7 @@ type Driver struct { func (d *Driver) SearchBridge(ctx context.Context, address, token string, dryRun bool) ([]models.Bridge, error) { if address == "" { if !dryRun { - return nil, models.ErrAddressOnlyDryRunnable + return nil, lerrors.ErrAddressOnlyDryRunnable } res, err := http.Get("https://discovery.meethue.com") diff --git a/internal/drivers/lifx/bridge.go b/internal/drivers/lifx/bridge.go index 410ecd1..d7b36a8 100644 --- a/internal/drivers/lifx/bridge.go +++ b/internal/drivers/lifx/bridge.go @@ -2,6 +2,8 @@ package lifx import ( "context" + "git.aiterp.net/lucifer/new-server/internal/color" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "log" "sync" @@ -20,7 +22,7 @@ type Bridge struct { func (b *Bridge) StartSearch(ctx context.Context) error { c := b.getClient() if c == nil { - return models.ErrBridgeRunningRequired + return lerrors.ErrBridgeRunningRequired } _, err := c.HorribleBroadcast(ctx, &GetService{}) @@ -129,9 +131,9 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error { for { target, seq, payload, err := client.Recv(time.Millisecond * 200) - if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType { + if err == lerrors.ErrInvalidPacketSize || err == lerrors.ErrPayloadTooShort || err == lerrors.ErrUnrecognizedPacketType { log.Println("LIFX udp socket received something weird:", err) - } else if err != nil && err != models.ErrReadTimeout { + } else if err != nil && err != lerrors.ErrReadTimeout { if ctx.Err() != nil { return ctx.Err() } @@ -168,10 +170,12 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error { if state.deviceState == nil { state.deviceState = &models.DeviceState{ Power: p.On, - Color: models.ColorValue{ - Hue: p.Hue, - Saturation: p.Sat, - Kelvin: p.Kelvin, + Color: color.Color{ + HS: &color.HueSat{ + Hue: p.Hue, + Sat: p.Sat, + }, + K: &p.Kelvin, }, Intensity: p.Bri, } @@ -204,7 +208,7 @@ func (b *Bridge) Run(ctx context.Context, debug bool) error { b.checkAndUpdateState(state) } - if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second * 30 { + if time.Since(state.discoveredTime) > time.Second*10 && time.Since(state.fwSpamTime) > time.Second*30 { state.fwSpamTime = time.Now() if state.firmware == nil { diff --git a/internal/drivers/lifx/client.go b/internal/drivers/lifx/client.go index 63c862d..43e5a8e 100644 --- a/internal/drivers/lifx/client.go +++ b/internal/drivers/lifx/client.go @@ -4,7 +4,7 @@ import ( "context" "encoding/binary" "errors" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "log" "math/rand" "net" @@ -215,7 +215,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload } if err != nil { if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() { - err = models.ErrReadTimeout + err = lerrors.ErrReadTimeout return } @@ -224,7 +224,7 @@ func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload packet := Packet(c.buf[:n]) if n < 2 || packet.Size() != n && packet.Protocol() != 1024 { - err = models.ErrInvalidAddress + err = lerrors.ErrInvalidAddress return } diff --git a/internal/drivers/lifx/packet.go b/internal/drivers/lifx/packet.go index 365aa1c..ebe1e5a 100644 --- a/internal/drivers/lifx/packet.go +++ b/internal/drivers/lifx/packet.go @@ -3,7 +3,7 @@ package lifx import ( "encoding/binary" "fmt" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "log" "net" ) @@ -60,7 +60,7 @@ func (p *Packet) SetTarget(v string) error { return err } if len(addr) != 6 { - return models.ErrInvalidAddress + return lerrors.ErrInvalidAddress } copy((*p)[8:], addr) @@ -133,7 +133,7 @@ func (p *Packet) Payload() (res Payload, err error) { res = &SetLightPower{} err = res.Decode((*p)[36:]) default: - err = models.ErrUnrecognizedPacketType + err = lerrors.ErrUnrecognizedPacketType } if err != nil { diff --git a/internal/drivers/lifx/payloads.go b/internal/drivers/lifx/payloads.go index 5e33a2a..9c3d484 100644 --- a/internal/drivers/lifx/payloads.go +++ b/internal/drivers/lifx/payloads.go @@ -3,7 +3,7 @@ package lifx import ( "encoding/binary" "fmt" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "math" "time" ) @@ -82,7 +82,7 @@ type StateHostFirmware struct { func (p *StateHostFirmware) Decode(data []byte) error { if len(data) < 20 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } ts := int64(binary.LittleEndian.Uint64(data[0:8])) @@ -123,7 +123,7 @@ type StateVersion struct { func (p *StateVersion) Decode(data []byte) error { if len(data) < 8 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } p.Vendor = binary.LittleEndian.Uint32(data[0:4]) @@ -207,7 +207,7 @@ type SetColor struct { func (p *SetColor) Decode(data []byte) error { if len(data) < 13 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } hue := binary.LittleEndian.Uint16(data[1:3]) @@ -263,7 +263,7 @@ type SetLightPower struct { func (p *SetLightPower) Decode(data []byte) error { if len(data) < 6 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } level := binary.LittleEndian.Uint16(data[0:2]) @@ -314,7 +314,7 @@ func (p *StateService) String() string { func (p *StateService) Decode(data []byte) error { if len(data) < 5 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } p.Service = int(data[0]) @@ -357,7 +357,7 @@ func (p *LightState) String() string { func (p *LightState) Decode(data []byte) error { if len(data) < 52 { - return models.ErrPayloadTooShort + return lerrors.ErrPayloadTooShort } hue := binary.LittleEndian.Uint16(data[0:2]) diff --git a/internal/drivers/lifx/state.go b/internal/drivers/lifx/state.go index bb0fac1..f28e86b 100644 --- a/internal/drivers/lifx/state.go +++ b/internal/drivers/lifx/state.go @@ -1,6 +1,7 @@ package lifx import ( + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "math" "time" @@ -36,17 +37,21 @@ func (s *State) generateUpdate() []Payload { return results } - c := s.deviceState.Color + c, ok := s.deviceState.Color.ToHSK() + if !ok { + c, _ = color.Parse("hsk:0,0,4000") + } + l := s.lightState di := s.deviceState.Intensity - k := c.Kelvin + k := *c.K if k == 0 { k = 4000 } - if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin { + if !equalish(c.HS.Hue, l.Hue) || !equalish(c.HS.Sat, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin { results = append(results, &SetColor{ - Hue: c.Hue, - Sat: c.Saturation, + Hue: c.HS.Hue, + Sat: c.HS.Sat, Bri: di, Kelvin: k, TransitionTime: time.Millisecond * 150, @@ -72,11 +77,16 @@ func (s *State) handleAck(seq uint8) { prevLabel = s.lightState.Label } + c, ok := s.deviceState.Color.ToHSK() + if !ok { + c, _ = color.Parse("hsk:0,0,4000") + } + s.lightState = &LightState{ - Hue: s.deviceState.Color.Hue, - Sat: s.deviceState.Color.Saturation, + Hue: c.HS.Hue, + Sat: c.HS.Sat, Bri: s.deviceState.Intensity, - Kelvin: s.deviceState.Color.Kelvin, + Kelvin: *c.K, On: s.deviceState.Power, Label: prevLabel, } diff --git a/internal/drivers/mill/bridge.go b/internal/drivers/mill/bridge.go index 46033e1..d5982ad 100644 --- a/internal/drivers/mill/bridge.go +++ b/internal/drivers/mill/bridge.go @@ -7,6 +7,7 @@ import ( "crypto/sha1" "encoding/json" "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "io" "log" @@ -163,9 +164,9 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{ res, err := http.DefaultClient.Do(req) if err != nil { - return models.ErrCannotForwardRequest + return lerrors.ErrCannotForwardRequest } else if res.StatusCode != 200 { - return models.ErrIncorrectToken + return lerrors.ErrIncorrectToken } if target == nil { @@ -174,7 +175,7 @@ func (b *bridge) command(ctx context.Context, command string, payload interface{ err = json.NewDecoder(res.Body).Decode(&target) if err != nil { - return models.ErrUnexpectedResponse + return lerrors.ErrUnexpectedResponse } return nil @@ -190,27 +191,27 @@ func (b *bridge) authenticate(ctx context.Context) error { Password: b.password, }) if err != nil { - return models.ErrMissingToken + return lerrors.ErrMissingToken } - req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint + "login", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, "POST", accountEndpoint+"login", bytes.NewReader(body)) if err != nil { - return models.ErrMissingToken + return lerrors.ErrMissingToken } addDefaultHeaders(req) res, err := http.DefaultClient.Do(req) if err != nil { - return models.ErrCannotForwardRequest + return lerrors.ErrCannotForwardRequest } else if res.StatusCode != 200 { - return models.ErrIncorrectToken + return lerrors.ErrIncorrectToken } var resBody authResBody err = json.NewDecoder(res.Body).Decode(&resBody) if err != nil { - return models.ErrBridgeSearchFailed + return lerrors.ErrBridgeSearchFailed } log.Printf("Mill: Authenticated as %s", resBody.NickName) diff --git a/internal/drivers/nanoleaf/bridge.go b/internal/drivers/nanoleaf/bridge.go index ce5d916..64b827b 100644 --- a/internal/drivers/nanoleaf/bridge.go +++ b/internal/drivers/nanoleaf/bridge.go @@ -5,12 +5,13 @@ import ( "context" "encoding/json" "fmt" + "git.aiterp.net/lucifer/new-server/internal/color" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "github.com/lucasb-eyer/go-colorful" "io" "io/ioutil" "log" - "math" "net" "net/http" "strconv" @@ -31,11 +32,12 @@ type bridge struct { func (b *bridge) Devices() []models.Device { results := make([]models.Device, 0, len(b.panels)) for i, panel := range b.panels { + // Find normalized RGB and intensity red := float64(panel.ColorRGBA[0]) / 255.0 green := float64(panel.ColorRGBA[1]) / 255.0 blue := float64(panel.ColorRGBA[2]) / 255.0 - - hue, sat, value := colorful.LinearRgb(red, green, blue).Hsv() + hue, sat, value := colorful.Color{R: red, G: green, B: blue}.Hsv() + rgb := colorful.Hsv(hue, sat, 1) shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType] if !shapeTypeOK { @@ -70,10 +72,11 @@ func (b *bridge) Devices() []models.Device { UserProperties: nil, State: models.DeviceState{ Power: panel.ColorRGBA[3] == 0, - Color: models.ColorValue{ - Hue: math.Mod(hue, 360), - Saturation: sat, - }, + Color: color.Color{RGB: &color.RGB{ + Red: rgb.R, + Green: rgb.G, + Blue: rgb.B, + }}, Intensity: value, Temperature: 0, }, @@ -139,9 +142,9 @@ func (b *bridge) Overview(ctx context.Context) (*Overview, error) { switch res.StatusCode { case 400, 403, 500, 503: - return nil, models.ErrUnexpectedResponse + return nil, lerrors.ErrUnexpectedResponse case 401: - return nil, models.ErrIncorrectToken + return nil, lerrors.ErrIncorrectToken } overview := Overview{} @@ -172,9 +175,20 @@ func (b *bridge) Update(devices []models.Device) { for _, panel := range b.panels { if panel.ID == uint16(id) { if device.State.Power { - color := colorful.Hsv(device.State.Color.Hue, device.State.Color.Saturation, device.State.Intensity) + rgbColor, ok := device.State.Color.ToRGB() + if !ok { + newColor := [4]byte{255, 255, 255, 255} + if newColor != panel.ColorRGBA { + panel.update(newColor, time.Now().Add(time.Millisecond*220)) + } + + continue + } - red, green, blue := color.RGB255() + rgb := rgbColor.RGB.AtIntensity(device.State.Intensity) + red := byte(rgb.Red * 255.9) + green := byte(rgb.Green * 255.9) + blue := byte(rgb.Blue * 255.9) newColor := [4]byte{red, green, blue, 255} if newColor != panel.ColorRGBA { panel.update(newColor, time.Now().Add(time.Millisecond*220)) @@ -321,7 +335,7 @@ func (b *bridge) updateEffect(ctx context.Context) error { defer res.Body.Close() if res.StatusCode != 204 { - return models.ErrUnexpectedResponse + return lerrors.ErrUnexpectedResponse } b.mu.Lock() diff --git a/internal/drivers/nanoleaf/driver.go b/internal/drivers/nanoleaf/driver.go index e4a2be5..f86a39b 100644 --- a/internal/drivers/nanoleaf/driver.go +++ b/internal/drivers/nanoleaf/driver.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "git.aiterp.net/lucifer/new-server/models" "net/http" "sync" @@ -30,7 +31,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo return nil, err } if deviceInfo.ModelNumber == "" { - return nil, models.ErrUnexpectedResponse + return nil, lerrors.ErrUnexpectedResponse } token := "" @@ -47,7 +48,7 @@ func (d *Driver) SearchBridge(ctx context.Context, address, _ string, dryRun boo defer res.Body.Close() if res.StatusCode != 200 { - return nil, models.ErrBridgeSearchFailed + return nil, lerrors.ErrBridgeSearchFailed } tokenResponse := TokenResponse{} diff --git a/internal/drivers/provider.go b/internal/drivers/provider.go index c3763b4..65626fc 100644 --- a/internal/drivers/provider.go +++ b/internal/drivers/provider.go @@ -1,14 +1,16 @@ package drivers -import "git.aiterp.net/lucifer/new-server/models" +import ( + "git.aiterp.net/lucifer/new-server/internal/lerrors" + "git.aiterp.net/lucifer/new-server/models" +) type DriverMap map[models.DriverKind]models.Driver func (m DriverMap) Provide(kind models.DriverKind) (models.Driver, error) { if m[kind] == nil { - return nil, models.ErrUnknownDriver + return nil, lerrors.ErrUnknownDriver } return m[kind], nil } - diff --git a/models/errors.go b/internal/lerrors/errors.go similarity index 99% rename from models/errors.go rename to internal/lerrors/errors.go index ff337ba..249e9da 100644 --- a/models/errors.go +++ b/internal/lerrors/errors.go @@ -1,4 +1,4 @@ -package models +package lerrors import "errors" diff --git a/internal/mysql/devicerepo.go b/internal/mysql/devicerepo.go index f077ffb..bcddc80 100644 --- a/internal/mysql/devicerepo.go +++ b/internal/mysql/devicerepo.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" sq "github.com/Masterminds/squirrel" "github.com/jmoiron/sqlx" @@ -30,6 +31,7 @@ type deviceStateRecord struct { Power bool `db:"power"` Intensity float64 `db:"intensity"` Temperature int `db:"temperature"` + Color string `db:"color"` } type devicePropertyRecord struct { @@ -228,13 +230,14 @@ func (r *DeviceRepo) SaveMany(ctx context.Context, mode models.SaveMode, devices if mode == 0 || mode&models.SMState != 0 { _, err = tx.NamedExecContext(ctx, ` - REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, temperature) - VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :temperature) + REPLACE INTO device_state(device_id, hue, saturation, kelvin, power, intensity, color, temperature) + VALUES (:device_id, :hue, :saturation, :kelvin, :power, :intensity, :color, :temperature) `, deviceStateRecord{ DeviceID: record.ID, - Hue: device.State.Color.Hue, - Saturation: device.State.Color.Saturation, - Kelvin: device.State.Color.Kelvin, + Hue: 40, + Saturation: 0, + Kelvin: 0, + Color: device.State.Color.String(), Power: device.State.Power, Intensity: device.State.Intensity, Temperature: device.State.Temperature, @@ -373,14 +376,12 @@ func (r *DeviceRepo) populate(ctx context.Context, records []deviceRecord) ([]mo for _, state := range states { if state.DeviceID == record.ID { + color, _ := color.Parse(state.Color) + device.State = models.DeviceState{ - Power: state.Power, - Color: models.ColorValue{ - Hue: state.Hue, - Saturation: state.Saturation, - Kelvin: state.Kelvin, - }, - Intensity: state.Intensity, + Power: state.Power, + Color: color, + Intensity: state.Intensity, Temperature: state.Temperature, } } diff --git a/internal/mysql/presetrepo.go b/internal/mysql/presetrepo.go index 296fcc1..c63a6e6 100644 --- a/internal/mysql/presetrepo.go +++ b/internal/mysql/presetrepo.go @@ -2,6 +2,7 @@ package mysql import ( "context" + "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "github.com/jmoiron/sqlx" ) @@ -12,6 +13,7 @@ type presetRecord struct { Hue float64 `db:"hue"` Saturation float64 `db:"saturation"` Kelvin int `db:"kelvin"` + Value string `db:"value"` } type ColorPresetRepo struct { @@ -42,8 +44,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset) if preset.ID > 0 { _, err := c.DBX.ExecContext( ctx, - "UPDATE color_preset SET name = ?, hue = ?, saturation = ?, kelvin = ? WHERE id = ?", - preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin, preset.ID, + "UPDATE color_preset SET name = ?, value = ? WHERE id = ?", + preset.Name, preset.Value.String(), preset.ID, ) if err != nil { @@ -52,8 +54,8 @@ func (c *ColorPresetRepo) Save(ctx context.Context, preset *models.ColorPreset) } else { rs, err := c.DBX.ExecContext( ctx, - "INSERT INTO color_preset (name, hue, saturation, kelvin) VALUES (?, ?, ?, ?)", - preset.Name, preset.Value.Hue, preset.Value.Saturation, preset.Value.Kelvin, + "INSERT INTO color_preset (name, value, hue, saturation, kelvin) VALUES (?, ?, 0, 0, 0)", + preset.Name, preset.Value.String(), ) if err != nil { @@ -84,14 +86,12 @@ func (c *ColorPresetRepo) Delete(ctx context.Context, preset *models.ColorPreset func (c *ColorPresetRepo) fromRecords(records ...presetRecord) []models.ColorPreset { newList := make([]models.ColorPreset, len(records), len(records)) for i, record := range records { + color, _ := color.Parse(record.Value) + newList[i] = models.ColorPreset{ - ID: record.ID, - Name: record.Name, - Value: models.ColorValue{ - Hue: record.Hue, - Saturation: record.Saturation, - Kelvin: record.Kelvin, - }, + ID: record.ID, + Name: record.Name, + Value: color, } } diff --git a/internal/mysql/util.go b/internal/mysql/util.go index 2dddaa3..4f87d3c 100644 --- a/internal/mysql/util.go +++ b/internal/mysql/util.go @@ -2,16 +2,16 @@ package mysql import ( "database/sql" - "git.aiterp.net/lucifer/new-server/models" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "log" ) func dbErr(err error) error { if err == sql.ErrNoRows { - return models.ErrNotFound + return lerrors.ErrNotFound } else if err != nil { log.Printf("Internal error: %s", err.Error()) - return models.ErrInternal + return lerrors.ErrInternal } return nil diff --git a/models/colorpreset.go b/models/colorpreset.go index c2b3f71..15887ee 100644 --- a/models/colorpreset.go +++ b/models/colorpreset.go @@ -2,13 +2,14 @@ package models import ( "context" + "git.aiterp.net/lucifer/new-server/internal/color" "strings" ) type ColorPreset struct { - ID int `json:"id"` - Name string `json:"name"` - Value ColorValue `json:"value"` + ID int `json:"id"` + Name string `json:"name"` + Value color.Color `json:"value"` } type ColorPresetRepository interface { diff --git a/models/colorvalue.go b/models/colorvalue.go deleted file mode 100644 index 68ae594..0000000 --- a/models/colorvalue.go +++ /dev/null @@ -1,129 +0,0 @@ -package models - -import ( - "fmt" - "math" - "strconv" - "strings" -) - -type ColorValue struct { - Hue float64 `json:"h,omitempty"` // 0..360 - Saturation float64 `json:"s,omitempty"` // 0..=1 - Kelvin int `json:"kelvin,omitempty"` - XY *ColorXY `json:"xy,omitempty"` -} - -func (c *ColorValue) IsEmpty() bool { - return c.XY == nil && c.Kelvin == 0 && c.Saturation == 0 && c.Hue == 0 -} - -func (c *ColorValue) IsHueSat() bool { - return !c.IsKelvin() -} - -func (c *ColorValue) IsKelvin() bool { - return !c.IsXY() && c.Kelvin > 0 -} - -func (c *ColorValue) IsXY() bool { - return c.XY != nil -} - -// ToXY converts the color to XY if possible. If the color already is XY, it returns -// a copy of its held value. There are no guarantees of conforming to a gamut, however. -func (c *ColorValue) ToXY() (xy ColorXY, ok bool) { - if c.XY != nil { - xy = *c.XY - ok = true - } else if c.Kelvin > 0 && c.Hue < 0.001 && c.Saturation <= 0.001 { - ok = false - } else { - xy = hsToXY(c.Hue, c.Saturation) - ok = true - } - - return -} - -func (c *ColorValue) String() string { - if c.Kelvin > 0 { - return fmt.Sprintf("k:%d", c.Kelvin) - } - - return fmt.Sprintf("hs:%.4g,%.3g", c.Hue, c.Saturation) -} - -func ParseColorValue(raw string) (ColorValue, error) { - tokens := strings.SplitN(raw, ":", 2) - if len(tokens) != 2 { - return ColorValue{}, ErrBadInput - } - - switch tokens[0] { - case "kelvin", "k": - { - parsedPart, err := strconv.Atoi(tokens[1]) - if err != nil { - return ColorValue{}, ErrBadInput - } - - return ColorValue{Kelvin: parsedPart}, nil - } - - case "xy": - { - parts := strings.Split(tokens[1], ",") - if len(parts) < 2 { - return ColorValue{}, ErrUnknownColorFormat - } - - x, err1 := strconv.ParseFloat(parts[0], 64) - y, err2 := strconv.ParseFloat(parts[1], 64) - if err1 != nil || err2 != nil { - return ColorValue{}, ErrBadInput - } - - return ColorValue{XY: &ColorXY{X: x, Y: y}}, nil - } - - case "hs": - { - parts := strings.Split(tokens[1], ",") - if len(parts) < 2 { - return ColorValue{}, ErrUnknownColorFormat - } - - part1, err1 := strconv.ParseFloat(parts[0], 64) - part2, err2 := strconv.ParseFloat(parts[1], 64) - if err1 != nil || err2 != nil { - return ColorValue{}, ErrBadInput - } - - return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil - } - - case "hsk": - { - parts := strings.Split(tokens[1], ",") - if len(parts) < 3 { - return ColorValue{}, ErrUnknownColorFormat - } - - part1, err1 := strconv.ParseFloat(parts[0], 64) - part2, err2 := strconv.ParseFloat(parts[1], 64) - part3, err3 := strconv.Atoi(parts[2]) - if err1 != nil || err2 != nil || err3 != nil { - return ColorValue{}, ErrBadInput - } - - return ColorValue{ - Hue: math.Mod(part1, 360), - Saturation: math.Min(math.Max(part2, 0), 1), - Kelvin: part3, - }, nil - } - } - - return ColorValue{}, ErrUnknownColorFormat -} diff --git a/models/device.go b/models/device.go index b8cc0d3..5c8cd9b 100644 --- a/models/device.go +++ b/models/device.go @@ -2,6 +2,8 @@ package models import ( "context" + "git.aiterp.net/lucifer/new-server/internal/color" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "strings" "time" ) @@ -34,10 +36,10 @@ type DeviceUpdate struct { // - Intensity: e.g. brightness, range 0..=1 // - Temperature: e.g. for thermostats type DeviceState struct { - Power bool `json:"power"` - Color ColorValue `json:"color,omitempty"` - Intensity float64 `json:"intensity,omitempty"` - Temperature int `json:"temperature"` + Power bool `json:"power"` + Color color.Color `json:"color,omitempty"` + Intensity float64 `json:"intensity,omitempty"` + Temperature int `json:"temperature"` } type DeviceScene struct { @@ -83,10 +85,11 @@ func DeviceCapabilitiesToStrings(caps []DeviceCapability) []string { var ( DCPower DeviceCapability = "Power" - DCColorHS DeviceCapability = "ColorHS" + DCColorHS DeviceCapability = "HueSat" DCColorHSK DeviceCapability = "ColorHSK" DCColorKelvin DeviceCapability = "ColorKelvin" - DCColorXY DeviceCapability = "ColorXY" + DCColorXY DeviceCapability = "XY" + DCColorRGB DeviceCapability = "RGB" DCButtons DeviceCapability = "Buttons" DCPresence DeviceCapability = "Presence" DCIntensity DeviceCapability = "Intensity" @@ -127,7 +130,7 @@ func (d *Device) ApplyUpdate(update DeviceUpdate) { func (d *Device) Validate() error { d.Name = strings.Trim(d.Name, " \t\n ") if d.Name == "" { - return ErrInvalidName + return lerrors.ErrInvalidName } newCaps := make([]DeviceCapability, 0, len(d.Capabilities)) @@ -185,12 +188,19 @@ func (d *Device) SetState(newState NewDeviceState) error { } if newState.Color != nil { - parsed, err := ParseColorValue(*newState.Color) + parsed, err := color.Parse(*newState.Color) if err != nil { return err } - if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) { + switch { + case (parsed.RGB != nil || parsed.XY != nil || parsed.HS != nil) || d.HasCapability(DCColorHS, DCColorXY, DCColorRGB): + d.State.Color = parsed + case parsed.K != nil && d.HasCapability(DCColorKelvin, DCColorHSK): + if !d.HasCapability(DCColorKelvin) { + d.State.Color.HS = &color.HueSat{Hue: 0, Sat: 0} + } + d.State.Color = parsed } } @@ -229,14 +239,10 @@ func (s *NewDeviceState) Interpolate(other NewDeviceState, fac float64) NewDevic } if s.Color != nil && other.Color != nil { - sc, err := ParseColorValue(*s.Color) - oc, err2 := ParseColorValue(*other.Color) + sc, err := color.Parse(*s.Color) + oc, err2 := color.Parse(*other.Color) if err == nil && err2 == nil { - rc := ColorValue{} - rc.Hue = interpolateFloat(sc.Hue, oc.Hue, fac) - rc.Saturation = interpolateFloat(sc.Saturation, oc.Saturation, fac) - rc.Kelvin = interpolateInt(sc.Kelvin, oc.Kelvin, fac) - + rc := sc.Interpolate(oc, fac) rcStr := rc.String() n.Color = &rcStr } diff --git a/models/scene.go b/models/scene.go index 63d1dba..18cbe1b 100644 --- a/models/scene.go +++ b/models/scene.go @@ -2,6 +2,7 @@ package models import ( "context" + "git.aiterp.net/lucifer/new-server/internal/lerrors" "math" "math/rand" "sort" @@ -18,10 +19,10 @@ type Scene struct { func (s *Scene) Validate() error { if s.IntervalMS < 0 { - return ErrSceneInvalidInterval + return lerrors.ErrSceneInvalidInterval } if len(s.Roles) == 0 { - return ErrSceneNoRoles + return lerrors.ErrSceneNoRoles } for _, role := range s.Roles { @@ -118,31 +119,31 @@ func (d *SceneRunContext) IntervalFac() float64 { func (r *SceneRole) Validate() error { if len(r.States) == 0 { - return ErrSceneRoleNoStates + return lerrors.ErrSceneRoleNoStates } switch r.TargetKind { case RKTag, RKBridgeID, RKDeviceID, RKName, RKAll: default: - return ErrBadInput + return lerrors.ErrBadInput } switch r.PowerMode { case SPScene, SPDevice, SPBoth: default: - return ErrSceneRoleUnknownPowerMode + return lerrors.ErrSceneRoleUnknownPowerMode } switch r.Effect { case SEStatic, SERandom, SEGradient, SEWalkingGradient, SETransition, SEMotion, SETemperature: default: - return ErrSceneRoleUnknownEffect + return lerrors.ErrSceneRoleUnknownEffect } switch r.Order { case "", "-name", "name", "+name", "-id", "id", "+id": default: - return ErrSceneRoleUnsupportedOrdering + return lerrors.ErrSceneRoleUnsupportedOrdering } return nil diff --git a/scripts/20220220121712_color_preset_wipe.sql b/scripts/20220220121712_color_preset_wipe.sql new file mode 100644 index 0000000..17121ab --- /dev/null +++ b/scripts/20220220121712_color_preset_wipe.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +TRUNCATE color_preset; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +-- +goose StatementEnd diff --git a/scripts/20220220121724_color_preset_add_column_value.sql b/scripts/20220220121724_color_preset_add_column_value.sql new file mode 100644 index 0000000..2c3f904 --- /dev/null +++ b/scripts/20220220121724_color_preset_add_column_value.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE color_preset ADD COLUMN value VARCHAR(255) NOT NULL DEFAULT 'hs:0,0'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE color_preset DROP COLUMN value; +-- +goose StatementEnd diff --git a/scripts/20220220124631_device_state_color.sql b/scripts/20220220124631_device_state_color.sql new file mode 100644 index 0000000..ada8f3e --- /dev/null +++ b/scripts/20220220124631_device_state_color.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE device_state ADD COLUMN color VARCHAR(255) NOT NULL DEFAULT 'hs:0,0'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE device_state DROP COLUMN IF EXISTS color; +-- +goose StatementEnd