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
114 changes: 114 additions & 0 deletions internal/beep/beep.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package beep

import (
"fmt"
"os"
"sync"
"time"
)

const (
// DefaultDebounceInterval is the default interval for debouncing beep sounds
DefaultDebounceInterval = 500 * time.Millisecond
)

// Beeper manages beep sound playback with debouncing
type Beeper struct {
enabled bool
debounceTime time.Duration
lastBeepTime time.Time
mu sync.Mutex
}

// NewBeeper creates a new Beeper instance
func NewBeeper() *Beeper {
return &Beeper{
enabled: true, // Default is ON
debounceTime: DefaultDebounceInterval,
}
}

// NewBeeperWithDebounce creates a new Beeper with custom debounce interval
func NewBeeperWithDebounce(debounceInterval time.Duration) *Beeper {
return &Beeper{
enabled: true,
debounceTime: debounceInterval,
}
}

// Beep plays a beep sound if enabled and debounce time has passed
// This function is non-blocking and thread-safe
func (b *Beeper) Beep() {
go func() {
b.mu.Lock()
defer b.mu.Unlock()

// Skip if disabled
if !b.enabled {
return
}

// Check debounce time
now := time.Now()
if now.Sub(b.lastBeepTime) < b.debounceTime {
return // Skip if within debounce period
}

// Play beep sound
if err := b.playBeep(); err != nil {
// Log error but don't propagate
// This ensures beep failures don't affect main processing
fmt.Fprintf(os.Stderr, "beep: failed to play sound: %v\n", err)
}

b.lastBeepTime = now
}()
}

// playBeep outputs the system bell character
func (b *Beeper) playBeep() error {
_, err := fmt.Print("\a")
return err
}

// Enable turns on beep sound
func (b *Beeper) Enable() {
b.mu.Lock()
defer b.mu.Unlock()
b.enabled = true
}

// Disable turns off beep sound
func (b *Beeper) Disable() {
b.mu.Lock()
defer b.mu.Unlock()
b.enabled = false
}

// Toggle switches beep sound on/off
func (b *Beeper) Toggle() {
b.mu.Lock()
defer b.mu.Unlock()
b.enabled = !b.enabled
}

// IsEnabled returns current beep sound state
func (b *Beeper) IsEnabled() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.enabled
}

// SetDebounceInterval sets the debounce interval
func (b *Beeper) SetDebounceInterval(interval time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
b.debounceTime = interval
}

// GetLastBeepTime returns the last beep time (for testing)
func (b *Beeper) GetLastBeepTime() time.Time {
b.mu.Lock()
defer b.mu.Unlock()
return b.lastBeepTime
}
145 changes: 145 additions & 0 deletions internal/beep/beep_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package beep

import (
"testing"
"time"
)

func TestNewBeeper(t *testing.T) {
beeper := NewBeeper()

if !beeper.IsEnabled() {
t.Error("Expected beeper to be enabled by default")
}

if beeper.debounceTime != DefaultDebounceInterval {
t.Errorf("Expected debounce time to be %v, got %v", DefaultDebounceInterval, beeper.debounceTime)
}
}

func TestNewBeeperWithDebounce(t *testing.T) {
customInterval := 200 * time.Millisecond
beeper := NewBeeperWithDebounce(customInterval)

if !beeper.IsEnabled() {
t.Error("Expected beeper to be enabled by default")
}

if beeper.debounceTime != customInterval {
t.Errorf("Expected debounce time to be %v, got %v", customInterval, beeper.debounceTime)
}
}

func TestToggle(t *testing.T) {
beeper := NewBeeper()

initialState := beeper.IsEnabled()
beeper.Toggle()

if beeper.IsEnabled() == initialState {
t.Error("Toggle did not change beeper state")
}

beeper.Toggle()
if beeper.IsEnabled() != initialState {
t.Error("Toggle did not restore original state")
}
}

func TestEnableDisable(t *testing.T) {
beeper := NewBeeper()

beeper.Disable()
if beeper.IsEnabled() {
t.Error("Expected beeper to be disabled")
}

beeper.Enable()
if !beeper.IsEnabled() {
t.Error("Expected beeper to be enabled")
}
}

func TestDebounce(t *testing.T) {
debounceInterval := 100 * time.Millisecond
beeper := NewBeeperWithDebounce(debounceInterval)

// First beep should work
beeper.Beep()
time.Sleep(10 * time.Millisecond) // Wait for goroutine to execute

firstBeepTime := beeper.GetLastBeepTime()
if firstBeepTime.IsZero() {
t.Error("Expected first beep to set lastBeepTime")
}

// Immediate second beep should be debounced
beeper.Beep()
time.Sleep(10 * time.Millisecond)

if !beeper.GetLastBeepTime().Equal(firstBeepTime) {
t.Error("Expected second beep to be debounced")
}

// Wait for debounce period and try again
time.Sleep(debounceInterval)
beeper.Beep()
time.Sleep(10 * time.Millisecond)

if beeper.GetLastBeepTime().Equal(firstBeepTime) {
t.Error("Expected beep to work after debounce period")
}
}

func TestBeepWhenDisabled(t *testing.T) {
beeper := NewBeeper()
beeper.Disable()

// Beep should not update lastBeepTime when disabled
beeper.Beep()
time.Sleep(10 * time.Millisecond)

if !beeper.GetLastBeepTime().IsZero() {
t.Error("Expected lastBeepTime to remain zero when disabled")
}
}

func TestConcurrentAccess(t *testing.T) {
beeper := NewBeeper()
done := make(chan bool)

// Test concurrent beep calls
for i := 0; i < 10; i++ {
go func() {
beeper.Beep()
done <- true
}()
}

// Test concurrent state changes
for i := 0; i < 10; i++ {
go func() {
beeper.Toggle()
_ = beeper.IsEnabled()
done <- true
}()
}

// Wait for all goroutines
for i := 0; i < 20; i++ {
<-done
}

// If we get here without deadlock or panic, the test passes
}

func TestSetDebounceInterval(t *testing.T) {
beeper := NewBeeper()
newInterval := 200 * time.Millisecond

beeper.SetDebounceInterval(newInterval)

if beeper.debounceTime != newInterval {
t.Errorf("Expected debounce time to be %v, got %v", newInterval, beeper.debounceTime)
}
}
2 changes: 2 additions & 0 deletions internal/stats/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type MetricsProvider interface {
// MetricsSystemManager provides system-level operations
type MetricsSystemManager interface {
ResetAllMetrics()
ToggleBeep()
IsBeepEnabled() bool
}

// MetricsEventRecorder handles internal event recording
Expand Down
23 changes: 23 additions & 0 deletions internal/stats/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"sync"
"time"

"github.com/servak/mping/internal/beep"
"github.com/servak/mping/internal/prober"
)

Expand All @@ -15,6 +16,7 @@ const (
type metricsManager struct {
metrics map[string]*metrics
historySize int // Number of history entries to keep
beeper *beep.Beeper
mu sync.Mutex
}

Expand All @@ -28,6 +30,7 @@ func NewMetricsManagerWithHistorySize(historySize int) MetricsManager {
return &metricsManager{
metrics: make(map[string]*metrics),
historySize: historySize,
beeper: beep.NewBeeper(),
}
}

Expand Down Expand Up @@ -109,6 +112,11 @@ func (mm *metricsManager) Failed(host string, sentTime time.Time, msg string) {
})
}
mm.mu.Unlock()

// Play beep sound on failure (non-blocking)
if mm.beeper != nil {
mm.beeper.Beep()
}
}

func (mm *metricsManager) Sent(host string) {
Expand Down Expand Up @@ -210,6 +218,21 @@ func (mm *metricsManager) GetMetricsAsReader(target string) Metrics {
return mm.getMetrics(target)
}

// ToggleBeep toggles beep sound on/off
func (mm *metricsManager) ToggleBeep() {
if mm.beeper != nil {
mm.beeper.Toggle()
}
}

// IsBeepEnabled returns current beep sound state
func (mm *metricsManager) IsBeepEnabled() bool {
if mm.beeper != nil {
return mm.beeper.IsEnabled()
}
return false
}

// rejectLessAscending is RTT comparison function for ascending sort
// Zero values (unmeasured) are always placed at the end
func rejectLessAscending(i, j time.Duration) bool {
Expand Down
Loading
Loading