1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-10 07:25:37 -05:00

switch hto monorepo structure

This commit is contained in:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View File

@@ -0,0 +1,88 @@
package wayland
import (
"math"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/utils"
)
type GammaRamp struct {
Red []uint16
Green []uint16
Blue []uint16
}
func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp {
ramp := GammaRamp{
Red: make([]uint16, size),
Green: make([]uint16, size),
Blue: make([]uint16, size),
}
for i := uint32(0); i < size; i++ {
val := float64(i) / float64(size-1)
valGamma := math.Pow(val, 1.0/gamma)
r, g, b := temperatureToRGB(temp)
ramp.Red[i] = uint16(utils.Clamp(valGamma*r*65535.0, 0, 65535))
ramp.Green[i] = uint16(utils.Clamp(valGamma*g*65535.0, 0, 65535))
ramp.Blue[i] = uint16(utils.Clamp(valGamma*b*65535.0, 0, 65535))
}
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
}
func temperatureToRGB(temp int) (float64, float64, float64) {
tempK := float64(temp) / 100.0
var r, g, b float64
if tempK <= 66 {
r = 1.0
} else {
r = tempK - 60
r = 329.698727446 * math.Pow(r, -0.1332047592)
r = utils.Clamp(r, 0, 255) / 255.0
}
if tempK <= 66 {
g = tempK
g = 99.4708025861*math.Log(g) - 161.1195681661
g = utils.Clamp(g, 0, 255) / 255.0
} else {
g = tempK - 60
g = 288.1221695283 * math.Pow(g, -0.0755148492)
g = utils.Clamp(g, 0, 255) / 255.0
}
if tempK >= 66 {
b = 1.0
} else if tempK <= 19 {
b = 0.0
} else {
b = tempK - 10
b = 138.5177312231*math.Log(b) - 305.0447927307
b = utils.Clamp(b, 0, 255) / 255.0
}
return r, g, b
}

View File

@@ -0,0 +1,120 @@
package wayland
import (
"testing"
"github.com/AvengeMedia/DankMaterialShell/backend/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 TestTemperatureToRGB(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) {
r, g, b := temperatureToRGB(tt.temp)
if r < 0 || r > 1 {
t.Errorf("red out of range: %f", r)
}
if g < 0 || g > 1 {
t.Errorf("green out of range: %f", g)
}
if b < 0 || b > 1 {
t.Errorf("blue out of range: %f", b)
}
})
}
}
func TestTemperatureProgression(t *testing.T) {
temps := []int{3000, 4000, 5000, 6000, 6500}
var prevBlue float64
for i, temp := range temps {
_, _, b := temperatureToRGB(temp)
if i > 0 && b < prevBlue {
t.Errorf("blue should increase with temperature, %d->%d: %f->%f",
temps[i-1], temp, prevBlue, b)
}
prevBlue = 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)
}
}
}

View File

@@ -0,0 +1,50 @@
package wayland
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/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
}

View File

@@ -0,0 +1,205 @@
package wayland
import (
"encoding/json"
"fmt"
"net"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/server/models"
)
type Request struct {
ID int `json:"id,omitempty"`
Method string `json:"method"`
Params map[string]interface{} `json:"params,omitempty"`
}
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn net.Conn, req 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 Request, manager *Manager) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleSetTemperature(conn net.Conn, req Request, manager *Manager) {
var lowTemp, highTemp int
if temp, ok := req.Params["temp"].(float64); ok {
lowTemp = int(temp)
highTemp = int(temp)
} else {
low, okLow := req.Params["low"].(float64)
high, okHigh := req.Params["high"].(float64)
if !okLow || !okHigh {
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, SuccessResult{Success: true, Message: "temperature set"})
}
func handleSetLocation(conn net.Conn, req Request, manager *Manager) {
lat, ok := req.Params["latitude"].(float64)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'latitude' parameter")
return
}
lon, ok := req.Params["longitude"].(float64)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'longitude' parameter")
return
}
if err := manager.SetLocation(lat, lon); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "location set"})
}
func handleSetManualTimes(conn net.Conn, req Request, manager *Manager) {
sunriseParam := req.Params["sunrise"]
sunsetParam := req.Params["sunset"]
if sunriseParam == nil || sunsetParam == nil {
manager.ClearManualTimes()
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times cleared"})
return
}
sunriseStr, ok := sunriseParam.(string)
if !ok || sunriseStr == "" {
manager.ClearManualTimes()
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "manual times cleared"})
return
}
sunsetStr, ok := sunsetParam.(string)
if !ok || sunsetStr == "" {
manager.ClearManualTimes()
models.Respond(conn, req.ID, 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, SuccessResult{Success: true, Message: "manual times set"})
}
func handleSetUseIPLocation(conn net.Conn, req Request, manager *Manager) {
use, ok := req.Params["use"].(bool)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'use' parameter")
return
}
manager.SetUseIPLocation(use)
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "IP location preference set"})
}
func handleSetGamma(conn net.Conn, req Request, manager *Manager) {
gamma, ok := req.Params["gamma"].(float64)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'gamma' parameter")
return
}
if err := manager.SetGamma(gamma); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "gamma set"})
}
func handleSetEnabled(conn net.Conn, req Request, manager *Manager) {
enabled, ok := req.Params["enabled"].(bool)
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'enabled' parameter")
return
}
manager.SetEnabled(enabled)
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "enabled state set"})
}
func handleSubscribe(conn net.Conn, req 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
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
package wayland
import (
"math"
"time"
)
const (
degToRad = math.Pi / 180.0
radToDeg = 180.0 / math.Pi
solarNoon = 12.0
sunriseAngle = -0.833
)
func CalculateSunTimes(lat, lon float64, date time.Time) SunTimes {
utcDate := date.UTC()
year, month, day := utcDate.Date()
loc := date.Location()
dayOfYear := utcDate.YearDay()
gamma := 2 * math.Pi / 365 * float64(dayOfYear-1)
eqTime := 229.18 * (0.000075 +
0.001868*math.Cos(gamma) -
0.032077*math.Sin(gamma) -
0.014615*math.Cos(2*gamma) -
0.040849*math.Sin(2*gamma))
decl := 0.006918 -
0.399912*math.Cos(gamma) +
0.070257*math.Sin(gamma) -
0.006758*math.Cos(2*gamma) +
0.000907*math.Sin(2*gamma) -
0.002697*math.Cos(3*gamma) +
0.00148*math.Sin(3*gamma)
latRad := lat * degToRad
cosHourAngle := (math.Sin(sunriseAngle*degToRad) -
math.Sin(latRad)*math.Sin(decl)) /
(math.Cos(latRad) * math.Cos(decl))
if cosHourAngle > 1 {
return SunTimes{
Sunrise: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
Sunset: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
}
}
if cosHourAngle < -1 {
return SunTimes{
Sunrise: time.Date(year, month, day, 0, 0, 0, 0, time.UTC).In(loc),
Sunset: time.Date(year, month, day, 23, 59, 59, 0, time.UTC).In(loc),
}
}
hourAngle := math.Acos(cosHourAngle) * radToDeg
sunriseTime := solarNoon - hourAngle/15.0 - lon/15.0 - eqTime/60.0
sunsetTime := solarNoon + hourAngle/15.0 - lon/15.0 - eqTime/60.0
sunrise := timeOfDayToTime(sunriseTime, year, month, day, time.UTC).In(loc)
sunset := timeOfDayToTime(sunsetTime, year, month, day, time.UTC).In(loc)
return SunTimes{
Sunrise: sunrise,
Sunset: sunset,
}
}
func timeOfDayToTime(hours float64, year int, month time.Month, day int, loc *time.Location) time.Time {
h := int(hours)
m := int((hours - float64(h)) * 60)
s := int(((hours-float64(h))*60 - float64(m)) * 60)
if h < 0 {
h += 24
day--
}
if h >= 24 {
h -= 24
day++
}
return time.Date(year, month, day, h, m, s, 0, loc)
}

View File

@@ -0,0 +1,378 @@
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 TestTimeOfDayToTime(t *testing.T) {
tests := []struct {
name string
hours float64
expected time.Time
}{
{
name: "noon",
hours: 12.0,
expected: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local),
},
{
name: "half_past",
hours: 12.5,
expected: time.Date(2024, 6, 21, 12, 30, 0, 0, time.Local),
},
{
name: "early_morning",
hours: 6.25,
expected: time.Date(2024, 6, 21, 6, 15, 0, 0, time.Local),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := timeOfDayToTime(tt.hours, 2024, 6, 21, time.Local)
if result.Hour() != tt.expected.Hour() {
t.Errorf("hour = %d, want %d", result.Hour(), tt.expected.Hour())
}
if result.Minute() != tt.expected.Minute() {
t.Errorf("minute = %d, want %d", result.Minute(), tt.expected.Minute())
}
})
}
}

View File

@@ -0,0 +1,194 @@
package wayland
import (
"math"
"sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/backend/internal/errdefs"
"github.com/godbus/dbus/v5"
wlclient "github.com/yaslama/go-wayland/wayland/client"
)
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
}
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"`
IsDay bool `json:"isDay"`
}
type cmd struct {
fn func()
}
type Manager struct {
config Config
configMutex sync.RWMutex
state *State
stateMutex sync.RWMutex
display *wlclient.Display
registry *wlclient.Registry
gammaControl interface{}
availableOutputs []*wlclient.Output
outputRegNames map[uint32]uint32
outputs map[uint32]*outputState
outputsMutex sync.RWMutex
controlsInitialized bool
cmdq chan cmd
alive bool
stopChan chan struct{}
updateTrigger chan struct{}
wg sync.WaitGroup
currentTemp int
targetTemp int
transitionMutex sync.RWMutex
transitionChan chan int
cachedIPLat *float64
cachedIPLon *float64
locationMutex sync.RWMutex
subscribers map[string]chan State
subMutex sync.RWMutex
dirty chan struct{}
notifierWg sync.WaitGroup
lastNotified *State
dbusConn *dbus.Conn
dbusSignal chan *dbus.Signal
}
type outputState struct {
id uint32
name string
registryName uint32
output *wlclient.Output
gammaControl interface{}
rampSize uint32
failed bool
isVirtual bool
retryCount int
lastFailTime time.Time
}
type SunTimes struct {
Sunrise time.Time
Sunset time.Time
}
func DefaultConfig() Config {
return Config{
Outputs: []string{},
LowTemp: 4000,
HighTemp: 6500,
Gamma: 1.0,
Enabled: false,
}
}
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{}
}
stateCopy := *m.state
return stateCopy
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 64)
m.subMutex.Lock()
m.subscribers[id] = ch
m.subMutex.Unlock()
return ch
}
func (m *Manager) Unsubscribe(id string) {
m.subMutex.Lock()
if ch, ok := m.subscribers[id]; ok {
close(ch)
delete(m.subscribers, id)
}
m.subMutex.Unlock()
}
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
}
return false
}

View File

@@ -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
}