refactor color system to be separate from models, also move errors out of models.
continuous-integration/drone/push Build is passing

This commit is contained in:
2022-02-28 22:52:06 +01:00
parent df865574bf
commit 62e7cb88f4
32 changed files with 532 additions and 516 deletions
-17
View File
@@ -1,17 +0,0 @@
package models
import "github.com/lucasb-eyer/go-colorful"
type ColorHS struct {
Hue float64 `json:"hue"`
Sat float64 `json:"sat"`
}
func (hs ColorHS) ToXY() ColorXY {
return hs.ToRGB().ToXY()
}
func (hs ColorHS) ToRGB() ColorRGB {
c := colorful.Hsv(hs.Hue, hs.Sat, 1)
return ColorRGB{Red: c.R, Green: c.G, Blue: c.B}
}
+4 -3
View File
@@ -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 {
-29
View File
@@ -1,29 +0,0 @@
package models
import "github.com/lucasb-eyer/go-colorful"
type ColorRGB struct {
Red float64 `json:"red"`
Green float64 `json:"green"`
Blue float64 `json:"blue"`
}
func (rgb ColorRGB) AtIntensity(intensity float64) ColorRGB {
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
hsv2 := colorful.Hsv(hue, sat, intensity)
return ColorRGB{Red: hsv2.R, Green: hsv2.G, Blue: hsv2.B}
}
func (rgb ColorRGB) ToHS() ColorHS {
hue, sat, _ := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
return ColorHS{Hue: hue, Sat: sat}
}
func (rgb ColorRGB) ToXY() ColorXY {
x, y, z := (colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}).Xyz()
return ColorXY{
X: x / (x + y + z),
Y: y / (x + y + z),
}
}
-368
View File
@@ -1,368 +0,0 @@
package models
import (
"fmt"
"github.com/lucasb-eyer/go-colorful"
"strconv"
"strings"
)
type ColorValue struct {
RGB *ColorRGB `json:"rgb,omitempty"`
HS *ColorHS `json:"hs,omitempty"`
K *int `json:"k,omitempty"`
XY *ColorXY `json:"xy,omitempty"`
}
func (cv *ColorValue) IsHueSat() bool {
return cv.HS != nil
}
func (cv *ColorValue) IsHueSatKelvin() bool {
return cv.HS != nil && cv.K != nil
}
func (cv *ColorValue) IsKelvin() bool {
return cv.K != nil
}
func (cv *ColorValue) IsEmpty() bool {
return *cv == ColorValue{}
}
func (cv *ColorValue) SetK(k int) {
*cv = ColorValue{K: &k}
}
func (cv *ColorValue) SetXY(xy ColorXY) {
*cv = ColorValue{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 (cv *ColorValue) ToRGB() (cv2 ColorValue, ok bool) {
if cv.RGB != nil {
rgb := *cv.RGB
cv2 = ColorValue{RGB: &rgb}
ok = true
} else if cv.HS != nil {
rgb := cv.HS.ToRGB()
cv2 = ColorValue{RGB: &rgb}
ok = true
} else if cv.XY != nil {
rgb := cv.XY.ToRGB()
cv2 = ColorValue{RGB: &rgb}
ok = true
}
return
}
func (cv *ColorValue) ToHS() (cv2 ColorValue, ok bool) {
if cv.HS != nil {
hs := *cv.HS
cv2 = ColorValue{HS: &hs}
ok = true
} else if cv.RGB != nil {
hs := cv.RGB.ToHS()
cv2 = ColorValue{HS: &hs}
ok = true
} else if cv.XY != nil {
hs := cv.XY.ToHS()
cv2 = ColorValue{HS: &hs}
ok = true
}
return
}
func (cv *ColorValue) ToHSK() (cv2 ColorValue, ok bool) {
k := 4000
if cv.HS != nil {
hs := *cv.HS
cv2 = ColorValue{HS: &hs}
if cv.K != nil {
k = *cv.K
}
cv2.K = &k
ok = true
} else if cv.RGB != nil {
hs := cv.RGB.ToHS()
cv2 = ColorValue{HS: &hs}
cv2.K = &k
ok = true
} else if cv.XY != nil {
hs := cv.XY.ToHS()
cv2 = ColorValue{HS: &hs}
cv2.K = &k
ok = true
} else if cv.K != nil {
k = *cv.K
cv2.HS = &ColorHS{Hue: 0, Sat: 0}
cv2.K = &k
ok = true
}
return
}
// ToXY tries to copy the color to an XY color.
func (cv *ColorValue) ToXY() (cv2 ColorValue, ok bool) {
if cv.XY != nil {
xy := *cv.XY
cv2 = ColorValue{XY: &xy}
ok = true
} else if cv.HS != nil {
xy := cv.HS.ToXY()
cv2 = ColorValue{XY: &xy}
ok = true
} else if cv.RGB != nil {
xy := cv.RGB.ToXY()
cv2 = ColorValue{XY: &xy}
ok = true
}
return
}
func (cv *ColorValue) colorful() colorful.Color {
switch {
case cv.HS != nil:
return colorful.Hsv(cv.HS.Hue, cv.HS.Sat, 1)
case cv.RGB != nil:
return colorful.Color{R: cv.RGB.Red, G: cv.RGB.Green, B: cv.RGB.Blue}
case cv.XY != nil:
return colorful.Xyy(cv.XY.X, cv.XY.Y, 0.5)
default:
return colorful.Color{R: 255, B: 255, G: 255}
}
}
func (cv *ColorValue) Interpolate(other ColorValue, fac float64) ColorValue {
// Special case for kelvin values.
if cv.IsKelvin() && other.IsKelvin() {
k1 := *cv.K
k2 := *cv.K
k3 := k1 + int(float64(k2-k1)*fac)
return ColorValue{K: &k3}
}
// Get the colorful values.
cvCF := cv.colorful()
otherCF := other.colorful()
// Blend and normalize
blended := cvCF.BlendLuv(otherCF, fac)
blendedHue, blendedSat, _ := blended.Hsv()
blendedHs := ColorHS{Hue: blendedHue, Sat: blendedSat}
// Convert to the first's type
switch cv.Kind() {
case "rgb":
rgb := blendedHs.ToRGB()
return ColorValue{RGB: &rgb}
case "xy":
xy := blendedHs.ToXY()
return ColorValue{XY: &xy}
default:
return ColorValue{HS: &blendedHs}
}
}
func (cv *ColorValue) Kind() string {
switch {
case cv.RGB != nil:
return "rgb"
case cv.XY != nil:
return "xy"
case cv.HS != nil && cv.K != nil:
return "hsk"
case cv.HS != nil:
return "hs"
case cv.K != nil:
return "k"
default:
return ""
}
}
func (cv *ColorValue) String() string {
switch {
case cv.RGB != nil:
return fmt.Sprintf("rgb:%.3f,%.3f,%.3f", cv.RGB.Red, cv.RGB.Green, cv.RGB.Blue)
case cv.XY != nil:
return fmt.Sprintf("xy:%.4f,%.4f", cv.XY.X, cv.XY.Y)
case cv.HS != nil && cv.K != nil:
return fmt.Sprintf("hsk:%.4f,%.3f,%d", cv.HS.Hue, cv.HS.Sat, *cv.K)
case cv.HS != nil:
return fmt.Sprintf("hs:%.4f,%.3f", cv.HS.Hue, cv.HS.Sat)
case cv.K != nil:
return fmt.Sprintf("k:%d", *cv.K)
default:
return ""
}
}
func ParseColorValue(raw string) (cv2 ColorValue, err error) {
if raw == "" {
return
}
tokens := strings.SplitN(raw, ":", 2)
if len(tokens) != 2 {
err = ErrBadInput
return
}
switch tokens[0] {
case "kelvin", "k":
{
parsedPart, err := strconv.Atoi(tokens[1])
if err != nil {
err = ErrBadInput
break
}
cv2.K = &parsedPart
}
case "xy":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
err = ErrUnknownColorFormat
return
}
x, err1 := strconv.ParseFloat(parts[0], 64)
y, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
err = ErrBadInput
break
}
cv2.XY = &ColorXY{x, y}
}
case "hs":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 2 {
err = ErrUnknownColorFormat
return
}
part1, err1 := strconv.ParseFloat(parts[0], 64)
part2, err2 := strconv.ParseFloat(parts[1], 64)
if err1 != nil || err2 != nil {
err = ErrBadInput
break
}
cv2.HS = &ColorHS{Hue: part1, Sat: part2}
}
case "hsk":
{
parts := strings.Split(tokens[1], ",")
if len(parts) < 3 {
err = 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 = ErrBadInput
break
}
cv2.HS = &ColorHS{Hue: part1, Sat: part2}
cv2.K = &part3
}
case "rgb":
{
if strings.HasPrefix(tokens[1], "#") {
hex := tokens[1][1:]
if !validHex(hex) {
err = ErrBadInput
break
}
if len(hex) == 6 {
cv2.RGB = &ColorRGB{
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 {
cv2.RGB = &ColorRGB{
Red: float64(hex2digit(hex[0])) / 15.0,
Green: float64(hex2digit(hex[1])) / 15.0,
Blue: float64(hex2digit(hex[2])) / 15.0,
}
} else {
err = ErrUnknownColorFormat
return
}
} else {
parts := strings.Split(tokens[1], ",")
if len(parts) < 3 {
err = 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 = ErrBadInput
break
}
cv2.RGB = &ColorRGB{Red: part1, Green: part2, Blue: part3}
}
normalizedRGB := cv2.RGB.ToHS().ToRGB()
cv2.RGB = &normalizedRGB
}
default:
err = 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')
}
}
-182
View File
@@ -1,182 +0,0 @@
package models
import (
"github.com/lucasb-eyer/go-colorful"
"math"
)
const eps = 0.0001
const epsSquare = eps * eps
type ColorGamut struct {
Red ColorXY `json:"red"`
Green ColorXY `json:"green"`
Blue ColorXY `json:"blue"`
}
func (cg *ColorGamut) 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 {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
checkSide1 := cg.side(x1, y1, x2, y2, x, y) < 0
checkSide2 := cg.side(x2, y2, x3, y3, x, y) < 0
checkSide3 := cg.side(x3, y3, x1, y1, x, y) < 0
return checkSide1 && checkSide2 && checkSide3
}
func (cg *ColorGamut) 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
xMin = math.Min(x1, math.Min(x2, x3)) - eps
xMax = math.Max(x1, math.Max(x2, x3)) + eps
yMin = math.Min(y1, math.Min(y2, y3)) - eps
yMax = math.Max(y1, math.Max(y2, y3)) + eps
return
}
func (cg *ColorGamut) isInBounds(color ColorXY) 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 {
sqLength1 := (x2-x1)*(x2-x1) + (y2-y1)*(y2-y1)
dotProduct := ((x-x1)*(x2-x1) + (y-y1)*(y2-y1)) / sqLength1
if dotProduct < 0 {
return (x-x1)*(x-x1) + (y-y1)*(y-y1)
} else if dotProduct <= 1 {
sqLength2 := (x1-x)*(x1-x) + (y1-y)*(y1-y)
return sqLength2 - dotProduct*dotProduct*sqLength1
} else {
return (x-x2)*(x-x2) + (y-y2)*(y-y2)
}
}
func (cg *ColorGamut) atTheEdge(color ColorXY) bool {
x, y := color.X, color.Y
x1, y1 := cg.Red.X, cg.Red.Y
x2, y2 := cg.Green.X, cg.Green.Y
x3, y3 := cg.Blue.X, cg.Blue.Y
if cg.distanceSquarePointToSegment(x1, y1, x2, y2, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x2, y2, x3, y3, x, y) <= epsSquare {
return true
}
if cg.distanceSquarePointToSegment(x3, y3, x1, y1, x, y) <= epsSquare {
return true
}
return false
}
func (cg *ColorGamut) Contains(color ColorXY) bool {
if cg == nil {
return true
}
return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color))
}
func (cg *ColorGamut) Conform(color ColorXY) ColorXY {
if cg.Contains(color) {
return color
}
var best *ColorXY
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}
if cg.Contains(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
if best == nil {
centerX := (cg.Red.X + cg.Green.X + cg.Blue.X) / 3
centerY := (cg.Red.Y + cg.Green.Y + cg.Blue.Y) / 3
stepX := (centerX - color.X) / 5000
stepY := (centerY - color.Y) / 5000
for !cg.Contains(color) {
color.X += stepX
color.Y += stepY
}
return color
}
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}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
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}
if cg.atTheEdge(color2) {
if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) {
best = &color2
}
}
}
}
return *best
}
type ColorXY struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
func (xy ColorXY) ToRGB() ColorRGB {
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
c := colorful.Hsv(h, s, 1)
return ColorRGB{Red: c.R, Green: c.G, Blue: c.B}
}
func (xy ColorXY) ToHS() ColorHS {
h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv()
return ColorHS{Hue: h, Sat: s}
}
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 ColorXY) Round() ColorXY {
return ColorXY{
X: math.Round(xy.X*10000) / 10000,
Y: math.Round(xy.Y*10000) / 10000,
}
}
+14 -12
View File
@@ -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,11 +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"
DCColorRGB DeviceCapability = "ColorRGB"
DCColorXY DeviceCapability = "XY"
DCColorRGB DeviceCapability = "RGB"
DCButtons DeviceCapability = "Buttons"
DCPresence DeviceCapability = "Presence"
DCIntensity DeviceCapability = "Intensity"
@@ -128,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))
@@ -186,7 +188,7 @@ 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
}
@@ -196,7 +198,7 @@ func (d *Device) SetState(newState NewDeviceState) error {
d.State.Color = parsed
case parsed.K != nil && d.HasCapability(DCColorKelvin, DCColorHSK):
if !d.HasCapability(DCColorKelvin) {
d.State.Color.HS = &ColorHS{Hue: 0, Sat: 0}
d.State.Color.HS = &color.HueSat{Hue: 0, Sat: 0}
}
d.State.Color = parsed
@@ -237,8 +239,8 @@ 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 := sc.Interpolate(oc, fac)
rcStr := rc.String()
-33
View File
@@ -1,33 +0,0 @@
package models
import "errors"
var ErrNotFound = errors.New("not found")
var ErrInvalidName = errors.New("invalid name")
var ErrBadInput = errors.New("bad input")
var ErrBadColor = errors.New("bad color")
var ErrInternal = errors.New("internal")
var ErrUnknownColorFormat = errors.New("unknown color format")
var ErrUnknownDriver = errors.New("unknown driver")
var ErrMissingToken = errors.New("driver is missing authentication information")
var ErrIncorrectToken = errors.New("driver is not accepting authentication information")
var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)")
var ErrBridgeSearchFailed = errors.New("bridge search failed")
var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run")
var ErrCannotForwardRequest = errors.New("driver is not able to forward requests")
var ErrCannotForget = errors.New("forget not supported on this bridge type")
var ErrInvalidAddress = errors.New("invalid mac address")
var ErrPayloadTooShort = errors.New("payload too short")
var ErrInvalidPacketSize = errors.New("invalid packet size")
var ErrReadTimeout = errors.New("read timeout")
var ErrUnrecognizedPacketType = errors.New("packet type not recognized")
var ErrBridgeRunningRequired = errors.New("this operation cannot be performed when bridge is not running")
var ErrSceneInvalidInterval = errors.New("scene interval must be 0 (=disabled) or greater")
var ErrSceneNoRoles = errors.New("scene cannot have zero rules")
var ErrSceneRoleNoStates = errors.New("scene rule has no states")
var ErrSceneRoleUnsupportedOrdering = errors.New("scene rule has an unsupported ordering")
var ErrSceneRoleUnknownEffect = errors.New("scene rule has an unknown effect")
var ErrSceneRoleUnknownPowerMode = errors.New("scene rule has an unknown power mode")
+8 -7
View File
@@ -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