summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go167
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go120
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go50
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go182
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go1256
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go414
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go122
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go387
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go216
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go330
10 files changed, 3244 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go
new file mode 100644
index 0000000..1fff03f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go
@@ -0,0 +1,167 @@
+package wayland
+
+import (
+ "math"
+)
+
+type GammaRamp struct {
+ Red []uint16
+ Green []uint16
+ Blue []uint16
+}
+
+type rgb struct {
+ r, g, b float64
+}
+
+type xyz struct {
+ x, y, z float64
+}
+
+func illuminantD(temp int) (float64, float64, bool) {
+ var x float64
+ switch {
+ case temp >= 2500 && temp <= 7000:
+ t := float64(temp)
+ x = 0.244063 + 0.09911e3/t + 2.9678e6/(t*t) - 4.6070e9/(t*t*t)
+ case temp > 7000 && temp <= 25000:
+ t := float64(temp)
+ x = 0.237040 + 0.24748e3/t + 1.9018e6/(t*t) - 2.0064e9/(t*t*t)
+ default:
+ return 0, 0, false
+ }
+ y := -3*(x*x) + 2.870*x - 0.275
+ return x, y, true
+}
+
+func planckianLocus(temp int) (float64, float64, bool) {
+ var x, y float64
+ switch {
+ case temp >= 1667 && temp <= 4000:
+ t := float64(temp)
+ x = -0.2661239e9/(t*t*t) - 0.2343589e6/(t*t) + 0.8776956e3/t + 0.179910
+ if temp <= 2222 {
+ y = -1.1064814*(x*x*x) - 1.34811020*(x*x) + 2.18555832*x - 0.20219683
+ } else {
+ y = -0.9549476*(x*x*x) - 1.37418593*(x*x) + 2.09137015*x - 0.16748867
+ }
+ case temp > 4000 && temp < 25000:
+ t := float64(temp)
+ x = -3.0258469e9/(t*t*t) + 2.1070379e6/(t*t) + 0.2226347e3/t + 0.240390
+ y = 3.0817580*(x*x*x) - 5.87338670*(x*x) + 3.75112997*x - 0.37001483
+ default:
+ return 0, 0, false
+ }
+ return x, y, true
+}
+
+func srgbGamma(value, gamma float64) float64 {
+ if value <= 0.0031308 {
+ return 12.92 * value
+ }
+ return math.Pow(1.055*value, 1.0/gamma) - 0.055
+}
+
+func clamp01(v float64) float64 {
+ switch {
+ case v > 1.0:
+ return 1.0
+ case v < 0.0:
+ return 0.0
+ default:
+ return v
+ }
+}
+
+func xyzToSRGB(c xyz) rgb {
+ return rgb{
+ r: srgbGamma(clamp01(3.2404542*c.x-1.5371385*c.y-0.4985314*c.z), 2.2),
+ g: srgbGamma(clamp01(-0.9692660*c.x+1.8760108*c.y+0.0415560*c.z), 2.2),
+ b: srgbGamma(clamp01(0.0556434*c.x-0.2040259*c.y+1.0572252*c.z), 2.2),
+ }
+}
+
+func normalizeRGB(c *rgb) {
+ maxw := math.Max(c.r, math.Max(c.g, c.b))
+ if maxw > 0 {
+ c.r /= maxw
+ c.g /= maxw
+ c.b /= maxw
+ }
+}
+
+func calcWhitepoint(temp int) rgb {
+ if temp == 6500 {
+ return rgb{r: 1.0, g: 1.0, b: 1.0}
+ }
+
+ var wp xyz
+
+ switch {
+ case temp >= 25000:
+ x, y, _ := illuminantD(25000)
+ wp.x = x
+ wp.y = y
+ case temp >= 4000:
+ x, y, _ := illuminantD(temp)
+ wp.x = x
+ wp.y = y
+ case temp >= 2500:
+ x1, y1, _ := illuminantD(temp)
+ x2, y2, _ := planckianLocus(temp)
+ factor := float64(4000-temp) / 1500.0
+ sineFactor := (math.Cos(math.Pi*factor) + 1.0) / 2.0
+ wp.x = x1*sineFactor + x2*(1.0-sineFactor)
+ wp.y = y1*sineFactor + y2*(1.0-sineFactor)
+ default:
+ t := temp
+ if t < 1667 {
+ t = 1667
+ }
+ x, y, _ := planckianLocus(t)
+ wp.x = x
+ wp.y = y
+ }
+
+ wp.z = 1.0 - wp.x - wp.y
+
+ wpRGB := xyzToSRGB(wp)
+ normalizeRGB(&wpRGB)
+ return wpRGB
+}
+
+func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ wp := calcWhitepoint(temp)
+
+ for i := uint32(0); i < size; i++ {
+ val := float64(i) / float64(size-1)
+ ramp.Red[i] = uint16(clamp01(math.Pow(val*wp.r, 1.0/gamma)) * 65535.0)
+ ramp.Green[i] = uint16(clamp01(math.Pow(val*wp.g, 1.0/gamma)) * 65535.0)
+ ramp.Blue[i] = uint16(clamp01(math.Pow(val*wp.b, 1.0/gamma)) * 65535.0)
+ }
+
+ return ramp
+}
+
+func GenerateIdentityRamp(size uint32) GammaRamp {
+ ramp := GammaRamp{
+ Red: make([]uint16, size),
+ Green: make([]uint16, size),
+ Blue: make([]uint16, size),
+ }
+
+ for i := uint32(0); i < size; i++ {
+ val := uint16((float64(i) / float64(size-1)) * 65535.0)
+ ramp.Red[i] = val
+ ramp.Green[i] = val
+ ramp.Blue[i] = val
+ }
+
+ return ramp
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go
new file mode 100644
index 0000000..0bf8e71
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go
@@ -0,0 +1,120 @@
+package wayland
+
+import (
+ "testing"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+)
+
+func TestGenerateGammaRamp(t *testing.T) {
+ tests := []struct {
+ name string
+ size uint32
+ temp int
+ gamma float64
+ }{
+ {"small_warm", 16, 6500, 1.0},
+ {"small_cool", 16, 4000, 1.0},
+ {"large_warm", 256, 6500, 1.0},
+ {"large_cool", 256, 4000, 1.0},
+ {"custom_gamma", 64, 5500, 1.2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ramp := GenerateGammaRamp(tt.size, tt.temp, tt.gamma)
+
+ if len(ramp.Red) != int(tt.size) {
+ t.Errorf("expected %d red values, got %d", tt.size, len(ramp.Red))
+ }
+ if len(ramp.Green) != int(tt.size) {
+ t.Errorf("expected %d green values, got %d", tt.size, len(ramp.Green))
+ }
+ if len(ramp.Blue) != int(tt.size) {
+ t.Errorf("expected %d blue values, got %d", tt.size, len(ramp.Blue))
+ }
+
+ if ramp.Red[0] != 0 || ramp.Green[0] != 0 || ramp.Blue[0] != 0 {
+ t.Errorf("first values should be 0, got R:%d G:%d B:%d",
+ ramp.Red[0], ramp.Green[0], ramp.Blue[0])
+ }
+
+ lastIdx := tt.size - 1
+ if ramp.Red[lastIdx] == 0 || ramp.Green[lastIdx] == 0 || ramp.Blue[lastIdx] == 0 {
+ t.Errorf("last values should be non-zero, got R:%d G:%d B:%d",
+ ramp.Red[lastIdx], ramp.Green[lastIdx], ramp.Blue[lastIdx])
+ }
+
+ for i := uint32(1); i < tt.size; i++ {
+ if ramp.Red[i] < ramp.Red[i-1] {
+ t.Errorf("red ramp not monotonic at index %d", i)
+ }
+ }
+ })
+ }
+}
+
+func TestCalcWhitepoint(t *testing.T) {
+ tests := []struct {
+ name string
+ temp int
+ }{
+ {"very_warm", 6500},
+ {"neutral", 5500},
+ {"cool", 4000},
+ {"very_cool", 3000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ wp := calcWhitepoint(tt.temp)
+
+ if wp.r < 0 || wp.r > 1 {
+ t.Errorf("red out of range: %f", wp.r)
+ }
+ if wp.g < 0 || wp.g > 1 {
+ t.Errorf("green out of range: %f", wp.g)
+ }
+ if wp.b < 0 || wp.b > 1 {
+ t.Errorf("blue out of range: %f", wp.b)
+ }
+ })
+ }
+}
+
+func TestWhitepointProgression(t *testing.T) {
+ temps := []int{3000, 4000, 5000, 6000, 6500}
+
+ var prevBlue float64
+ for i, temp := range temps {
+ wp := calcWhitepoint(temp)
+ if i > 0 && wp.b < prevBlue {
+ t.Errorf("blue should increase with temperature, %d->%d: %f->%f",
+ temps[i-1], temp, prevBlue, wp.b)
+ }
+ prevBlue = wp.b
+ }
+}
+
+func TestClamp(t *testing.T) {
+ tests := []struct {
+ val float64
+ min float64
+ max float64
+ expected float64
+ }{
+ {5, 0, 10, 5},
+ {-5, 0, 10, 0},
+ {15, 0, 10, 10},
+ {0, 0, 10, 0},
+ {10, 0, 10, 10},
+ }
+
+ for _, tt := range tests {
+ result := utils.Clamp(tt.val, tt.min, tt.max)
+ if result != tt.expected {
+ t.Errorf("clamp(%f, %f, %f) = %f, want %f",
+ tt.val, tt.min, tt.max, result, tt.expected)
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go
new file mode 100644
index 0000000..ba99b5f
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go
@@ -0,0 +1,50 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+type ipAPIResponse struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ City string `json:"city"`
+}
+
+func FetchIPLocation() (*float64, *float64, error) {
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ resp, err := client.Get("http://ip-api.com/json/")
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to fetch IP location: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, nil, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ var data ipAPIResponse
+ if err := json.Unmarshal(body, &data); err != nil {
+ return nil, nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if data.Lat == 0 && data.Lon == 0 {
+ return nil, nil, fmt.Errorf("missing location data in response")
+ }
+
+ log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon)
+ return &data.Lat, &data.Lon, nil
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go
new file mode 100644
index 0000000..76b08b5
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go
@@ -0,0 +1,182 @@
+package wayland
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
+)
+
+func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
+ if manager == nil {
+ models.RespondError(conn, req.ID, "wayland manager not initialized")
+ return
+ }
+
+ switch req.Method {
+ case "wayland.gamma.getState":
+ handleGetState(conn, req, manager)
+ case "wayland.gamma.setTemperature":
+ handleSetTemperature(conn, req, manager)
+ case "wayland.gamma.setLocation":
+ handleSetLocation(conn, req, manager)
+ case "wayland.gamma.setManualTimes":
+ handleSetManualTimes(conn, req, manager)
+ case "wayland.gamma.setUseIPLocation":
+ handleSetUseIPLocation(conn, req, manager)
+ case "wayland.gamma.setGamma":
+ handleSetGamma(conn, req, manager)
+ case "wayland.gamma.setEnabled":
+ handleSetEnabled(conn, req, manager)
+ case "wayland.gamma.subscribe":
+ handleSubscribe(conn, req, manager)
+ default:
+ models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
+ }
+}
+
+func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
+ models.Respond(conn, req.ID, manager.GetState())
+}
+
+func handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) {
+ var lowTemp, highTemp int
+
+ if temp, ok := models.Get[float64](req, "temp"); ok {
+ lowTemp = int(temp)
+ highTemp = int(temp)
+ } else {
+ low, err := params.Float(req.Params, "low")
+ if err != nil {
+ models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')")
+ return
+ }
+ high, err := params.Float(req.Params, "high")
+ if err != nil {
+ models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')")
+ return
+ }
+ lowTemp = int(low)
+ highTemp = int(high)
+ }
+
+ if err := manager.SetTemperature(lowTemp, highTemp); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "temperature set"})
+}
+
+func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) {
+ lat, err := params.Float(req.Params, "latitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ lon, err := params.Float(req.Params, "longitude")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetLocation(lat, lon); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"})
+}
+
+func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) {
+ sunriseStr, sunriseOK := models.Get[string](req, "sunrise")
+ sunsetStr, sunsetOK := models.Get[string](req, "sunset")
+
+ if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" {
+ manager.ClearManualTimes()
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"})
+ return
+ }
+
+ sunrise, err := time.Parse("15:04", sunriseStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunrise format (use HH:MM)")
+ return
+ }
+
+ sunset, err := time.Parse("15:04", sunsetStr)
+ if err != nil {
+ models.RespondError(conn, req.ID, "invalid sunset format (use HH:MM)")
+ return
+ }
+
+ if err := manager.SetManualTimes(sunrise, sunset); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times set"})
+}
+
+func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) {
+ use, err := params.Bool(req.Params, "use")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetUseIPLocation(use)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "IP location preference set"})
+}
+
+func handleSetGamma(conn net.Conn, req models.Request, manager *Manager) {
+ gamma, err := params.Float(req.Params, "gamma")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ if err := manager.SetGamma(gamma); err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "gamma set"})
+}
+
+func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) {
+ enabled, err := params.Bool(req.Params, "enabled")
+ if err != nil {
+ models.RespondError(conn, req.ID, err.Error())
+ return
+ }
+
+ manager.SetEnabled(enabled)
+ models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "enabled state set"})
+}
+
+func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
+ clientID := fmt.Sprintf("client-%p", conn)
+ stateChan := manager.Subscribe(clientID)
+ defer manager.Unsubscribe(clientID)
+
+ initialState := manager.GetState()
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ ID: req.ID,
+ Result: &initialState,
+ }); err != nil {
+ return
+ }
+
+ for state := range stateChan {
+ if err := json.NewEncoder(conn).Encode(models.Response[State]{
+ Result: &state,
+ }); err != nil {
+ return
+ }
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go
new file mode 100644
index 0000000..3540e3d
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go
@@ -0,0 +1,1256 @@
+package wayland
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "syscall"
+ "time"
+
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/godbus/dbus/v5"
+ "golang.org/x/sys/unix"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
+)
+
+const animKelvinStep = 25
+
+func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
+ if err := config.Validate(); err != nil {
+ return nil, err
+ }
+
+ if config.ElevationTwilight == 0 {
+ config.ElevationTwilight = -6.0
+ }
+ if config.ElevationDaylight == 0 {
+ config.ElevationDaylight = 3.0
+ }
+
+ m := &Manager{
+ config: config,
+ display: display,
+ ctx: display.Context(),
+ cmdq: make(chan cmd, 128),
+ stopChan: make(chan struct{}),
+ updateTrigger: make(chan struct{}, 1),
+ dirty: make(chan struct{}, 1),
+ dbusSignal: make(chan *dbus.Signal, 16),
+ }
+
+ if err := m.setupRegistry(); err != nil {
+ return nil, err
+ }
+
+ if err := m.setupDBusMonitor(); err != nil {
+ log.Warnf("Failed to setup D-Bus monitoring: %v", err)
+ }
+
+ m.alive = true
+ m.recalcSchedule(time.Now())
+ m.updateStateFromSchedule()
+
+ m.notifierWg.Add(1)
+ go m.notifier()
+
+ m.wg.Add(1)
+ go m.schedulerLoop()
+
+ if m.dbusConn != nil {
+ m.wg.Add(1)
+ go m.dbusMonitor()
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ if config.Enabled {
+ m.post(func() {
+ log.Info("Gamma control enabled at startup")
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ m.availOutputsMu.RLock()
+ outs := slices.Clone(m.availableOutputs)
+ m.availOutputsMu.RUnlock()
+ if err := m.setupOutputControls(outs, gammaMgr); err != nil {
+ log.Errorf("Failed to initialize gamma controls: %v", err)
+ return
+ }
+ m.controlsInitialized = true
+ })
+ }
+
+ return m, nil
+}
+
+func (m *Manager) post(fn func()) {
+ select {
+ case m.cmdq <- cmd{fn: fn}:
+ default:
+ log.Warn("Actor command queue full")
+ }
+}
+
+func (m *Manager) waylandActor() {
+ defer m.wg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case c := <-m.cmdq:
+ c.fn()
+ }
+ }
+}
+
+func (m *Manager) anyOutputReady() bool {
+ anyReady := false
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ if out.rampSize > 0 && !out.failed {
+ anyReady = true
+ return false // stop iteration
+ }
+ return true
+ })
+ return anyReady
+}
+
+func (m *Manager) setupDBusMonitor() error {
+ conn, err := dbus.ConnectSystemBus()
+ if err != nil {
+ return fmt.Errorf("system bus: %w", err)
+ }
+
+ matchRule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'"
+ if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil {
+ conn.Close()
+ return fmt.Errorf("add match: %w", err)
+ }
+
+ conn.Signal(m.dbusSignal)
+ m.dbusConn = conn
+ return nil
+}
+
+func (m *Manager) setupRegistry() error {
+ registry, err := m.display.GetRegistry()
+ if err != nil {
+ return fmt.Errorf("get registry: %w", err)
+ }
+ m.registry = registry
+
+ outputs := make([]*wlclient.Output, 0)
+ outputNames := make(map[uint32]string)
+ var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1
+
+ registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
+ switch e.Interface {
+ case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName:
+ manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(m.ctx)
+ version := e.Version
+ if version > 1 {
+ version = 1
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
+ gammaMgr = manager
+ }
+ case "wl_output":
+ output := wlclient.NewOutput(m.ctx)
+ version := e.Version
+ if version > 4 {
+ version = 4
+ }
+ if err := registry.Bind(e.Name, e.Interface, version, output); err != nil {
+ return
+ }
+ outputID := output.ID()
+ output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
+ outputNames[outputID] = ev.Name
+ })
+ if gammaMgr != nil {
+ outputs = append(outputs, output)
+ m.addAvailableOutput(output)
+ }
+ m.outputRegNames.Store(outputID, e.Name)
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if enabled && m.controlsInitialized {
+ m.post(func() {
+ if err := m.addOutputControl(output); err != nil {
+ log.Warnf("Failed to add output control: %v", err)
+ }
+ })
+ }
+ }
+ })
+
+ registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
+ m.post(func() {
+ var foundID uint32
+ var foundOut *outputState
+ m.outputs.Range(func(id uint32, out *outputState) bool {
+ if out.registryName == e.Name {
+ foundID = id
+ foundOut = out
+ return false
+ }
+ return true
+ })
+ if foundOut == nil {
+ return
+ }
+ if foundOut.gammaControl != nil {
+ foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
+ foundOut.gammaControl = nil
+ }
+ m.removeAvailableOutput(foundOut.output)
+ if foundOut.output != nil && !foundOut.output.IsZombie() {
+ _ = foundOut.output.Release()
+ }
+ m.outputs.Delete(foundID)
+
+ hasOutputs := false
+ m.outputs.Range(func(_ uint32, _ *outputState) bool {
+ hasOutputs = true
+ return false
+ })
+ if !hasOutputs {
+ m.controlsInitialized = false
+ }
+ })
+ })
+
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("roundtrip 1: %w", err)
+ }
+ if err := m.display.Roundtrip(); err != nil {
+ return fmt.Errorf("roundtrip 2: %w", err)
+ }
+
+ if gammaMgr == nil {
+ return errdefs.ErrNoGammaControl
+ }
+ if len(outputs) == 0 {
+ return fmt.Errorf("no outputs")
+ }
+
+ physicalOutputs := make([]*wlclient.Output, 0, len(outputs))
+ for _, output := range outputs {
+ name := outputNames[output.ID()]
+ if len(name) >= 9 && name[:9] == "HEADLESS-" {
+ continue
+ }
+ physicalOutputs = append(physicalOutputs, output)
+ }
+
+ m.gammaControl = gammaMgr
+ m.availableOutputs = physicalOutputs
+ return nil
+}
+
+func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error {
+ for _, output := range outputs {
+ control, err := manager.GetGammaControl(output)
+ if err != nil {
+ continue
+ }
+ outputID := output.ID()
+ registryName, _ := m.outputRegNames.Load(outputID)
+ outState := &outputState{
+ id: outputID,
+ registryName: registryName,
+ output: output,
+ gammaControl: control,
+ }
+ m.setupControlHandlers(outState, control)
+ m.outputs.Store(outputID, outState)
+ }
+ return nil
+}
+
+func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
+ outputID := state.id
+
+ control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
+ size := e.Size
+ m.post(func() {
+ if out, ok := m.outputs.Load(outputID); ok {
+ out.rampSize = size
+ out.failed = false
+ out.retryCount = 0
+ }
+ m.lastAppliedTemp = 0
+ m.applyCurrentTemp("gamma_size")
+ })
+ })
+
+ control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
+ m.post(func() {
+ out, ok := m.outputs.Load(outputID)
+ if !ok {
+ return
+ }
+ if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
+ ctrl.Destroy()
+ }
+ out.gammaControl = nil
+ out.failed = true
+ out.rampSize = 0
+ out.retryCount++
+ out.lastFailTime = time.Now()
+
+ if !m.outputStillValid(out) {
+ return
+ }
+
+ backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
+ time.AfterFunc(backoff, func() {
+ m.post(func() {
+ if !m.outputStillValid(out) {
+ return
+ }
+ if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
+ return
+ }
+ m.recreateOutputControl(out)
+ })
+ })
+ })
+ })
+}
+
+func (m *Manager) addAvailableOutput(o *wlclient.Output) {
+ if o == nil {
+ return
+ }
+ m.availOutputsMu.Lock()
+ defer m.availOutputsMu.Unlock()
+ if slices.Contains(m.availableOutputs, o) {
+ return
+ }
+ m.availableOutputs = append(m.availableOutputs, o)
+}
+
+func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
+ if o == nil {
+ return
+ }
+ m.availOutputsMu.Lock()
+ defer m.availOutputsMu.Unlock()
+ m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool {
+ return existing == o
+ })
+}
+
+func (m *Manager) outputStillValid(out *outputState) bool {
+ switch {
+ case out == nil:
+ return false
+ case out.output == nil:
+ return false
+ case out.output.IsZombie():
+ return false
+ }
+ m.availOutputsMu.RLock()
+ defer m.availOutputsMu.RUnlock()
+ return slices.Contains(m.availableOutputs, out.output)
+}
+
+func isConnectionDeadErr(err error) bool {
+ switch {
+ case err == nil:
+ return false
+ case errors.Is(err, syscall.EPIPE):
+ return true
+ case errors.Is(err, syscall.ECONNRESET):
+ return true
+ case errors.Is(err, syscall.EBADF):
+ return true
+ case errors.Is(err, io.EOF):
+ return true
+ }
+ return false
+}
+
+func (m *Manager) addOutputControl(output *wlclient.Output) error {
+ switch {
+ case m.connectionDead.Load():
+ return nil
+ case output == nil || output.IsZombie():
+ return nil
+ }
+
+ outputID := output.ID()
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+
+ control, err := gammaMgr.GetGammaControl(output)
+ if err != nil {
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ }
+ return err
+ }
+
+ registryName, _ := m.outputRegNames.Load(outputID)
+ outState := &outputState{
+ id: outputID,
+ registryName: registryName,
+ output: output,
+ gammaControl: control,
+ }
+ m.setupControlHandlers(outState, control)
+ m.outputs.Store(outputID, outState)
+ return nil
+}
+
+func (m *Manager) recreateOutputControl(out *outputState) error {
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ switch {
+ case m.connectionDead.Load():
+ return nil
+ case !enabled || !m.controlsInitialized:
+ return nil
+ case out.isVirtual:
+ return nil
+ case out.retryCount >= 10:
+ return nil
+ case !m.outputStillValid(out):
+ return nil
+ }
+ if _, ok := m.outputs.Load(out.id); !ok {
+ return nil
+ }
+
+ gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ if !ok {
+ return fmt.Errorf("no gamma manager")
+ }
+
+ if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() {
+ existing.Destroy()
+ out.gammaControl = nil
+ }
+
+ control, err := gammaMgr.GetGammaControl(out.output)
+ if err != nil {
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ }
+ return err
+ }
+
+ m.setupControlHandlers(out, control)
+ out.gammaControl = control
+ out.failed = false
+ return nil
+}
+
+func (m *Manager) markConnectionDead(err error) {
+ if m.connectionDead.Swap(true) {
+ return
+ }
+ log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err)
+}
+
+func (m *Manager) recalcSchedule(now time.Time) {
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ m.scheduleMutex.Lock()
+ defer m.scheduleMutex.Unlock()
+
+ dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ alreadyValid := !m.schedule.times.Sunrise.IsZero()
+ if m.schedule.calcDay.Equal(dayStart) && alreadyValid {
+ return
+ }
+
+ var times SunTimes
+ var cond SunCondition
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ dur := time.Hour
+ if config.ManualDuration != nil {
+ dur = *config.ManualDuration
+ }
+ sunrise := time.Date(now.Year(), now.Month(), now.Day(),
+ config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
+ sunset := time.Date(now.Year(), now.Month(), now.Day(),
+ config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
+ times = SunTimes{
+ Dawn: sunrise.Add(-dur),
+ Sunrise: sunrise,
+ Sunset: sunset,
+ Night: sunset.Add(dur),
+ }
+ cond = SunNormal
+ } else {
+ lat, lon := m.getLocation()
+ if lat == nil || lon == nil {
+ m.gammaState = StateStatic
+ return
+ }
+ times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
+ }
+
+ m.schedule.calcDay = dayStart
+ m.schedule.times = times
+ m.schedule.condition = cond
+
+ switch cond {
+ case SunNormal:
+ m.gammaState = StateNormal
+ tempDiff := config.HighTemp - config.LowTemp
+ if tempDiff > 0 {
+ dawnDur := times.Sunrise.Sub(times.Dawn)
+ nightDur := times.Night.Sub(times.Sunset)
+ m.schedule.dawnStepTime = time.Duration(max(1, int(dawnDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
+ m.schedule.nightStepTime = time.Duration(max(1, int(nightDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
+ }
+ case SunMidnightSun:
+ m.gammaState = StateStatic
+ case SunPolarNight:
+ m.gammaState = StateStatic
+ }
+}
+
+func (m *Manager) SetGeoClient(client geolocation.Client) {
+ m.geoClient = client
+}
+
+func (m *Manager) getLocation() (*float64, *float64) {
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ if config.Latitude != nil && config.Longitude != nil {
+ return config.Latitude, config.Longitude
+ }
+ if !config.UseIPLocation {
+ return nil, nil
+ }
+ if m.geoClient == nil {
+ return nil, nil
+ }
+
+ m.locationMutex.RLock()
+ if m.cachedIPLat != nil && m.cachedIPLon != nil {
+ lat, lon := m.cachedIPLat, m.cachedIPLon
+ m.locationMutex.RUnlock()
+ return lat, lon
+ }
+ m.locationMutex.RUnlock()
+
+ location, err := m.geoClient.GetLocation()
+ if err != nil {
+ return nil, nil
+ }
+
+ m.locationMutex.Lock()
+ m.cachedIPLat = &location.Latitude
+ m.cachedIPLon = &location.Longitude
+ m.locationMutex.Unlock()
+ return m.cachedIPLat, m.cachedIPLon
+}
+
+func (m *Manager) hasValidSchedule() bool {
+ m.scheduleMutex.RLock()
+ defer m.scheduleMutex.RUnlock()
+ return !m.schedule.times.Sunrise.IsZero()
+}
+
+func (m *Manager) getSunPosition(now time.Time) float64 {
+ m.scheduleMutex.RLock()
+ sched := m.schedule
+ state := m.gammaState
+ m.scheduleMutex.RUnlock()
+
+ if sched.times.Sunrise.IsZero() {
+ return 1.0
+ }
+
+ switch state {
+ case StateStatic:
+ if sched.condition == SunMidnightSun {
+ return 1.0
+ }
+ return 0.0
+ case StateNormal:
+ return m.getSunPositionNormal(now, sched.times)
+ }
+ return 1.0
+}
+
+func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
+ if now.Before(times.Dawn) {
+ return 0.0
+ }
+ if now.Before(times.Sunrise) {
+ return interpolate(now, times.Dawn, times.Sunrise)
+ }
+ if now.Before(times.Sunset) {
+ return 1.0
+ }
+ if now.Before(times.Night) {
+ return interpolate(now, times.Night, times.Sunset)
+ }
+ return 0.0
+}
+
+func interpolate(now time.Time, start, stop time.Time) float64 {
+ if start.Equal(stop) {
+ return 1.0
+ }
+ pos := float64(now.Sub(start)) / float64(stop.Sub(start))
+ switch {
+ case pos > 1.0:
+ return 1.0
+ case pos < 0.0:
+ return 0.0
+ default:
+ return pos
+ }
+}
+
+func (m *Manager) getTempFromPosition(pos float64) int {
+ m.configMutex.RLock()
+ low, high := m.config.LowTemp, m.config.HighTemp
+ m.configMutex.RUnlock()
+ return low + int(float64(high-low)*pos)
+}
+
+func (m *Manager) getNextDeadline(now time.Time) time.Time {
+ m.scheduleMutex.RLock()
+ sched := m.schedule
+ state := m.gammaState
+ m.scheduleMutex.RUnlock()
+
+ switch state {
+ case StateStatic:
+ return m.tomorrow(now)
+ case StateNormal:
+ return m.getDeadlineNormal(now, sched)
+ default:
+ return m.tomorrow(now)
+ }
+}
+
+func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
+ times := sched.times
+ switch {
+ case now.Before(times.Dawn):
+ return times.Dawn
+ case now.Before(times.Sunrise):
+ return now.Add(sched.dawnStepTime)
+ case now.Before(times.Sunset):
+ return times.Sunset
+ case now.Before(times.Night):
+ return now.Add(sched.nightStepTime)
+ default:
+ return m.tomorrowDawn(now)
+ }
+}
+
+func (m *Manager) tomorrowDawn(now time.Time) time.Time {
+ tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ if config.ManualSunrise != nil {
+ dur := time.Hour
+ if config.ManualDuration != nil {
+ dur = *config.ManualDuration
+ }
+ return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(),
+ config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, tomorrow.Location()).Add(-dur)
+ }
+
+ lat, lon := m.getLocation()
+ if lat == nil || lon == nil {
+ return tomorrow
+ }
+
+ times, cond := CalculateSunTimesWithTwilight(*lat, *lon, tomorrow, config.ElevationTwilight, config.ElevationDaylight)
+ if cond != SunNormal {
+ return tomorrow
+ }
+ return times.Dawn
+}
+
+func (m *Manager) tomorrow(now time.Time) time.Time {
+ return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
+}
+
+func (m *Manager) schedulerLoop() {
+ defer m.wg.Done()
+
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("startup") })
+ }
+
+ var timer *time.Timer
+ for {
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ now := time.Now()
+ m.recalcSchedule(now)
+
+ var waitDur time.Duration
+ if enabled {
+ deadline := m.getNextDeadline(now)
+ waitDur = time.Until(deadline)
+ if waitDur < time.Second {
+ waitDur = time.Second
+ }
+ } else {
+ waitDur = 24 * time.Hour
+ }
+
+ if timer != nil {
+ timer.Stop()
+ }
+ timer = time.NewTimer(waitDur)
+
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.updateTrigger:
+ timer.Stop()
+ m.scheduleMutex.Lock()
+ m.schedule.calcDay = time.Time{}
+ m.scheduleMutex.Unlock()
+ m.recalcSchedule(time.Now())
+ m.updateStateFromSchedule()
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("updateTrigger") })
+ }
+ case <-timer.C:
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if enabled {
+ m.post(func() { m.applyCurrentTemp("timer") })
+ }
+ }
+ }
+}
+
+func (m *Manager) applyCurrentTemp(_ string) {
+ if !m.controlsInitialized || !m.anyOutputReady() {
+ return
+ }
+
+ // Ensure schedule is up-to-date (handles display wake after overnight sleep)
+ m.recalcSchedule(time.Now())
+
+ m.configMutex.RLock()
+ low, high := m.config.LowTemp, m.config.HighTemp
+ m.configMutex.RUnlock()
+
+ if low == high {
+ m.applyGamma(low)
+ m.updateStateFromSchedule()
+ return
+ }
+
+ if !m.hasValidSchedule() {
+ m.updateStateFromSchedule()
+ return
+ }
+
+ now := time.Now()
+ pos := m.getSunPosition(now)
+ temp := m.getTempFromPosition(pos)
+
+ m.applyGamma(temp)
+ m.updateStateFromSchedule()
+}
+
+func (m *Manager) applyGamma(temp int) {
+ m.configMutex.RLock()
+ gamma := m.config.Gamma
+ m.configMutex.RUnlock()
+
+ switch {
+ case m.connectionDead.Load():
+ return
+ case !m.controlsInitialized:
+ return
+ case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:
+ return
+ }
+
+ var outs []*outputState
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ outs = append(outs, out)
+ return true
+ })
+ if len(outs) == 0 {
+ return
+ }
+
+ type job struct {
+ out *outputState
+ data []byte
+ }
+ var jobs []job
+
+ for _, out := range outs {
+ switch {
+ case out.failed:
+ continue
+ case out.rampSize == 0:
+ continue
+ case out.gammaControl == nil:
+ continue
+ case !m.outputStillValid(out):
+ continue
+ }
+ ramp := GenerateGammaRamp(out.rampSize, temp, gamma)
+ buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6))
+ for _, v := range ramp.Red {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ for _, v := range ramp.Green {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ for _, v := range ramp.Blue {
+ binary.Write(buf, binary.LittleEndian, v)
+ }
+ jobs = append(jobs, job{out: out, data: buf.Bytes()})
+ }
+
+ for _, j := range jobs {
+ err := m.setGammaBytes(j.out, j.data)
+ if err == nil {
+ continue
+ }
+ log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
+ j.out.failed = true
+ j.out.rampSize = 0
+ if isConnectionDeadErr(err) {
+ m.markConnectionDead(err)
+ return
+ }
+ }
+
+ m.lastAppliedTemp = temp
+ m.lastAppliedGamma = gamma
+}
+
+func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
+ if out.gammaControl == nil {
+ return fmt.Errorf("no gamma control")
+ }
+ ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
+ if !ok || ctrl == nil || ctrl.IsZombie() {
+ return fmt.Errorf("gamma control invalid")
+ }
+
+ fd, err := MemfdCreate("gamma-ramp", 0)
+ if err != nil {
+ return err
+ }
+ defer syscall.Close(fd)
+
+ if err := syscall.Ftruncate(fd, int64(len(data))); err != nil {
+ return err
+ }
+
+ dupFd, err := syscall.Dup(fd)
+ if err != nil {
+ return err
+ }
+ f := os.NewFile(uintptr(dupFd), "gamma")
+ defer f.Close()
+
+ if _, err := f.Write(data); err != nil {
+ return err
+ }
+ syscall.Seek(fd, 0, 0)
+
+ return ctrl.SetGamma(fd)
+}
+
+func (m *Manager) updateStateFromSchedule() {
+ now := time.Now()
+
+ m.configMutex.RLock()
+ config := m.config
+ m.configMutex.RUnlock()
+
+ m.scheduleMutex.RLock()
+ times := m.schedule.times
+ m.scheduleMutex.RUnlock()
+
+ var pos float64
+ var temp int
+ var isDay bool
+ var deadline time.Time
+
+ if times.Sunrise.IsZero() {
+ pos = 1.0
+ temp = config.HighTemp
+ isDay = true
+ deadline = m.tomorrow(now)
+ } else {
+ pos = m.getSunPosition(now)
+ temp = m.getTempFromPosition(pos)
+ deadline = m.getNextDeadline(now)
+ isDay = now.After(times.Sunrise) && now.Before(times.Sunset)
+ }
+
+ newState := State{
+ Config: config,
+ CurrentTemp: temp,
+ NextTransition: deadline,
+ SunriseTime: times.Sunrise,
+ SunsetTime: times.Sunset,
+ DawnTime: times.Dawn,
+ NightTime: times.Night,
+ IsDay: isDay,
+ SunPosition: pos,
+ }
+
+ m.stateMutex.Lock()
+ m.state = &newState
+ m.stateMutex.Unlock()
+
+ m.notifySubscribers()
+}
+
+func (m *Manager) notifier() {
+ defer m.notifierWg.Done()
+ const minGap = 100 * time.Millisecond
+ timer := time.NewTimer(minGap)
+ timer.Stop()
+ var pending bool
+
+ for {
+ select {
+ case <-m.stopChan:
+ timer.Stop()
+ return
+ case <-m.dirty:
+ if pending {
+ continue
+ }
+ pending = true
+ timer.Reset(minGap)
+ case <-timer.C:
+ if !pending {
+ continue
+ }
+ currentState := m.GetState()
+ if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
+ pending = false
+ continue
+ }
+ m.subscribers.Range(func(_ string, ch chan State) bool {
+ select {
+ case ch <- currentState:
+ default:
+ }
+ return true
+ })
+ stateCopy := currentState
+ m.lastNotified = &stateCopy
+ pending = false
+ }
+ }
+}
+
+func (m *Manager) dbusMonitor() {
+ defer m.wg.Done()
+ for {
+ select {
+ case <-m.stopChan:
+ return
+ case sig := <-m.dbusSignal:
+ if sig == nil {
+ continue
+ }
+ m.handleDBusSignal(sig)
+ }
+ }
+}
+
+func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
+ switch {
+ case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
+ return
+ case len(sig.Body) == 0:
+ return
+ }
+ preparing, ok := sig.Body[0].(bool)
+ if !ok || preparing {
+ return
+ }
+ m.configMutex.RLock()
+ enabled := m.config.Enabled
+ m.configMutex.RUnlock()
+ if !enabled {
+ return
+ }
+ time.AfterFunc(500*time.Millisecond, func() {
+ m.post(m.handleResume)
+ })
+}
+
+func (m *Manager) handleResume() {
+ m.configMutex.RLock()
+ stillEnabled := m.config.Enabled
+ m.configMutex.RUnlock()
+
+ switch {
+ case !stillEnabled:
+ return
+ case !m.controlsInitialized:
+ return
+ case m.connectionDead.Load():
+ return
+ }
+
+ // Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma
+ // ramp to DRM on resume; gamma_control objects stay valid. We just need
+ // to force a resend so the schedule catches up with the current time of
+ // day — the original #1235 regression was caused by lastAppliedTemp
+ // matching and the send being skipped.
+ m.recalcSchedule(time.Now())
+ m.lastAppliedTemp = 0
+ m.applyCurrentTemp("resume")
+}
+
+func (m *Manager) triggerUpdate() {
+ select {
+ case m.updateTrigger <- struct{}{}:
+ default:
+ }
+}
+
+func (m *Manager) SetConfig(config Config) error {
+ if err := config.Validate(); err != nil {
+ return err
+ }
+ m.configMutex.Lock()
+ m.config = config
+ m.configMutex.Unlock()
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetTemperature(low, high int) error {
+ m.configMutex.Lock()
+ if m.config.LowTemp == low && m.config.HighTemp == high {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.LowTemp = low
+ m.config.HighTemp = high
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetLocation(lat, lon float64) error {
+ m.configMutex.Lock()
+ if m.config.Latitude != nil && m.config.Longitude != nil &&
+ *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.Latitude = &lat
+ m.config.Longitude = &lon
+ m.config.UseIPLocation = false
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetUseIPLocation(use bool) {
+ m.configMutex.Lock()
+ if m.config.UseIPLocation == use {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.UseIPLocation = use
+ if use {
+ m.config.Latitude = nil
+ m.config.Longitude = nil
+ }
+ m.configMutex.Unlock()
+
+ if use {
+ m.locationMutex.Lock()
+ m.cachedIPLat = nil
+ m.cachedIPLon = nil
+ m.locationMutex.Unlock()
+ }
+ m.triggerUpdate()
+}
+
+func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error {
+ m.configMutex.Lock()
+ if m.config.ManualSunrise != nil && m.config.ManualSunset != nil &&
+ m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() &&
+ m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.ManualSunrise = &sunrise
+ m.config.ManualSunset = &sunset
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) ClearManualTimes() {
+ m.configMutex.Lock()
+ if m.config.ManualSunrise == nil && m.config.ManualSunset == nil {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.ManualSunrise = nil
+ m.config.ManualSunset = nil
+ m.configMutex.Unlock()
+ m.triggerUpdate()
+}
+
+func (m *Manager) SetGamma(gamma float64) error {
+ m.configMutex.Lock()
+ if m.config.Gamma == gamma {
+ m.configMutex.Unlock()
+ return nil
+ }
+ m.config.Gamma = gamma
+ err := m.config.Validate()
+ m.configMutex.Unlock()
+ if err != nil {
+ return err
+ }
+ m.triggerUpdate()
+ return nil
+}
+
+func (m *Manager) SetEnabled(enabled bool) {
+ m.configMutex.Lock()
+ wasEnabled := m.config.Enabled
+ if wasEnabled == enabled {
+ m.configMutex.Unlock()
+ return
+ }
+ m.config.Enabled = enabled
+ highTemp := m.config.HighTemp
+ m.configMutex.Unlock()
+
+ switch {
+ case enabled && !m.controlsInitialized:
+ m.post(func() {
+ gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
+ m.availOutputsMu.RLock()
+ outs := slices.Clone(m.availableOutputs)
+ m.availOutputsMu.RUnlock()
+ if err := m.setupOutputControls(outs, gammaMgr); err != nil {
+ log.Errorf("gamma: failed to create controls: %v", err)
+ return
+ }
+ m.controlsInitialized = true
+ m.triggerUpdate()
+ })
+ case enabled && !wasEnabled:
+ m.triggerUpdate()
+ case !enabled && m.controlsInitialized:
+ m.post(func() {
+ m.outputs.Range(func(id uint32, out *outputState) bool {
+ if out.gammaControl != nil {
+ out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
+ }
+ return true
+ })
+ m.outputs.Range(func(key uint32, _ *outputState) bool {
+ m.outputs.Delete(key)
+ return true
+ })
+ m.controlsInitialized = false
+ })
+ _ = highTemp
+ }
+}
+
+func (m *Manager) Close() {
+ close(m.stopChan)
+ m.wg.Wait()
+ m.notifierWg.Wait()
+
+ m.subscribers.Range(func(key string, ch chan State) bool {
+ close(ch)
+ m.subscribers.Delete(key)
+ return true
+ })
+
+ m.outputs.Range(func(_ uint32, out *outputState) bool {
+ if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok {
+ ctrl.Destroy()
+ }
+ return true
+ })
+ m.outputs.Range(func(key uint32, _ *outputState) bool {
+ m.outputs.Delete(key)
+ return true
+ })
+
+ if manager, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1); ok {
+ manager.Destroy()
+ }
+
+ if m.dbusConn != nil {
+ m.dbusConn.RemoveSignal(m.dbusSignal)
+ m.dbusConn.Close()
+ }
+}
+
+func MemfdCreate(name string, flags int) (int, error) {
+ fd, err := unix.MemfdCreate(name, flags)
+ if err != nil {
+ return -1, err
+ }
+ return fd, nil
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go
new file mode 100644
index 0000000..8c00129
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go
@@ -0,0 +1,414 @@
+package wayland
+
+import (
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
+)
+
+func TestManager_ActorSerializesOutputStateAccess(t *testing.T) {
+ m := &Manager{
+ cmdq: make(chan cmd, 8192),
+ stopChan: make(chan struct{}),
+ }
+
+ m.wg.Add(1)
+ go m.waylandActor()
+
+ state := &outputState{
+ id: 1,
+ registryName: 100,
+ rampSize: 256,
+ }
+ m.outputs.Store(state.id, state)
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.post(func() {
+ if out, ok := m.outputs.Load(state.id); ok {
+ out.rampSize = uint32(j)
+ out.failed = j%2 == 0
+ out.retryCount = j
+ out.lastFailTime = time.Now()
+ }
+ })
+ }
+ }(i)
+ }
+
+ wg.Wait()
+
+ done := make(chan struct{})
+ m.post(func() { close(done) })
+ <-done
+
+ close(m.stopChan)
+ m.wg.Wait()
+}
+
+func TestManager_ConcurrentSubscriberAccess(t *testing.T) {
+ m := &Manager{
+ stopChan: make(chan struct{}),
+ dirty: make(chan struct{}, 1),
+ updateTrigger: make(chan struct{}, 1),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ subID := string(rune('a' + id))
+ ch := m.Subscribe(subID)
+ assert.NotNil(t, ch)
+ time.Sleep(time.Millisecond)
+ m.Unsubscribe(subID)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentGetState(t *testing.T) {
+ m := &Manager{
+ state: &State{
+ CurrentTemp: 5000,
+ IsDay: true,
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 50
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ s := m.GetState()
+ assert.GreaterOrEqual(t, s.CurrentTemp, 0)
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.stateMutex.Lock()
+ m.state = &State{
+ CurrentTemp: 4000 + i*100,
+ IsDay: j%2 == 0,
+ }
+ m.stateMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ConcurrentConfigAccess(t *testing.T) {
+ m := &Manager{
+ config: DefaultConfig(),
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.RLock()
+ _ = m.config.LowTemp
+ _ = m.config.HighTemp
+ _ = m.config.Enabled
+ m.configMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.configMutex.Lock()
+ m.config.LowTemp = 3000 + j
+ m.config.HighTemp = 7000 - j
+ m.config.Enabled = j%2 == 0
+ m.configMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 30
+ const iterations = 50
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func(id int) {
+ defer wg.Done()
+ key := uint32(id)
+
+ for j := 0; j < iterations; j++ {
+ state := &outputState{
+ id: key,
+ rampSize: uint32(j),
+ failed: j%2 == 0,
+ }
+ m.outputs.Store(key, state)
+
+ if loaded, ok := m.outputs.Load(key); ok {
+ assert.Equal(t, key, loaded.id)
+ }
+
+ m.outputs.Range(func(k uint32, v *outputState) bool {
+ _ = v.rampSize
+ _ = v.failed
+ return true
+ })
+ }
+
+ m.outputs.Delete(key)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_LocationCacheConcurrentAccess(t *testing.T) {
+ m := &Manager{}
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.locationMutex.RLock()
+ _ = m.cachedIPLat
+ _ = m.cachedIPLon
+ m.locationMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ lat := float64(40 + i)
+ lon := float64(-74 + j)
+ m.locationMutex.Lock()
+ m.cachedIPLat = &lat
+ m.cachedIPLon = &lon
+ m.locationMutex.Unlock()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+func TestManager_ScheduleConcurrentAccess(t *testing.T) {
+ now := time.Now()
+ m := &Manager{
+ schedule: sunSchedule{
+ times: SunTimes{
+ Dawn: now,
+ Sunrise: now.Add(time.Hour),
+ Sunset: now.Add(12 * time.Hour),
+ Night: now.Add(13 * time.Hour),
+ },
+ },
+ }
+
+ var wg sync.WaitGroup
+ const goroutines = 20
+ const iterations = 100
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.scheduleMutex.RLock()
+ _ = m.schedule.times.Dawn
+ _ = m.schedule.times.Sunrise
+ _ = m.schedule.times.Sunset
+ _ = m.schedule.condition
+ m.scheduleMutex.RUnlock()
+ }
+ }()
+ }
+
+ for i := 0; i < goroutines/2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ m.scheduleMutex.Lock()
+ m.schedule.times.Dawn = time.Now()
+ m.schedule.times.Sunrise = time.Now().Add(time.Hour)
+ m.schedule.condition = SunNormal
+ m.scheduleMutex.Unlock()
+ }
+ }()
+ }
+
+ wg.Wait()
+}
+
+func TestInterpolate_EdgeCases(t *testing.T) {
+ now := time.Now()
+
+ tests := []struct {
+ name string
+ now time.Time
+ start time.Time
+ stop time.Time
+ expected float64
+ }{
+ {
+ name: "same start and stop",
+ now: now,
+ start: now,
+ stop: now,
+ expected: 1.0,
+ },
+ {
+ name: "now before start",
+ now: now,
+ start: now.Add(time.Hour),
+ stop: now.Add(2 * time.Hour),
+ expected: 0.0,
+ },
+ {
+ name: "now after stop",
+ now: now.Add(3 * time.Hour),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 1.0,
+ },
+ {
+ name: "now at midpoint",
+ now: now.Add(30 * time.Minute),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 0.5,
+ },
+ {
+ name: "now equals start",
+ now: now,
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 0.0,
+ },
+ {
+ name: "now equals stop",
+ now: now.Add(time.Hour),
+ start: now,
+ stop: now.Add(time.Hour),
+ expected: 1.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := interpolate(tt.now, tt.start, tt.stop)
+ assert.InDelta(t, tt.expected, result, 0.01)
+ })
+ }
+}
+
+func TestGenerateGammaRamp_ZeroSize(t *testing.T) {
+ ramp := GenerateGammaRamp(0, 5000, 1.0)
+ assert.Empty(t, ramp.Red)
+ assert.Empty(t, ramp.Green)
+ assert.Empty(t, ramp.Blue)
+}
+
+func TestGenerateGammaRamp_ValidSizes(t *testing.T) {
+ sizes := []uint32{1, 256, 1024}
+ temps := []int{1000, 4000, 6500, 10000}
+ gammas := []float64{0.5, 1.0, 2.0}
+
+ for _, size := range sizes {
+ for _, temp := range temps {
+ for _, gamma := range gammas {
+ ramp := GenerateGammaRamp(size, temp, gamma)
+ assert.Len(t, ramp.Red, int(size))
+ assert.Len(t, ramp.Green, int(size))
+ assert.Len(t, ramp.Blue, int(size))
+ }
+ }
+ }
+}
+
+func TestNotifySubscribers_NonBlocking(t *testing.T) {
+ m := &Manager{
+ dirty: make(chan struct{}, 1),
+ }
+
+ for i := 0; i < 10; i++ {
+ m.notifySubscribers()
+ }
+
+ assert.Len(t, m.dirty, 1)
+}
+
+func TestNewManager_GetRegistryError(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ mockDisplay.EXPECT().Context().Return(nil)
+ mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
+
+ config := DefaultConfig()
+ _, err := NewManager(mockDisplay, config)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "get registry")
+}
+
+func TestNewManager_InvalidConfig(t *testing.T) {
+ mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
+
+ config := Config{
+ LowTemp: 500,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ }
+
+ _, err := NewManager(mockDisplay, config)
+ assert.Error(t, err)
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go
new file mode 100644
index 0000000..c978a8e
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go
@@ -0,0 +1,122 @@
+package wayland
+
+import (
+ "math"
+ "time"
+)
+
+const (
+ degToRad = math.Pi / 180.0
+ radToDeg = 180.0 / math.Pi
+)
+
+type SunCondition int
+
+const (
+ SunNormal SunCondition = iota
+ SunMidnightSun
+ SunPolarNight
+)
+
+type SunTimes struct {
+ Dawn time.Time
+ Sunrise time.Time
+ Sunset time.Time
+ Night time.Time
+}
+
+func daysInYear(year int) int {
+ if (year%4 == 0 && year%100 != 0) || year%400 == 0 {
+ return 366
+ }
+ return 365
+}
+
+func dateOrbitAngle(t time.Time) float64 {
+ return 2 * math.Pi / float64(daysInYear(t.Year())) * float64(t.YearDay()-1)
+}
+
+func equationOfTime(orbitAngle float64) float64 {
+ return 4 * (0.000075 +
+ 0.001868*math.Cos(orbitAngle) -
+ 0.032077*math.Sin(orbitAngle) -
+ 0.014615*math.Cos(2*orbitAngle) -
+ 0.040849*math.Sin(2*orbitAngle))
+}
+
+func sunDeclination(orbitAngle float64) float64 {
+ return 0.006918 -
+ 0.399912*math.Cos(orbitAngle) +
+ 0.070257*math.Sin(orbitAngle) -
+ 0.006758*math.Cos(2*orbitAngle) +
+ 0.000907*math.Sin(2*orbitAngle) -
+ 0.002697*math.Cos(3*orbitAngle) +
+ 0.00148*math.Sin(3*orbitAngle)
+}
+
+func sunHourAngle(latRad, declination, targetSunRad float64) float64 {
+ return math.Acos(math.Cos(targetSunRad)/
+ math.Cos(latRad)*math.Cos(declination) -
+ math.Tan(latRad)*math.Tan(declination))
+}
+
+func hourAngleToSeconds(hourAngle, eqtime float64) float64 {
+ return radToDeg * (4.0*math.Pi - 4*hourAngle - eqtime) * 60
+}
+
+func sunCondition(latRad, declination float64) SunCondition {
+ signLat := latRad >= 0
+ signDecl := declination >= 0
+ if signLat == signDecl {
+ return SunMidnightSun
+ }
+ return SunPolarNight
+}
+
+func CalculateSunTimesWithTwilight(lat, lon float64, date time.Time, elevTwilight, elevDaylight float64) (SunTimes, SunCondition) {
+ latRad := lat * degToRad
+ elevTwilightRad := (90.833 - elevTwilight) * degToRad
+ elevDaylightRad := (90.833 - elevDaylight) * degToRad
+
+ utc := date.UTC()
+ orbitAngle := dateOrbitAngle(utc)
+ decl := sunDeclination(orbitAngle)
+ eqtime := equationOfTime(orbitAngle)
+
+ haTwilight := sunHourAngle(latRad, decl, elevTwilightRad)
+ haDaylight := sunHourAngle(latRad, decl, elevDaylightRad)
+
+ if math.IsNaN(haTwilight) || math.IsNaN(haDaylight) {
+ cond := sunCondition(latRad, decl)
+ return SunTimes{}, cond
+ }
+
+ dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
+ lonOffset := time.Duration(-lon*4) * time.Minute
+
+ dawnSecs := hourAngleToSeconds(math.Abs(haTwilight), eqtime)
+ sunriseSecs := hourAngleToSeconds(math.Abs(haDaylight), eqtime)
+ sunsetSecs := hourAngleToSeconds(-math.Abs(haDaylight), eqtime)
+ nightSecs := hourAngleToSeconds(-math.Abs(haTwilight), eqtime)
+
+ return SunTimes{
+ Dawn: dayStart.Add(time.Duration(dawnSecs)*time.Second + lonOffset).In(date.Location()),
+ Sunrise: dayStart.Add(time.Duration(sunriseSecs)*time.Second + lonOffset).In(date.Location()),
+ Sunset: dayStart.Add(time.Duration(sunsetSecs)*time.Second + lonOffset).In(date.Location()),
+ Night: dayStart.Add(time.Duration(nightSecs)*time.Second + lonOffset).In(date.Location()),
+ }, SunNormal
+}
+
+func CalculateSunTimes(lat, lon float64, date time.Time) SunTimes {
+ times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0)
+ switch cond {
+ case SunMidnightSun:
+ dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
+ dayEnd := dayStart.Add(24*time.Hour - time.Second)
+ return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayEnd, Night: dayEnd}
+ case SunPolarNight:
+ dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
+ return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayStart, Night: dayStart}
+ }
+ return times
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go
new file mode 100644
index 0000000..b2815d9
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go
@@ -0,0 +1,387 @@
+package wayland
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+func calculateTemperature(config Config, now time.Time) int {
+ if !config.Enabled {
+ return config.HighTemp
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return config.HighTemp
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return config.HighTemp
+ }
+
+ if now.Before(sunrise) || now.After(sunset) {
+ return config.LowTemp
+ }
+ return config.HighTemp
+}
+
+func calculateNextTransition(config Config, now time.Time) time.Time {
+ if !config.Enabled {
+ return now.Add(24 * time.Hour)
+ }
+
+ var sunrise, sunset time.Time
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Date()
+ loc := now.Location()
+
+ sunrise = time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ sunset = time.Date(year, month, day,
+ config.ManualSunset.Hour(),
+ config.ManualSunset.Minute(),
+ config.ManualSunset.Second(), 0, loc)
+
+ if sunset.Before(sunrise) {
+ sunset = sunset.Add(24 * time.Hour)
+ }
+ } else if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ times := CalculateSunTimes(*lat, *lon, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else if config.Latitude != nil && config.Longitude != nil {
+ times := CalculateSunTimes(*config.Latitude, *config.Longitude, now)
+ sunrise = times.Sunrise
+ sunset = times.Sunset
+ } else {
+ return now.Add(24 * time.Hour)
+ }
+
+ if now.Before(sunrise) {
+ return sunrise
+ }
+ if now.Before(sunset) {
+ return sunset
+ }
+
+ if config.ManualSunrise != nil && config.ManualSunset != nil {
+ year, month, day := now.Add(24 * time.Hour).Date()
+ loc := now.Location()
+ nextSunrise := time.Date(year, month, day,
+ config.ManualSunrise.Hour(),
+ config.ManualSunrise.Minute(),
+ config.ManualSunrise.Second(), 0, loc)
+ return nextSunrise
+ }
+
+ if config.UseIPLocation {
+ lat, lon, err := FetchIPLocation()
+ if err != nil {
+ return now.Add(24 * time.Hour)
+ }
+ nextDayTimes := CalculateSunTimes(*lat, *lon, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ if config.Latitude != nil && config.Longitude != nil {
+ nextDayTimes := CalculateSunTimes(*config.Latitude, *config.Longitude, now.Add(24*time.Hour))
+ return nextDayTimes.Sunrise
+ }
+
+ return now.Add(24 * time.Hour)
+}
+
+func TestCalculateSunTimes(t *testing.T) {
+ tests := []struct {
+ name string
+ lat float64
+ lon float64
+ date time.Time
+ checkFunc func(*testing.T, SunTimes)
+ }{
+ {
+ name: "new_york_summer",
+ lat: 40.7128,
+ lon: -74.0060,
+ date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 4 || times.Sunrise.Hour() > 6 {
+ t.Logf("sunrise: %v", times.Sunrise)
+ }
+ if times.Sunset.Hour() < 19 || times.Sunset.Hour() > 21 {
+ t.Logf("sunset: %v", times.Sunset)
+ }
+ if !times.Sunset.After(times.Sunrise) {
+ t.Error("sunset should be after sunrise")
+ }
+ },
+ },
+ {
+ name: "london_winter",
+ lat: 51.5074,
+ lon: -0.1278,
+ date: time.Date(2024, 12, 21, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 7 || times.Sunrise.Hour() > 9 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 15 || times.Sunset.Hour() > 17 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ {
+ name: "equator_equinox",
+ lat: 0.0,
+ lon: 0.0,
+ date: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC),
+ checkFunc: func(t *testing.T, times SunTimes) {
+ if times.Sunrise.Hour() < 5 || times.Sunrise.Hour() > 7 {
+ t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour())
+ }
+ if times.Sunset.Hour() < 17 || times.Sunset.Hour() > 19 {
+ t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour())
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ times := CalculateSunTimes(tt.lat, tt.lon, tt.date)
+ tt.checkFunc(t, times)
+ })
+ }
+}
+
+func TestCalculateTemperature(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ timeFunc func() time.Time
+ wantTemp int
+ }{
+ {
+ name: "midnight",
+ timeFunc: func() time.Time { return times.Sunrise.Add(-4 * time.Hour) },
+ wantTemp: 4000,
+ },
+ {
+ name: "sunrise",
+ timeFunc: func() time.Time { return times.Sunrise },
+ wantTemp: 6500,
+ },
+ {
+ name: "noon",
+ timeFunc: func() time.Time { return times.Sunrise.Add(6 * time.Hour) },
+ wantTemp: 6500,
+ },
+ {
+ name: "after_sunset_transition",
+ timeFunc: func() time.Time { return times.Sunset.Add(2 * time.Hour) },
+ wantTemp: 4000,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.timeFunc())
+
+ if math.Abs(float64(temp-tt.wantTemp)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.wantTemp)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureManualTimes(t *testing.T) {
+ sunrise := time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)
+ sunset := time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: &sunrise,
+ ManualSunset: &sunset,
+ Enabled: true,
+ }
+
+ tests := []struct {
+ name string
+ time time.Time
+ want int
+ }{
+ {"before_sunrise", time.Date(2024, 1, 1, 3, 0, 0, 0, time.Local), 4000},
+ {"at_sunrise", time.Date(2024, 1, 1, 6, 30, 0, 0, time.Local), 6500},
+ {"midday", time.Date(2024, 1, 1, 12, 0, 0, 0, time.Local), 6500},
+ {"at_sunset", time.Date(2024, 1, 1, 18, 30, 0, 0, time.Local), 6500},
+ {"after_sunset", time.Date(2024, 1, 1, 22, 0, 0, 0, time.Local), 4000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ temp := calculateTemperature(config, tt.time)
+ if math.Abs(float64(temp-tt.want)) > 500 {
+ t.Errorf("temperature = %d, want approximately %d", temp, tt.want)
+ }
+ })
+ }
+}
+
+func TestCalculateTemperatureDisabled(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: false,
+ }
+
+ temp := calculateTemperature(config, time.Now())
+ if temp != 6500 {
+ t.Errorf("disabled should return high temp, got %d", temp)
+ }
+}
+
+func TestCalculateNextTransition(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local)
+
+ config := Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: &lat,
+ Longitude: &lon,
+ Enabled: true,
+ }
+
+ times := CalculateSunTimes(lat, lon, date)
+
+ tests := []struct {
+ name string
+ now time.Time
+ checkFunc func(*testing.T, time.Time)
+ }{
+ {
+ name: "before_sunrise",
+ now: times.Sunrise.Add(-2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.Equal(times.Sunrise) && !next.After(times.Sunrise.Add(-1*time.Minute)) {
+ t.Error("next transition should be at or near sunrise")
+ }
+ },
+ },
+ {
+ name: "after_sunrise",
+ now: times.Sunrise.Add(2 * time.Hour),
+ checkFunc: func(t *testing.T, next time.Time) {
+ if !next.After(times.Sunrise) {
+ t.Error("next transition should be after sunrise")
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ next := calculateNextTransition(config, tt.now)
+ tt.checkFunc(t, next)
+ })
+ }
+}
+
+func TestSunTimesWithTwilight(t *testing.T) {
+ lat := 40.7128
+ lon := -74.0060
+ date := time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local)
+
+ times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0)
+
+ if cond != SunNormal {
+ t.Errorf("expected SunNormal, got %v", cond)
+ }
+ if !times.Dawn.Before(times.Sunrise) {
+ t.Error("dawn should be before sunrise")
+ }
+ if !times.Sunrise.Before(times.Sunset) {
+ t.Error("sunrise should be before sunset")
+ }
+ if !times.Sunset.Before(times.Night) {
+ t.Error("sunset should be before night")
+ }
+}
+
+func TestSunConditions(t *testing.T) {
+ tests := []struct {
+ name string
+ lat float64
+ date time.Time
+ expected SunCondition
+ }{
+ {
+ name: "normal_conditions",
+ lat: 40.0,
+ date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.UTC),
+ expected: SunNormal,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, cond := CalculateSunTimesWithTwilight(tt.lat, 0, tt.date, -6.0, 3.0)
+ if cond != tt.expected {
+ t.Errorf("expected condition %v, got %v", tt.expected, cond)
+ }
+ })
+ }
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go
new file mode 100644
index 0000000..31bac15
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go
@@ -0,0 +1,216 @@
+package wayland
+
+import (
+ "math"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
+ wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
+ "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
+ "github.com/godbus/dbus/v5"
+)
+
+type GammaState int
+
+const (
+ StateNormal GammaState = iota
+ StateTransition
+ StateStatic
+)
+
+type Config struct {
+ Outputs []string
+ LowTemp int
+ HighTemp int
+ Latitude *float64
+ Longitude *float64
+ UseIPLocation bool
+ ManualSunrise *time.Time
+ ManualSunset *time.Time
+ ManualDuration *time.Duration
+ Gamma float64
+ Enabled bool
+ ElevationTwilight float64
+ ElevationDaylight float64
+}
+
+type State struct {
+ Config Config `json:"config"`
+ CurrentTemp int `json:"currentTemp"`
+ NextTransition time.Time `json:"nextTransition"`
+ SunriseTime time.Time `json:"sunriseTime"`
+ SunsetTime time.Time `json:"sunsetTime"`
+ DawnTime time.Time `json:"dawnTime"`
+ NightTime time.Time `json:"nightTime"`
+ IsDay bool `json:"isDay"`
+ SunPosition float64 `json:"sunPosition"`
+}
+
+type cmd struct {
+ fn func()
+}
+
+type sunSchedule struct {
+ times SunTimes
+ condition SunCondition
+ dawnStepTime time.Duration
+ nightStepTime time.Duration
+ calcDay time.Time
+}
+
+type Manager struct {
+ config Config
+ configMutex sync.RWMutex
+ state *State
+ stateMutex sync.RWMutex
+
+ display wlclient.WaylandDisplay
+ ctx *wlclient.Context
+ registry *wlclient.Registry
+ gammaControl any
+ availableOutputs []*wlclient.Output
+ availOutputsMu sync.RWMutex
+ outputRegNames syncmap.Map[uint32, uint32]
+ outputs syncmap.Map[uint32, *outputState]
+ controlsInitialized bool
+ connectionDead atomic.Bool
+
+ cmdq chan cmd
+ alive bool
+
+ stopChan chan struct{}
+ updateTrigger chan struct{}
+ wg sync.WaitGroup
+
+ schedule sunSchedule
+ scheduleMutex sync.RWMutex
+ gammaState GammaState
+
+ cachedIPLat *float64
+ cachedIPLon *float64
+ locationMutex sync.RWMutex
+
+ subscribers syncmap.Map[string, chan State]
+ dirty chan struct{}
+ notifierWg sync.WaitGroup
+ lastNotified *State
+
+ dbusConn *dbus.Conn
+ dbusSignal chan *dbus.Signal
+
+ geoClient geolocation.Client
+
+ lastAppliedTemp int
+ lastAppliedGamma float64
+}
+
+type outputState struct {
+ id uint32
+ registryName uint32
+ output *wlclient.Output
+ gammaControl any
+ rampSize uint32
+ failed bool
+ isVirtual bool
+ retryCount int
+ lastFailTime time.Time
+}
+
+func DefaultConfig() Config {
+ return Config{
+ Outputs: []string{},
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: false,
+ ElevationTwilight: -6.0,
+ ElevationDaylight: 3.0,
+ }
+}
+
+func (c *Config) Validate() error {
+ if c.LowTemp < 1000 || c.LowTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.HighTemp < 1000 || c.HighTemp > 10000 {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.LowTemp > c.HighTemp {
+ return errdefs.ErrInvalidTemperature
+ }
+ if c.Gamma <= 0 || c.Gamma > 10 {
+ return errdefs.ErrInvalidGamma
+ }
+ if c.Latitude != nil && (math.Abs(*c.Latitude) > 90) {
+ return errdefs.ErrInvalidLocation
+ }
+ if c.Longitude != nil && (math.Abs(*c.Longitude) > 180) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.Latitude != nil) != (c.Longitude != nil) {
+ return errdefs.ErrInvalidLocation
+ }
+ if (c.ManualSunrise != nil) != (c.ManualSunset != nil) {
+ return errdefs.ErrInvalidManualTimes
+ }
+ return nil
+}
+
+func (m *Manager) GetState() State {
+ m.stateMutex.RLock()
+ defer m.stateMutex.RUnlock()
+ if m.state == nil {
+ return State{}
+ }
+ return *m.state
+}
+
+func (m *Manager) Subscribe(id string) chan State {
+ ch := make(chan State, 64)
+ m.subscribers.Store(id, ch)
+ return ch
+}
+
+func (m *Manager) Unsubscribe(id string) {
+ if val, ok := m.subscribers.LoadAndDelete(id); ok {
+ close(val)
+ }
+}
+
+func (m *Manager) notifySubscribers() {
+ select {
+ case m.dirty <- struct{}{}:
+ default:
+ }
+}
+
+func stateChanged(old, new *State) bool {
+ if old == nil || new == nil {
+ return true
+ }
+ if old.CurrentTemp != new.CurrentTemp {
+ return true
+ }
+ if old.IsDay != new.IsDay {
+ return true
+ }
+ if !old.NextTransition.Equal(new.NextTransition) {
+ return true
+ }
+ if !old.SunriseTime.Equal(new.SunriseTime) {
+ return true
+ }
+ if !old.SunsetTime.Equal(new.SunsetTime) {
+ return true
+ }
+ if old.Config.Enabled != new.Config.Enabled {
+ return true
+ }
+ if old.SunPosition != new.SunPosition {
+ return true
+ }
+ return false
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go
new file mode 100644
index 0000000..f5bbb42
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go
@@ -0,0 +1,330 @@
+package wayland
+
+import (
+ "testing"
+ "time"
+)
+
+func TestConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config Config
+ wantErr bool
+ }{
+ {
+ name: "valid_default",
+ config: DefaultConfig(),
+ wantErr: false,
+ },
+ {
+ name: "valid_with_location",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40.7128),
+ Longitude: floatPtr(-74.0060),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "valid_manual_times",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_low_temp_too_low",
+ config: Config{
+ LowTemp: 500,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_low_temp_too_high",
+ config: Config{
+ LowTemp: 15000,
+ HighTemp: 20000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_high_temp_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 500,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "valid_temps_equal",
+ config: Config{
+ LowTemp: 5000,
+ HighTemp: 5000,
+ Gamma: 1.0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid_temps_reversed",
+ config: Config{
+ LowTemp: 6500,
+ HighTemp: 4000,
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_zero",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_negative",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: -1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_gamma_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 15.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(-100),
+ Longitude: floatPtr(0),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_high",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_too_low",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Longitude: floatPtr(-200),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_latitude_without_longitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Latitude: floatPtr(40),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_longitude_without_latitude",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Longitude: floatPtr(-74),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunrise_without_sunset",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ {
+ name: "invalid_sunset_without_sunrise",
+ config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)),
+ Gamma: 1.0,
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestDefaultConfig(t *testing.T) {
+ config := DefaultConfig()
+
+ if config.LowTemp != 4000 {
+ t.Errorf("default low temp = %d, want 4000", config.LowTemp)
+ }
+ if config.HighTemp != 6500 {
+ t.Errorf("default high temp = %d, want 6500", config.HighTemp)
+ }
+ if config.Gamma != 1.0 {
+ t.Errorf("default gamma = %f, want 1.0", config.Gamma)
+ }
+ if config.Enabled {
+ t.Error("default should be disabled")
+ }
+ if config.Latitude != nil {
+ t.Error("default should not have latitude")
+ }
+ if config.Longitude != nil {
+ t.Error("default should not have longitude")
+ }
+}
+
+func TestStateChanged(t *testing.T) {
+ baseState := &State{
+ CurrentTemp: 5000,
+ NextTransition: time.Now(),
+ SunriseTime: time.Now().Add(6 * time.Hour),
+ SunsetTime: time.Now().Add(18 * time.Hour),
+ IsDay: true,
+ Config: DefaultConfig(),
+ }
+
+ tests := []struct {
+ name string
+ old *State
+ new *State
+ wantChanged bool
+ }{
+ {
+ name: "nil_old",
+ old: nil,
+ new: baseState,
+ wantChanged: true,
+ },
+ {
+ name: "nil_new",
+ old: baseState,
+ new: nil,
+ wantChanged: true,
+ },
+ {
+ name: "same_state",
+ old: baseState,
+ new: baseState,
+ wantChanged: false,
+ },
+ {
+ name: "temp_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: 6000,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "is_day_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: false,
+ Config: baseState.Config,
+ },
+ wantChanged: true,
+ },
+ {
+ name: "enabled_changed",
+ old: baseState,
+ new: &State{
+ CurrentTemp: baseState.CurrentTemp,
+ NextTransition: baseState.NextTransition,
+ SunriseTime: baseState.SunriseTime,
+ SunsetTime: baseState.SunsetTime,
+ IsDay: baseState.IsDay,
+ Config: Config{
+ LowTemp: 4000,
+ HighTemp: 6500,
+ Gamma: 1.0,
+ Enabled: true,
+ },
+ },
+ wantChanged: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := stateChanged(tt.old, tt.new)
+ if changed != tt.wantChanged {
+ t.Errorf("stateChanged() = %v, want %v", changed, tt.wantChanged)
+ }
+ })
+ }
+}
+
+func floatPtr(f float64) *float64 {
+ return &f
+}
+
+func timePtr(t time.Time) *time.Time {
+ return &t
+}