diff --git a/cmd/cyclone2/daemon.go b/cmd/cyclone2/daemon.go index 4a46404..86d23a8 100644 --- a/cmd/cyclone2/daemon.go +++ b/cmd/cyclone2/daemon.go @@ -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") @@ -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 { @@ -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 diff --git a/cmd/cyclone2/daemon_settle_test.go b/cmd/cyclone2/daemon_settle_test.go new file mode 100644 index 0000000..557655d --- /dev/null +++ b/cmd/cyclone2/daemon_settle_test.go @@ -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)) + } +}