commit e752b1bafdd0dc64e7bf82fb69dce679ae83b9b1 Author: Gisle Aune Date: Sat Aug 13 16:37:45 2022 +0200 initial commit (some event stuffs) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..706fd07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.vscode diff --git a/bus.go b/bus.go new file mode 100644 index 0000000..c0a1b3c --- /dev/null +++ b/bus.go @@ -0,0 +1,138 @@ +package lucifer3 + +import ( + "log" + "sync" +) + +type ServiceKey struct{} + +type Event interface { + EventName() string +} + +type EventBus struct { + mu sync.Mutex + listeners []*serviceListener + signal chan struct{} +} + +// JoinCallback joins the event bus for a moment. +func (b *EventBus) JoinCallback(cb func(event Event, sender ServiceID) bool) { + b.Join(newCallbackService(cb)) +} + +func (b *EventBus) Join(service Service) { + // Take the signal here so that it will receive the events sent by the calling function + // so that they're processed without having to wait for a new event. + signal := b.signalCh() + + b.mu.Lock() + listener := &serviceListener{ + bus: b, + queue: make([]queuedEvent, 0, 16), + service: service, + } + + go listener.run(signal) + + b.listeners = append(b.listeners, listener) + b.mu.Unlock() +} + +func (b *EventBus) Send(event Event, sender ServiceID, recipient *ServiceID) { + b.mu.Lock() + defer b.mu.Unlock() + + deleteList := make([]int, 0, 0) + for i, listener := range b.listeners { + if !listener.service.Active() { + deleteList = append(deleteList, i-len(deleteList)) + continue + } + if recipient != nil && *recipient != listener.service.ServiceID() { + continue + } + + listener.mu.Lock() + listener.queue = append(listener.queue, queuedEvent{ + event: event, + sender: sender, + }) + listener.mu.Unlock() + } + + for i := range deleteList { + b.listeners = append(b.listeners[:i], b.listeners[i+1:]...) + } + + if recipient != nil { + log.Printf("%s (-> %s): %s", &sender, recipient, event.EventName()) + } else { + log.Printf("%s: %s", &sender, event.EventName()) + } + + if b.signal != nil { + close(b.signal) + b.signal = nil + } +} + +func (b *EventBus) signalCh() <-chan struct{} { + b.mu.Lock() + defer b.mu.Unlock() + + if b.signal == nil { + b.signal = make(chan struct{}) + } + + return b.signal +} + +type serviceListener struct { + mu sync.Mutex + bus *EventBus + queue []queuedEvent + service Service +} + +func (l *serviceListener) run(signal <-chan struct{}) { + queue := make([]queuedEvent, 0, 16) + + for { + // Listen for the signal, but stop here if the service has marked + // itself as inactive. + <-signal + if !l.service.Active() { + return + } + + // Take a new signal before copying the queue to avoid delay + // in case a message is about to be enqueued right now! + signal = l.bus.signalCh() + + // Take a copy of the queue. + l.mu.Lock() + for _, message := range l.queue { + queue = append(queue, message) + } + l.queue = l.queue[:0] + l.mu.Unlock() + + // Handle the messages in the queue, but stop mid-queue if the previous message + // or external circumstances marked the service inactive. + for _, message := range queue { + if !l.service.Active() { + return + } + + l.service.Handle(l.bus, message.event, message.sender) + } + queue = queue[:0] + } +} + +type queuedEvent struct { + event Event + sender ServiceID +} diff --git a/cmd/bustest/main.go b/cmd/bustest/main.go new file mode 100644 index 0000000..b7caa58 --- /dev/null +++ b/cmd/bustest/main.go @@ -0,0 +1,77 @@ +package main + +import ( + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/device" + "git.aiterp.net/lucifer3/server/events" + "git.aiterp.net/lucifer3/server/internal/color" + "git.aiterp.net/lucifer3/server/internal/gentools" + "log" + "time" +) + +func main() { + bus := lucifer3.EventBus{} + + bus.JoinCallback(func(event lucifer3.Event, sender lucifer3.ServiceID) bool { + switch event := event.(type) { + case events.HubConnected: + log.Println("Callback got connect event") + case events.DeviceAssignment: + log.Println("Callback can see", len(event.IDs), "devices are being assigned") + case events.DeviceRegister: + log.Println("Callback should not see that", event.InternalID, "has got a new ID") + case events.DeviceDesiredStateChange: + log.Println("Callback saw", event.ID, "got a new state.") + } + + return true + }) + + c := color.RGB{Red: 1, Green: 0.7, Blue: 0.25}.ToXY() + + bus.Send(events.HubConnected{}, lucifer3.ServiceID{Kind: lucifer3.SKDeviceHub, ID: 1}, nil) + bus.Send(events.DeviceAssignment{ + IDs: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9}, + Version: 1, + State: &device.State{ + Power: gentools.Ptr(true), + Temperature: nil, + Intensity: nil, + Color: &color.Color{XY: &c}, + }, + }, lucifer3.ServiceID{Kind: lucifer3.SKSingleton, Name: "DeviceManager"}, nil) + bus.Send(events.DeviceAssignment{ + IDs: []int64{44, 45, 46, 47, 48, 49, 50, 51, 52, 53}, + Version: 2, + Effect: gentools.Ptr[int64](1044), + }, lucifer3.ServiceID{Kind: lucifer3.SKSingleton, Name: "DeviceManager"}, nil) + + time.Sleep(time.Second / 2) + + bus.Send(events.DeviceHardwareStateChange{ + InternalID: "c68b310c-7fd6-47a4-ae11-d60d701769da", + DeviceFlags: device.SFlagPower | device.SFlagIntensity, + ColorFlags: 0, + State: device.State{ + Power: gentools.Ptr(true), + Intensity: gentools.Ptr(0.75), + }, + }, lucifer3.ServiceID{Kind: lucifer3.SKDeviceHub, ID: 1}, nil) + + time.Sleep(time.Second / 4) + + bus.Send(events.DeviceRegister{ + InternalID: "c68b310c-7fd6-47a4-ae11-d60d701769da", + ID: 10, + }, lucifer3.ServiceID{Kind: lucifer3.SKSingleton, Name: "DeviceManager"}, &lucifer3.ServiceID{Kind: lucifer3.SKDeviceHub, ID: 1}) + bus.Send(events.DeviceDesiredStateChange{ + ID: 10, + NewState: device.State{ + Power: gentools.Ptr(true), + Intensity: gentools.Ptr(0.75), + }, + }, lucifer3.ServiceID{Kind: lucifer3.SKSingleton, Name: "DeviceManager"}, nil) + + time.Sleep(time.Second * 4) +} diff --git a/device/flags.go b/device/flags.go new file mode 100644 index 0000000..3e61ba5 --- /dev/null +++ b/device/flags.go @@ -0,0 +1,59 @@ +package device + +import "math/bits" + +type SupportFlags uint32 + +func (f SupportFlags) HasAny(d SupportFlags) bool { + return (f & d) != 0 +} + +func (f SupportFlags) HasAll(d SupportFlags) bool { + return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d)) +} + +const ( + SFlagPower SupportFlags = 1 << iota + SFlagIntensity + SFlagColor + SFlagTemperature + SFlagSensorButtons + SFlagSensorTemperature + SFlagSensorLightLevel + SFlagSensorPresence +) + +// ColorFlag is primarily to detect warm-white lights, as XY/RGB/HS/HSK can convert without trouble. +type ColorFlag uint32 + +const ( + CFlagXY ColorFlag = 1 << iota + CFlagRGB + CFlagHS + CFlagHSK + CFlagKelvin +) + +func (f ColorFlag) IsColor() bool { + return f.HasAny(CFlagXY | CFlagRGB | CFlagHS | CFlagHSK) +} + +func (f ColorFlag) IsWarmWhite() bool { + return f&CFlagKelvin == CFlagKelvin +} + +func (f ColorFlag) IsColorOnly() bool { + return f != 0 && f&CFlagKelvin == 0 +} + +func (f ColorFlag) IsWarmWhiteOnly() bool { + return f == CFlagKelvin +} + +func (f ColorFlag) HasAny(d ColorFlag) bool { + return (f & d) != 0 +} + +func (f ColorFlag) HasAll(d ColorFlag) bool { + return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d)) +} diff --git a/device/state.go b/device/state.go new file mode 100644 index 0000000..e5ba277 --- /dev/null +++ b/device/state.go @@ -0,0 +1,55 @@ +package device + +import ( + "fmt" + "git.aiterp.net/lucifer3/server/internal/color" + "strings" +) + +type State struct { + Power *bool `json:"power"` + Temperature *float64 `json:"temperature"` + Intensity *float64 `json:"intensity"` + Color *color.Color `json:"color"` + + SensedTemperature *float64 `json:"sensedTemperature"` + SensedLightLevel *float64 `json:"sensedLightLevel"` + SensedPresence *PresenceState `json:"sensedPresence"` + SensedButton *int `json:"sensedButton"` +} + +func (d State) String() string { + parts := make([]string, 0, 4) + if d.Power != nil { + parts = append(parts, fmt.Sprintf("power:%t", *d.Power)) + } + if d.Temperature != nil { + parts = append(parts, fmt.Sprintf("temperature:%f", *d.Temperature)) + } + if d.Intensity != nil { + parts = append(parts, fmt.Sprintf("intensity:%.2f", *d.Intensity)) + } + if d.Color != nil { + parts = append(parts, fmt.Sprintf("color:%s", d.Color.String())) + } + + if d.SensedTemperature != nil { + parts = append(parts, fmt.Sprintf("sensedTemperature:%.2f", *d.SensedTemperature)) + } + if d.SensedLightLevel != nil { + parts = append(parts, fmt.Sprintf("sensedLightLevel:%.1f", *d.SensedLightLevel)) + } + if d.SensedPresence != nil { + parts = append(parts, fmt.Sprintf("sensedPresense:(%t,%.1f)", d.SensedPresence.Present, d.SensedPresence.AbsenceSeconds)) + } + if d.SensedButton != nil { + parts = append(parts, fmt.Sprintf("sensedButton:%d", *d.SensedButton)) + } + + return fmt.Sprint("(", strings.Join(parts, ", "), ")") +} + +type PresenceState struct { + Present bool `json:"present"` + AbsenceSeconds float64 `json:"absenceSeconds,omitempty"` +} diff --git a/events/device.go b/events/device.go new file mode 100644 index 0000000..c100e0e --- /dev/null +++ b/events/device.go @@ -0,0 +1,88 @@ +package events + +import ( + "fmt" + "git.aiterp.net/lucifer3/server/device" + "git.aiterp.net/lucifer3/server/internal/gentools" + "strings" +) + +type DeviceHardwareStateChange struct { + ID int64 `json:"id,omitempty"` + InternalID string `json:"internalId"` + DeviceFlags device.SupportFlags `json:"deviceFlags"` + ColorFlags device.ColorFlag `json:"colorFlags"` + State device.State `json:"state"` +} + +func (d DeviceHardwareStateChange) EventName() string { + idStr := "" + if d.ID != 0 { + idStr = fmt.Sprintf("id:%d, ", d.ID) + } + + return fmt.Sprintf("DeviceHardwareStateChange(%siid:%s, dflags:%d, cflags:%d, state:%s)", + idStr, d.InternalID, d.DeviceFlags, d.ColorFlags, d.State, + ) +} + +// DeviceRegister must be sent directly to the device hub that took it. +type DeviceRegister struct { + ID int64 `json:"id"` + InternalID string `json:"internalId"` +} + +func (d DeviceRegister) EventName() string { + return fmt.Sprintf("DeviceRegister(id:%d, iid:%s)", d.ID, d.InternalID) +} + +type DeviceDesiredStateChange struct { + ID int64 `json:"id"` + NewState device.State `json:"newState"` +} + +func (d DeviceDesiredStateChange) EventName() string { + return fmt.Sprintf("DeviceDesiredStateChange(id:%d, state:%s)", + d.ID, d.NewState, + ) +} + +type DeviceAssignment struct { + IDs []int64 `json:"ids"` + Version int64 `json:"version"` // DeviceManager sets the version. + State *device.State `json:"state"` // DeviceManager sets the state in a follow-up event. Others still need to see it to kick the device. + Effect *int64 `json:"effect"` // An effect will pick this up. A scene may defer to own effects + Scene *int64 `json:"scene"` // A scene will take it and handle it. Might move it out a level so scenes are just filters that create assignments +} + +func (d DeviceAssignment) EventName() string { + s := "(!!no change!!)" + switch { + case d.State != nil: + s = fmt.Sprintf("state:%s", *d.State) + case d.Effect != nil: + s = fmt.Sprintf("effect:%d", *d.Effect) + case d.Scene != nil: + s = fmt.Sprintf("scene:%d", *d.Scene) + } + + return fmt.Sprintf("DeviceAssignment(ids:%s, version:%d, %s)", + strings.Join(gentools.FmtSprintArray(d.IDs), ","), + d.Version, + s, + ) +} + +// DeviceRestore attempts to restore devices to a previous version. It may not be +// successful. +type DeviceRestore struct { + IDs []int64 `json:"ids"` + Version int64 `json:"version"` +} + +func (d DeviceRestore) EventName() string { + return fmt.Sprintf("DeviceRestore(ids:%s, version:%d)", + strings.Join(gentools.FmtSprintArray(d.IDs), ","), + d.Version, + ) +} diff --git a/events/hub.go b/events/hub.go new file mode 100644 index 0000000..1d56e19 --- /dev/null +++ b/events/hub.go @@ -0,0 +1,17 @@ +package events + +import "fmt" + +type HubConnected struct{} + +func (c HubConnected) EventName() string { + return "HubConnected" +} + +type HubDisconnected struct { + Reason string +} + +func (d HubDisconnected) EventName() string { + return fmt.Sprintf("HubDisconnected(reason:%s)", d.Reason) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ecc638a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.aiterp.net/lucifer3/server + +go 1.19 + +require github.com/lucasb-eyer/go-colorful v1.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eae3068 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= diff --git a/internal/color/color.go b/internal/color/color.go new file mode 100644 index 0000000..0fabaa9 --- /dev/null +++ b/internal/color/color.go @@ -0,0 +1,368 @@ +package color + +import ( + "fmt" + "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 = ErrBadColorInput + return + } + + switch tokens[0] { + case "kelvin", "k": + { + parsedPart, err := strconv.Atoi(tokens[1]) + if err != nil { + err = ErrBadColorInput + break + } + + col.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 = ErrBadColorInput + break + } + + col.XY = &XY{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 = ErrBadColorInput + break + } + + col.HS = &HueSat{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 = ErrBadColorInput + 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 = ErrBadColorInput + 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 = 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 = ErrBadColorInput + break + } + + col.RGB = &RGB{Red: part1, Green: part2, Blue: part3} + } + + normalizedRGB := col.RGB.ToHS().ToRGB() + col.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') + } +} diff --git a/internal/color/errors.go b/internal/color/errors.go new file mode 100644 index 0000000..7df72dd --- /dev/null +++ b/internal/color/errors.go @@ -0,0 +1,6 @@ +package color + +import "errors" + +var ErrBadColorInput = errors.New("bad color input") +var ErrUnknownColorFormat = errors.New("bad color format") 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/internal/color/xy.go b/internal/color/xy.go new file mode 100644 index 0000000..d2665d0 --- /dev/null +++ b/internal/color/xy.go @@ -0,0 +1,182 @@ +package color + +import ( + "github.com/lucasb-eyer/go-colorful" + "math" +) + +const eps = 0.0001 +const epsSquare = eps * eps + +type Gamut struct { + Red XY `json:"red"` + Green XY `json:"green"` + Blue XY `json:"blue"` +} + +func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 { + return (y2-y1)*(x-x1) + (-x2+x1)*(y-y1) +} + +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 + 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 *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 + + 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 *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 *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 { + 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 *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 + 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 *Gamut) Contains(color XY) bool { + if cg == nil { + return true + } + + return cg.isInBounds(color) && (cg.naiveContains(color) || cg.atTheEdge(color)) +} + +func (cg *Gamut) Conform(color XY) XY { + if cg.Contains(color) { + return color + } + + 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 := XY{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 := XY{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 := XY{X: x, Y: y} + + if cg.atTheEdge(color2) { + if best == nil || color.DistanceTo(color2) < color.DistanceTo(*best) { + best = &color2 + } + } + } + } + + return *best +} + +type XY struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +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 XY) ToHS() HueSat { + h, s, _ := colorful.Xyy(xy.X, xy.Y, 0.5).Hsv() + return HueSat{Hue: h, Sat: s} +} + +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 (xy XY) Round() XY { + return XY{ + X: math.Round(xy.X*10000) / 10000, + Y: math.Round(xy.Y*10000) / 10000, + } +} diff --git a/internal/gentools/commalist.go b/internal/gentools/commalist.go new file mode 100644 index 0000000..6b93466 --- /dev/null +++ b/internal/gentools/commalist.go @@ -0,0 +1,12 @@ +package gentools + +import "fmt" + +func FmtSprintArray[T any](arr []T) []string { + res := make([]string, len(arr)) + for i, e := range arr { + res[i] = fmt.Sprint(e) + } + + return res +} diff --git a/internal/gentools/ptr.go b/internal/gentools/ptr.go new file mode 100644 index 0000000..baa203e --- /dev/null +++ b/internal/gentools/ptr.go @@ -0,0 +1,5 @@ +package gentools + +func Ptr[T any](t T) *T { + return &t +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..bdc3ee3 --- /dev/null +++ b/service.go @@ -0,0 +1,107 @@ +package lucifer3 + +import ( + "fmt" + "sync/atomic" +) + +type Service interface { + Active() bool + ServiceID() ServiceID + Handle(bus *EventBus, event Event, sender ServiceID) +} + +type ServiceID struct { + Kind ServiceKind + ID int64 + Name string +} + +func (s *ServiceID) String() string { + if s == nil { + return "BROADCAST" + } + if s.Kind == SKNotService { + return "EXTERNAL" + } + + if s.Name != "" { + return s.Name + } else { + return fmt.Sprintf("%s[%d]", s.Kind.String(), s.ID) + } + +} + +type ServiceCommon struct { + serviceID ServiceID + inactive uint32 +} + +func (s *ServiceCommon) Active() bool { + return atomic.LoadUint32(&s.inactive) == 0 +} + +func (s *ServiceCommon) markInactive() { + atomic.StoreUint32(&s.inactive, 1) +} + +func (s *ServiceCommon) ServiceID() ServiceID { + return s.serviceID +} + +type ServiceKind int + +func (s ServiceKind) String() string { + switch s { + case SKSingleton: + return "Singleton" + case SKStorage: + return "Storage" + case SKDeviceHub: + return "DeviceHub" + case SKEffect: + return "Effect" + case SKCallback: + return "Callback" + case SKNotService: + return "NotService" + default: + return "???" + } +} + +const ( + SKNotService ServiceKind = iota + SKSingleton + SKStorage + SKDeviceHub + SKEffect + SKCallback +) + +type callbackService struct { + ServiceCommon + cb func(event Event, sender ServiceID) bool +} + +func (c *callbackService) Handle(_ *EventBus, event Event, sender ServiceID) { + if !c.cb(event, sender) { + c.markInactive() + } +} + +var nextCallback int64 = 0 + +func newCallbackService(cb func(event Event, sender ServiceID) bool) Service { + return &callbackService{ + ServiceCommon: ServiceCommon{ + serviceID: ServiceID{ + Kind: SKCallback, + ID: atomic.AddInt64(&nextCallback, 1), + }, + inactive: 0, + }, + cb: cb, + } +}