diff --git a/cmd/main.go b/cmd/main.go index 4915599..fbef2ee 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,7 +5,9 @@ import ( "errors" "log" "os" + "os/exec" "os/signal" + "sync" "syscall" ble "github.com/richbl/go-ble-sync-cycle/internal/ble" @@ -30,12 +32,19 @@ func main() { // Load configuration cfg, err := config.LoadFile("config.toml") if err != nil { - log.Fatal(logger.Magenta+"[FATAL]" + logger.Reset + " [APP] failed to load TOML configuration: " + err.Error()) + log.Fatal(logger.Magenta + "[FATAL]" + logger.Reset + " [APP] failed to load TOML configuration: " + err.Error()) } // Initialize logger logger.Initialize(cfg.App.LogLevel) + // Configure terminal output to prevent display of break (^C) character + restoreTerm := configureTerminal() + defer restoreTerm() + + // Ensure goodbye message is always output last + defer logger.Info(logger.APP, "BLE Sync Cycle 0.6.2 shutdown complete. Goodbye!") + // Create contexts for managing goroutines and cancellations rootCtx, rootCancel := context.WithCancel(context.Background()) defer rootCancel() @@ -46,18 +55,33 @@ func main() { logger.Fatal(componentType, "failed to create controllers: "+err.Error()) } - // Run the application - if componentType, err := startAppControllers(rootCtx, controllers); err != nil { + // Create a WaitGroup to track goroutine lifetimes, and run the application controllers + var wg sync.WaitGroup + + if componentType, err := startAppControllers(rootCtx, controllers, &wg); err != nil { logger.Error(componentType, err.Error()) } - // Shutdown the application... buh bye! - log.Println("BLE Sync Cycle 0.6.2 shutdown complete. Goodbye!") + wg.Wait() // Wait here for all goroutines to finish in main()... be patient +} + +// configureTerminal handles terminal char echo to prevent display of break (^C) character +func configureTerminal() func() { + // Disable control character echo using stty + rawMode := exec.Command("stty", "-echo") + rawMode.Stdin = os.Stdin + _ = rawMode.Run() + + // Return cleanup function + return func() { + cooked := exec.Command("stty", "echo") + cooked.Stdin = os.Stdin + _ = cooked.Run() + } } // setupAppControllers creates and initializes the application controllers func setupAppControllers(cfg config.Config) (appControllers, logger.ComponentType, error) { - // Create speed and video controllers speedController := speed.NewSpeedController(cfg.Speed.SmoothingWindow) videoPlayer, err := video.NewPlaybackController(cfg.Video, cfg.Speed) @@ -79,12 +103,11 @@ func setupAppControllers(cfg config.Config) (appControllers, logger.ComponentTyp } // startAppControllers is responsible for starting and managing the component controllers -func startAppControllers(ctx context.Context, controllers appControllers) (logger.ComponentType, error) { - +func startAppControllers(ctx context.Context, controllers appControllers, wg *sync.WaitGroup) (logger.ComponentType, error) { // componentErr holds the error type and component type used for logging type componentErr struct { componentType logger.ComponentType - err error + err error } // Create shutdown signal @@ -94,38 +117,67 @@ func startAppControllers(ctx context.Context, controllers appControllers) (logge // Scan for BLE peripheral of interest bleSpeedCharacter, err := scanForBLESpeedCharacteristic(ctx, controllers) if err != nil { + + // Check if the context was cancelled (user pressed Ctrl+C) + if ctx.Err() == context.Canceled { + return logger.APP, nil + } + return logger.BLE, errors.New("BLE peripheral scan failed: " + err.Error()) } // Start component controllers concurrently errs := make(chan componentErr, 1) + // Add two goroutines to the WaitGroup + wg.Add(2) // One for BLE monitoring, one for video playback + // Monitor BLE speed (goroutine) go func() { + defer wg.Done() + if err := monitorBLESpeed(ctx, controllers, bleSpeedCharacter); err != nil { + + // Check if the context was cancelled (user pressed Ctrl+C) + if ctx.Err() == context.Canceled { + errs <- componentErr{logger.BLE, nil} + return + } + errs <- componentErr{logger.BLE, err} return } + errs <- componentErr{logger.BLE, nil} }() // Play video (goroutine) go func() { + defer wg.Done() + if err := playVideo(ctx, controllers); err != nil { + + // Check if the context was cancelled (user pressed Ctrl+C) + if ctx.Err() == context.Canceled { + errs <- componentErr{logger.VIDEO, nil} + return + } + errs <- componentErr{logger.VIDEO, err} return } + errs <- componentErr{logger.VIDEO, nil} }() - // Wait for context cancellation or error - select { - case <-ctx.Done(): - return logger.APP, ctx.Err() - case compErr := <-errs: + // Wait for both component results + for i := 0; i < 2; i++ { + compErr := <-errs + if compErr.err != nil { return compErr.componentType, compErr.err } + } return logger.APP, nil @@ -133,7 +185,6 @@ func startAppControllers(ctx context.Context, controllers appControllers) (logge // scanForBLESpeedCharacteristic scans for the BLE CSC speed characteristic func scanForBLESpeedCharacteristic(ctx context.Context, controllers appControllers) (*bluetooth.DeviceCharacteristic, error) { - // create a channel to receive the characteristic results := make(chan *bluetooth.DeviceCharacteristic, 1) errChan := make(chan error, 1) @@ -141,6 +192,7 @@ func scanForBLESpeedCharacteristic(ctx context.Context, controllers appControlle // Scan for the BLE CSC speed characteristic go func() { characteristic, err := controllers.bleController.GetBLECharacteristic(ctx, controllers.speedController) + if err != nil { errChan <- err return @@ -159,6 +211,7 @@ func scanForBLESpeedCharacteristic(ctx context.Context, controllers appControlle case characteristic := <-results: return characteristic, nil } + } // monitorBLESpeed monitors the BLE speed characteristic diff --git a/go.mod b/go.mod index 83dee50..e91130b 100644 --- a/go.mod +++ b/go.mod @@ -17,12 +17,13 @@ require ( github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect golang.org/x/exp v0.0.0-20230728194245-b0cb94b80691 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.28.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/gen2brain/go-mpv v0.2.3 github.com/stretchr/testify v1.10.0 + golang.org/x/term v0.27.0 tinygo.org/x/bluetooth v0.10.0 ) diff --git a/go.sum b/go.sum index 2d0ba37..86b07b4 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,10 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/ble/sensor_controller.go b/internal/ble/sensor_controller.go index e3aa91d..7a2c1eb 100644 --- a/internal/ble/sensor_controller.go +++ b/internal/ble/sensor_controller.go @@ -44,9 +44,9 @@ var ( // NewBLEController creates a new BLE central controller for accessing a BLE peripheral func NewBLEController(bleConfig config.BLEConfig, speedConfig config.SpeedConfig) (*BLEController, error) { - // Enable BLE adapter bleAdapter := bluetooth.DefaultAdapter + if err := bleAdapter.Enable(); err != nil { return nil, err } @@ -62,7 +62,6 @@ func NewBLEController(bleConfig config.BLEConfig, speedConfig config.SpeedConfig // ScanForBLEPeripheral scans for a BLE peripheral with the specified UUID func (m *BLEController) ScanForBLEPeripheral(ctx context.Context) (bluetooth.ScanResult, error) { - // Create context with timeout scanCtx, cancel := context.WithTimeout(ctx, time.Duration(m.bleConfig.ScanTimeoutSecs)*time.Second) defer cancel() @@ -76,6 +75,7 @@ func (m *BLEController) ScanForBLEPeripheral(ctx context.Context) (bluetooth.Sca if err := m.startScanning(found); err != nil { errChan <- err } + }() // Wait for device discovery or timeout @@ -86,20 +86,24 @@ func (m *BLEController) ScanForBLEPeripheral(ctx context.Context) (bluetooth.Sca case err := <-errChan: return bluetooth.ScanResult{}, err case <-scanCtx.Done(): + if err := m.bleAdapter.StopScan(); err != nil { logger.Error(logger.BLE, "failed to stop scan: "+err.Error()) } + return bluetooth.ScanResult{}, errors.New("scanning time limit reached") } + } // startScanning starts the BLE scan and sends the result to the found channel when the target device is discovered func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { - // Start BLE scan err := m.bleAdapter.Scan(func(adapter *bluetooth.Adapter, result bluetooth.ScanResult) { + // Check if the target peripheral was found if result.Address.String() == m.bleConfig.SensorUUID { + // Stop scanning if err := m.bleAdapter.StopScan(); err != nil { logger.Error(fmt.Sprintf(string(logger.BLE)+"failed to stop scan: %v", err)) @@ -108,7 +112,9 @@ func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { // Found the target peripheral found <- result } + }) + if err != nil { logger.Error(logger.BLE, "scan error: "+err.Error()) } @@ -118,7 +124,6 @@ func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { // GetBLECharacteristic scans for the BLE peripheral and returns CSC services/characteristics func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedController *speed.SpeedController) (*bluetooth.DeviceCharacteristic, error) { - // Scan for BLE peripheral result, err := m.ScanForBLEPeripheral(ctx) if err != nil { @@ -129,6 +134,7 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle // Connect to BLE peripheral device var device bluetooth.Device + if device, err = m.bleAdapter.Connect(result.Address, bluetooth.ConnectionParams{}); err != nil { return nil, err } @@ -158,10 +164,10 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle // GetBLEUpdates enables BLE peripheral monitoring to report real-time sensor data func (m *BLEController) GetBLEUpdates(ctx context.Context, speedController *speed.SpeedController, char *bluetooth.DeviceCharacteristic) error { - logger.Debug(logger.BLE, "starting real-time monitoring of BLE sensor notifications...") + errChan := make(chan error, 1) - // Subscribe to live BLE sensor notifications + // Enable notifications with cleanup handling if err := char.EnableNotifications(func(buf []byte) { speed := m.ProcessBLESpeed(buf) speedController.UpdateSpeed(speed) @@ -169,13 +175,26 @@ func (m *BLEController) GetBLEUpdates(ctx context.Context, speedController *spee return err } - <-ctx.Done() - return nil + // Ensure notifications are disabled on exit + defer func() { + + if err := char.EnableNotifications(nil); err != nil { + logger.Error(logger.BLE, "failed to disable notifications: "+err.Error()) + } + + }() + + // Handle context cancellation in separate goroutine + go func() { + <-ctx.Done() + errChan <- nil + }() + + return <-errChan } // ProcessBLESpeed processes the raw speed data from the BLE peripheral func (m *BLEController) ProcessBLESpeed(data []byte) float64 { - // Parse speed data newSpeedData, err := m.parseSpeedData(data) if err != nil { @@ -192,7 +211,6 @@ func (m *BLEController) ProcessBLESpeed(data []byte) float64 { // calculateSpeed calculates the current speed based on the sensor data func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { - // First time through the loop set the last wheel revs and time if lastWheelTime == 0 { lastWheelRevs = sm.wheelRevs @@ -202,6 +220,7 @@ func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { // Calculate delta between time intervals timeDiff := sm.wheelTime - lastWheelTime + if timeDiff == 0 { return 0.0 } @@ -211,6 +230,7 @@ func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { // Determine speed unit conversion multiplier speedConversion := kphConversion + if m.speedConfig.SpeedUnits == config.SpeedUnitsMPH { speedConversion = mphConversion } @@ -225,7 +245,6 @@ func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { // parseSpeedData parses the raw speed data from the BLE peripheral func (m *BLEController) parseSpeedData(data []byte) (SpeedMeasurement, error) { - // Check for data if len(data) < 1 { return SpeedMeasurement{}, errors.New("empty data") diff --git a/internal/ble/sensor_controller_test.go b/internal/ble/sensor_controller_test.go index 7de37b3..9325db5 100644 --- a/internal/ble/sensor_controller_test.go +++ b/internal/ble/sensor_controller_test.go @@ -83,6 +83,7 @@ func controllersIntegrationTest() (*ble.BLEController, error) { func createTestContextWithTimeout(t *testing.T) (context.Context, context.CancelFunc) { ctx, cancel := context.WithTimeout(context.Background(), testTimeout) t.Cleanup(cancel) + return ctx, cancel } @@ -93,6 +94,7 @@ func setupTestBLEController(t *testing.T) *ble.BLEController { t.Skip(noBLEAdapterError) return nil } + return controller } @@ -175,6 +177,7 @@ func TestProcessBLESpeed(t *testing.T) { assert.InDelta(t, tt.want, got, 0.1, "Speed calculation mismatch") }) } + } // TestNewBLEControllerIntegration tests the creation of a new BLEController @@ -193,6 +196,7 @@ func TestScanForBLEPeripheralIntegration(t *testing.T) { // Expect error since test UUID won't be found _, err := controller.ScanForBLEPeripheral(ctx) + assert.Error(t, err) } @@ -204,5 +208,6 @@ func TestGetBLECharacteristicIntegration(t *testing.T) { // Expect error since test UUID won't be found _, err := controller.GetBLECharacteristic(ctx, nil) + assert.Error(t, err) } diff --git a/internal/configuration/config.go b/internal/configuration/config.go index 619cf56..82ad3ae 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -69,7 +69,6 @@ type VideoConfig struct { // LoadFile attempts to load the TOML configuration file from the specified path, // falling back to the default configuration directory if not found func LoadFile(filename string) (*Config, error) { - // Define configuration file paths paths := []string{ filename, @@ -84,9 +83,11 @@ func LoadFile(filename string) (*Config, error) { // Load TOML file if _, err := toml.DecodeFile(path, cfg); err != nil { + if !os.IsNotExist(err) || path == paths[len(paths)-1] { lastErr = fmt.Errorf("failed to load config from %s: %w", path, err) } + continue } @@ -115,9 +116,11 @@ func (c *Config) validate() error { if err := c.Speed.validate(); err != nil { return err } + if err := c.BLE.validate(); err != nil { return err } + if err := c.Video.validate(); err != nil { return err } @@ -127,7 +130,6 @@ func (c *Config) validate() error { // validate validates AppConfig elements func (ac *AppConfig) validate() error { - // Validate log level switch ac.LogLevel { case logLevelDebug, logLevelInfo, logLevelWarn, logLevelError: @@ -135,6 +137,7 @@ func (ac *AppConfig) validate() error { default: return errors.New("invalid log level: " + ac.LogLevel) } + } // validate validates BLEConfig elements @@ -158,6 +161,7 @@ func (sc *SpeedConfig) validate() error { default: return errors.New("invalid speed units: " + sc.SpeedUnits) } + } // validate validates VideoConfig elements diff --git a/internal/configuration/config.toml b/internal/configuration/config.toml index 689866d..cfbd95f 100644 --- a/internal/configuration/config.toml +++ b/internal/configuration/config.toml @@ -11,14 +11,14 @@ [speed] smoothing_window = 5 # Number of speed look-backs to use for generating a moving average - speed_threshold = 0.25 # Minimum speed change to trigger video speed update + speed_threshold = 0.25 # Minimum speed change to trigger video speed update wheel_circumference_mm = 1932 # Wheel circumference in millimeters speed_units = "mph" # "km/h" or "mph" [video] file_path = "cycling_test.mp4" # Path to the video file to play window_scale_factor = 1.0 # Scale factor for the video window (1.0 = full screen) - update_interval_sec = 0.25 # Seconds (>0.0) to wait between video player updates + update_interval_sec = 0.25 # Seconds (>0.0) to wait between video player updates speed_multiplier = 0.6 # Multiplier that translates sensor speed to video playback speed # (0.0 = stopped, 1.0 = normal speed) [video.OSD] diff --git a/internal/configuration/config_test.go b/internal/configuration/config_test.go index 2828b33..5624fa4 100644 --- a/internal/configuration/config_test.go +++ b/internal/configuration/config_test.go @@ -112,6 +112,7 @@ func runValidationTests[T any](t *testing.T, tests []testConfig[T]) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var err error + if v, ok := any(tt.input).(interface{ validate() error }); ok { err = v.validate() } else if v, ok := any(&tt.input).(interface{ validate() error }); ok { @@ -123,8 +124,10 @@ func runValidationTests[T any](t *testing.T, tests []testConfig[T]) { if (err != nil) != tt.wantErr { t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) } + }) } + } func TestLoadFile(t *testing.T) { @@ -148,8 +151,10 @@ func TestLoadFile(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("LoadFile() error = %v, wantErr %v", err, tt.wantErr) } + }) } + } // TestValidateAppConfig tests AppConfig validation @@ -264,17 +269,23 @@ func TestValidateVideoConfig(t *testing.T) { // Run tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.input.FilePath != td.invalidFile { tmpFile, err := os.CreateTemp("", "test") + if err != nil { t.Fatal(err) } + defer os.Remove(tmpFile.Name()) tt.input.FilePath = tmpFile.Name() } + if err := tt.input.validate(); (err != nil) != tt.wantErr { t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr) } + }) } + } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 3c38065..249628e 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -48,7 +48,7 @@ const ( const LevelFatal slog.Level = slog.Level(12) // Initialize sets up the logger -func Initialize(logLevel string) (*slog.Logger) { +func Initialize(logLevel string) *slog.Logger { level := parseLogLevel(logLevel) logger = slog.New(NewCustomTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) return logger @@ -82,11 +82,11 @@ func Fatal(first interface{}, args ...interface{}) { // NewCustomTextHandler creates a new custom text handler func NewCustomTextHandler(w io.Writer, opts *slog.HandlerOptions) *CustomTextHandler { - // Set default values if not provided if w == nil { w = os.Stdout } + if opts == nil { opts = &slog.HandlerOptions{Level: slog.LevelInfo} } @@ -102,7 +102,6 @@ func NewCustomTextHandler(w io.Writer, opts *slog.HandlerOptions) *CustomTextHan // Handle handles the log record func (h *CustomTextHandler) Handle(ctx context.Context, r slog.Record) error { - // Check if context is done if ctx.Err() != nil { return ctx.Err() @@ -110,15 +109,17 @@ func (h *CustomTextHandler) Handle(ctx context.Context, r slog.Record) error { // Create custom logger output timestamp := r.Time.Format("2006/01/02 15:04:05") - level := strings.TrimSpace("["+(r.Level.String())+"]") + level := strings.TrimSpace("[" + (r.Level.String()) + "]") + if r.Level == LevelFatal { level = "[FATAL]" } + msg := r.Message // Write output format to writer - fmt.Fprintf(h.out, "%s %s%s %s%s%s%s\n", - timestamp, + fmt.Fprintf(h.out, "%s %s%s %s%s%s%s\n", + timestamp, h.getColorForLevel(r.Level), level, Reset, @@ -167,22 +168,26 @@ func (h *CustomTextHandler) getColorForLevel(level slog.Level) string { default: return White } + } // getComponentFromAttrs extracts and formats the component from record attributes func (h *CustomTextHandler) getComponentFromAttrs(r slog.Record) string { - var component string // Extract optional component from attributes r.Attrs(func(a slog.Attr) bool { + if a.Key == "component" { component = a.Value.String() + if component != "" { component = component + " " } + return false } + return true }) @@ -190,7 +195,7 @@ func (h *CustomTextHandler) getComponentFromAttrs(r slog.Record) string { } // parseLogLevel converts a string log level to slog.Level -func parseLogLevel(level string) (slog.Level) { +func parseLogLevel(level string) slog.Level { // Convert log level to slog.Level switch strings.ToLower(level) { @@ -205,11 +210,11 @@ func parseLogLevel(level string) (slog.Level) { default: return slog.LevelInfo // default to Info level } + } // logWithOptionalComponent logs a message with an optional component func logWithOptionalComponent(ctx context.Context, level slog.Level, first interface{}, args ...interface{}) { - // Check if context is nil if ctx == nil { ctx = context.Background() diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index dd92531..8e12a7d 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -63,6 +63,7 @@ func validateLogOutput(t *testing.T, output, expectedLevel string) { if !strings.Contains(output, td.message) { t.Errorf("output %q missing message %q", output, td.message) } + } func TestInitialize(t *testing.T) { @@ -96,8 +97,10 @@ func TestInitialize(t *testing.T) { if h.level != tt.wantLevel { t.Errorf("got level %v, want %v", h.level, tt.wantLevel) } + }) } + } func TestCustomTextHandler(t *testing.T) { @@ -135,8 +138,10 @@ func TestCustomTextHandler(t *testing.T) { if !strings.Contains(output, td.message) { t.Errorf("output %q does not contain message %q", output, td.message) } + }) } + } func TestLogLevels(t *testing.T) { @@ -162,6 +167,7 @@ func TestLogLevels(t *testing.T) { validateLogOutput(t, buf.String(), tt.level) }) } + } func TestFatal(t *testing.T) { @@ -195,9 +201,12 @@ func TestEnabled(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewCustomTextHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: tt.setLevel}) + if got := h.Enabled(context.Background(), tt.level); got != tt.want.(bool) { t.Errorf("Enabled() = %v, want %v", got, tt.want) } + }) } + } diff --git a/internal/speed/speed_controller.go b/internal/speed/speed_controller.go index 6909eca..ffb4943 100644 --- a/internal/speed/speed_controller.go +++ b/internal/speed/speed_controller.go @@ -21,7 +21,6 @@ var mutex sync.RWMutex // NewSpeedController creates a new speed controller with a specified window size func NewSpeedController(window int) *SpeedController { - r := ring.New(window) // Initialize ring with zero values @@ -38,7 +37,6 @@ func NewSpeedController(window int) *SpeedController { // GetSmoothedSpeed returns the smoothed speed measurement func (t *SpeedController) GetSmoothedSpeed() float64 { - mutex.RLock() defer mutex.RUnlock() @@ -47,15 +45,16 @@ func (t *SpeedController) GetSmoothedSpeed() float64 { // GetSpeedBuffer returns the speed buffer as an array of formatted strings func (t *SpeedController) GetSpeedBuffer() []string { - mutex.RLock() defer mutex.RUnlock() var speeds []string t.speeds.Do(func(x interface{}) { + if x != nil { speeds = append(speeds, fmt.Sprintf("%.2f", x.(float64))) } + }) return speeds @@ -63,7 +62,6 @@ func (t *SpeedController) GetSpeedBuffer() []string { // UpdateSpeed updates the current speed measurement and calculates a smoothed average func (t *SpeedController) UpdateSpeed(speed float64) { - mutex.Lock() defer mutex.Unlock() @@ -74,9 +72,11 @@ func (t *SpeedController) UpdateSpeed(speed float64) { // Calculate smoothed speed sum := float64(0) t.speeds.Do(func(x interface{}) { + if x != nil { sum += x.(float64) } + }) t.smoothedSpeed = sum / float64(t.window) diff --git a/internal/speed/speed_controller_test.go b/internal/speed/speed_controller_test.go index ab0047d..1656ab6 100644 --- a/internal/speed/speed_controller_test.go +++ b/internal/speed/speed_controller_test.go @@ -25,6 +25,7 @@ var td = testData{ // helper function to calculate average of speeds func calculateAverage(data []float64) float64 { + if len(data) == 0 { return 0.0 } @@ -55,6 +56,7 @@ func TestNewSpeedController(t *testing.T) { if got := controller.smoothedSpeed; got != 0 { t.Errorf("smoothedSpeed = %f, want 0", got) } + } // TestUpdateSpeed tests the UpdateSpeed method of SpeedController @@ -72,6 +74,7 @@ func TestUpdateSpeed(t *testing.T) { if got != want { t.Errorf("GetSmoothedSpeed() = %f, want %f", got, want) } + } // TestGetSmoothedSpeed tests the GetSmoothedSpeed method of SpeedController @@ -90,14 +93,18 @@ func TestGetSmoothedSpeed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { controller := NewSpeedController(td.window) + for _, speed := range tt.updates { controller.UpdateSpeed(speed) } + if got := controller.GetSmoothedSpeed(); got != tt.expected { t.Errorf("GetSmoothedSpeed() = %f, want %f", got, tt.expected) } + }) } + } func TestGetSpeedBuffer(t *testing.T) { @@ -113,11 +120,15 @@ func TestGetSpeedBuffer(t *testing.T) { // Verify buffer got := controller.GetSpeedBuffer() + for i, val := range want { + if got[i] != val { t.Errorf("GetSpeedBuffer()[%d] = %s, want %s", i, got[i], val) } + } + } func TestConcurrency(t *testing.T) { @@ -128,11 +139,13 @@ func TestConcurrency(t *testing.T) { // Run concurrent updates for i := 1; i <= td.updateCount; i++ { wg.Add(1) + go func(speed float64) { defer wg.Done() controller.UpdateSpeed(speed) time.Sleep(td.sleepDuration) }(float64(i)) + } wg.Wait() @@ -140,4 +153,5 @@ func TestConcurrency(t *testing.T) { if got := controller.GetSmoothedSpeed(); got == 0 { t.Error("GetSmoothedSpeed() = 0, want non-zero value after concurrent updates") } + } diff --git a/internal/video-player/playback_controller.go b/internal/video-player/playback_controller.go index fa1082a..ed4a10f 100644 --- a/internal/video-player/playback_controller.go +++ b/internal/video-player/playback_controller.go @@ -38,7 +38,6 @@ type PlaybackController struct { // NewPlaybackController creates a new video player with the given configuration func NewPlaybackController(videoConfig config.VideoConfig, speedConfig config.SpeedConfig) (*PlaybackController, error) { - player := mpv.New() if err := player.Initialize(); err != nil { return nil, err @@ -53,7 +52,6 @@ func NewPlaybackController(videoConfig config.VideoConfig, speedConfig config.Sp // Start configures and starts the MPV media player func (p *PlaybackController) Start(ctx context.Context, speedController *speed.SpeedController) error { - logger.Info(logger.VIDEO, "starting MPV video player...") defer p.player.TerminateDestroy() @@ -81,22 +79,26 @@ func (p *PlaybackController) Start(ctx context.Context, speedController *speed.S return nil case <-ticker.C: reachedEOF, err := p.player.GetProperty("eof-reached", mpv.FormatFlag) + if err == nil && reachedEOF.(bool) { return ErrVideoComplete } if err := p.updatePlaybackSpeed(speedController, &lastSpeed); err != nil { + if !strings.Contains(err.Error(), "end of file") { logger.Warn(logger.VIDEO, "error updating playback speed: "+err.Error()) } + } + } } + } // configureMPVPlayer configures the MPV video player settings func (p *PlaybackController) configureMPVPlayer() error { - // Keep video window open so we can later determine mpv video file EOF status if err := p.player.SetOptionString("keep-open", "yes"); err != nil { return err @@ -120,7 +122,6 @@ func (p *PlaybackController) loadMPVVideo() error { // updatePlaybackSpeed updates the video playback speed based on the sensor speed func (p *PlaybackController) updatePlaybackSpeed(speedController *speed.SpeedController, lastSpeed *float64) error { - currentSpeed := speedController.GetSmoothedSpeed() p.logSpeedInfo(speedController, currentSpeed) @@ -155,7 +156,6 @@ func (p *PlaybackController) checkSpeedState(currentSpeed float64, lastSpeed *fl // pausePlayback pauses the video playback in the MPV media player func (p *PlaybackController) pausePlayback() error { - logger.Debug(logger.VIDEO, "no speed detected, so pausing video") if err := p.updateMPVDisplay(0.0, 0.0); err != nil { @@ -167,7 +167,6 @@ func (p *PlaybackController) pausePlayback() error { // adjustPlayback adjusts the video playback speed func (p *PlaybackController) adjustPlayback(currentSpeed float64, lastSpeed *float64) error { - playbackSpeed := (currentSpeed * p.config.SpeedMultiplier) / 10.0 logger.Info(logger.VIDEO, logger.Cyan+"updating video playback speed to "+strconv.FormatFloat(playbackSpeed, 'f', 2, 64)) @@ -195,12 +194,15 @@ func (p *PlaybackController) updateMPVDisplay(cycleSpeed, playbackSpeed float64) // Build and display OSD text based on display flags if cycleSpeed > 0 { + if p.config.OnScreenDisplay.DisplayCycleSpeed { osdText += fmt.Sprintf(" Cycle Speed: %.2f %s\n", cycleSpeed, p.speedConfig.SpeedUnits) } + if p.config.OnScreenDisplay.DisplayPlaybackSpeed { osdText += fmt.Sprintf(" Playback Speed: %.2fx\n", playbackSpeed) } + } else { osdText = " Paused" } diff --git a/internal/video-player/playback_controller_test.go b/internal/video-player/playback_controller_test.go index 1d5750e..ce89073 100644 --- a/internal/video-player/playback_controller_test.go +++ b/internal/video-player/playback_controller_test.go @@ -118,6 +118,7 @@ func TestPauseControl(t *testing.T) { assert.Equal(t, tt.setPause, result.(bool), "pause state should match") }) } + } // createTestController creates a PlaybackController with default test configurations