Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/cyclone2/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ func isOurController(ev uevent.Event) bool {
return ev.Matches("3537") || ev.Matches("54c/9cc") || ev.Matches("57e/2009")
}

// settleDelays are short re-poll offsets applied after a controller (re)connect.
// A physical mode switch makes the controller re-enumerate (USB remove+add); the
// immediate post-add poll can read the device mid-transition (openHID rides the
// ~1.5s udev uaccess race, and reader.Read / power_supply attrs may not be ready
// yet, yielding a Stale read that carries the previous mode's percent). These
// follow-up polls land once the new mode has settled, so the correct value
// reaches the frontends within seconds rather than after the next poll interval.
var settleDelays = []time.Duration{1 * time.Second, 3 * time.Second}

// scheduleSettle arms a one-shot timer per settle delay; each fires a
// non-blocking send into ch (extra sends coalesce when ch's buffer is full, so
// bursts of reconnects never block or pile up). after is injectable for tests;
// production passes a time.AfterFunc wrapper.
func scheduleSettle(after func(time.Duration, func()), ch chan<- struct{}) {
for _, d := range settleDelays {
after(d, func() {
select {
case ch <- struct{}{}:
default:
}
})
}
}

func runDaemon(args []string) error {
fs := flag.NewFlagSet("daemon", flag.ContinueOnError)
intervalFlag := fs.String("interval", "", "poll interval (Go duration, e.g. 30s); overrides config")
Expand Down Expand Up @@ -57,6 +81,7 @@ func runDaemon(args []string) error {
}

configCh := make(chan struct{}, 1)
settleCh := make(chan struct{}, 1)
if cw, err := watchConfig(configCh); err != nil {
log.Printf("config watch unavailable (%v); interval changes apply on next tick", err)
} else {
Expand Down Expand Up @@ -101,7 +126,12 @@ func runDaemon(args []string) error {
resetRGBState() // freshly connected: re-enter static mode once
poll()
applyRGBFromConfig() // re-apply lighting on reconnect
// A mode switch re-enumerates the device; re-poll once it settles
// so the new mode's battery value isn't shown wrong until next tick.
scheduleSettle(func(d time.Duration, fn func()) { time.AfterFunc(d, fn) }, settleCh)
}
case <-settleCh:
poll()
case <-configCh:
reapply()
applyRGBFromConfig() // lighting settings may have changed
Expand Down
34 changes: 34 additions & 0 deletions cmd/cyclone2/daemon_settle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"testing"
"time"
)

// scheduleSettle should arm one timer per configured delay, preserving order.
func TestScheduleSettleArmsOnePerDelay(t *testing.T) {
var got []time.Duration
ch := make(chan struct{}, 1)
scheduleSettle(func(d time.Duration, _ func()) { got = append(got, d) }, ch)

if len(got) != len(settleDelays) {
t.Fatalf("armed %d timers, want %d", len(got), len(settleDelays))
}
for i, d := range settleDelays {
if got[i] != d {
t.Fatalf("delay[%d]=%v, want %v", i, got[i], d)
}
}
}

// When several settle timers fire near-simultaneously, the non-blocking send
// into a size-1 channel must coalesce them to a single queued poll trigger.
func TestScheduleSettleSendsCoalesce(t *testing.T) {
ch := make(chan struct{}, 1)
// after invokes fn immediately, simulating all timers firing at once.
scheduleSettle(func(_ time.Duration, fn func()) { fn() }, ch)

if len(ch) != 1 {
t.Fatalf("coalesced sends = %d, want 1 (channel buffer size)", len(ch))
}
}
Loading