diff --git a/app/gpio/GpioTimerService.js b/app/gpio/GpioTimerService.js index 61dbd29527..4d36b7590b 100644 --- a/app/gpio/GpioTimerService.js +++ b/app/gpio/GpioTimerService.js @@ -7,14 +7,48 @@ 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.info('Hardware initialization: simulateWithoutHardware is true. GPIO service is bypassed.') + return + } + + let pigpio + try { + 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 + } + + 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 + } + // Import the settings from the settings file const triggeredFlank = config.gpioTriggeredFlank const pollingInterval = config.gpioPollingInterval diff --git a/app/peripherals/PeripheralManager.js b/app/peripherals/PeripheralManager.js index ca29deae15..883c4eac76 100644 --- a/app/peripherals/PeripheralManager.js +++ b/app/peripherals/PeripheralManager.js @@ -201,12 +201,18 @@ 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() } } catch (error) { - log.error('BleManager creation error: ', error) + log.warn('BleManager creation error (BLE not available on this platform): ', error.message) + bleMode = 'OFF' return } @@ -280,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 @@ -376,7 +387,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..b02e8a413d 100644 --- a/app/peripherals/ble/BleManager.js +++ b/app/peripherals/ble/BleManager.js @@ -1,7 +1,5 @@ import loglevel from 'loglevel' - -import HciSocket from 'hci-socket' -import NodeBleHost from 'ble-host' +import config from '../../tools/ConfigManager.js' /** * @typedef {import('./ble-host.interface.js').BleManager} BleHostManager @@ -11,7 +9,7 @@ const log = loglevel.getLogger('Peripherals') export class BleManager { /** - * @type {HciSocket | undefined} + * @type {import('hci-socket') | undefined} */ #transport /** @@ -22,31 +20,65 @@ export class BleManager { * @type {Promise | undefined} */ #managerOpeningTask + /** + * @type {typeof import('hci-socket') | undefined} + */ + #HciSocket + /** + * @type {typeof import('ble-host').default | undefined} + */ + #NodeBleHost open () { + if (config.simulateWithoutHardware) { + 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) } - if (this.#managerOpeningTask === undefined) { - this.#managerOpeningTask = new Promise((resolve, reject) => { - if (this.#manager) { - resolve(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') if (this.#transport === undefined) { - this.#transport = new HciSocket() + this.#transport = new this.#HciSocket() } - 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 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 } diff --git a/app/server.js b/app/server.js index 5ec746a545..72610795f6 100644 --- a/app/server.js +++ b/app/server.js @@ -141,7 +141,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') diff --git a/app/tools/ConfigManager.js b/app/tools/ConfigManager.js index 0ed1fb16f3..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') @@ -180,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 aeb7047928..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 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"