diff --git a/README.md b/README.md index 7ed9aa0..9e23f92 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,12 @@ For my own indoor cycling configuration, I use a Performance Travel Trac 3 train ### Software Components -- The open source, cross-platform [mpv media player](https://mpv.io/), installed and operational +- The open source, cross-platform [mpv media player](https://mpv.io/), installed (e.g., `sudo apt-get install mpv`) and operational +- The `libmpv2` library, installed (e.g., `sudo apt-get install libmpv2`) +- In order to compile the executable for this project, an operational [Go language](https://go.dev/) environment is required (this release was developed using Go 1.23.2). Once the **BLE Sync Cycle** application is compiled into an executable, it can be run without the dependencies on the Go language environment - A local video file for playback using mpv, preferably a first-person view cycling video. Check out [YouTube with this query: "first person cycling"](https://www.youtube.com/results?search_query=first+person+cycling) for some ideas -- This application. While **BLE Sync Cycle** has been written and tested using Ubuntu 24.04 (LTS) on an Intel processor (amd64), it should work across any recent Unix-like platform and architecture - - In order to compile the executable for this project, an operational [Go language](https://go.dev/) environment is required (this release was developed using Go 1.23.2) + +While **BLE Sync Cycle** has been written and tested using Ubuntu 24.04 (LTS) on an Intel processor (amd64), it should work across any recent comparable Unix-like platform and architecture. ## Installation @@ -70,20 +72,20 @@ For my own indoor cycling configuration, I use a Performance Travel Trac 3 train 1. Clone the repository: - ```bash + ```console git clone https://github.com/richbl/go-ble-sync-cycle cd go-ble-sync-cycle ``` 2. Install dependencies: - ```bash + ```console go mod download ``` 3. Build the application: - ```bash + ```console go build -o ble-sync-cycle cmd/main.go ``` @@ -95,7 +97,7 @@ Edit the `config.toml` file found in the `internal/configuration` directory. The ```toml # BLE Sync Cycle TOML configuration -# 0.6.1 +# 0.6.2 [app] logging_level = "debug" # Log messages to see during execution: "debug", "info", "warn", "error" @@ -177,13 +179,13 @@ At a high level, **BLE Sync Cycle** will perform the following: To run the application, execute the following command: -```bash +```console ./ble-sync-cycle ``` Or, if the application hasn't yet been built using the `go build` command, you can execute the following command: -```bash +```console go run cmd/main.go ``` @@ -191,8 +193,8 @@ go run cmd/main.go At this point, you should see the following output: - ```bash -2024/12/11 22:02:28 Starting BLE Sync Cycle 0.6.1 + ```console +2024/12/11 22:02:28 Starting BLE Sync Cycle 0.6.2 2024/12/11 22:02:28 INFO [BLE] Created new BLE central controller 2024/12/11 22:02:28 INFO [BLE] Now scanning the ether for BLE peripheral UUID of F1:42:D8:DE:35:16... 2024/12/11 22:02:42 INFO [BLE] Found BLE peripheral F1:42:D8:DE:35:16 @@ -206,8 +208,8 @@ At this point, you should see the following output: In this first example, while the application was able to find the BLE peripheral, it failed to discover the CSC services and characteristics before timing out. Depending on the BLE peripheral, it may take some time before a BLE peripheral advertises both its device services and characteristics. If the peripheral is not responding, you may need to increase the timeout in the `config.toml` file. - ```bash -2024/12/11 22:03:59 Starting BLE Sync Cycle 0.6.1 + ```console +2024/12/11 22:03:59 Starting BLE Sync Cycle 0.6.2 2024/12/11 22:03:59 INFO [BLE] Created new BLE central controller 2024/12/11 22:03:59 INFO [BLE] Now scanning the ether for BLE peripheral UUID of F1:42:D8:DE:35:16... 2024/12/11 22:03:59 INFO [BLE] Found BLE peripheral F1:42:D8:DE:35:16 @@ -229,7 +231,7 @@ In this first example, while the application was able to find the BLE peripheral In the example above, the application is now running in a loop, periodically querying the BLE peripheral for speed data. The application will also update the video player to match the speed of the sensor. Here, since the video has just begun, its speed is set to 0.0 (paused). -```bash +```console ... 024/12/11 22:05:08 INFO [SPEED] BLE sensor speed: 8.54 mph 2024/12/11 22:05:09 INFO [VIDEO] Sensor speed buffer: [9.06 8.28 8.11 8.20 8.54] @@ -265,7 +267,7 @@ In this last example, **BLE Sync Cycle** is coordinating with both the BLE perip **To quit the application, press `Ctrl+C`.** -```bash +```console ... 2024/12/11 22:05:11 INFO [SPEED] BLE sensor speed: 0.00 mph 2024/12/11 22:05:12 INFO [VIDEO] Sensor speed buffer: [8.44 7.84 6.56 6.65 0.00] @@ -287,7 +289,6 @@ In this last example, **BLE Sync Cycle** is coordinating with both the BLE perip 2024/12/11 22:05:14 INFO [APP] Shutdown signal received 2024/12/11 22:05:14 INFO [VIDEO] Context cancelled. Shutting down video player component 2024/12/11 22:05:14 INFO [APP] Application shutdown complete. Goodbye! - ``` ## FAQ diff --git a/cmd/main.go b/cmd/main.go index fccf541..6313f77 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -26,11 +26,10 @@ type appControllers struct { } func main() { - - log.Println("Starting BLE Sync Cycle 0.6.1") + log.Println("Starting BLE Sync Cycle 0.6.2") // Load configuration - cfg, err := config.LoadFile("internal/configuration/config.toml") + cfg, err := config.LoadFile("config.toml") if err != nil { log.Fatal("FATAL - Failed to load TOML configuration: " + err.Error()) } @@ -55,7 +54,6 @@ func main() { // Shutdown the application... buh bye! logger.Info("[APP] Application shutdown complete. Goodbye!") - } // startAppControllers is responsible for starting and managing the component controllers @@ -136,7 +134,6 @@ func setupAppControllers(cfg config.Config) (appControllers, error) { videoPlayer: videoPlayer, bleController: bleController, }, nil - } // scanForBLESpeedCharacteristic scans for the BLE CSC speed characteristic @@ -149,7 +146,6 @@ 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 @@ -157,7 +153,6 @@ func scanForBLESpeedCharacteristic(ctx context.Context, controllers appControlle // Return the characteristic results <- characteristic - }() // Wait for the characteristic or an error @@ -169,19 +164,14 @@ func scanForBLESpeedCharacteristic(ctx context.Context, controllers appControlle case characteristic := <-results: return characteristic, nil } - } // monitorBLESpeed monitors the BLE speed characteristic func monitorBLESpeed(ctx context.Context, controllers appControllers, bleSpeedCharacter *bluetooth.DeviceCharacteristic) error { - return controllers.bleController.GetBLEUpdates(ctx, controllers.speedController, bleSpeedCharacter) - } // playVideo starts the video player func playVideo(ctx context.Context, controllers appControllers) error { - return controllers.videoPlayer.Start(ctx, controllers.speedController) - } diff --git a/internal/ble/sensor_controller.go b/internal/ble/sensor_controller.go index 1003cac..8b1d5fc 100644 --- a/internal/ble/sensor_controller.go +++ b/internal/ble/sensor_controller.go @@ -43,6 +43,7 @@ const ( // 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 @@ -55,7 +56,6 @@ func NewBLEController(bleConfig config.BLEConfig, speedConfig config.SpeedConfig speedConfig: speedConfig, bleAdapter: *bleAdapter, }, nil - } // GetBLECharacteristic scans for the BLE peripheral and returns CSC services/characteristics @@ -67,7 +67,7 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle return nil, err } - logger.Info("[BLE] Connecting to BLE peripheral device " + result.Address.String()) + logger.Debug("[BLE] Connecting to BLE peripheral device " + result.Address.String()) // Connect to BLE peripheral device var device bluetooth.Device @@ -76,7 +76,7 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle } logger.Info("[BLE] BLE peripheral device connected") - logger.Info("[BLE] Discovering CSC services " + bluetooth.New16BitUUID(0x1816).String()) + logger.Debug("[BLE] Discovering CSC services " + bluetooth.New16BitUUID(0x1816).String()) // Find CSC service and characteristic svc, err := device.DiscoverServices([]bluetooth.UUID{bluetooth.New16BitUUID(0x1816)}) @@ -85,8 +85,8 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle return nil, err } - logger.Info("[BLE] Found CSC service " + svc[0].UUID().String()) - logger.Info("[BLE] Discovering CSC characteristics " + bluetooth.New16BitUUID(0x2A5B).String()) + logger.Debug("[BLE] Found CSC service " + svc[0].UUID().String()) + logger.Debug("[BLE] Discovering CSC characteristics " + bluetooth.New16BitUUID(0x2A5B).String()) char, err := svc[0].DiscoverCharacteristics([]bluetooth.UUID{bluetooth.New16BitUUID(0x2A5B)}) if err != nil { @@ -94,16 +94,14 @@ func (m *BLEController) GetBLECharacteristic(ctx context.Context, speedControlle return nil, err } - logger.Info("[BLE] Found CSC characteristic " + char[0].UUID().String()) - + logger.Debug("[BLE] Found CSC characteristic " + char[0].UUID().String()) return &char[0], nil - } // 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.Info("[BLE] Starting real-time monitoring of BLE sensor notifications...") + logger.Debug("[BLE] Starting real-time monitoring of BLE sensor notifications...") // Subscribe to live BLE sensor notifications if err := char.EnableNotifications(func(buf []byte) { @@ -115,7 +113,6 @@ func (m *BLEController) GetBLEUpdates(ctx context.Context, speedController *spee <-ctx.Done() return nil - } // ScanForBLEPeripheral scans for a BLE peripheral with the specified UUID @@ -128,30 +125,24 @@ func (m *BLEController) ScanForBLEPeripheral(ctx context.Context) (bluetooth.Sca errChan := make(chan error, 1) go func() { - logger.Info("[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.Info("[BLE] Found BLE peripheral " + result.Address.String()) + logger.Debug("[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("[BLE] Failed to stop scan: " + err.Error()) } - return bluetooth.ScanResult{}, errors.New("scanning time limit reached") } } @@ -161,6 +152,7 @@ func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { 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 @@ -170,17 +162,13 @@ func (m *BLEController) startScanning(found chan<- bluetooth.ScanResult) error { // Found the target peripheral found <- result - } - }) if err != nil { logger.Error("[BLE] Scan error: " + err.Error()) } - return nil - } // ProcessBLESpeed processes the raw speed data from the BLE peripheral @@ -195,10 +183,9 @@ func (m *BLEController) ProcessBLESpeed(data []byte) float64 { // Calculate speed from parsed data speed := m.calculateSpeed(newSpeedData) - logger.Info("[SPEED] BLE sensor speed: " + strconv.FormatFloat(speed, 'f', 2, 64) + " " + m.speedConfig.SpeedUnits) + logger.Info("[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 @@ -232,7 +219,6 @@ func (m *BLEController) calculateSpeed(sm SpeedMeasurement) float64 { lastWheelTime = sm.wheelTime return speed - } // parseSpeedData parses the raw speed data from the BLE peripheral @@ -253,5 +239,4 @@ func (m *BLEController) parseSpeedData(data []byte) (SpeedMeasurement, error) { wheelRevs: binary.LittleEndian.Uint32(data[1:]), wheelTime: binary.LittleEndian.Uint16(data[5:]), }, nil - } diff --git a/internal/ble/sensor_controller_test.go b/internal/ble/sensor_controller_test.go index 9508e69..5285ae4 100644 --- a/internal/ble/sensor_controller_test.go +++ b/internal/ble/sensor_controller_test.go @@ -28,23 +28,17 @@ const ( // init initializes the logger for testing func init() { - logger.Initialize("debug") - } // resetBLEData resets the BLE data for testing func resetBLEData(controller *ble.BLEController) { - controller.ProcessBLESpeed([]byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) - } // waitForScanReset implements a delay before scanning for a BLE peripheral func waitForScanReset() { - time.Sleep(initialScanDelay) - } // createTestController creates test BLE and speed controllers @@ -63,7 +57,6 @@ func createTestController(speedUnits string) (*ble.BLEController, error) { } return ble.NewBLEController(bleConfig, speedConfig) - } // TestProcessBLESpeed tests the ProcessBLESpeed() function @@ -122,7 +115,6 @@ func TestProcessBLESpeed(t *testing.T) { // Loop through the test cases for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { // Create test BLE and speed controllers @@ -146,15 +138,14 @@ func TestProcessBLESpeed(t *testing.T) { // Process BLE data got := controller.ProcessBLESpeed(tt.data) assert.InDelta(t, tt.want, got, 0.1, "Speed calculation mismatch") - }) } - } // TestNewBLEControllerIntegration tests the creation of a new BLEController func TestNewBLEControllerIntegration(t *testing.T) { + // Create test BLE controller controller, err := controllersIntegrationTest() if err != nil { t.Skip(noBLEAdapterError) @@ -162,43 +153,40 @@ func TestNewBLEControllerIntegration(t *testing.T) { } assert.NotNil(t, controller) - } // TestScanForBLEPeripheralIntegration tests the ScanForBLEPeripheral() function func TestScanForBLEPeripheralIntegration(t *testing.T) { + // Create test BLE controller controller, err := controllersIntegrationTest() if err != nil { t.Skip(noBLEAdapterError) return } - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() // Expect error since test UUID won't be found _, err = controller.ScanForBLEPeripheral(ctx) assert.Error(t, err) - } // TestGetBLECharacteristicIntegration tests the GetBLECharacteristic() function func TestGetBLECharacteristicIntegration(t *testing.T) { + // Create test BLE controller controller, err := controllersIntegrationTest() if err != nil { t.Skip(noBLEAdapterError) return } - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() // Expect error since test UUID won't be found _, err = controller.GetBLECharacteristic(ctx, nil) assert.Error(t, err) - } // controllersIntegrationTest pauses BLE scan and then creates controllers @@ -214,5 +202,4 @@ func controllersIntegrationTest() (*ble.BLEController, error) { } return controller, nil - } diff --git a/internal/configuration/config.go b/internal/configuration/config.go index 6c4c761..678022f 100644 --- a/internal/configuration/config.go +++ b/internal/configuration/config.go @@ -2,7 +2,9 @@ package config import ( "errors" + "fmt" "os" + "path/filepath" "github.com/BurntSushi/toml" ) @@ -61,23 +63,42 @@ const ( SpeedUnitsMPH = "mph" ) -// LoadFile loads the application configuration from the given filepath -func LoadFile(filepath string) (*Config, error) { +// 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) { - var config Config - - // Read the TOML configuration file - if _, err := toml.DecodeFile(filepath, &config); err != nil { - return nil, err + // Define configuration file paths + paths := []string{ + filename, + filepath.Join("internal", "configuration", filepath.Base(filename)), } - // Validate all configuration elements - if err := config.validate(); err != nil { - return nil, err - } + 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] { + lastErr = fmt.Errorf("failed to load config from %s: %w", path, err) + } + continue + } - return &config, nil + // 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 @@ -92,52 +113,47 @@ 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 } - return nil - } // validate validates AppConfig elements func (ac *AppConfig) validate() error { + // Validate log level switch ac.LogLevel { case logLevelDebug, logLevelInfo, logLevelWarn, logLevelError, logLevelFatal: return nil default: return errors.New("invalid log level: " + ac.LogLevel) } - } // validate validates SpeedConfig elements func (sc *SpeedConfig) validate() error { + // Validate speed units switch sc.SpeedUnits { case SpeedUnitsKMH, SpeedUnitsMPH: return nil default: return errors.New("invalid speed units: " + sc.SpeedUnits) } - } // validate validates BLEConfig elements 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") } - return nil - } // validate validates VideoConfig elements @@ -157,5 +173,4 @@ func (vc *VideoConfig) validate() error { vc.OnScreenDisplay.ShowOSD = (vc.OnScreenDisplay.DisplayCycleSpeed || vc.OnScreenDisplay.DisplayPlaybackSpeed) return nil - } diff --git a/internal/configuration/config.toml b/internal/configuration/config.toml index 557d637..689866d 100644 --- a/internal/configuration/config.toml +++ b/internal/configuration/config.toml @@ -1,5 +1,5 @@ # BLE Sync Cycle TOML configuration -# 0.6.1 +# 0.6.2 [app] logging_level = "debug" # Log messages to see during execution: "debug", "info", "warn", "error" @@ -11,14 +11,14 @@ [speed] smoothing_window = 5 # Number of speed look-backs to use for generating a moving average - speed_threshold = 1.0 # 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.3 # 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 725282f..7146c2e 100644 --- a/internal/configuration/config_test.go +++ b/internal/configuration/config_test.go @@ -109,7 +109,6 @@ func createTempConfigFile(t *testing.T, config string) (string, func()) { } return tmpFile.Name(), func() { os.Remove(tmpFile.Name()) } - } // runValidationTest is a generic helper function for running validation tests @@ -117,7 +116,6 @@ func runValidationTest[T any](t *testing.T, tests []configTestCase[T]) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { // Handle both value and pointer receivers @@ -134,10 +132,8 @@ func runValidationTest[T any](t *testing.T, tests []configTestCase[T]) { if (err != nil) != tt.wantErr { t.Errorf(validateErrMessage, err, tt.wantErr) } - }) } - } // TestLoadFile tests the LoadFile function @@ -151,7 +147,6 @@ func TestLoadFile(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { tmpFile, cleanup := createTempConfigFile(t, tt.config) defer cleanup() @@ -160,10 +155,8 @@ func TestLoadFile(t *testing.T) { if (err != nil) != tt.wantErr { t.Errorf("LoadFile() error = %v, wantErr %v", err, tt.wantErr) } - }) } - } // TestValidate tests the validate function @@ -177,29 +170,22 @@ func TestValidate(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpFile, cleanup := createTempConfigFile(t, tt.config) defer cleanup() config, err := LoadFile(tmpFile) if err != nil { - if !tt.wantErr { t.Errorf("LoadFile() error = %v, wantErr %v", err, tt.wantErr) } - return - } - if err := config.validate(); (err != nil) != tt.wantErr { t.Errorf(validateErrMessage, err, tt.wantErr) } }) } - } // TestValidateVideoConfig tests the validate function @@ -237,33 +223,26 @@ func TestValidateVideoConfig(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.config.FilePath != invalidTestFilename { tmpFile, err := os.CreateTemp("", "test") - if err != nil { t.Fatal(err) } - defer os.Remove(tmpFile.Name()) tt.config.FilePath = tmpFile.Name() - } - if err := tt.config.validate(); (err != nil) != tt.wantErr { t.Errorf(validateErrMessage, err, tt.wantErr) } - }) } - } // TestValidateAppConfig tests the validate function func TestValidateAppConfig(t *testing.T) { + // Define test cases tests := []configTestCase[AppConfig]{ { name: validConfig, @@ -277,13 +256,14 @@ func TestValidateAppConfig(t *testing.T) { }, } + // Run tests runValidationTest(t, tests) - } // TestValidateBLEConfig tests the validate function func TestValidateBLEConfig(t *testing.T) { + // Define test cases tests := []configTestCase[BLEConfig]{ { name: validConfig, @@ -303,13 +283,14 @@ func TestValidateBLEConfig(t *testing.T) { }, } + // Run tests runValidationTest(t, tests) - } // TestValidateSpeedConfig tests the validate function func TestValidateSpeedConfig(t *testing.T) { + // Define test cases tests := []configTestCase[SpeedConfig]{ { name: validConfig, @@ -333,6 +314,6 @@ func TestValidateSpeedConfig(t *testing.T) { }, } + // Run tests runValidationTest(t, tests) - } diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 8e784ff..93ce6a0 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -40,6 +40,7 @@ type CustomTextHandler struct { // NewCustomTextHandler creates a new custom text handler func NewCustomTextHandler(w io.Writer, opts *slog.HandlerOptions) *CustomTextHandler { textHandler := slog.NewTextHandler(w, opts) + return &CustomTextHandler{ Handler: textHandler, writer: w, @@ -79,39 +80,33 @@ func (h *CustomTextHandler) Handle(ctx context.Context, r slog.Record) error { // Write formatted output for all other logging levels fmt.Fprintf(h.writer, "%s %s%s%s %s%s\n", timestamp, color, level, Reset, msg, Reset) return nil - } // Enabled implements slog.Handler func (h *CustomTextHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.Handler.Enabled(ctx, level) - } // WithAttrs implements slog.Handler which is used to add attributes func (h *CustomTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &CustomTextHandler{ Handler: h.Handler.WithAttrs(attrs), writer: h.writer, } - } // WithGroup implements slog.Handler which is used to group logs func (h *CustomTextHandler) WithGroup(name string) slog.Handler { - return &CustomTextHandler{ Handler: h.Handler.WithGroup(name), writer: h.writer, } - } // Initialize initializes the logger with a specified log level func Initialize(logLevel string) *slog.Logger { + // Set log level var level slog.Level switch logLevel { @@ -131,40 +126,29 @@ func Initialize(logLevel string) *slog.Logger { logger = slog.New(NewCustomTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) return logger - } // Info logs an informational message func Info(msg string, keysAndValues ...interface{}) { - logger.Info(msg, keysAndValues...) - } // Warn logs a warning message func Warn(msg string, keysAndValues ...interface{}) { - logger.Warn(msg, keysAndValues...) - } // Error logs an error message func Error(msg string, keysAndValues ...interface{}) { - logger.Error(msg, keysAndValues...) - } // Debug logs a debug message func Debug(msg string, keysAndValues ...interface{}) { - logger.Debug(msg, keysAndValues...) - } func Fatal(msg string, keysAndValues ...interface{}) { - logger.Log(context.Background(), LevelFatal, msg, keysAndValues...) ExitFunc(1) - } diff --git a/internal/logging/logger_test.go b/internal/logging/logger_test.go index 480a7e9..2da4334 100644 --- a/internal/logging/logger_test.go +++ b/internal/logging/logger_test.go @@ -44,9 +44,7 @@ func TestInitialize(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - Initialize(tt.level.(string)) if logger == nil { @@ -61,10 +59,8 @@ func TestInitialize(t *testing.T) { if h.level != tt.want.(slog.Level) { t.Errorf("got logger level %v, want %v", h.level, tt.want) } - }) } - } // TestCustomTextHandler tests the custom text handler @@ -81,9 +77,7 @@ func TestCustomTextHandler(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := &bytes.Buffer{} h := NewCustomTextHandler(buf, &slog.HandlerOptions{Level: defaultOptions}) r := slog.NewRecord(time.Now(), tt.level.(slog.Level), testMessage, 0) @@ -91,14 +85,11 @@ func TestCustomTextHandler(t *testing.T) { if err := h.Handle(context.Background(), r); err != nil { t.Fatalf("Handle() error = %v", err) } - if !strings.Contains(buf.String(), tt.want.(string)) { t.Errorf("got %q, want %q", buf.String(), tt.want) } - }) } - } // TestLogLevels tests all logging level functions @@ -118,9 +109,7 @@ func TestLogLevels(t *testing.T) { // Run tests for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf, testLogger := setupTestLogger() logger = testLogger @@ -128,10 +117,8 @@ func TestLogLevels(t *testing.T) { if !strings.Contains(buf.String(), tt.wantLevel) { t.Errorf("got %q, want to contain %q", buf.String(), tt.wantLevel) } - }) } - } // TestFatal tests the Fatal function @@ -147,25 +134,20 @@ func TestFatal(t *testing.T) { // Mock exit function ExitFunc = func(code int) { - exitCalled = true if code != 1 { t.Errorf("Fatal called exit function with code %d, want 1", code) } - } // Call Fatal function for testing Fatal(testMessage) - if !exitCalled { t.Error("Fatal did not call exit function") } - if buf.String() == "" { t.Error("Fatal did not log a message") } - } // TestEnabled tests the Enabled function @@ -183,16 +165,12 @@ func TestEnabled(t *testing.T) { // Run tests 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.(slog.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 d654387..dedb67a 100644 --- a/internal/speed/speed_controller.go +++ b/internal/speed/speed_controller.go @@ -23,6 +23,7 @@ var mutex sync.RWMutex // storing speed measurements for video playback speed smoothing func NewSpeedController(window int) *SpeedController { + // Create ring buffer r := ring.New(window) // Initialize ring with zero values @@ -35,16 +36,17 @@ func NewSpeedController(window int) *SpeedController { speeds: r, window: window, } - } // UpdateSpeed updates the current speed measurement and calculates a smoothed average speed over // the specified window of time func (t *SpeedController) UpdateSpeed(speed float64) { + // Lock mutex to prevent concurrent access mutex.Lock() defer mutex.Unlock() + // Get speeds t.currentSpeed = speed t.speeds.Value = speed t.speeds = t.speeds.Next() @@ -60,25 +62,26 @@ func (t *SpeedController) UpdateSpeed(speed float64) { t.smoothedSpeed = sum / float64(t.window) t.lastUpdate = time.Now() - } // GetSmoothedSpeed returns the smoothed speed measurement func (t *SpeedController) GetSmoothedSpeed() float64 { + // Lock mutex to prevent concurrent access mutex.RLock() defer mutex.RUnlock() return t.smoothedSpeed - } // GetSpeedBuffer returns the speed buffer as an array of string func (t *SpeedController) GetSpeedBuffer() []string { + // Lock mutex to prevent concurrent access mutex.RLock() defer mutex.RUnlock() + // Create speed buffer var speeds []string t.speeds.Do(func(x interface{}) { if x != nil { @@ -87,5 +90,4 @@ func (t *SpeedController) GetSpeedBuffer() []string { }) return speeds - } diff --git a/internal/speed/speed_controller_test.go b/internal/speed/speed_controller_test.go index 09a7b8b..1afa6a2 100644 --- a/internal/speed/speed_controller_test.go +++ b/internal/speed/speed_controller_test.go @@ -13,23 +13,23 @@ const ( // calculateAverage calculates the average of a slice of float64 func calculateAverage(data []float64) float64 { + // Check for empty slice if len(data) == 0 { return 0.0 } sum := 0.0 - for _, value := range data { sum += value } return sum / float64(len(data)) - } // TestNewSpeedController tests the creation of a new SpeedController func TestNewSpeedController(t *testing.T) { + // Create new SpeedController controller := NewSpeedController(defaultWindow) // Verify initial values @@ -46,12 +46,12 @@ func TestNewSpeedController(t *testing.T) { if controller.smoothedSpeed != 0 { t.Errorf("Expected initial smoothed speed to be 0, got %f", controller.smoothedSpeed) } - } // TestUpdateSpeed tests the UpdateSpeed method and verifies smoothed speed calculation func TestUpdateSpeed(t *testing.T) { + // Create new SpeedController controller := NewSpeedController(defaultWindow) speeds := []float64{1.0, 2.0, 3.0, 4.0, 5.0} @@ -65,12 +65,12 @@ func TestUpdateSpeed(t *testing.T) { if smoothedSpeed := controller.GetSmoothedSpeed(); smoothedSpeed != expectedSmoothedSpeed { t.Errorf("Expected smoothed speed %f, got %f", expectedSmoothedSpeed, smoothedSpeed) } - } // TestGetSmoothedSpeed tests the GetSmoothedSpeed method func TestGetSmoothedSpeed(t *testing.T) { + // Create new SpeedController controller := NewSpeedController(defaultWindow) // Create test case @@ -84,20 +84,17 @@ func TestGetSmoothedSpeed(t *testing.T) { // Run test cases for _, tc := range testCases { - controller.UpdateSpeed(tc.speed) - if smoothed := controller.GetSmoothedSpeed(); smoothed != tc.expected { t.Errorf("Expected smoothed speed to be %f, got %f", tc.expected, smoothed) } - } - } // TestGetSpeedBuffer tests the GetSpeedBuffer method func TestGetSpeedBuffer(t *testing.T) { + // Create new SpeedController controller := NewSpeedController(defaultWindow) speeds := []float64{3.5, 2.5, 1.5, 0.0, 0.0} @@ -111,18 +108,16 @@ func TestGetSpeedBuffer(t *testing.T) { buffer := controller.GetSpeedBuffer() for i, val := range expectedBuffer { - if buffer[i] != val { t.Errorf("Expected buffer[%d] to be %s, got %s", i, val, buffer[i]) } - } - } // TestConcurrency tests concurrent updates to the SpeedController func TestConcurrency(t *testing.T) { + // Create new SpeedController controller := NewSpeedController(defaultWindow) var wg sync.WaitGroup @@ -131,11 +126,9 @@ func TestConcurrency(t *testing.T) { // Simulate concurrent updates for i := 1; i <= numUpdates; i++ { - wg.Add(1) go func(speed float64) { - defer wg.Done() controller.UpdateSpeed(speed) time.Sleep(sleepDuration) @@ -148,5 +141,4 @@ func TestConcurrency(t *testing.T) { if smoothedSpeed := controller.GetSmoothedSpeed(); smoothedSpeed == 0 { t.Error("Expected non-zero smoothed speed after concurrent updates") } - } diff --git a/internal/video-player/playback_controller.go b/internal/video-player/playback_controller.go index b1cb16f..9174ddc 100644 --- a/internal/video-player/playback_controller.go +++ b/internal/video-player/playback_controller.go @@ -39,16 +39,18 @@ 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 { - defer p.player.TerminateDestroy() logger.Info("[VIDEO] Starting MPV video player...") + // Defer terminating and destroying the MPV media player + defer p.player.TerminateDestroy() + // Configure the MPV media player if err := p.configureMPVPlayer(); err != nil { return err } // Load the video file into MPV - logger.Info("[VIDEO] Loading video file: " + p.config.FilePath) + logger.Debug("[VIDEO] Loading video file: " + p.config.FilePath) if err := p.loadMPVvideo(); err != nil { return err } @@ -58,7 +60,7 @@ func (p *PlaybackController) Start(ctx context.Context, speedController *speed.S defer ticker.Stop() lastSpeed := 0.0 - logger.Info("[VIDEO] Entering MPV playback loop...") + logger.Debug("[VIDEO] Entering MPV playback loop...") for { select { @@ -70,23 +72,24 @@ func (p *PlaybackController) Start(ctx context.Context, speedController *speed.S logger.Warn("[VIDEO] Error updating playback speed: " + err.Error()) } } - } - } // configureMPVPlayer configures the MPV video player func (p *PlaybackController) configureMPVPlayer() error { - return p.player.SetOptionString("autofit", strconv.Itoa(int(p.config.WindowScaleFactor*100))+"%") + // If the window scale factor is set to 1.0, set the window to be maximized + if p.config.WindowScaleFactor == 1.0 { + logger.Debug("[VIDEO] Maximizing video window") + return p.player.SetOptionString("window-maximized", "yes") + } + return p.player.SetOptionString("autofit", strconv.Itoa(int(p.config.WindowScaleFactor*100))+"%") } // loadMPVvideo loads the video file into the MPV video player 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 @@ -101,16 +104,13 @@ func (p *PlaybackController) updatePlaybackSpeed(speedController *speed.SpeedCon return err } - *lastSpeed = currentSpeed return nil } // logSpeedInfo logs the sensor speed details func (p *PlaybackController) logSpeedInfo(sc *speed.SpeedController, currentSpeed float64) { - - logger.Info("[VIDEO] Sensor speed buffer: [" + strings.Join(sc.GetSpeedBuffer(), " ") + "]") - logger.Info("[VIDEO] Smoothed sensor speed: " + strconv.FormatFloat(currentSpeed, 'f', 2, 64) + " " + p.speedConfig.SpeedUnits) - + logger.Debug("[VIDEO] Sensor speed buffer: [" + strings.Join(sc.GetSpeedBuffer(), " ") + "]") + logger.Info("[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 @@ -121,19 +121,25 @@ func (p *PlaybackController) checkSpeedState(currentSpeed float64, lastSpeed *fl return p.pausePlayback() } + // Calculate the delta between the current and last sensor speed + deltaSpeed := math.Abs(currentSpeed - *lastSpeed) + + logger.Debug("[VIDEO] " + logger.Magenta + "Last playback speed: " + strconv.FormatFloat(*lastSpeed, 'f', 2, 64) + " " + p.speedConfig.SpeedUnits) + logger.Debug("[VIDEO] " + logger.Magenta + "Sensor speed delta: " + strconv.FormatFloat(deltaSpeed, 'f', 2, 64) + " " + p.speedConfig.SpeedUnits) + logger.Debug("[VIDEO] " + logger.Magenta + "Playback speed update threshold: " + strconv.FormatFloat(p.speedConfig.SpeedThreshold, 'f', 2, 64) + " " + p.speedConfig.SpeedUnits) + // Adjust the video playback speed if the sensor speed has changed beyond threshold value - if math.Abs(currentSpeed-*lastSpeed) > p.speedConfig.SpeedThreshold { - return p.adjustPlayback(currentSpeed) + if deltaSpeed > p.speedConfig.SpeedThreshold { + return p.adjustPlayback(currentSpeed, lastSpeed) } return nil - } // pausePlayback pauses the video playback in the MPV media player func (p *PlaybackController) pausePlayback() error { - logger.Info("[VIDEO] No speed detected, so pausing video") + logger.Debug("[VIDEO] No speed detected, so pausing video") // Update the on-screen display if err := p.updateMPVdisplay(0.0, 0.0); err != nil { @@ -142,20 +148,23 @@ func (p *PlaybackController) pausePlayback() error { // Pause the video return p.setMPVpauseState(true) - } // adjustPlayback adjusts the video playback speed -func (p *PlaybackController) adjustPlayback(currentSpeed float64) error { +func (p *PlaybackController) adjustPlayback(currentSpeed float64, lastSpeed *float64) error { + // Calculate the new playback speed playbackSpeed := (currentSpeed * p.config.SpeedMultiplier) / 10.0 - logger.Info("[VIDEO] Updating video playback speed to " + strconv.FormatFloat(playbackSpeed, 'f', 2, 64)) + logger.Info("[VIDEO] " + logger.Cyan + "Updating video playback speed to " + strconv.FormatFloat(playbackSpeed, 'f', 2, 64)) // Update the video playback speed if err := p.updateMPVplaybackSpeed(playbackSpeed); err != nil { return errors.New("failed to set playback speed: " + err.Error()) } + // Update the last sensor speed value sent to the media player to the current value + *lastSpeed = currentSpeed + // Update the on-screen display if err := p.updateMPVdisplay(currentSpeed, playbackSpeed); err != nil { return errors.New("failed to update OSD: " + err.Error()) @@ -185,18 +194,17 @@ func (p *PlaybackController) updateMPVdisplay(cycleSpeed, playbackSpeed float64) // Update the MPV media player on-screen display (OSD) return p.player.SetOptionString("osd-msg1", osdMsg) - } // updateMPVplaybackSpeed sets the video playback speed func (p *PlaybackController) updateMPVplaybackSpeed(playbackSpeed float64) error { + // Set the new playback speed in MPV if err := p.player.SetProperty("speed", mpv.FormatDouble, playbackSpeed); err != nil { return errors.New("failed to update video speed: " + err.Error()) } return nil - } // setMPVpauseState sets the video playback pause state @@ -223,7 +231,6 @@ func (p *PlaybackController) setMPVpauseState(pause bool) error { pauseState = "paused" } - logger.Info("[VIDEO] Video " + pauseState + " successfully") + logger.Debug("[VIDEO] Video " + pauseState + " successfully") return nil - } diff --git a/internal/video-player/playback_controller_test.go b/internal/video-player/playback_controller_test.go index 9aec1c9..085ed0f 100644 --- a/internal/video-player/playback_controller_test.go +++ b/internal/video-player/playback_controller_test.go @@ -35,6 +35,7 @@ func TestNewPlaybackController(t *testing.T) { // createTestController creates a PlaybackController with default test configurations func createTestController(t *testing.T) *PlaybackController { + // Create test Video and Speed configurations vc := config.VideoConfig{ FilePath: testFilename, WindowScaleFactor: defaultWindowScale, @@ -44,60 +45,61 @@ func createTestController(t *testing.T) *PlaybackController { DisplayPlaybackSpeed: true, }, } - sc := config.SpeedConfig{ SpeedThreshold: speedThreshold, } + // Create new PlaybackController controller, err := NewPlaybackController(vc, sc) assert.NotNil(t, controller, "PlaybackController should not be nil") assert.NoError(t, err, "Error while creating PlaybackController") return controller - } // TestPlaybackControllerStart tests the Start method of the PlaybackController func TestPlaybackControllerStart(t *testing.T) { + // Create test controller controller := createTestController(t) ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) defer cancel() + // Create new SpeedController speedController := &speed.SpeedController{} err := controller.Start(ctx, speedController) assert.NoError(t, err, "Error while starting PlaybackController") - } // TestPlaybackControllerConfigurePlayer tests the configurePlayer method of the PlaybackController func TestPlaybackControllerConfigurePlayer(t *testing.T) { + // Create test controller controller := createTestController(t) err := controller.configureMPVPlayer() assert.NoError(t, err, "Error while configuring MPV player") - } // TestPlaybackControllerLoadVideoFile tests the loadVideoFile method of the PlaybackController func TestPlaybackControllerLoadVideoFile(t *testing.T) { + // Create test controller controller := createTestController(t) err := controller.loadMPVvideo() assert.NoError(t, err, "Error while loading video file") - } // TestPlaybackControllerSetPauseStatus tests the setPauseStatus method of the PlaybackController func TestPlaybackControllerSetPauseStatus(t *testing.T) { + // Create test controller controller := createTestController(t) + // Set and check pause status err := controller.player.SetProperty("pause", mpv.FormatFlag, true) assert.NoError(t, err, "Error while setting pause property") result, err := controller.player.GetProperty("pause", mpv.FormatFlag) assert.NoError(t, err, "Error while getting pause property") assert.True(t, result.(bool), "Pause property should be true") - }