Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions app/gpio/GpioTimerService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions app/peripherals/PeripheralManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 49 additions & 17 deletions app/peripherals/ble/BleManager.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,7 +9,7 @@ const log = loglevel.getLogger('Peripherals')

export class BleManager {
/**
* @type {HciSocket | undefined}
* @type {import('hci-socket') | undefined}
*/
#transport
/**
Expand All @@ -22,31 +20,65 @@ export class BleManager {
* @type {Promise<BleHostManager> | 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
}
Expand Down
4 changes: 3 additions & 1 deletion app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
29 changes: 21 additions & 8 deletions app/tools/ConfigManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config | OldConfig>}
* @returns {Promise<ConfigFileSettings | OldConfig>}
*/
async function getConfig () {
/**
Expand All @@ -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<ConfigFileSettings>} */(customConfig.default)) : defaultConfig
}

/**
* @param {Config | OldConfig} configToCheck
* @param {ConfigFileSettings | OldConfig} configToCheck
*/
function runConfigMigration (configToCheck) {
if ('peripheralUpdateInterval' in configToCheck) {
Expand All @@ -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')
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/config.interface.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config, 'simulateWithoutHardware'>} ConfigFileSettings
*/
2 changes: 1 addition & 1 deletion config/default.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down