From 74b652dfe63b00b1f01f91de11c24a2ca09b8b6e Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 29 Jan 2026 18:28:47 -0800 Subject: [PATCH 01/12] Improve cross-platform compatibility by making BLE and GPIO Linux-only with graceful fallbacks Changed BleManager to lazy-load native modules only on Linux platform and throw descriptive error on unsupported platforms. Updated PeripheralManager error handling to downgrade BLE initialization errors from error to warn level and automatically disable BLE/HRM modes when unavailable. Made GPIO timer service conditional on Linux platform in server.js with warning message for other platforms. --- app/peripherals/PeripheralManager.js | 6 ++- app/peripherals/ble/BleManager.js | 60 ++++++++++++++++++++-------- app/server.js | 13 ++++-- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index ca29deae15..95bdbf469a 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -206,7 +206,8 @@ export function createPeripheralManager (config) { _bleManager = new BleManager() } } catch (error) { - log.error('BleManager creation error: ', error) + log.warn('BleManager creation error (BLE not available on this platform): ', error.message) + bleMode = 'OFF' return } @@ -376,7 +377,8 @@ export function createPeripheralManager (config) { _bleManager = new BleManager() } } catch (error) { - log.error('BleManager creation error: ', error) + log.warn('BleManager creation error (BLE not available on this platform): ', error.message) + hrmMode = 'OFF' return } hrmPeripheral = createBleHrmPeripheral(_bleManager) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index f42f90d189..1874f69147 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -1,17 +1,16 @@ import loglevel from 'loglevel' -import HciSocket from 'hci-socket' -import NodeBleHost from 'ble-host' - /** * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager */ const log = loglevel.getLogger('Peripherals') +const isBleSupported = process.platform === 'linux' + export class BleManager { /** - * @type {HciSocket | undefined} + * @type {any} */ #transport /** @@ -22,6 +21,21 @@ export class BleManager { * @type {Promise | undefined} */ #managerOpeningTask + /** + * @type {any} + */ + #HciSocket + /** + * @type {any} + */ + #NodeBleHost + + constructor () { + if (!isBleSupported) { + log.warn(`BLE support unavailable on ${process.platform} (Linux only)`) + throw new Error(`BLE is only supported on Linux, current platform: ${process.platform}`) + } + } open () { if (this.#manager !== undefined) { @@ -29,23 +43,37 @@ export class BleManager { } if (this.#managerOpeningTask === undefined) { - this.#managerOpeningTask = new Promise((resolve, reject) => { + this.#managerOpeningTask = (async () => { if (this.#manager) { - resolve(this.#manager) + return this.#manager } log.debug('Opening BLE manager') - if (this.#transport === undefined) { - this.#transport = new HciSocket() + try { + if (this.#HciSocket === undefined || this.#NodeBleHost === undefined) { + const hciSocketModule = await import('hci-socket') + const bleHostModule = await import('ble-host') + this.#HciSocket = hciSocketModule.default + this.#NodeBleHost = bleHostModule.default + } + + if (this.#transport === undefined) { + this.#transport = new this.#HciSocket() + } + + return new Promise((resolve, reject) => { + this.#NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => { + if (err) { reject(err) } + this.#manager = manager + this.#managerOpeningTask = undefined + resolve(manager) + }) + }) + } catch (error) { + log.error('Failed to load BLE modules:', error) + throw error } - - NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => { - if (err) { reject(err) } - this.#manager = manager - this.#managerOpeningTask = undefined - resolve(manager) - }) - }) + })() } return this.#managerOpeningTask diff --git a/app/server.js b/app/server.js index 5ec746a545..56c99b0dfb 100644 --- a/app/server.js +++ b/app/server.js @@ -55,8 +55,13 @@ peripheralManager.on('heartRateMeasurement', (heartRateMeasurement) => { webServer.presentHeartRate(heartRateMeasurement) }) -const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js') -gpioTimerService.on('message', handleRotationImpulse) +let gpioTimerService +if (process.platform === 'linux') { + gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js') + gpioTimerService.on('message', handleRotationImpulse) +} else { + log.warn(`GPIO service not available on ${process.platform} (Linux/Raspberry Pi only)`) +} // Be aware, both the GPIO as well as the replayer use this as an entrypoint! function handleRotationImpulse (dataPoint) { @@ -141,7 +146,9 @@ process.once('uncaughtException', async (error) => { // This shuts down the pi, use with caution! async function shutdownApp () { // As we are shutting down, we need to make sure things are closed down nicely and save what we can - gpioTimerService.kill() + if (gpioTimerService) { + gpioTimerService.kill() + } await recordingManager.handleCommand('shutdown') // We don't want to wait for the peripherals to close, as then an unresponsive peripheral will block the shutdown process that can remedy it peripheralManager.handleCommand('shutdown') From ae2597384365f751daf2590c09f486ab0cf7e42d Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 00:36:16 -0800 Subject: [PATCH 02/12] Reset manager opening task on BLE module load failure to allow retry attempts --- app/peripherals/ble/BleManager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index 1874f69147..58e3501bb6 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -71,6 +71,7 @@ export class BleManager { }) } catch (error) { log.error('Failed to load BLE modules:', error) + this.#managerOpeningTask = undefined throw error } })() From efabed3cf873d9c164139070ae149837c47ecc02 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 22:06:05 -0800 Subject: [PATCH 03/12] Add simulateWithoutHardware configuration option Introduces the `simulateWithoutHardware` flag to the default configuration and config validation. This enables developers to bypass GPIO and BLE hardware checks to simulate the environment on headless servers or non-Linux systems without causing error logs. AI-assisted-by: Gemini 3.1 Pro --- app/tools/ConfigManager.js | 1 + config/default.config.js | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/app/tools/ConfigManager.js b/app/tools/ConfigManager.js index 0ed1fb16f3..c373e5fa1c 100644 --- a/app/tools/ConfigManager.js +++ b/app/tools/ConfigManager.js @@ -88,6 +88,7 @@ function checkConfig (configToCheck) { checkBooleanValue(configToCheck, 'gzipTcxFiles', true, false) checkBooleanValue(configToCheck, 'createFitFiles', true, true) checkBooleanValue(configToCheck, 'gzipFitFiles', true, false) + checkBooleanValue(configToCheck, 'simulateWithoutHardware', true, false) checkFloatValue(configToCheck.userSettings, 'restingHR', 30, 220, false, true, 40) checkFloatValue(configToCheck.userSettings, 'maxHR', configToCheck.userSettings.restingHR, 220, false, true, 220) if (configToCheck.createTcxFiles || configToCheck.createFitFiles) { diff --git a/config/default.config.js b/config/default.config.js index aeb7047928..6d98f162bb 100644 --- a/config/default.config.js +++ b/config/default.config.js @@ -111,6 +111,10 @@ export default { // Please note that a smaller value will use more network and cpu ressources webUpdateInterval: 200, + // If set to true, bypasses all hardware checks for BLE and GPIO. Useful for development/simulation + // on devices without the proper hardware (e.g., Windows, macOS, or headless Linux servers). + simulateWithoutHardware: false, + // Interval between updates of the bluetooth devices (miliseconds) // Advised is to update at least once per second, as consumers expect this interval // Some apps, like EXR like a more frequent interval of 200 ms to better sync the stroke From 33660e8e62c578b433cdaf236ea9d3fb0201efe1 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 22:06:17 -0800 Subject: [PATCH 04/12] Refactor BleManager to use dynamic module loading and guard clauses Removes the static `process.platform === 'linux'` check in favor of dynamically importing `hci-socket` and `ble-host`. Uses the new `simulateWithoutHardware` config flag to bypass BLE initialization when requested. Refactors `open()` to use guard clauses, preventing deep nesting and improving readability. Catches and logs missing dependencies cleanly. AI-assisted-by: Gemini 3.1 Pro --- app/peripherals/ble/BleManager.js | 76 +++++++++++++++---------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index 58e3501bb6..14faea858c 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -1,4 +1,5 @@ import loglevel from 'loglevel' +import config from '../../tools/ConfigManager.js' /** * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager @@ -6,8 +7,6 @@ import loglevel from 'loglevel' const log = loglevel.getLogger('Peripherals') -const isBleSupported = process.platform === 'linux' - export class BleManager { /** * @type {any} @@ -30,52 +29,49 @@ export class BleManager { */ #NodeBleHost - constructor () { - if (!isBleSupported) { - log.warn(`BLE support unavailable on ${process.platform} (Linux only)`) - throw new Error(`BLE is only supported on Linux, current platform: ${process.platform}`) + open () { + if (config.simulateWithoutHardware) { + log.warn('simulateWithoutHardware is true, BLE manager is disabled.') + return Promise.reject(new Error('simulateWithoutHardware is true, BLE disabled.')) } - } - open () { if (this.#manager !== undefined) { return Promise.resolve(this.#manager) } - if (this.#managerOpeningTask === undefined) { - this.#managerOpeningTask = (async () => { - if (this.#manager) { - return this.#manager + if (this.#managerOpeningTask !== undefined) { + return this.#managerOpeningTask + } + + this.#managerOpeningTask = (async () => { + log.debug('Opening BLE manager') + + try { + if (this.#HciSocket === undefined || this.#NodeBleHost === undefined) { + const hciSocketModule = await import('hci-socket') + const bleHostModule = await import('ble-host') + this.#HciSocket = hciSocketModule.default + this.#NodeBleHost = bleHostModule.default } - log.debug('Opening BLE manager') - - try { - if (this.#HciSocket === undefined || this.#NodeBleHost === undefined) { - const hciSocketModule = await import('hci-socket') - const bleHostModule = await import('ble-host') - this.#HciSocket = hciSocketModule.default - this.#NodeBleHost = bleHostModule.default - } - - if (this.#transport === undefined) { - this.#transport = new this.#HciSocket() - } - - return new Promise((resolve, reject) => { - this.#NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => { - if (err) { reject(err) } - this.#manager = manager - this.#managerOpeningTask = undefined - resolve(manager) - }) - }) - } catch (error) { - log.error('Failed to load BLE modules:', error) - this.#managerOpeningTask = undefined - throw error + + if (this.#transport === undefined) { + this.#transport = new this.#HciSocket() } - })() - } + + return new Promise((resolve, reject) => { + this.#NodeBleHost.BleManager.create(this.#transport, {}, (/** @type {Error | null} */err, /** @type {BleHostManager} */manager) => { + if (err) { reject(err) } + this.#manager = manager + this.#managerOpeningTask = undefined + resolve(manager) + }) + }) + } catch (error) { + log.warn('Failed to load BLE modules or open BLE socket. BLE will be unavailable.', error.message) + this.#managerOpeningTask = undefined + throw error + } + })() return this.#managerOpeningTask } From e54c70ce3c80c96a3458d10856c86defddc8dc52 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 22:06:37 -0800 Subject: [PATCH 05/12] Refactor GpioTimerService to gracefully handle module load failures Removes the static `process.platform === 'linux'` check from `server.js` and moves hardware checking into `GpioTimerService.js`. Uses dynamic imports for `pigpio` wrapped in a try/catch block. If `simulateWithoutHardware` is true or if `pigpio` fails to load (e.g., on non-Raspberry Pi devices or without root), the service logs a warning and exits cleanly instead of crashing the app. AI-assisted-by: Gemini 3.1 Pro --- app/gpio/GpioTimerService.js | 17 +++++++++++++++-- app/server.js | 9 ++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js index 61dbd29527..26b97c1e66 100644 --- a/app/gpio/GpioTimerService.js +++ b/app/gpio/GpioTimerService.js @@ -7,14 +7,18 @@ possible to real time. */ import process from 'process' -import pigpio from 'pigpio' import os from 'os' import config from '../tools/ConfigManager.js' import log from 'loglevel' log.setLevel(config.loglevel.default) -export function createGpioTimerService () { +export async function createGpioTimerService () { + if (config.simulateWithoutHardware) { + log.warn('simulateWithoutHardware is true, GPIO service is disabled.') + return + } + // Import the settings from the settings file const triggeredFlank = config.gpioTriggeredFlank const pollingInterval = config.gpioPollingInterval @@ -31,6 +35,15 @@ export function createGpioTimerService () { } } + let pigpio + try { + const pigpioModule = await import('pigpio') + pigpio = pigpioModule.default + } catch (error) { + log.warn('Failed to load pigpio module. GPIO service will be unavailable (Linux/Raspberry Pi only).', error.message) + return + } + const Gpio = pigpio.Gpio // Configure the gpio polling frequency diff --git a/app/server.js b/app/server.js index 56c99b0dfb..72610795f6 100644 --- a/app/server.js +++ b/app/server.js @@ -55,13 +55,8 @@ peripheralManager.on('heartRateMeasurement', (heartRateMeasurement) => { webServer.presentHeartRate(heartRateMeasurement) }) -let gpioTimerService -if (process.platform === 'linux') { - gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js') - gpioTimerService.on('message', handleRotationImpulse) -} else { - log.warn(`GPIO service not available on ${process.platform} (Linux/Raspberry Pi only)`) -} +const gpioTimerService = child_process.fork('./app/gpio/GpioTimerService.js') +gpioTimerService.on('message', handleRotationImpulse) // Be aware, both the GPIO as well as the replayer use this as an entrypoint! function handleRotationImpulse (dataPoint) { From aa368984d167720d7017e2183e2ac2bd58408357 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 22:37:54 -0800 Subject: [PATCH 06/12] Enhance hardware checks to explicitly verify OS and RPi model Updates `GpioTimerService` to explicitly read `/proc/device-tree/model` to confirm it is running on a supported Raspberry Pi (3, 4, or Zero 2W) before attempting to load `pigpio`. Updates `BleManager` to explicitly check for Linux before attempting to load `hci-socket`. Both services now log explicit messages detailing their initialization decisions as requested by maintainers, ensuring clear visibility into why hardware modules are bypassed or loaded. AI-assisted-by: Gemini 3.1 Pro --- app/gpio/GpioTimerService.js | 25 ++++++++++++++++++++++++- app/peripherals/ble/BleManager.js | 9 ++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js index 26b97c1e66..34d1eaff67 100644 --- a/app/gpio/GpioTimerService.js +++ b/app/gpio/GpioTimerService.js @@ -8,6 +8,7 @@ */ import process from 'process' import os from 'os' +import fs from 'fs' import config from '../tools/ConfigManager.js' import log from 'loglevel' @@ -15,7 +16,29 @@ log.setLevel(config.loglevel.default) export async function createGpioTimerService () { if (config.simulateWithoutHardware) { - log.warn('simulateWithoutHardware is true, GPIO service is disabled.') + log.info('Hardware initialization: simulateWithoutHardware is true. GPIO service is bypassed.') + return + } + + if (process.platform !== 'linux') { + log.info(`Hardware initialization: GPIO requires Linux. Current platform is ${process.platform}. GPIO service is bypassed.`) + return + } + + let isSupportedPi = false + try { + const model = fs.readFileSync('/proc/device-tree/model', 'utf8') + if (model.includes('Raspberry Pi 3') || model.includes('Raspberry Pi 4') || model.includes('Raspberry Pi Zero 2')) { + isSupportedPi = true + log.info(`Hardware initialization: Detected supported Raspberry Pi (${model.trim()}). Attempting to start GPIO service.`) + } else { + log.info(`Hardware initialization: Detected unsupported device (${model.trim()}). GPIO service is bypassed.`) + } + } catch (err) { + log.info(`Hardware initialization: Could not read /proc/device-tree/model (${err.message}). Assuming not a supported Raspberry Pi. GPIO service is bypassed.`) + } + + if (!isSupportedPi) { return } diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index 14faea858c..00929945c4 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -31,9 +31,16 @@ export class BleManager { open () { if (config.simulateWithoutHardware) { - log.warn('simulateWithoutHardware is true, BLE manager is disabled.') + log.info('Hardware initialization: simulateWithoutHardware is true. BLE manager is bypassed.') return Promise.reject(new Error('simulateWithoutHardware is true, BLE disabled.')) } + + if (process.platform !== 'linux') { + log.info(`Hardware initialization: BLE requires Linux. Current platform is ${process.platform}. BLE manager is bypassed.`) + return Promise.reject(new Error(`BLE requires Linux, current platform: ${process.platform}`)) + } + + log.info('Hardware initialization: BLE enabled on Linux. Attempting to start BLE manager.') if (this.#manager !== undefined) { return Promise.resolve(this.#manager) From a11162b567666bc64f95e74bd9f4cacede7f6888 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 19 Feb 2026 22:55:39 -0800 Subject: [PATCH 07/12] Refactor GPIO hardware checks to handle pigpio C-library initialization failure gracefully The `pigpio` JS wrapper exports functions even if the underlying C library fails to initialize (e.g. on macOS or non-Pi Linux without root), causing a hard crash later when those undefined C-bindings are called. This commit explicitly wraps the first call to `pigpio.hardwareRevision()` in a try/catch block to catch the `TypeError: is not a function`. This prevents the application from crashing and instead cleanly bypasses the GPIO service as requested by maintainers. AI-assisted-by: Gemini 3.1 Pro --- app/gpio/GpioTimerService.js | 50 +++++++++++++++++------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js index 34d1eaff67..4d36b7590b 100644 --- a/app/gpio/GpioTimerService.js +++ b/app/gpio/GpioTimerService.js @@ -8,7 +8,6 @@ */ import process from 'process' import os from 'os' -import fs from 'fs' import config from '../tools/ConfigManager.js' import log from 'loglevel' @@ -20,25 +19,33 @@ export async function createGpioTimerService () { return } - if (process.platform !== 'linux') { - log.info(`Hardware initialization: GPIO requires Linux. Current platform is ${process.platform}. GPIO service is bypassed.`) - return - } - - let isSupportedPi = false + let pigpio try { - const model = fs.readFileSync('/proc/device-tree/model', 'utf8') - if (model.includes('Raspberry Pi 3') || model.includes('Raspberry Pi 4') || model.includes('Raspberry Pi Zero 2')) { - isSupportedPi = true - log.info(`Hardware initialization: Detected supported Raspberry Pi (${model.trim()}). Attempting to start GPIO service.`) - } else { - log.info(`Hardware initialization: Detected unsupported device (${model.trim()}). GPIO service is bypassed.`) + const pigpioModule = await import('pigpio') + pigpio = pigpioModule.default + + // The JS wrapper loads even if the C library fails to initialize (e.g. on non-Pi or missing root). + // When the C library fails, the exported functions exist but wrap undefined C-bindings, causing a + // TypeError when called. We catch this explicitly to verify true compatibility before proceeding. + let hwRev + try { + hwRev = pigpio.hardwareRevision() + } catch (e) { + if (e instanceof TypeError && e.message.includes('is not a function')) { + log.info('Hardware initialization: pigpio C library failed to initialize (likely not a supported Raspberry Pi or missing root). GPIO service is bypassed.') + return + } + throw e // re-throw unexpected errors } - } catch (err) { - log.info(`Hardware initialization: Could not read /proc/device-tree/model (${err.message}). Assuming not a supported Raspberry Pi. GPIO service is bypassed.`) - } - if (!isSupportedPi) { + if (hwRev === 0) { + log.info('Hardware initialization: pigpio reports unknown/unsupported hardware revision. GPIO service is bypassed.') + return + } + + log.info(`Hardware initialization: pigpio initialized successfully (Hardware Revision: ${hwRev.toString(16)}). Attempting to start GPIO service.`) + } catch (error) { + log.info(`Hardware initialization: Failed to load pigpio module (${error.message}). GPIO service is bypassed.`) return } @@ -58,15 +65,6 @@ export async function createGpioTimerService () { } } - let pigpio - try { - const pigpioModule = await import('pigpio') - pigpio = pigpioModule.default - } catch (error) { - log.warn('Failed to load pigpio module. GPIO service will be unavailable (Linux/Raspberry Pi only).', error.message) - return - } - const Gpio = pigpio.Gpio // Configure the gpio polling frequency From db9be3e3107369a184704bce872cd2b05aa00e2e Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 20 Feb 2026 10:35:02 -0800 Subject: [PATCH 08/12] Restore strict typing for BleManager lazy-loaded modules using inline JSDoc imports Replaces the temporary `any` types with `typeof import(...)` for the BLE modules (`hci-socket` and `ble-host`). This restores strong type checking for the class properties while keeping the actual module loading deferred to runtime, satisfying the requirement to lazy-load these modules only on supported platforms (Linux) without losing static analysis benefits. --- app/peripherals/ble/BleManager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index 00929945c4..b02e8a413d 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -9,7 +9,7 @@ const log = loglevel.getLogger('Peripherals') export class BleManager { /** - * @type {any} + * @type {import('hci-socket') | undefined} */ #transport /** @@ -21,11 +21,11 @@ export class BleManager { */ #managerOpeningTask /** - * @type {any} + * @type {typeof import('hci-socket') | undefined} */ #HciSocket /** - * @type {any} + * @type {typeof import('ble-host').default | undefined} */ #NodeBleHost From 3a3465f00a874f854c5b73f6c31ed491d46fdf50 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 20 Feb 2026 11:06:45 -0800 Subject: [PATCH 09/12] Switching to `@import` tags from older dynamic import syntax. --- app/peripherals/ble/BleManager.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index b02e8a413d..e0f29338d4 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -3,13 +3,15 @@ import config from '../../tools/ConfigManager.js' /** * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager + * @import HciSocket from 'hci-socket' + * @import NodeBleHost from 'ble-host' */ const log = loglevel.getLogger('Peripherals') export class BleManager { /** - * @type {import('hci-socket') | undefined} + * @type {HciSocket | undefined} */ #transport /** @@ -21,11 +23,11 @@ export class BleManager { */ #managerOpeningTask /** - * @type {typeof import('hci-socket') | undefined} + * @type {typeof HciSocket | undefined} */ #HciSocket /** - * @type {typeof import('ble-host').default | undefined} + * @type {typeof NodeBleHost | undefined} */ #NodeBleHost From f9f283a60db698144b0cd7bd654f1774389cfbaf Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 20 Feb 2026 14:20:09 -0800 Subject: [PATCH 10/12] Revert "Switching to `@import` tags from older dynamic import syntax." This reverts commit 5a352e534800ab032c1fe18c8e96ba12d03e01f8. --- app/peripherals/ble/BleManager.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/peripherals/ble/BleManager.js b/app/peripherals/ble/BleManager.js index e0f29338d4..b02e8a413d 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -3,15 +3,13 @@ import config from '../../tools/ConfigManager.js' /** * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager - * @import HciSocket from 'hci-socket' - * @import NodeBleHost from 'ble-host' */ const log = loglevel.getLogger('Peripherals') export class BleManager { /** - * @type {HciSocket | undefined} + * @type {import('hci-socket') | undefined} */ #transport /** @@ -23,11 +21,11 @@ export class BleManager { */ #managerOpeningTask /** - * @type {typeof HciSocket | undefined} + * @type {typeof import('hci-socket') | undefined} */ #HciSocket /** - * @type {typeof NodeBleHost | undefined} + * @type {typeof import('ble-host').default | undefined} */ #NodeBleHost From 823dcc58158647bdebdb35172a4509981ed5c075 Mon Sep 17 00:00:00 2001 From: David C Date: Fri, 27 Mar 2026 21:06:03 -0700 Subject: [PATCH 11/12] Move hardware simulation check to PeripheralManager to prevent BLE/ANT+ initialization attempts Relocates the `simulateWithoutHardware` check from individual BLE peripheral setup() functions to the `createBlePeripheral()` and `createAntPeripheral()` functions in PeripheralManager. This prevents BLE and ANT+ peripherals from being created at all when hardware simulation is enabled, rather than catching rejected promises after initialization has already started. Removes now-unnecessary .catch() handlers --- app/peripherals/PeripheralManager.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index 95bdbf469a..883c4eac76 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -201,6 +201,11 @@ export function createPeripheralManager (config) { * @param {BluetoothModes} newMode */ async function createBlePeripheral (newMode) { + if (config.simulateWithoutHardware) { + log.info('Hardware initialization: simulateWithoutHardware is true. BLE peripherals are bypassed.') + newMode = 'OFF' + } + try { if (_bleManager === undefined && newMode !== 'OFF') { _bleManager = new BleManager() @@ -281,6 +286,11 @@ export function createPeripheralManager (config) { * @param {AntPlusModes} newMode */ async function createAntPeripheral (newMode) { + if (config.simulateWithoutHardware) { + log.info('Hardware initialization: simulateWithoutHardware is true. ANT+ peripherals are bypassed.') + newMode = 'OFF' + } + if (antPeripheral) { await antPeripheral?.destroy() antPeripheral = undefined From 529aff038cf537d79ec8d814cb87f7c83a5ada36 Mon Sep 17 00:00:00 2001 From: Abasz <32517724+Abasz@users.noreply.github.com> Date: Thu, 9 Apr 2026 08:19:47 +0000 Subject: [PATCH 12/12] Replace simulateWithoutHardware config with CLI flag Move simulateWithoutHardware out of the config file and into a --no-hardware CLI flag and ORM_NO_HARDWARE env var. This keeps the central config object as single source of truth while removing the need for config file editing and config checks. The flag is parsed in ConfigManager.js and injected into the config object so all downstream consumers remain unchanged. The env var is auto-set so forked child processes (e.g. GpioTimerService) inherit it. Add npm run dev script for convenience. --- app/tools/ConfigManager.js | 30 +++++++++++++++++++++--------- config/config.interface.js | 6 ++++++ config/default.config.js | 6 +----- package.json | 1 + 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/tools/ConfigManager.js b/app/tools/ConfigManager.js index c373e5fa1c..2ff1f37d95 100644 --- a/app/tools/ConfigManager.js +++ b/app/tools/ConfigManager.js @@ -13,11 +13,11 @@ import log from 'loglevel' /** * @typedef {{ minumumForceBeforeStroke: number, minumumRecoverySlope: number}} OldRowerProfile - * @typedef { Config & { peripheralUpdateInterval:number, antplusMode:AntPlusModes, rowerSettings:OldRowerProfile }} OldConfig + * @typedef { ConfigFileSettings & { peripheralUpdateInterval:number, antplusMode:AntPlusModes, rowerSettings:OldRowerProfile }} OldConfig */ /** - * @returns {Promise} + * @returns {Promise} */ async function getConfig () { /** @@ -32,11 +32,11 @@ async function getConfig () { // ToDo: check if config.js is a valdif JSON object - return customConfig !== undefined ? deepMerge(defaultConfig, /** @type {Config} */(customConfig.default)) : defaultConfig + return customConfig !== undefined ? deepMerge(defaultConfig, /** @type {DeepPartial} */(customConfig.default)) : defaultConfig } /** - * @param {Config | OldConfig} configToCheck + * @param {ConfigFileSettings | OldConfig} configToCheck */ function runConfigMigration (configToCheck) { if ('peripheralUpdateInterval' in configToCheck) { @@ -62,7 +62,7 @@ function runConfigMigration (configToCheck) { } /** - * @param {Config | OldConfig} configToCheck + * @param {ConfigFileSettings | OldConfig} configToCheck */ function checkConfig (configToCheck) { checkRangeValue(configToCheck.loglevel, 'default', ['trace', 'debug', 'info', 'warn', 'error', 'silent'], true, 'error') @@ -88,7 +88,6 @@ function checkConfig (configToCheck) { checkBooleanValue(configToCheck, 'gzipTcxFiles', true, false) checkBooleanValue(configToCheck, 'createFitFiles', true, true) checkBooleanValue(configToCheck, 'gzipFitFiles', true, false) - checkBooleanValue(configToCheck, 'simulateWithoutHardware', true, false) checkFloatValue(configToCheck.userSettings, 'restingHR', 30, 220, false, true, 40) checkFloatValue(configToCheck.userSettings, 'maxHR', configToCheck.userSettings.restingHR, 220, false, true, 220) if (configToCheck.createTcxFiles || configToCheck.createFitFiles) { @@ -181,9 +180,22 @@ function checkConfig (configToCheck) { } } -const config = await getConfig() +const fileConfig = await getConfig() -runConfigMigration(config) -checkConfig(config) +runConfigMigration(fileConfig) +checkConfig(fileConfig) + +// Determine simulateWithoutHardware from CLI flag or environment variable +// This is intentionally not part of the config file to prevent accidental commits +// The env var is also set so that child processes (e.g. forked GpioTimerService) inherit it +const simulateWithoutHardware = process.argv.includes('--no-hardware') || process.env.ORM_NO_HARDWARE === '1' + +if (simulateWithoutHardware) { + process.env.ORM_NO_HARDWARE = '1' + log.info('Hardware simulation mode enabled via --no-hardware flag or ORM_NO_HARDWARE environment variable') +} + +/** @type {Config} */ +const config = { ...fileConfig, simulateWithoutHardware } export default config diff --git a/config/config.interface.js b/config/config.interface.js index 20043cc87b..89706733ee 100644 --- a/config/config.interface.js +++ b/config/config.interface.js @@ -64,4 +64,10 @@ * @property {string} shutdownCommand - The command to shutdown the device via the user interface. * @property {string} stravaClientId - The "Client ID" of your Strava API Application. * @property {string} stravaClientSecret - The "Client Secret" of your Strava API Application. + * @property {boolean} simulateWithoutHardware - Whether hardware (GPIO, BLE, ANT+) is bypassed. Set at runtime via --no-hardware CLI flag or ORM_NO_HARDWARE env var. + */ + +/** + * Config type as provided by the config files, without runtime-injected properties. + * @typedef {Omit} ConfigFileSettings */ diff --git a/config/default.config.js b/config/default.config.js index 6d98f162bb..446af71801 100644 --- a/config/default.config.js +++ b/config/default.config.js @@ -15,7 +15,7 @@ import rowerProfiles from './rowerProfiles.js' /** * The default configuration for the Open Rowing Monitor. - * @type {Config} + * @type {ConfigFileSettings} */ export default { // Available log levels: trace, debug, info, warn, error, silent @@ -111,10 +111,6 @@ export default { // Please note that a smaller value will use more network and cpu ressources webUpdateInterval: 200, - // If set to true, bypasses all hardware checks for BLE and GPIO. Useful for development/simulation - // on devices without the proper hardware (e.g., Windows, macOS, or headless Linux servers). - simulateWithoutHardware: false, - // Interval between updates of the bluetooth devices (miliseconds) // Advised is to update at least once per second, as consumers expect this interval // Some apps, like EXR like a more frequent interval of 200 ms to better sync the stroke diff --git a/package.json b/package.json index 24694efce4..91dc270bfd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "lint": "eslint ./app ./config && markdownlint-cli2 '**/*.md' '#node_modules'", "start": "node app/server.js", + "no-hardware": "node app/server.js --no-hardware", "build": "rollup -c", "build:watch": "rollup -cw", "test": "uvu"