diff --git a/.gitattributes b/.gitattributes index c03bfb9f..77dd00f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,4 @@ *.gif binary *.fcp binary *.webm binary +*.mp3 binary diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index de8763ea..d104859d 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -3,8 +3,9 @@ name: Build and Test on: push: branches: [ main, dev ] - tags: - - 'v*' + # Don't need to run on tags anymore, we always push artifacts to a draft release now. + # tags: + # - 'v*' # pull_request: # branches: [ main, dev ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7563a614..a4433fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +## [5.8.0] - 2025-10-13 + +### Added + +- Added the ability to play sounds when "pass" or "fail" is received from the serial connection. + +### Fixed + +- Improved Bluetooth disconnection logic by de-registering peripheral event listeners when the connection is lost. + ## [5.7.1] - 2025-10-11 ### Fixed @@ -945,7 +955,8 @@ Fixed bug where pressing Ctrl-Shift-C to copy text from a terminal would enable - Added auto-scroll to TX pane, closes #89. - Added special delete behaviour for backspace button when in "send on enter" mode, closes #90. -[unreleased]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.1...HEAD +[unreleased]: https://github.com/gbmhunter/NinjaTerm/compare/v5.8.0...HEAD +[5.8.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.1...v5.8.0 [5.7.1]: https://github.com/gbmhunter/NinjaTerm/compare/v5.7.0...v5.7.1 [5.7.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.6.0...v5.7.0 [5.6.0]: https://github.com/gbmhunter/NinjaTerm/compare/v5.5.0...v5.6.0 diff --git a/README.md b/README.md index c1bf6dad..e29553fd 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ npm run dev npm run build ``` +## To Build A Single Executable + +```bash +npm run dist +``` + +This will be generated in the `dist` directory. + ## Testing Both unit tests and end-to-end tests can be run with: @@ -99,7 +107,7 @@ Arduino sketches in `arduino-serial` allow you to program different applications 1. If you have updated the app data structure, save a copy of the default app data created by the app to `local-storage-data/`. You can do this by running the app, clearing app data in `Settings > General Settings`, loading up the Chrome dev. tools, and copying the key `appData` from local storage. 1. Create pull request on GitHub merging your branch into `main`. 1. Once the build on `main` has been successfully run, merge your branch into `main` via the merge request. -1. Tag the branch on main with the version number, e.g. `v4.1.0`. +1. Tag the branch on main with the version number, e.g. `v4.1.0`. Wait for the GitHub build and publish action spawned from the merge into main to complete (so that the artifacts from the build are used in the release in the next step). 1. Find the draft release on GitHub and publish it. Enter the CHANGELOG contents into the release body text. The app is built by GitHub Actions on every commit. If the build is successful and there is not already a non-draft release for this version number, the build artifacts will be uploaded to the release (files in existing draft releases are overwritten). @@ -174,9 +182,6 @@ The files with `default` in the name are the default data for that app version. * Prettier ESLint: Provides formatting of .tsx files. * Playwright: Provides useful add-ons for running and debugging the Playwright E2E tests. - -npm install @abandonware/noble --save --target=37.2.4 --runtime=electron --dist-url=https://electronjs.org/headers - [github-actions-status]: https://github.com/gbmhunter/NinjaTerm/actions/workflows/build-and-test.yml/badge.svg?branch=main [github-actions-url]: https://github.com/gbmhunter/NinjaTerm/actions [github-tag-image]: https://img.shields.io/github/tag/gbmhunter/NinjaTerm.svg?label=version diff --git a/electron.vite.config.ts b/electron.vite.config.ts index db5beea7..dcbdd378 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ }, renderer: { root: 'src/renderer', + // publicDir is where assets are served from that are accessible to the renderer process + // One use is for sounds, + publicDir: resolve(__dirname, 'public'), optimizeDeps: { include: [ '@emotion/react', diff --git a/package.json b/package.json index 713562c5..7d59dd1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ninjaterm", "productName": "NinjaTerm", - "version": "5.7.1", + "version": "5.8.0", "description": "A modern, powerful serial terminal for developers and engineers.", "author": { "name": "Geoffrey Hunter", diff --git a/public/assets/sounds/README.md b/public/assets/sounds/README.md new file mode 100644 index 00000000..33c6661d --- /dev/null +++ b/public/assets/sounds/README.md @@ -0,0 +1,8 @@ +# Sound Files + +Place your sound files in this directory: + +- **pass.mp3** - Played when "pass" is detected in received data +- **fail.mp3** - Played when "fail" is detected in received data + +These files are used by the Sounds settings feature in NinjaTerm. diff --git a/public/assets/sounds/fail.mp3 b/public/assets/sounds/fail.mp3 new file mode 100644 index 00000000..516704c9 Binary files /dev/null and b/public/assets/sounds/fail.mp3 differ diff --git a/public/assets/sounds/pass.mp3 b/public/assets/sounds/pass.mp3 new file mode 100644 index 00000000..d2b9b20e Binary files /dev/null and b/public/assets/sounds/pass.mp3 differ diff --git a/src/main/Logging.ts b/src/main/Logging.ts index aecc4cc6..73581e04 100644 --- a/src/main/Logging.ts +++ b/src/main/Logging.ts @@ -6,6 +6,8 @@ export function initLogging() { mainLogger.initialize(); // {scope} is in the form "(main)" or "(renderer)". Doesn't need square brackets as already has brackets. mainLogger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] {scope} [{level}] {text}'; + mainLogger.transports.file.level = 'info'; + mainLogger.transports.console.level = 'info'; // Setting the IPC level in the main process here results in main log messages being forwarded to the renderer process and shown in the devtools console (useful for debugging without having to dig up the log file). // Set to false to disable. diff --git a/src/main/MainBluetoothService.ts b/src/main/MainBluetoothService.ts index ea73a716..65920ecd 100644 --- a/src/main/MainBluetoothService.ts +++ b/src/main/MainBluetoothService.ts @@ -23,6 +23,8 @@ enum ConnectionState { * Provide a Bluetooth service running in the Electron main process for the renderer process to use. * * Uses the noble library under the hood to communicate with Bluetooth devices. + * + * This should probably be refactored to use a finite state machine, as the connection/disconnection logic is a bit messy, especially with all the event handler callbacks. */ export class MainBluetoothService { @@ -283,6 +285,7 @@ export class MainBluetoothService { const peripheral = this.discoveredDevices.find(p => p.id === deviceId); if (!peripheral) { log.error(`Device not found in discovered peripherals. deviceId=${deviceId}`); + this.connectionState = ConnectionState.DISCONNECTED; return { error: 'Device not found in discovered peripherals.' }; } @@ -318,6 +321,7 @@ export class MainBluetoothService { this.connectionAttemptTimeout = null; } this.connectionState = ConnectionState.DISCONNECTED; + peripheral.removeAllListeners(); // Emit a IPC connection attempt complete message, indicating failure this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', error, null); return; @@ -362,7 +366,10 @@ export class MainBluetoothService { deviceId: this.peripheral!.id, services: this.convertServicesToSerializable(services) }; - this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', error, bluetoothConnectionAttemptSuccess); + this.mainWindow!.webContents.send( + 'bluetooth:connection-attempt-complete', + error, + bluetoothConnectionAttemptSuccess); } async disconnectFromDevice(deviceId: string): Promise<{ success: boolean; error?: string }> { @@ -382,7 +389,7 @@ export class MainBluetoothService { } }); }); - + peripheral.removeAllListeners(); return { success: true }; } catch (error) { log.error(`Failed to disconnect from Bluetooth device ${deviceId}:`, error); @@ -407,7 +414,6 @@ export class MainBluetoothService { // but also does not trigger an error (fails silently). This event gets triggered, so in this case we need to set connectionState to DISCONNECTED. if (this.connectionState === ConnectionState.CONNECTING) { log.info('Got disconnect event for peripheral, but we are still connecting to it. Setting connectionState to DISCONNECTED.'); - this.connectionState = ConnectionState.DISCONNECTED; // Emit a IPC connection attempt complete message, indicating failure this.mainWindow!.webContents.send('bluetooth:connection-attempt-complete', 'Device disconnected while still connecting and scanning for services and characteristics.', null); } @@ -415,6 +421,7 @@ export class MainBluetoothService { const deviceId = peripheral.id; log.info(`Bluetooth device disconnected: ${deviceId}`); this.connectionState = ConnectionState.DISCONNECTED; + peripheral.removeAllListeners(); this.peripheral = null; this.discoveredServices = []; this.txCharacteristic = null; @@ -465,7 +472,7 @@ export class MainBluetoothService { } }); txCharacteristic.on('data', (data: Buffer) => { - log.info(`Received data from ${peripheral.id} on write characteristic. data: ${data.toString('hex')}`); + log.debug(`Received data from ${peripheral.id} on write characteristic. data: ${data.toString('hex')}`); // Send data to renderer this.mainWindow?.webContents.send('bluetooth:data-received', peripheral.id, data); }); diff --git a/src/main/index.ts b/src/main/index.ts index d278d888..a09e1627 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, Menu, shell, session } from 'electron'; import pkg from 'electron-updater'; const { autoUpdater } = pkg; import * as path from 'path'; @@ -165,6 +165,19 @@ app.whenReady().then(async () => { log.info('Main process started.'); + // Uncomment this if we want to retry applying CSP correctly + // Apply Content Security Policy (CSP) + // session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + // callback({ + // responseHeaders: { + // ...details.responseHeaders, + // 'Content-Security-Policy': [ + // "default-src 'self'; script-src 'self' 'unsafe-inline' https://accounts.google.com https://*.gstatic.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://*.gstatic.com; img-src 'self' data: https://*.gstatic.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://accounts.google.com https://www.googleapis.com; frame-src https://accounts.google.com;" + // ], + // }, + // }); + // }); + createWindow(); // Initialize serial handlers diff --git a/src/renderer/index.html b/src/renderer/index.html index 94bf4e87..279fa699 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -10,4 +10,4 @@
- \ No newline at end of file + diff --git a/src/renderer/src/model/App.tsx b/src/renderer/src/model/App.tsx index 4737b57f..f5ec01fd 100644 --- a/src/renderer/src/model/App.tsx +++ b/src/renderer/src/model/App.tsx @@ -24,6 +24,7 @@ import { AppDataManager } from './AppDataManager/AppDataManager'; import PerformanceMonitor from './Performance/PerformanceMonitor'; import PerformanceTester, { PerformanceTestSuiteResult } from './Performance/PerformanceTester'; import { ConnController } from './ConnController/ConnController'; +import { SoundPlayer } from './Util/SoundPlayer'; declare global { interface String { @@ -130,6 +131,16 @@ export class App { */ connController: ConnController; + /** + * Sound player for playing audio feedback based on received data. + */ + soundPlayer: SoundPlayer; + + /** + * Buffer for detecting pass/fail strings across data chunks. + */ + private soundDetectionBuffer: string = ''; + private readonly SOUND_DETECTION_BUFFER_MAX_LENGTH = 100; constructor(testing = false) { initLogging(); @@ -155,6 +166,8 @@ export class App { this.connController = new ConnController(this); + this.soundPlayer = new SoundPlayer(); + this.terminals = new Terminals(this); this.numBytesReceived = 0; @@ -208,6 +221,7 @@ export class App { this.connController.cleanup(); this.stopRateCalculation(); this.stopCpuMonitoring(); + this.soundPlayer.cleanup(); // Clean up auto-updater listeners if ((window as any).electronAPI?.updater) { @@ -555,12 +569,9 @@ export class App { } /** - * In normal operation this is called from the readUntilClose() function above. + * This is called from whatever connection type is currently being used. All data should be funnelled through this function no matter what the connection type is. * - * Unit tests call this instead of mocking out the serial port read() function - * as setting up the deferred promise was too tricky. - * - * @param rxData + * @param rxData The received data. */ parseRxData(rxData: Uint8Array) { // Start performance monitoring for data processing @@ -578,6 +589,11 @@ export class App { this.logging.handleRxData(rxData); + // Check for pass/fail strings and play sounds if enabled + if (this.settings.soundsSettings.playSoundsOnPassFail) { + this.detectAndPlaySounds(rxData); + } + // End performance monitoring and record metrics const totalProcessingTime = this.performanceMonitor.endTiming('dataProcessing'); this.performanceMonitor.recordDataProcessing(rxData.length, totalProcessingTime); @@ -587,6 +603,65 @@ export class App { this.recordRxDataPoint(rxData.length); } + /** + * Detects "pass" and "fail" strings in received data and plays appropriate sounds. + * Uses a buffer to handle detection across data chunks. + * Finds all occurrences of both patterns and plays them in order. + * + * @param rxData The received data as a Uint8Array + */ + private detectAndPlaySounds(rxData: Uint8Array) { + // Convert received data to string (lowercase for case-insensitive matching) + const dataStr = new TextDecoder().decode(rxData).toLowerCase(); + + // Add new data to buffer + this.soundDetectionBuffer += dataStr; + + // Find all occurrences of both patterns + const foundPatterns: Array<{index: number, type: 'pass' | 'fail', length: number}> = []; + + // Find all "pass" occurrences + let searchIndex = 0; + while ((searchIndex = this.soundDetectionBuffer.indexOf('pass', searchIndex)) !== -1) { + foundPatterns.push({index: searchIndex, type: 'pass', length: 4}); + searchIndex += 4; // Move past this occurrence + } + + // Find all "fail" occurrences + searchIndex = 0; + while ((searchIndex = this.soundDetectionBuffer.indexOf('fail', searchIndex)) !== -1) { + foundPatterns.push({index: searchIndex, type: 'fail', length: 4}); + searchIndex += 4; // Move past this occurrence + } + + // Sort by index to play sounds in order they appear + foundPatterns.sort((a, b) => a.index - b.index); + + // Play sounds in the order they appear + for (const pattern of foundPatterns) { + if (pattern.type === 'pass') { + this.soundPlayer.playDing(); + } else { + this.soundPlayer.playBuzzer(); + } + } + + // Clear buffer only up to the end of the last found pattern + // This preserves any partial patterns at the end of the buffer + if (foundPatterns.length > 0) { + const lastPattern = foundPatterns[foundPatterns.length - 1]; + const endOfLastPattern = lastPattern.index + lastPattern.length; + // Keep everything after the last pattern to preserve partial matches + this.soundDetectionBuffer = this.soundDetectionBuffer.slice(endOfLastPattern); + } + + // Keep buffer length manageable + if (this.soundDetectionBuffer.length > this.SOUND_DETECTION_BUFFER_MAX_LENGTH) { + // Keep only the last portion of the buffer to catch strings split across chunks + this.soundDetectionBuffer = this.soundDetectionBuffer.slice(-this.SOUND_DETECTION_BUFFER_MAX_LENGTH); + } + } + /** * Run performance tests to measure baseline performance and identify bottlenecks diff --git a/src/renderer/src/model/AppDataManager/AppDataManager.ts b/src/renderer/src/model/AppDataManager/AppDataManager.ts index 244a888e..7198176f 100644 --- a/src/renderer/src/model/AppDataManager/AppDataManager.ts +++ b/src/renderer/src/model/AppDataManager/AppDataManager.ts @@ -1,14 +1,15 @@ import { makeAutoObservable } from 'mobx'; +import { VariantType } from 'notistack'; +import { PortInfo } from '@serialport/bindings-interface'; import { ConnState, ConnectionType, PortSettings } from '../Settings/PortSettings/PortSettings'; import { App } from '../App'; -import { VariantType } from 'notistack'; import { AppData } from './DataClasses/AppData'; import { Profile } from './DataClasses/Profile'; import DisplaySettings, { TerminalHeightMode } from '../Settings/DisplaySettings/DisplaySettings'; import { TimestampFormat } from '../Settings/RxSettings/RxSettings'; import { DEFAULT_BACKGROUND_COLOR, DEFAULT_TX_COLOR, DEFAULT_RX_COLOR } from './DataClasses/DisplaySettingsData'; -import { PortInfo } from '@serialport/bindings-interface'; +import { log } from '@/model/Util/Log'; export class LastUsedSerialPort { path: string = ''; @@ -54,20 +55,20 @@ export class AppDataManager { * @returns */ onStorageEvent = (event: StorageEvent) => { - console.log('Caught storage event. event.key: ', event.key, ' event.newValue: ', event.newValue); + log.info('Caught storage event. event.key: ', event.key, ' event.newValue: ', event.newValue); if (event.key === APP_DATA_STORAGE_KEY) { - console.log('App data changed from another process. Checking if profiles changed...'); + log.info('App data changed from another process. Checking if profiles changed...'); // Check if the profiles changed const appDataAsJson = window.localStorage.getItem(APP_DATA_STORAGE_KEY); if (appDataAsJson === null) { - console.error('App data not found in local storage.'); + log.error('App data not found in local storage.'); return; } const appDataInStorage = JSON.parse(appDataAsJson); // Compare the JSON strings of the profiles to work out if they are different if (JSON.stringify(appDataInStorage.profiles) !== JSON.stringify(this.appData.profiles)) { - console.log('Profiles changed. Reloading profiles...'); + log.info('Profiles changed. Reloading profiles...'); // Reload just the profiles, we don't want to overwrite the current app config this.appData.profiles = appDataInStorage.profiles; } @@ -84,7 +85,7 @@ export class AppDataManager { let appData: AppData; if (appDataAsJson === null) { // No config key found in users store, create one! - console.log('App data not found in local storage. Creating default app data...'); + log.info('App data not found in local storage. Creating default app data...'); appData = new AppData(); // Save just-created config back to store. window.localStorage.setItem(APP_DATA_STORAGE_KEY, JSON.stringify(appData)); @@ -119,11 +120,11 @@ export class AppDataManager { // VERSION 1 -> VERSION 2 //============================================================================= if (updatedAppData.version === 1) { - console.log('Updating app data from version 1 to version 2...'); + log.info('Updating app data from version 1 to version 2...'); // Convert to v2 // Port settings got a new field, display settings got two new fields let upgradeRootConfig = (rootConfig: any) => { - console.log('Upgrading profile: ', rootConfig); + log.info('Upgrading profile: ', rootConfig); rootConfig.settings.portSettings.allowSettingsChangesWhenOpen = false; rootConfig.settings.displaySettings.terminalHeightMode = TerminalHeightMode.AUTO_HEIGHT; rootConfig.settings.displaySettings.terminalHeightChars = 25; @@ -140,7 +141,7 @@ export class AppDataManager { // VERSION 2 -> VERSION 3 //============================================================================= if (updatedAppData.version === 2) { - console.log('Updating app data from version 2 to version 3...'); + log.info('Updating app data from version 2 to version 3...'); let updateRootConfig = (rootConfig: any) => { // Add timestamp settings rootConfig.settings.rxSettings.addTimestamps = false; @@ -174,7 +175,7 @@ export class AppDataManager { // VERSION 3 -> VERSION 4 //============================================================================= if (updatedAppData.version === 3) { - console.log('Updating app data from version 3 to version 4...'); + log.info('Updating app data from version 3 to version 4...'); // Add auto-updates setting to app data (global setting, not per profile) updatedAppData.autoUpdatesEnabled = true; @@ -220,7 +221,7 @@ export class AppDataManager { // VERSION 4 -> VERSION 5 //============================================================================= if (updatedAppData.version === 4) { - console.log('Updating app data from version 4 to version 5...'); + log.info('Updating app data from version 4 to version 5...'); // Add detection mode to graphing settings for all profiles for (let i = 0; i < updatedAppData.profiles.length; i++) { const graphingSettings = updatedAppData.profiles[i].rootConfig.settings.graphingSettings; @@ -236,7 +237,7 @@ export class AppDataManager { // VERSION 5 -> VERSION 6 //============================================================================= if (updatedAppData.version === 5) { - console.log('Updating app data from version 5 to version 6...'); + log.info('Updating app data from version 5 to version 6...'); // Rename bufferDelimiter to processingTrigger in graphing settings for all profiles for (let i = 0; i < updatedAppData.profiles.length; i++) { const graphingSettings = updatedAppData.profiles[i].rootConfig.settings.graphingSettings; @@ -259,7 +260,7 @@ export class AppDataManager { // VERSION 6 -> VERSION 7 //============================================================================= if (updatedAppData.version === 6) { - console.log('Updating app data from version 6 to version 7...'); + log.info('Updating app data from version 6 to version 7...'); // Add flow control settings to app data let updateProfileConfig = (rootConfig: any) => { rootConfig.terminal.rightDrawer.flowControlIsExpanded = true; @@ -276,7 +277,7 @@ export class AppDataManager { // VERSION 7 -> VERSION 8 //============================================================================= if (updatedAppData.version === 7) { - console.log('Updating app data from version 7 to version 8...'); + log.info('Updating app data from version 7 to version 8...'); // Add new flow control parameters and remove old flowControl property let updateProfileConfig = (rootConfig: any) => { // Remove the old flowControl property @@ -313,7 +314,7 @@ export class AppDataManager { // VERSION 8 -> VERSION 9 //============================================================================= if (updatedAppData.version === 8) { - console.log('Updating app data from version 8 to version 9...'); + log.info('Updating app data from version 8 to version 9...'); // Create new logSettings structure and move any existing log directory let updateProfileConfig = (rootConfig: any) => { // Create the new logSettings object with defaults @@ -341,7 +342,7 @@ export class AppDataManager { // VERSION 9 -> VERSION 10 //============================================================================= if (updatedAppData.version === 9) { - console.log('Updating app data from version 9 to version 10...'); + log.info('Updating app data from version 9 to version 10...'); // Add socket connection settings to port configuration let updateProfileConfig = (rootConfig: any) => { rootConfig.settings.portSettings.connectionType = ConnectionType.SERIAL_PORT; @@ -361,7 +362,7 @@ export class AppDataManager { // VERSION 10 -> VERSION 11 //============================================================================= if (updatedAppData.version === 10) { - console.log('Updating app data from version 10 to version 11...'); + log.info('Updating app data from version 10 to version 11...'); // Add tooltip settings to display settings for all profiles let updateProfileConfig = (rootConfig: any) => { rootConfig.settings.displaySettings.tooltipsEnabled = DisplaySettings.DEFAULT_TOOLTIPS_ENABLED; @@ -375,13 +376,33 @@ export class AppDataManager { wasChanged = true; } - if (updatedAppData.version !== 11) { - console.error('Unknown app data version found: ', appData.version); + //============================================================================= + // VERSION 11 -> VERSION 12 + //============================================================================= + // Sound settings were added for the first time in this version. + if (updatedAppData.version === 11) { + log.info('Updating app data from version 11 to version 12...'); + // Add sounds settings to settings for all profiles + let updateProfileConfig = (rootConfig: any) => { + rootConfig.settings.soundsSettings = { + playSoundsOnPassFail: false + }; + } + for (let i = 0; i < updatedAppData.profiles.length; i++) { + updateProfileConfig(updatedAppData.profiles[i].rootConfig); + } + updateProfileConfig(updatedAppData.currentAppConfig); + updatedAppData.version = 12; + wasChanged = true; + } + + if (updatedAppData.version !== 12) { + log.error('Unknown app data version found: ', appData.version); updatedAppData = new AppData(); wasChanged = true; } - console.log('Updated app data to latest version.'); + log.info('Updated app data to latest version.'); return { appData: updatedAppData, wasChanged }; } @@ -454,7 +475,7 @@ export class AppDataManager { snackbarMessage += '\nAlready connected port matches one specified in profile. Leaving port connected.'; } else { // They are both different and the profile one is non-empty. Check to see if the profile ports is available - console.log('Port infos are both different and non-empty. Checking if ports are available...'); + log.info('Port infos are both different and non-empty. Checking if ports are available...'); // const availablePorts = await navigator.serial.getPorts(); const availablePortsResult = await window.electronAPI.serial.listPorts(); if (!availablePortsResult.success) { @@ -516,7 +537,7 @@ export class AppDataManager { * @param profileIdx The index of the profile to save the current app config to. */ saveCurrentAppConfigToProfile = (profileIdx: number, noSnackbar = false) => { - console.log('Saving current app config to profile...'); + log.info('Saving current app config to profile...'); const profile = this.appData.profiles[profileIdx]; profile.rootConfig = JSON.parse(JSON.stringify(this.appData.currentAppConfig)); this.saveAppData(); diff --git a/src/renderer/src/model/AppDataManager/DataClasses/AppData.ts b/src/renderer/src/model/AppDataManager/DataClasses/AppData.ts index 0dfc41bc..da6236eb 100644 --- a/src/renderer/src/model/AppDataManager/DataClasses/AppData.ts +++ b/src/renderer/src/model/AppDataManager/DataClasses/AppData.ts @@ -3,7 +3,7 @@ import { makeAutoObservable } from "mobx"; import { Profile } from "./Profile"; import { ProfileConfig } from "./ProfileConfig"; -export const LATEST_VERSION = 11; +export const LATEST_VERSION = 12; export class AppData { // Version of the AppData class. diff --git a/src/renderer/src/model/AppDataManager/DataClasses/SettingsData.ts b/src/renderer/src/model/AppDataManager/DataClasses/SettingsData.ts index cf820bcd..d8728947 100644 --- a/src/renderer/src/model/AppDataManager/DataClasses/SettingsData.ts +++ b/src/renderer/src/model/AppDataManager/DataClasses/SettingsData.ts @@ -5,6 +5,7 @@ import { TxSettingsData } from './TxSettingsData'; import { GeneralSettingsConfig } from './GeneralSettingsData'; import { GraphingSettingsData } from './GraphingSettingsData'; import { LogSettingsData } from './LogSettingsData'; +import { SoundsSettingsData } from './SoundsSettingsData'; /** * Encapsulates all application settings data. @@ -18,4 +19,5 @@ export class SettingsData { generalSettings = new GeneralSettingsConfig(); graphingSettings = new GraphingSettingsData(); logSettings = new LogSettingsData(); + soundsSettings = new SoundsSettingsData(); } diff --git a/src/renderer/src/model/AppDataManager/DataClasses/SoundsSettingsData.ts b/src/renderer/src/model/AppDataManager/DataClasses/SoundsSettingsData.ts new file mode 100644 index 00000000..5244e654 --- /dev/null +++ b/src/renderer/src/model/AppDataManager/DataClasses/SoundsSettingsData.ts @@ -0,0 +1,7 @@ +/** + * Encapsulates sounds settings data. + * Everything in this class must be POD (plain old data) and serializable to JSON. + */ +export class SoundsSettingsData { + playSoundsOnPassFail = false; +} diff --git a/src/renderer/src/model/ConnController/BluetoothLEController.ts b/src/renderer/src/model/ConnController/BluetoothLEController.ts index c3d1193a..1e1e2c67 100644 --- a/src/renderer/src/model/ConnController/BluetoothLEController.ts +++ b/src/renderer/src/model/ConnController/BluetoothLEController.ts @@ -191,6 +191,7 @@ export class BluetoothLEController { sortDirection: 'asc' | 'desc' = 'desc'; constructor(app: App) { + log.info('BluetoothLEController constructor called.'); this.app = app; // Reset the main process Bluetooth state, in case the renderer was reloaded but the main process was not @@ -209,6 +210,11 @@ export class BluetoothLEController { await this.onIpcBluetoothConnectionAttemptComplete(error, bluetoothConnectionAttemptSuccess); }); + // Listen for RX data. We should get any data until a device is connected. + window.electronAPI.bluetooth.onDataReceived((deviceId: string, data: Buffer) => { + this.app.parseRxData(data); + }); + this.validateAndApplyScanDurationMs(); // Make sure to do this at the end of the constructor @@ -611,11 +617,6 @@ export class BluetoothLEController { return; } - // Setup listener for RX data - window.electronAPI.bluetooth.onDataReceived((deviceId: string, data: Buffer) => { - this.app.parseRxData(data); - }); - // If we get here, we have connected to the device and have found valid services and characteristics for the selected serial protocol // We can consider our connection attempt successful this.app.snackbar.sendToSnackbar( @@ -698,7 +699,8 @@ export class BluetoothLEController { this.app.connController.connState = portState; }); - window.electronAPI.bluetooth.removeAllListeners('bluetooth:data-received'); + // We don't need to remove the listener here as it is only added once in the constructor, not on every device connection. + // window.electronAPI.bluetooth.removeAllListeners('bluetooth:data-received'); } /** diff --git a/src/renderer/src/model/FakePorts/FakePortsController.tsx b/src/renderer/src/model/FakePorts/FakePortsController.tsx index 2c17066b..203261e6 100644 --- a/src/renderer/src/model/FakePorts/FakePortsController.tsx +++ b/src/renderer/src/model/FakePorts/FakePortsController.tsx @@ -46,6 +46,7 @@ export default class FakePortsController { constructor(app: App) { this.app = app; + //================================================================================= // hello world, 0.1lps //================================================================================= this.fakePorts.push( @@ -72,6 +73,7 @@ export default class FakePortsController { ) ); + //================================================================================= // hello world, 1lps //================================================================================= this.fakePorts.push( @@ -98,6 +100,7 @@ export default class FakePortsController { ) ); + //================================================================================= // hello world, 5lps //================================================================================= this.fakePorts.push( @@ -124,6 +127,7 @@ export default class FakePortsController { ) ); + //================================================================================= // hello world, 10lps //================================================================================= this.fakePorts.push( @@ -150,6 +154,7 @@ export default class FakePortsController { ) ); + //================================================================================= // hello world, 20lps //================================================================================= this.fakePorts.push( @@ -178,6 +183,7 @@ export default class FakePortsController { ) ); + //================================================================================= // 50 numbered lines all at once //================================================================================= this.fakePorts.push( @@ -201,6 +207,40 @@ export default class FakePortsController { ) ); + //================================================================================= + // pass/fail alternating, 0.2items/s (for testing sound functionality) + //================================================================================= + this.fakePorts.push( + new FakePort( + 'pass/fail alternating, 0.5items/s', + 'Alternates between "pass" and "fail" every 2 seconds. Useful for testing sound notifications.', + () => { + let stringIdx = 0; + const strings = ['pass\n', 'fail\n']; + const intervalId = setInterval(() => { + const textToSend = strings[stringIdx]; + let bytesToSend = []; + for (let i = 0; i < textToSend.length; i++) { + bytesToSend.push(textToSend.charCodeAt(i)); + } + app.parseRxData(Uint8Array.from(bytesToSend)); + + stringIdx += 1; + if (stringIdx === strings.length) { + stringIdx = 0; + } + }, 2000); + return intervalId; + }, + (intervalId: NodeJS.Timeout | null) => { + // Stop the interval + if (intervalId !== null) { + clearInterval(intervalId); + } + } + ) + ); + //================================================================================= // red green, 0.2lps //================================================================================= @@ -235,6 +275,7 @@ export default class FakePortsController { ) ); + //================================================================================= // all colors, 5cps //================================================================================= this.fakePorts.push( diff --git a/src/renderer/src/model/Settings/Settings.tsx b/src/renderer/src/model/Settings/Settings.tsx index b6f1835a..60453450 100644 --- a/src/renderer/src/model/Settings/Settings.tsx +++ b/src/renderer/src/model/Settings/Settings.tsx @@ -9,6 +9,7 @@ import DisplaySettings from './DisplaySettings/DisplaySettings'; import { PortSettings } from './PortSettings/PortSettings'; import GeneralSettings from './GeneralSettings/GeneralSettings'; import ProfilesSettings from './ProfileSettings/ProfileSettings'; +import SoundsSettings from './SoundsSettings/SoundsSettings'; import { App } from '../App'; export enum SettingsCategories { @@ -18,6 +19,7 @@ export enum SettingsCategories { DISPLAY, GENERAL, PROFILES, + SOUNDS, } export class Settings { @@ -39,6 +41,8 @@ export class Settings { profilesSettings: ProfilesSettings; + soundsSettings: SoundsSettings; + /** * Constructor for the Settings class. * @@ -54,6 +58,7 @@ export class Settings { this.displaySettings = new DisplaySettings(this.app.profileManager); this.generalSettings = new GeneralSettings(this.app.profileManager); this.profilesSettings = new ProfilesSettings(this.app.profileManager); + this.soundsSettings = new SoundsSettings(this.app.profileManager); makeAutoObservable(this); // Make sure this is at the end of the constructor } diff --git a/src/renderer/src/model/Settings/SoundsSettings/SoundsSettings.ts b/src/renderer/src/model/Settings/SoundsSettings/SoundsSettings.ts new file mode 100644 index 00000000..9f325ddf --- /dev/null +++ b/src/renderer/src/model/Settings/SoundsSettings/SoundsSettings.ts @@ -0,0 +1,37 @@ +import { makeAutoObservable } from "mobx"; +import { AppDataManager } from "src/model/AppDataManager/AppDataManager"; + +export default class SoundsSettings { + profileManager: AppDataManager; + + // THESE DEFAULTS DON'T MATTER AS THEY ARE OVERRIDDEN WHEN CONFIG IS LOADED + playSoundsOnPassFail = false; + + constructor(profileManager: AppDataManager) { + this.profileManager = profileManager; + this._loadConfig(); + this.profileManager.registerOnProfileLoad(() => { + this._loadConfig(); + }); + makeAutoObservable(this); // Make sure this is at the end of the constructor + } + + setPlaySoundsOnPassFail = (value: boolean) => { + this.playSoundsOnPassFail = value; + this._saveConfig(); + }; + + _saveConfig = () => { + let config = this.profileManager.appData.currentAppConfig.settings.soundsSettings; + + config.playSoundsOnPassFail = this.playSoundsOnPassFail; + + this.profileManager.saveAppData(); + }; + + _loadConfig = () => { + let configToLoad = this.profileManager.appData.currentAppConfig.settings.soundsSettings; + + this.playSoundsOnPassFail = configToLoad.playSoundsOnPassFail; + }; +} diff --git a/src/renderer/src/model/Util/SoundPlayer.ts b/src/renderer/src/model/Util/SoundPlayer.ts new file mode 100644 index 00000000..55e6b3a3 --- /dev/null +++ b/src/renderer/src/model/Util/SoundPlayer.ts @@ -0,0 +1,140 @@ +import { log } from '@/model/Util/Log'; + +/** + * Utility class for playing sound files in the application. + * + * Uses the HTML5 Audio API to play MP3 files for success/failure feedback. + */ +export class SoundPlayer { + private passAudio: HTMLAudioElement | null = null; + private failAudio: HTMLAudioElement | null = null; + + constructor() { + // Preload audio files for better performance + this.loadAudioFiles(); + } + + /** + * Preloads the audio files so they're ready to play. + * Audio files should be placed in public/assets/sounds/ + */ + private loadAudioFiles() { + try { + // Try to construct the proper path for Electron + // In development, files are served from public/ + // In production, they should be in the resources + const basePath = this.getBasePath(); + + log.info('SoundPlayer: Loading audio files from base path:', basePath); + + this.passAudio = new Audio(`${basePath}/assets/sounds/pass.mp3`); + this.passAudio.preload = 'auto'; + this.passAudio.volume = 0.1; // Set to 100% volume + + this.failAudio = new Audio(`${basePath}/assets/sounds/fail.mp3`); + this.failAudio.preload = 'auto'; + this.failAudio.volume = 1.0; // Set to 70% volume + + // Handle loading errors gracefully + this.passAudio.addEventListener('error', (e) => { + log.error('Failed to load pass.mp3 sound file. Path tried:', `${basePath}/assets/sounds/pass.mp3`, 'Error:', e); + }); + + this.failAudio.addEventListener('error', (e) => { + log.error('Failed to load fail.mp3 sound file. Path tried:', `${basePath}/assets/sounds/fail.mp3`, 'Error:', e); + }); + + // Add load success handlers for debugging + this.passAudio.addEventListener('canplaythrough', () => { + log.info('SoundPlayer: pass.mp3 loaded successfully'); + }); + + this.failAudio.addEventListener('canplaythrough', () => { + log.info('SoundPlayer: fail.mp3 loaded successfully'); + }); + } catch (error) { + log.error('Error initializing sound player:', error); + } + } + + /** + * Gets the base path for loading assets. + * In Electron, this handles both development and production paths. + */ + private getBasePath(): string { + // Use relative path (./assets) instead of absolute path (/assets) + // This works in both dev and production for Electron + // In dev: served from public/ folder by Vite + // In production: bundled into out/renderer/ folder + return '.'; + } + + /** + * Plays the "pass" sound (pass.mp3). + */ + playDing() { + if (!this.passAudio) { + log.warn('Pass audio not loaded'); + return; + } + + try { + // Clone the audio element if we need to play multiple sounds simultaneously + const audio = this.passAudio.cloneNode() as HTMLAudioElement; + + // Play and handle potential autoplay restrictions + const playPromise = audio.play(); + + if (playPromise !== undefined) { + playPromise.catch((error) => { + log.warn('Failed to play pass sound:', error); + }); + } + } catch (error) { + log.error('Error playing pass sound:', error); + } + } + + /** + * Plays the "fail" sound (fail.mp3). + */ + playBuzzer() { + if (!this.failAudio) { + log.warn('Fail audio not loaded'); + return; + } + + try { + // Clone the audio element if we need to play multiple sounds simultaneously + const audio = this.failAudio.cloneNode() as HTMLAudioElement; + + // Play and handle potential autoplay restrictions + const playPromise = audio.play(); + + if (playPromise !== undefined) { + playPromise.catch((error) => { + log.warn('Failed to play fail sound:', error); + }); + } + } catch (error) { + log.error('Error playing fail sound:', error); + } + } + + /** + * Clean up audio resources. + */ + cleanup() { + if (this.passAudio) { + this.passAudio.pause(); + this.passAudio.src = ''; + this.passAudio = null; + } + + if (this.failAudio) { + this.failAudio.pause(); + this.failAudio.src = ''; + this.failAudio = null; + } + } +} diff --git a/src/renderer/src/view/Settings/SettingsView.tsx b/src/renderer/src/view/Settings/SettingsView.tsx index fe98c6c8..e551a968 100644 --- a/src/renderer/src/view/Settings/SettingsView.tsx +++ b/src/renderer/src/view/Settings/SettingsView.tsx @@ -14,6 +14,7 @@ import DisplaySettingsView from './DisplaySettings/DisplaySettingsView'; import TxSettingsView from './TxSettings/TxSettingsView'; import GeneralSettingsView from './GeneralSettings/GeneralSettingsView'; import ProfileSettingsView from './ProfileSettings/ProfileSettingsView'; +import SoundsSettingsView from './SoundsSettings/SoundsSettingsView'; interface Props { app: App; @@ -41,6 +42,9 @@ function SettingsDialog(props: Props) { [SettingsCategories.PROFILES]: ( ), + [SettingsCategories.SOUNDS]: ( + + ), }; return ( @@ -158,6 +162,23 @@ function SettingsDialog(props: Props) { > Profiles + {/* ================================================ */} + {/* SOUNDS */} + {/* ================================================ */} + { + app.settings.setActiveSettingsCategory( + SettingsCategories.SOUNDS + ); + }} + selected={ + app.settings.activeSettingsCategory === + SettingsCategories.SOUNDS + } + data-testid="sounds-settings-button" + > + Sounds + diff --git a/src/renderer/src/view/Settings/SoundsSettings/SoundsSettingsView.tsx b/src/renderer/src/view/Settings/SoundsSettings/SoundsSettingsView.tsx new file mode 100644 index 00000000..621bd234 --- /dev/null +++ b/src/renderer/src/view/Settings/SoundsSettings/SoundsSettingsView.tsx @@ -0,0 +1,71 @@ +import { Checkbox, FormControlLabel, Tooltip, Button } from "@mui/material"; +import { observer } from "mobx-react-lite"; + +import { App } from "src/model/App"; +import BorderedSection from "src/view/Components/BorderedSection"; + +interface Props { + app: App; +} + +function SoundsSettingsView(props: Props) { + const { app } = props; + const soundsSettings = app.settings.soundsSettings + + return ( +
+ {/* =============================================================================== */} + {/* SOUND NOTIFICATIONS */} + {/* =============================================================================== */} + +
+ + { + soundsSettings.setPlaySoundsOnPassFail(e.target.checked); + }} + data-testid="play-sounds-on-pass-fail" + /> + } + label='Play ding sound on "pass" and incorrect buzzer sound on "fail" (case-insensitive)' + sx={{ marginBottom: "10px" }} + /> + + + {/* Test Sound Buttons */} +
+ + +
+
+
+
+ ); +} + +export default observer(SoundsSettingsView);