From 274dab105ac7437b2a95ce4999db31cb6d16e2ca Mon Sep 17 00:00:00 2001 From: richbl Date: Tue, 24 Dec 2024 11:54:15 -0800 Subject: [PATCH] refactor(APP): :recycle: Refactor in logger and main packages; removed dependency on term config/reset Signed-off-by: richbl --- cmd/main.go | 31 +-- internal/ble/sensor_controller.go | 201 ++++++++---------- internal/ble/sensor_controller_test.go | 8 + internal/configuration/config.go | 115 +++++----- internal/configuration/config_test.go | 7 + internal/logging/logger.go | 140 ++++++------ internal/logging/logger_test.go | 13 ++ internal/speed/speed_controller.go | 72 ++++--- internal/speed/speed_controller_test.go | 5 + internal/video-player/playback_controller.go | 150 ++++++++----- .../video-player/playback_controller_test.go | 5 + 11 files changed, 402 insertions(+), 345 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index b934d51..2b870c6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,9 +3,9 @@ package main import ( "context" "errors" + "fmt" "log" "os" - "os/exec" "time" ble "github.com/richbl/go-ble-sync-cycle/internal/ble" @@ -34,6 +34,8 @@ type appControllers struct { } func main() { + + // Hello world! log.Println(appPrefix, "Starting", appName, appVersion) cfg := loadConfig("config.toml") @@ -41,9 +43,6 @@ func main() { // Initialize the shutdown manager and exit handler sm := NewShutdownManager(shutdownTimeout) exitHandler := NewExitHandler(sm) - - // Add configureTerminal cleanup function to reset terminal settings on exit - sm.AddCleanupFn(configureTerminal()) sm.Start() // Initialize the logger with the configured log level and exit handler @@ -56,7 +55,7 @@ func main() { // Initialize the application controllers controllers, componentType, err := setupAppControllers(*cfg) if err != nil { - logger.Fatal(componentType, "failed to create controllers: "+err.Error()) + logger.Fatal(componentType, "failed to create controllers:", err.Error()) return } @@ -65,7 +64,7 @@ func main() { if err != nil { if err != context.Canceled { - logger.Fatal(logger.BLE, "failed to scan for BLE characteristic: "+err.Error()) + logger.Fatal(logger.BLE, "failed to scan for BLE characteristic:", err.Error()) return } @@ -88,7 +87,7 @@ func main() { // Wait for services to complete and check for errors for _, runner := range []*ServiceRunner{bleRunner, videoRunner} { if err := runner.Error(); err != nil { - logger.Fatal(logger.APP, "service error: "+err.Error()) + logger.Fatal(logger.APP, "service error:", err.Error()) return } } @@ -140,6 +139,7 @@ func scanForBLECharacteristic(ctx context.Context, controllers appControllers) ( select { case <-ctx.Done(): + fmt.Print("\r") // Clear the ^C character from the terminal line logger.Info(logger.BLE, "user-generated interrupt, stopping BLE discovery...") return nil, ctx.Err() case result := <-resultsChan: @@ -152,28 +152,13 @@ func loadConfig(file string) *config.Config { cfg, err := config.LoadFile(file) if err != nil { - log.Println(logger.Red + "[FTL]" + logger.Reset + " [APP] failed to load TOML configuration: " + err.Error()) + log.Println(logger.Red+"[FTL] "+logger.Reset+"[APP] failed to load TOML configuration:", err.Error()) waveGoodbye() } return cfg } -// configureTerminal handles terminal character echo settings, returning a cleanup function -// to restore original terminal settings -func configureTerminal() func() { - - rawMode := exec.Command("stty", "-echo") - rawMode.Stdin = os.Stdin - _ = rawMode.Run() - - return func() { - cooked := exec.Command("stty", "echo") - cooked.Stdin = os.Stdin - _ = cooked.Run() - } -} - // waveGoodbye outputs a goodbye message and exits the program func waveGoodbye() { log.Println(appPrefix, appName, appVersion, "shutdown complete. Goodbye!") diff --git a/internal/ble/sensor_controller.go b/internal/ble/sensor_controller.go index 09edc30..18cc731 100644 --- a/internal/ble/sensor_controller.go +++ b/internal/ble/sensor_controller.go @@ -1,3 +1,4 @@ +// Package ble provides Bluetooth Low Energy (BLE) functionality for cycling speed sensors package ble import ( @@ -15,12 +16,18 @@ import ( speed "github.com/richbl/go-ble-sync-cycle/internal/speed" ) -// Constants for speed calculations and BLE data parsing +// Constants for BLE data parsing and speed calculations const ( minDataLength = 7 wheelRevFlag = uint8(0x01) - kphConversion = 3.6 - mphConversion = 2.23694 + kphConversion = 3.6 // Conversion factor for kilometers per hour + mphConversion = 2.23694 // Conversion factor for miles per hour +) + +// Package-level variables for tracking speed measurements +var ( + lastWheelRevs uint32 + lastWheelTime uint16 ) // SpeedMeasurement represents the wheel revolution and time data from a BLE sensor @@ -29,23 +36,16 @@ type SpeedMeasurement struct { wheelTime uint16 } -// BLEController represents the BLE central controller component +// BLEController manages BLE communication with cycling speed sensors type BLEController struct { bleConfig config.BLEConfig speedConfig config.SpeedConfig bleAdapter bluetooth.Adapter } -// Package-level variables for tracking speed measurements -var ( - lastWheelRevs uint32 - lastWheelTime uint16 -) - // 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 { @@ -61,117 +61,53 @@ func NewBLEController(bleConfig config.BLEConfig, speedConfig config.SpeedConfig }, nil } -// 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() - - found := make(chan bluetooth.ScanResult, 1) - errChan := make(chan error, 1) - - go func() { - logger.Info(logger.BLE, "now scanning the ether for BLE peripheral UUID of "+m.bleConfig.SensorUUID+"...") - - if err := m.startScanning(found); err != nil { - errChan <- err - } - - }() - - // Wait for device discovery or timeout - select { - case result := <-found: - logger.Debug(logger.BLE, "found BLE peripheral "+result.Address.String()) - return result, nil - 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)) - } - - // Found the target peripheral - found <- result - } - - }) - - if err != nil { - logger.Error(logger.BLE, "scan error: "+err.Error()) - } - - return nil -} - // 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 + // Scan for BLE peripheral device result, err := m.ScanForBLEPeripheral(ctx) if err != nil { return nil, err } - logger.Debug(logger.BLE, "connecting to BLE peripheral device "+result.Address.String()) + logger.Debug(logger.BLE, "connecting to BLE peripheral device", result.Address.String()) - // Connect to BLE peripheral device - var device bluetooth.Device - if device, err = m.bleAdapter.Connect(result.Address, bluetooth.ConnectionParams{}); err != nil { + device, err := m.bleAdapter.Connect(result.Address, bluetooth.ConnectionParams{}) + if err != nil { return nil, err } logger.Info(logger.BLE, "BLE peripheral device connected") - logger.Debug(logger.BLE, "discovering CSC services "+bluetooth.New16BitUUID(0x1816).String()) + logger.Debug(logger.BLE, "discovering CSC services", bluetooth.New16BitUUID(0x1816).String()) - // Find CSC service and characteristic + // Discover CSC services svc, err := device.DiscoverServices([]bluetooth.UUID{bluetooth.New16BitUUID(0x1816)}) if err != nil { - logger.Error(logger.BLE, "CSC services discovery failed: "+err.Error()) + logger.Error(logger.BLE, "CSC services discovery failed:", err.Error()) return nil, err } - logger.Debug(logger.BLE, "found CSC service "+svc[0].UUID().String()) - logger.Debug(logger.BLE, "discovering CSC characteristics "+bluetooth.New16BitUUID(0x2A5B).String()) + logger.Debug(logger.BLE, "found CSC service", svc[0].UUID().String()) + logger.Debug(logger.BLE, "discovering CSC characteristics", bluetooth.New16BitUUID(0x2A5B).String()) + // Discover CSC characteristics char, err := svc[0].DiscoverCharacteristics([]bluetooth.UUID{bluetooth.New16BitUUID(0x2A5B)}) if err != nil { - logger.Warn(logger.BLE, "CSC characteristics discovery failed: "+err.Error()) + logger.Warn(logger.BLE, "CSC characteristics discovery failed:", err.Error()) return nil, err } - logger.Debug(logger.BLE, "found CSC characteristic "+char[0].UUID().String()) + logger.Debug(logger.BLE, "found CSC characteristic", char[0].UUID().String()) return &char[0], nil } -// GetBLEUpdates enables BLE peripheral monitoring to report real-time sensor data +// GetBLEUpdates enables real-time monitoring of BLE peripheral sensor data, handling +// notification setup/teardown, and updates the speed controller with new readings 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) - // Enable notifications with cleanup handling if err := char.EnableNotifications(func(buf []byte) { speed := m.ProcessBLESpeed(buf) speedController.UpdateSpeed(speed) @@ -179,18 +115,16 @@ func (m *BLEController) GetBLEUpdates(ctx context.Context, speedController *spee return err } - // Ensure notifications are disabled on exit + // Need to disable BLE notifications when done defer func() { - if err := char.EnableNotifications(nil); err != nil { - logger.Error(logger.BLE, "failed to disable notifications: "+err.Error()) + logger.Error(logger.BLE, "failed to disable notifications:", err.Error()) } - }() - // Handle context cancellation in separate goroutine go func() { <-ctx.Done() + fmt.Print("\r") // Clear the ^C character from the terminal line logger.Info(logger.BLE, "user-generated interrupt, stopping BLE component reporting...") errChan <- nil }() @@ -198,51 +132,97 @@ func (m *BLEController) GetBLEUpdates(ctx context.Context, speedController *spee return <-errChan } -// ProcessBLESpeed processes the raw speed data from the BLE peripheral +// ScanForBLEPeripheral scans for a BLE peripheral with the specified UUID +func (m *BLEController) ScanForBLEPeripheral(ctx context.Context) (bluetooth.ScanResult, error) { + + scanCtx, cancel := context.WithTimeout(ctx, time.Duration(m.bleConfig.ScanTimeoutSecs)*time.Second) + defer cancel() + + found := make(chan bluetooth.ScanResult, 1) + errChan := make(chan error, 1) + + go func() { + logger.Info(logger.BLE, "now scanning the ether for BLE peripheral UUID of", m.bleConfig.SensorUUID+"...") + if err := m.startScanning(found); err != nil { + errChan <- err + } + }() + + select { + case result := <-found: + logger.Debug(logger.BLE, "found BLE peripheral", result.Address.String()) + return result, nil + 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") + } +} + +// ProcessBLESpeed processes raw speed data from the BLE peripheral and returns the calculated speed func (m *BLEController) ProcessBLESpeed(data []byte) float64 { - // Parse speed data newSpeedData, err := m.parseSpeedData(data) if err != nil { - logger.Error(logger.SPEED, "invalid BLE data: "+err.Error()) + logger.Error(logger.SPEED, "invalid BLE data:", err.Error()) return 0.0 } - // Calculate speed from parsed data speed := m.calculateSpeed(newSpeedData) - logger.Info(logger.SPEED, logger.Blue+"BLE sensor speed: "+strconv.FormatFloat(speed, 'f', 2, 64)+" "+m.speedConfig.SpeedUnits) + logger.Debug(logger.SPEED, logger.Blue+"BLE sensor speed:", strconv.FormatFloat(speed, 'f', 2, 64), m.speedConfig.SpeedUnits) return speed } -// calculateSpeed calculates the current speed based on the sensor data +// startScanning starts the BLE scan and sends results to the found channel +func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { + + err := m.bleAdapter.Scan(func(adapter *bluetooth.Adapter, result bluetooth.ScanResult) { + + if result.Address.String() == m.bleConfig.SensorUUID { + + if err := m.bleAdapter.StopScan(); err != nil { + logger.Error(logger.BLE, "failed to stop scan:", err.Error()) + } + + found <- result + } + + }) + + if err != nil { + logger.Error(logger.BLE, "scan error:", err.Error()) + } + + return nil +} + +// calculateSpeed calculates the current speed based on wheel revolution data... interestingly, +// a BLE speed sensor has no concept of rate: just wheel revolutions and timestamps func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { - // First time through the loop set the last wheel revs and time + // Initialize last wheel data if not set if lastWheelTime == 0 { lastWheelRevs = sm.wheelRevs lastWheelTime = sm.wheelTime return 0.0 } - // Calculate delta between time intervals timeDiff := sm.wheelTime - lastWheelTime - if timeDiff == 0 { return 0.0 } - // Calculate delta between wheel revs revDiff := int32(sm.wheelRevs - lastWheelRevs) - - // Determine speed unit conversion multiplier speedConversion := kphConversion - if m.speedConfig.SpeedUnits == config.SpeedUnitsMPH { speedConversion = mphConversion } - // Calculate new speed speed := float64(revDiff) * float64(m.speedConfig.WheelCircumferenceMM) * speedConversion / float64(timeDiff) lastWheelRevs = sm.wheelRevs lastWheelTime = sm.wheelTime @@ -250,20 +230,17 @@ func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { return speed } -// parseSpeedData parses the raw speed data from the BLE peripheral +// parseSpeedData parses raw byte data from the BLE peripheral into a SpeedMeasurement func (m *BLEController) parseSpeedData(data []byte) (SpeedMeasurement, error) { - // Check for data if len(data) < 1 { return SpeedMeasurement{}, errors.New("empty data") } - // Validate data if data[0]&wheelRevFlag == 0 || len(data) < minDataLength { return SpeedMeasurement{}, errors.New("invalid data format or length") } - // Return new speed data return SpeedMeasurement{ wheelRevs: binary.LittleEndian.Uint32(data[1:]), wheelTime: binary.LittleEndian.Uint16(data[5:]), diff --git a/internal/ble/sensor_controller_test.go b/internal/ble/sensor_controller_test.go index 9325db5..2de0b7e 100644 --- a/internal/ble/sensor_controller_test.go +++ b/internal/ble/sensor_controller_test.go @@ -50,6 +50,7 @@ func waitForScanReset() { // createTestController creates test BLE and speed controllers func createTestController(speedUnits string) (*ble.BLEController, error) { + // Create test BLE controller bleConfig := config.BLEConfig{ SensorUUID: sensorTestUUID, @@ -67,6 +68,7 @@ func createTestController(speedUnits string) (*ble.BLEController, error) { // controllersIntegrationTest pauses BLE scan and then creates controllers func controllersIntegrationTest() (*ble.BLEController, error) { + // Pause to permit BLE adapter to reset waitForScanReset() @@ -81,6 +83,7 @@ func controllersIntegrationTest() (*ble.BLEController, error) { // createTestContextWithTimeout creates a context with a predefined timeout func createTestContextWithTimeout(t *testing.T) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) t.Cleanup(cancel) @@ -89,6 +92,7 @@ func createTestContextWithTimeout(t *testing.T) (context.Context, context.Cancel // setupTestBLEController creates a test BLE controller and handles BLE adapter errors func setupTestBLEController(t *testing.T) *ble.BLEController { + controller, err := controllersIntegrationTest() if err != nil { t.Skip(noBLEAdapterError) @@ -100,6 +104,7 @@ func setupTestBLEController(t *testing.T) *ble.BLEController { // TestProcessBLESpeed tests the ProcessBLESpeed() function func TestProcessBLESpeed(t *testing.T) { + // Define test cases tests := []struct { name string @@ -182,6 +187,7 @@ func TestProcessBLESpeed(t *testing.T) { // TestNewBLEControllerIntegration tests the creation of a new BLEController func TestNewBLEControllerIntegration(t *testing.T) { + // Create test BLE controller controller := setupTestBLEController(t) @@ -190,6 +196,7 @@ func TestNewBLEControllerIntegration(t *testing.T) { // TestScanForBLEPeripheralIntegration tests the ScanForBLEPeripheral() function func TestScanForBLEPeripheralIntegration(t *testing.T) { + // Create test BLE controller controller := setupTestBLEController(t) ctx, _ := createTestContextWithTimeout(t) @@ -202,6 +209,7 @@ func TestScanForBLEPeripheralIntegration(t *testing.T) { // TestGetBLECharacteristicIntegration tests the GetBLECharacteristic() function func TestGetBLECharacteristicIntegration(t *testing.T) { + // Create test BLE controller controller := setupTestBLEController(t) ctx, _ := createTestContextWithTimeout(t) diff --git a/internal/configuration/config.go b/internal/configuration/config.go index 82ad3ae..c1bcdf6 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -1,3 +1,5 @@ +// Package config provides configuration management for the application, +// including loading and validation of TOML configuration files package config import ( @@ -9,7 +11,7 @@ import ( "github.com/BurntSushi/toml" ) -// Constants for valid configuration values +// Configuration constants const ( // Log levels logLevelDebug = "debug" @@ -19,11 +21,11 @@ const ( logLevelFatal = "fatal" // Speed units - SpeedUnitsKMH = "km/h" - SpeedUnitsMPH = "mph" + SpeedUnitsKMH = "km/h" // Kilometers per hour + SpeedUnitsMPH = "mph" // Miles per hour ) -// Config represents the application configuration +// Config represents the complete application configuration structure type Config struct { App AppConfig `toml:"app"` BLE BLEConfig `toml:"ble"` @@ -31,18 +33,18 @@ type Config struct { Video VideoConfig `toml:"video"` } -// AppConfig represents the application configuration +// AppConfig defines application-wide settings type AppConfig struct { LogLevel string `toml:"logging_level"` } -// BLEConfig represents the BLE controller configuration +// BLEConfig defines Bluetooth Low Energy settings type BLEConfig struct { SensorUUID string `toml:"sensor_uuid"` ScanTimeoutSecs int `toml:"scan_timeout_secs"` } -// SpeedConfig represents the speed controller configuration +// SpeedConfig defines speed calculation and measurement settings type SpeedConfig struct { SmoothingWindow int `toml:"smoothing_window"` SpeedThreshold float64 `toml:"speed_threshold"` @@ -50,14 +52,7 @@ type SpeedConfig struct { SpeedUnits string `toml:"speed_units"` } -// VideoOSDConfig represents the on-screen display configuration -type VideoOSDConfig struct { - DisplayCycleSpeed bool `toml:"display_cycle_speed"` - DisplayPlaybackSpeed bool `toml:"display_playback_speed"` - ShowOSD bool -} - -// VideoConfig represents the MPV video player configuration +// VideoConfig defines video playback and display settings type VideoConfig struct { FilePath string `toml:"file_path"` WindowScaleFactor float64 `toml:"window_scale_factor"` @@ -66,10 +61,17 @@ type VideoConfig struct { OnScreenDisplay VideoOSDConfig `toml:"OSD"` } -// LoadFile attempts to load the TOML configuration file from the specified path, -// falling back to the default configuration directory if not found +// VideoOSDConfig defines on-screen display settings for video playback +type VideoOSDConfig struct { + DisplayCycleSpeed bool `toml:"display_cycle_speed"` + DisplayPlaybackSpeed bool `toml:"display_playback_speed"` + ShowOSD bool // Computed field based on display settings +} + +// LoadFile attempts to load and validate a TOML configuration file, trying multiple paths and +// returns the first valid configuration found func LoadFile(filename string) (*Config, error) { - // Define configuration file paths + paths := []string{ filename, filepath.Join("internal", "configuration", filepath.Base(filename)), @@ -77,11 +79,9 @@ func LoadFile(filename string) (*Config, error) { var lastErr error - // Attempt to load the configuration file from each path for _, path := range paths { cfg := &Config{} - // Load TOML file if _, err := toml.DecodeFile(path, cfg); err != nil { if !os.IsNotExist(err) || path == paths[len(paths)-1] { @@ -91,59 +91,61 @@ func LoadFile(filename string) (*Config, error) { continue } - // Validate TOML file if err := cfg.validate(); err != nil { return nil, err } - // Successfully loaded TOML file return cfg, nil } - // Failed to load TOML file return nil, lastErr } -// validate performs validation on the configuration values +// validate performs validation across all configuration sections func (c *Config) validate() error { - // Validate application configuration elements - if err := c.App.validate(); err != nil { - return err + validators := []struct { + validate func() error + name string + }{ + {c.App.validate, "App"}, + {c.Speed.validate, "Speed"}, + {c.BLE.validate, "BLE"}, + {c.Video.validate, "Video"}, } - // Validate remaining configuration elements - if err := c.Speed.validate(); err != nil { - return err - } + for _, v := range validators { - if err := c.BLE.validate(); err != nil { - return err - } + if err := v.validate(); err != nil { + return fmt.Errorf("%s configuration error: %w", v.name, err) + } - if err := c.Video.validate(); err != nil { - return err } return nil } -// validate validates AppConfig elements +// validate checks AppConfig for valid settings func (ac *AppConfig) validate() error { - // Validate log level - switch ac.LogLevel { - case logLevelDebug, logLevelInfo, logLevelWarn, logLevelError: - return nil - default: + + validLogLevels := map[string]bool{ + logLevelDebug: true, + logLevelInfo: true, + logLevelWarn: true, + logLevelError: true, + logLevelFatal: true, + } + + if !validLogLevels[ac.LogLevel] { return errors.New("invalid log level: " + ac.LogLevel) } + return nil } -// validate validates BLEConfig elements +// validate checks BLEConfig for valid settings func (bc *BLEConfig) validate() error { - // Check if the sensor UUID is specified if bc.SensorUUID == "" { return errors.New("sensor UUID must be specified in configuration") } @@ -151,34 +153,35 @@ func (bc *BLEConfig) validate() error { return nil } -// validate validates SpeedConfig elements +// validate checks SpeedConfig for valid settings func (sc *SpeedConfig) validate() error { - // Validate speed units - switch sc.SpeedUnits { - case SpeedUnitsKMH, SpeedUnitsMPH: - return nil - default: + validSpeedUnits := map[string]bool{ + SpeedUnitsKMH: true, + SpeedUnitsMPH: true, + } + + if !validSpeedUnits[sc.SpeedUnits] { return errors.New("invalid speed units: " + sc.SpeedUnits) } + return nil } -// validate validates VideoConfig elements +// validate checks VideoConfig for valid settings func (vc *VideoConfig) validate() error { - // Check if the video file exists if _, err := os.Stat(vc.FilePath); err != nil { - return err + return fmt.Errorf("video file error: %w", err) } - // Confirm that update_interval_sec is >0.0 if vc.UpdateIntervalSec <= 0.0 { return errors.New("update_interval_sec must be greater than 0.0") } - // Check if at least one OSD display flag is set - vc.OnScreenDisplay.ShowOSD = (vc.OnScreenDisplay.DisplayCycleSpeed || vc.OnScreenDisplay.DisplayPlaybackSpeed) + // Set computed field based on display settings + vc.OnScreenDisplay.ShowOSD = vc.OnScreenDisplay.DisplayCycleSpeed || + vc.OnScreenDisplay.DisplayPlaybackSpeed return nil } diff --git a/internal/configuration/config_test.go b/internal/configuration/config_test.go index 5624fa4..e827ac7 100644 --- a/internal/configuration/config_test.go +++ b/internal/configuration/config_test.go @@ -32,6 +32,7 @@ type testConfig[T any] struct { // generateConfigTOML returns valid or invalid TOML config based on isValid flag func generateConfigTOML(isValid bool) string { + // Generate valid and invalid TOML configs if isValid { return fmt.Sprintf(` @@ -82,6 +83,7 @@ func generateConfigTOML(isValid bool) string { // createTempFile creates a temporary file with given content func createTempFile(t *testing.T, prefix, content string) (string, func()) { + t.Helper() // Create temp file @@ -106,6 +108,7 @@ func createTempFile(t *testing.T, prefix, content string) (string, func()) { // runValidationTests runs validation tests for any config type func runValidationTests[T any](t *testing.T, tests []testConfig[T]) { + t.Helper() // Run tests @@ -131,6 +134,7 @@ func runValidationTests[T any](t *testing.T, tests []testConfig[T]) { } func TestLoadFile(t *testing.T) { + // Create tests tests := []struct { name string @@ -159,6 +163,7 @@ func TestLoadFile(t *testing.T) { // TestValidateAppConfig tests AppConfig validation func TestValidateAppConfig(t *testing.T) { + // Create tests tests := []testConfig[AppConfig]{ { @@ -179,6 +184,7 @@ func TestValidateAppConfig(t *testing.T) { // TestValidateBLEConfig tests BLEConfig validation func TestValidateBLEConfig(t *testing.T) { + // Create tests tests := []testConfig[BLEConfig]{ { @@ -205,6 +211,7 @@ func TestValidateBLEConfig(t *testing.T) { // TestValidateSpeedConfig tests SpeedConfig validation func TestValidateSpeedConfig(t *testing.T) { + // Create tests tests := []testConfig[SpeedConfig]{ { diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 8752b28..333bb7e 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -1,3 +1,5 @@ +// Package logger provides structured logging capabilities with colored output +// and component-based logging support. package logger import ( @@ -10,23 +12,10 @@ import ( "sync" ) -// logger is the global logger -var logger *slog.Logger - -// ExitFunc represents the exit function (used for testing) -var ExitFunc = os.Exit - -// ComponentType represents the type of component +// ComponentType represents the type of component for logging identification type ComponentType string -// CustomTextHandler represents a custom text handler -type CustomTextHandler struct { - slog.Handler - out io.Writer - level slog.Level - levelNames map[slog.Level]string -} - +// Supported component types const ( APP ComponentType = "[APP]" BLE ComponentType = "[BLE]" @@ -34,7 +23,7 @@ const ( VIDEO ComponentType = "[VID]" ) -// Color constants +// ANSI color codes for terminal output const ( Reset = "\033[0m" Red = "\033[31m" @@ -46,54 +35,66 @@ const ( White = "\033[37m" ) -// Create a new slog level for the Fatal logging level +// LevelFatal defines a new slog level for fatal errors const LevelFatal slog.Level = slog.Level(12) -// ExitHandler is a function type for handling fatal exits -type ExitHandler func() - +// Global variables var ( + logger *slog.Logger + ExitFunc = os.Exit // ExitFunc represents the exit function (used for testing) exitHandler ExitHandler exitOnce sync.Once ) -// SetExitHandler sets the handler for fatal exits -func SetExitHandler(handler ExitHandler) { - exitHandler = handler -} +// Type definitions +type ( + // ExitHandler is a function type for handling fatal exits + ExitHandler func() + + // CustomTextHandler represents a custom text handler for log formatting + CustomTextHandler struct { + slog.Handler + out io.Writer + level slog.Level + levelNames map[slog.Level]string + } +) -// Initialize sets up the logger +// Initialize sets up the logger with the specified log level func Initialize(logLevel string) *slog.Logger { + level := parseLogLevel(logLevel) logger = slog.New(NewCustomTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) return logger } -// Info logs an info message +// SetExitHandler sets the handler for fatal exits +func SetExitHandler(handler ExitHandler) { + exitHandler = handler +} + +// Logging functions +func Debug(first interface{}, args ...interface{}) { + logWithOptionalComponent(context.Background(), slog.LevelDebug, first, args...) +} + func Info(first interface{}, args ...interface{}) { logWithOptionalComponent(context.Background(), slog.LevelInfo, first, args...) } -// Warn logs a warning message func Warn(first interface{}, args ...interface{}) { logWithOptionalComponent(context.Background(), slog.LevelWarn, first, args...) } -// Error logs an error message func Error(first interface{}, args ...interface{}) { logWithOptionalComponent(context.Background(), slog.LevelError, first, args...) } -// Debug logs a debug message -func Debug(first interface{}, args ...interface{}) { - logWithOptionalComponent(context.Background(), slog.LevelDebug, first, args...) -} - -// Fatal logs a fatal message and triggers exit handler func Fatal(first interface{}, args ...interface{}) { + logWithOptionalComponent(context.Background(), LevelFatal, first, args...) - // Trigger exit handler + // Since we are exiting, we need to ensure that the exit handler is called exitOnce.Do(func() { if exitHandler != nil { @@ -104,9 +105,9 @@ func Fatal(first interface{}, args ...interface{}) { }) } -// NewCustomTextHandler creates a new custom text handler +// NewCustomTextHandler creates a new custom text handler with the specified options func NewCustomTextHandler(w io.Writer, opts *slog.HandlerOptions) *CustomTextHandler { - // Set default values if not provided + if w == nil { w = os.Stdout } @@ -123,48 +124,43 @@ func NewCustomTextHandler(w io.Writer, opts *slog.HandlerOptions) *CustomTextHan LevelFatal: "FTL", } - // Create the custom text handler - textHandler := slog.NewTextHandler(w, opts) return &CustomTextHandler{ - Handler: textHandler, + Handler: slog.NewTextHandler(w, opts), out: w, level: opts.Level.(slog.Level), levelNames: levelNames, } } -// Handle handles the log record +// Handle handles the log record and writes it to the output stream func (h *CustomTextHandler) Handle(ctx context.Context, r slog.Record) error { - // Check if context is done + if ctx.Err() != nil { return ctx.Err() } - // Create custom logger output timestamp := r.Time.Format("2006/01/02 15:04:05") level := strings.TrimSpace("[" + h.levelNames[r.Level] + "]") - msg := r.Message - - // Write output format to writer fmt.Fprintf(h.out, "%s %s%s %s%s%s%s\n", timestamp, h.getColorForLevel(r.Level), level, Reset, h.getComponentFromAttrs(r), - msg, + r.Message, Reset) return nil } -// Enabled checks if the handler is enabled +// Enabled returns true if the specified log level is enabled func (h *CustomTextHandler) Enabled(ctx context.Context, level slog.Level) bool { return h.Handler.Enabled(ctx, level) } -// WithAttrs adds attributes to the handler +// WithAttrs returns a new handler with the specified attributes func (h *CustomTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &CustomTextHandler{ Handler: h.Handler.WithAttrs(attrs), out: h.out, @@ -173,7 +169,7 @@ func (h *CustomTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { } } -// WithGroup adds a group to the handler +// WithGroup returns a new handler with the specified group name func (h *CustomTextHandler) WithGroup(name string) slog.Handler { return &CustomTextHandler{ Handler: h.Handler.WithGroup(name), @@ -183,10 +179,8 @@ func (h *CustomTextHandler) WithGroup(name string) slog.Handler { } } -// getColorForLevel returns the color for the given log level +// getColorForLevel returns the ANSI color code for the specified log level func (h *CustomTextHandler) getColorForLevel(level slog.Level) string { - - // Make color assignments based on log level switch level { case slog.LevelDebug: return Blue @@ -201,14 +195,13 @@ func (h *CustomTextHandler) getColorForLevel(level slog.Level) string { default: return White } - } -// getComponentFromAttrs extracts and formats the component from record attributes +// getComponentFromAttrs returns the component name from the log 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" { @@ -223,14 +216,11 @@ func (h *CustomTextHandler) getComponentFromAttrs(r slog.Record) string { return true }) - return component } -// parseLogLevel converts a string log level to slog.Level +// parseLogLevel converts the specified log level string to a slog.Level func parseLogLevel(level string) slog.Level { - - // Convert log level to slog.Level switch strings.ToLower(level) { case "debug": return slog.LevelDebug @@ -241,14 +231,13 @@ func parseLogLevel(level string) slog.Level { case "error": return slog.LevelError default: - return slog.LevelInfo // default to Info level + return slog.LevelInfo } - } -// logWithOptionalComponent logs a message with an optional component +// logWithOptionalComponent logs a message with an optional component name func logWithOptionalComponent(ctx context.Context, level slog.Level, first interface{}, args ...interface{}) { - // Check if context is nil + if ctx == nil { ctx = context.Background() } @@ -256,15 +245,30 @@ func logWithOptionalComponent(ctx context.Context, level slog.Level, first inter var msg string var component string - // Check if the first argument is an optional ComponentType switch v := first.(type) { case ComponentType: component = string(v) - if len(args) > 0 { - msg = fmt.Sprint(args[0]) + var sb strings.Builder + + for i, arg := range args { + sb.WriteString(fmt.Sprint(arg)) + + if i < len(args)-1 { + sb.WriteString(" ") + } + } + msg = sb.String() default: - msg = fmt.Sprint(first) + var sb strings.Builder + sb.WriteString(fmt.Sprint(first)) + + for _, arg := range args { + sb.WriteString(" ") + sb.WriteString(fmt.Sprint(arg)) + } + + msg = sb.String() } msg = strings.TrimSpace(msg) diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index ad5e479..3ad779d 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -34,13 +34,16 @@ type testCase struct { // setupTest creates a new test logger with buffer func setupTest() (*bytes.Buffer, *slog.Logger) { + buf := &bytes.Buffer{} handler := NewCustomTextHandler(buf, &slog.HandlerOptions{Level: td.level}) + return buf, slog.New(handler) } // validateLogOutput checks if log output matches expected format func validateLogOutput(t *testing.T, output, expectedLevel string) { + t.Helper() timestampRegex := `^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2}` @@ -58,7 +61,9 @@ func validateLogOutput(t *testing.T, output, expectedLevel string) { } +// TestNewCustomTextHandler tests the NewCustomTextHandler function func TestInitialize(t *testing.T) { + // Define test cases tests := []struct { name string @@ -95,7 +100,9 @@ func TestInitialize(t *testing.T) { } +// TestCustomTextHandler tests the CustomTextHandler struct func TestCustomTextHandler(t *testing.T) { + // Define test cases tests := []testCase{ {"debug", slog.LevelDebug, Blue + "[DBG]", 0}, @@ -136,7 +143,9 @@ func TestCustomTextHandler(t *testing.T) { } +// TestLogLevels tests the log level functions func TestLogLevels(t *testing.T) { + // Define test cases tests := []struct { name string @@ -162,7 +171,9 @@ func TestLogLevels(t *testing.T) { } +// TestFatal tests the Fatal function func TestFatal(t *testing.T) { + buf, testLogger := setupTest() logger = testLogger @@ -180,7 +191,9 @@ func TestFatal(t *testing.T) { validateLogOutput(t, buf.String(), "FTL") } +// TestEnabled tests the Enabled function func TestEnabled(t *testing.T) { + // Define test cases tests := []testCase{ {"debug enabled", slog.LevelDebug, true, slog.LevelDebug}, diff --git a/internal/speed/speed_controller.go b/internal/speed/speed_controller.go index ffb4943..dd63d5f 100644 --- a/internal/speed/speed_controller.go +++ b/internal/speed/speed_controller.go @@ -1,3 +1,4 @@ +// Package speed provides speed measurement and smoothing functionality package speed import ( @@ -7,8 +8,9 @@ import ( "time" ) -// SpeedController manages speed measurements with smoothing +// SpeedController manages speed measurements with smoothing over a specified time window type SpeedController struct { + mu sync.RWMutex // protects all fields speeds *ring.Ring window int currentSpeed float64 @@ -16,14 +18,12 @@ type SpeedController struct { lastUpdate time.Time } -// mutex manages concurrent access to SpeedController -var mutex sync.RWMutex - -// NewSpeedController creates a new speed controller with a specified window size +// NewSpeedController creates a new speed controller with a specified window size, which +// determines the number of speed measurements used for smoothing func NewSpeedController(window int) *SpeedController { + r := ring.New(window) - // Initialize ring with zero values for i := 0; i < window; i++ { r.Value = float64(0) r = r.Next() @@ -35,50 +35,56 @@ func NewSpeedController(window int) *SpeedController { } } -// GetSmoothedSpeed returns the smoothed speed measurement -func (t *SpeedController) GetSmoothedSpeed() float64 { - mutex.RLock() - defer mutex.RUnlock() +// UpdateSpeed updates the current speed measurement and calculates a smoothed average +func (sc *SpeedController) UpdateSpeed(speed float64) { - return t.smoothedSpeed -} + // Lock the mutex to protect the fields + sc.mu.Lock() + defer sc.mu.Unlock() -// GetSpeedBuffer returns the speed buffer as an array of formatted strings -func (t *SpeedController) GetSpeedBuffer() []string { - mutex.RLock() - defer mutex.RUnlock() + sc.currentSpeed = speed + sc.speeds.Value = speed + sc.speeds = sc.speeds.Next() - var speeds []string - t.speeds.Do(func(x interface{}) { + var sum float64 + sc.speeds.Do(func(x interface{}) { if x != nil { - speeds = append(speeds, fmt.Sprintf("%.2f", x.(float64))) + sum += x.(float64) } }) - return speeds + // Ahhh... smoothness + sc.smoothedSpeed = sum / float64(sc.window) + sc.lastUpdate = time.Now() } -// UpdateSpeed updates the current speed measurement and calculates a smoothed average -func (t *SpeedController) UpdateSpeed(speed float64) { - mutex.Lock() - defer mutex.Unlock() +// GetSmoothedSpeed returns the current smoothed speed measurement +func (sc *SpeedController) GetSmoothedSpeed() float64 { - t.currentSpeed = speed - t.speeds.Value = speed - t.speeds = t.speeds.Next() + // Lock the mutex to protect the fields + sc.mu.RLock() + defer sc.mu.RUnlock() - // Calculate smoothed speed - sum := float64(0) - t.speeds.Do(func(x interface{}) { + return sc.smoothedSpeed +} + +// GetSpeedBuffer returns the speed buffer as an array of formatted strings +func (sc *SpeedController) GetSpeedBuffer() []string { + + // Lock the mutex to protect the fields + sc.mu.RLock() + defer sc.mu.RUnlock() + + var speeds []string + sc.speeds.Do(func(x interface{}) { if x != nil { - sum += x.(float64) + speeds = append(speeds, fmt.Sprintf("%.2f", x.(float64))) } }) - t.smoothedSpeed = sum / float64(t.window) - t.lastUpdate = time.Now() + return speeds } diff --git a/internal/speed/speed_controller_test.go b/internal/speed/speed_controller_test.go index 1656ab6..c3cae1a 100644 --- a/internal/speed/speed_controller_test.go +++ b/internal/speed/speed_controller_test.go @@ -40,6 +40,7 @@ func calculateAverage(data []float64) float64 { // TestNewSpeedController tests the initialization of a new SpeedController func TestNewSpeedController(t *testing.T) { + controller := NewSpeedController(td.window) // Verify initialization @@ -61,6 +62,7 @@ func TestNewSpeedController(t *testing.T) { // TestUpdateSpeed tests the UpdateSpeed method of SpeedController func TestUpdateSpeed(t *testing.T) { + controller := NewSpeedController(td.window) // Update with test speeds @@ -79,6 +81,7 @@ func TestUpdateSpeed(t *testing.T) { // TestGetSmoothedSpeed tests the GetSmoothedSpeed method of SpeedController func TestGetSmoothedSpeed(t *testing.T) { + // Define test cases tests := []struct { name string @@ -108,6 +111,7 @@ func TestGetSmoothedSpeed(t *testing.T) { } func TestGetSpeedBuffer(t *testing.T) { + // Define test cases controller := NewSpeedController(td.window) speeds := []float64{3.5, 2.5, 1.5, 0.0, 0.0} @@ -132,6 +136,7 @@ func TestGetSpeedBuffer(t *testing.T) { } func TestConcurrency(t *testing.T) { + // Create SpeedController controller := NewSpeedController(td.window) var wg sync.WaitGroup diff --git a/internal/video-player/playback_controller.go b/internal/video-player/playback_controller.go index 139f836..470c08f 100644 --- a/internal/video-player/playback_controller.go +++ b/internal/video-player/playback_controller.go @@ -1,3 +1,4 @@ +// Package video provides video playback control functionality synchronized with speed measurements package video import ( @@ -24,11 +25,6 @@ var ( ErrSpeedUpdate = errors.New("failed to update video speed") ) -// wrapError wraps an error with a specific error type for more context -func wrapError(baseErr error, contextErr error) error { - return fmt.Errorf("%w: %v", baseErr, contextErr) -} - // PlaybackController manages video playback using MPV media player type PlaybackController struct { config config.VideoConfig @@ -36,11 +32,12 @@ type PlaybackController struct { player *mpv.Mpv } -// NewPlaybackController creates a new video player with the given configuration +// NewPlaybackController creates a new video player with the given config func NewPlaybackController(videoConfig config.VideoConfig, speedConfig config.SpeedConfig) (*PlaybackController, error) { + player := mpv.New() if err := player.Initialize(); err != nil { - return nil, err + return nil, fmt.Errorf("failed to initialize MPV player: %w", err) } return &PlaybackController{ @@ -50,102 +47,125 @@ func NewPlaybackController(videoConfig config.VideoConfig, speedConfig config.Sp }, nil } -// Start configures and starts the MPV media player +// Start configures and starts the MPV media player, then manages the playback loop and +// synchronizes video speed with the provided speed controller func (p *PlaybackController) Start(ctx context.Context, speedController *speed.SpeedController) error { + logger.Info(logger.VIDEO, "starting MPV video player...") defer p.player.TerminateDestroy() if err := p.configureMPVPlayer(); err != nil { - return err + return fmt.Errorf("failed to configure MPV player: %w", err) } - logger.Debug(logger.VIDEO, "loading video file: "+p.config.FilePath) + logger.Debug(logger.VIDEO, "loading video file:", p.config.FilePath) if err := p.loadMPVVideo(); err != nil { - return err + return fmt.Errorf("failed to load video file: %w", err) } - // Define playback loop interval + return p.runPlaybackLoop(ctx, speedController) +} + +// runPlaybackLoop runs the main playback loop, updating the video playback speed +func (p *PlaybackController) runPlaybackLoop(ctx context.Context, speedController *speed.SpeedController) error { + ticker := time.NewTicker(time.Millisecond * time.Duration(p.config.UpdateIntervalSec*1000)) defer ticker.Stop() var lastSpeed float64 + logger.Info(logger.VIDEO, "MPV video playback started") logger.Debug(logger.VIDEO, "entering MPV playback loop...") - // Start playback loop for { select { case <-ctx.Done(): - logger.Info(logger.VIDEO, "user-generated interrupt, stopping video player...") + fmt.Print("\r") // Clear the ^C character + logger.Info(logger.VIDEO, "user-generated interrupt, stopping MPV video player...") 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 err := p.handlePlaybackTick(speedController, &lastSpeed); err != nil { - if !strings.Contains(err.Error(), "end of file") { - logger.Warn(logger.VIDEO, "error updating playback speed: "+err.Error()) + if errors.Is(err, ErrVideoComplete) { + return err } + logger.Warn(logger.VIDEO, "playback error:", err.Error()) } + } + } +} + +// handlePlaybackTick updates the video playback speed based on the speed controller +func (p *PlaybackController) handlePlaybackTick(speedController *speed.SpeedController, lastSpeed *float64) error { + // Check for end of file + 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") { + return fmt.Errorf("error updating playback speed: %w", err) } } + return nil } -// configureMPVPlayer configures the MPV video player settings +// configureMPVPlayer configures the MPV media player 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 } - // Set video window size, forcing window-maximized if scale factor is 1.0 + // Set video window size if p.config.WindowScaleFactor == 1.0 { logger.Debug(logger.VIDEO, "maximizing video window") return p.player.SetOptionString("window-maximized", "yes") } - // Scale video window logger.Debug(logger.VIDEO, "scaling video window") - return p.player.SetOptionString("autofit", strconv.Itoa(int(p.config.WindowScaleFactor*100))+"%") + scalePercent := strconv.Itoa(int(p.config.WindowScaleFactor * 100)) + + return p.player.SetOptionString("autofit", scalePercent+"%") } -// loadMPVVideo loads the video file into the MPV video player +// loadMPVVideo loads the video file func (p *PlaybackController) loadMPVVideo() error { return p.player.Command([]string{"loadfile", p.config.FilePath}) } -// updatePlaybackSpeed updates the video playback speed based on the sensor speed +// updatePlaybackSpeed updates the video playback speed func (p *PlaybackController) updatePlaybackSpeed(speedController *speed.SpeedController, lastSpeed *float64) error { + currentSpeed := speedController.GetSmoothedSpeed() p.logSpeedInfo(speedController, currentSpeed) return p.checkSpeedState(currentSpeed, lastSpeed) } -// logSpeedInfo logs the sensor speed details +// logSpeedInfo logs speed information func (p *PlaybackController) logSpeedInfo(sc *speed.SpeedController, currentSpeed float64) { + logger.Debug(logger.VIDEO, "sensor speed buffer: ["+strings.Join(sc.GetSpeedBuffer(), " ")+"]") - logger.Info(logger.VIDEO, logger.Magenta+"smoothed sensor speed: "+strconv.FormatFloat(currentSpeed, 'f', 2, 64)+" "+p.speedConfig.SpeedUnits) + logger.Debug(logger.VIDEO, logger.Magenta+"smoothed sensor speed:", + strconv.FormatFloat(currentSpeed, 'f', 2, 64), p.speedConfig.SpeedUnits) } -// checkSpeedState checks the current sensor speed and adjusts video playback +// checkSpeedState checks the current speed and updates the playback speed func (p *PlaybackController) checkSpeedState(currentSpeed float64, lastSpeed *float64) error { + // If no speed detected, pause playback if currentSpeed == 0 { return p.pausePlayback() } + // If the delta between the current speed and the last speed is greater than the threshold, deltaSpeed := math.Abs(currentSpeed - *lastSpeed) - - logger.Debug(logger.VIDEO, logger.Magenta+"last playback speed: "+strconv.FormatFloat(*lastSpeed, 'f', 2, 64)+" "+p.speedConfig.SpeedUnits) - logger.Debug(logger.VIDEO, logger.Magenta+"sensor speed delta: "+strconv.FormatFloat(deltaSpeed, 'f', 2, 64)+" "+p.speedConfig.SpeedUnits) - logger.Debug(logger.VIDEO, logger.Magenta+"playback speed update threshold: "+strconv.FormatFloat(p.speedConfig.SpeedThreshold, 'f', 2, 64)+" "+p.speedConfig.SpeedUnits) + p.logSpeedDebugInfo(*lastSpeed, deltaSpeed) if deltaSpeed > p.speedConfig.SpeedThreshold { return p.adjustPlayback(currentSpeed, lastSpeed) @@ -154,8 +174,20 @@ func (p *PlaybackController) checkSpeedState(currentSpeed float64, lastSpeed *fl return nil } -// pausePlayback pauses the video playback in the MPV media player +// logSpeedDebugInfo logs debug information +func (p *PlaybackController) logSpeedDebugInfo(lastSpeed, deltaSpeed float64) { + + logger.Debug(logger.VIDEO, logger.Magenta+"last playback speed:", + strconv.FormatFloat(lastSpeed, 'f', 2, 64), p.speedConfig.SpeedUnits) + logger.Debug(logger.VIDEO, logger.Magenta+"sensor speed delta:", + strconv.FormatFloat(deltaSpeed, 'f', 2, 64), p.speedConfig.SpeedUnits) + logger.Debug(logger.VIDEO, logger.Magenta+"playback speed update threshold:", + strconv.FormatFloat(p.speedConfig.SpeedThreshold, 'f', 2, 64), p.speedConfig.SpeedUnits) +} + +// pausePlayback pauses the video playback 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,8 +199,10 @@ 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)) + logger.Debug(logger.VIDEO, logger.Cyan+"updating video playback speed to", + strconv.FormatFloat(playbackSpeed, 'f', 2, 64)) if err := p.updateMPVPlaybackSpeed(playbackSpeed); err != nil { return wrapError(ErrPlaybackSpeed, err) @@ -183,39 +217,49 @@ func (p *PlaybackController) adjustPlayback(currentSpeed float64, lastSpeed *flo return p.setMPVPauseState(false) } -// updateMPVDisplay updates the MPV media player on-screen display +// updateMPVDisplay updates the MPV OSD func (p *PlaybackController) updateMPVDisplay(cycleSpeed, playbackSpeed float64) error { if !p.config.OnScreenDisplay.ShowOSD { return nil } - var osdText string + osdText := p.buildOSDText(cycleSpeed, playbackSpeed) - // Build and display OSD text based on display flags - if cycleSpeed > 0 { + return p.player.SetOptionString("osd-msg1", osdText) +} - if p.config.OnScreenDisplay.DisplayCycleSpeed { - osdText += fmt.Sprintf(" Cycle Speed: %.2f %s\n", cycleSpeed, p.speedConfig.SpeedUnits) - } +// buildOSDText builds the MPV OSD text +func (p *PlaybackController) buildOSDText(cycleSpeed, playbackSpeed float64) string { - if p.config.OnScreenDisplay.DisplayPlaybackSpeed { - osdText += fmt.Sprintf(" Playback Speed: %.2fx\n", playbackSpeed) - } + // If no speed detected, show "Paused" + if cycleSpeed == 0 { + return " Paused" + } + + var osdText string + if p.config.OnScreenDisplay.DisplayCycleSpeed { + osdText += fmt.Sprintf(" Cycle Speed: %.2f %s\n", cycleSpeed, p.speedConfig.SpeedUnits) + } - } else { - osdText = " Paused" + if p.config.OnScreenDisplay.DisplayPlaybackSpeed { + osdText += fmt.Sprintf(" Playback Speed: %.2fx\n", playbackSpeed) } - return p.player.SetOptionString("osd-msg1", osdText) + return osdText } -// updateMPVPlaybackSpeed sets the video playback speed +// updateMPVPlaybackSpeed updates the video playback speed func (p *PlaybackController) updateMPVPlaybackSpeed(playbackSpeed float64) error { return p.player.SetProperty("speed", mpv.FormatDouble, playbackSpeed) } -// setMPVPauseState sets the video playback pause state +// setMPVPauseState sets the MPV pause state func (p *PlaybackController) setMPVPauseState(pause bool) error { return p.player.SetProperty("pause", mpv.FormatFlag, pause) } + +// wrapError wraps an error with a specific error type for more context +func wrapError(baseErr error, contextErr error) error { + return fmt.Errorf("%w: %v", baseErr, contextErr) +} diff --git a/internal/video-player/playback_controller_test.go b/internal/video-player/playback_controller_test.go index ce89073..384c2c3 100644 --- a/internal/video-player/playback_controller_test.go +++ b/internal/video-player/playback_controller_test.go @@ -38,6 +38,7 @@ func init() { // createTestConfig returns test video and speed configurations func createTestConfig() (config.VideoConfig, config.SpeedConfig) { + vc := config.VideoConfig{ FilePath: td.filename, WindowScaleFactor: td.windowScale, @@ -57,6 +58,7 @@ func createTestConfig() (config.VideoConfig, config.SpeedConfig) { // TestNewPlaybackController verifies controller creation and initialization func TestNewPlaybackController(t *testing.T) { + // Create test configuration vc, sc := createTestConfig() @@ -67,6 +69,7 @@ func TestNewPlaybackController(t *testing.T) { // TestPlaybackFlow tests the complete playback flow func TestPlaybackFlow(t *testing.T) { + // Create test controller controller := createTestController(t) @@ -95,6 +98,7 @@ func TestPlaybackFlow(t *testing.T) { // TestPauseControl tests pause functionality func TestPauseControl(t *testing.T) { + // Create test controller controller := createTestController(t) @@ -123,6 +127,7 @@ func TestPauseControl(t *testing.T) { // createTestController creates a PlaybackController with default test configurations func createTestController(t *testing.T) *PlaybackController { + // Create test configuration vc, sc := createTestConfig()