@voltras/node-sdk
v0.14.0
Published
SDK for connecting to and controlling Voltra fitness devices
Maintainers
Readme
@voltras/node-sdk
SDK for connecting to and controlling Voltra fitness devices.
What's new in 0.6.0
- Typed vendor-frame events:
onPerRep,onSummary,onPreSummary,onInProgressreplace the payload-lessonRepBoundary/onSetBoundarycallbacks (which were removed). Each fires with a structured event payload. - Mode-config setters:
setDamperLevel,setAssistMode,setBandMaxForce,setIsokineticTargetSpeed,setIsokineticEccMode,setIsokineticEccSpeedLimit,setIsokineticEccConstWeight,setIsokineticEccOverloadWeight.damperLevelis reflected inclient.settings. @experimentalQoL setters:setTelemetryRate,setTelemetrySubscribe,setCableTrigger,setResistanceExperience. Validated at the protocol level but not yet on-device.- Breaking changes: see MIGRATION.md.
Features
- Device Control: Configure weight (5-200 lbs, any integer), chains (0-100 lbs), inverse chains (0-100 lbs), and eccentric load (-195% to +195%)
- Real-time Telemetry: Stream position, velocity, and force data during workouts
- Device Notifications: Rep/set boundaries, mode confirmations, settings updates, battery level
- Recording Lifecycle: Prepare, start, and stop recording with motor engagement control
- Cross-platform: Web browsers, Node.js, and React Native
- Multi-device: Connect to and control multiple devices simultaneously
- React Hooks:
useVoltraScanneranduseVoltraDevicefor seamless React integration - TypeScript: Full type definitions included
Installation
npm install @voltras/node-sdkPlatform Dependencies
| Platform | Additional Install |
|----------|-------------------|
| Web browsers | None (uses native Bluetooth API) |
| Node.js | npm install webbluetooth (polyfill) |
| React Native | npm install react-native-ble-plx |
Quick Start
The typical workflow is: scan for devices → let user select → connect → configure → workout → disconnect.
import { VoltraManager, type DiscoveredDevice, type TelemetryFrame } from '@voltras/node-sdk';
// 1. Create a manager (auto-detects platform)
const manager = new VoltraManager();
// 2. Scan for devices
const devices = await manager.scan({ timeout: 10000 });
console.log('Found devices:', devices.map(d => d.name));
// 3. Let user select a device (or connect programmatically)
const selectedDevice = devices[0]; // In a real app, user would choose
const client = await manager.connect(selectedDevice);
// 4. Configure resistance settings
await client.setWeight(50); // 5-200 lbs (any integer)
await client.setChains(25); // 0-100 lbs (reverse resistance)
await client.setInverseChains(15); // 0-100 lbs (progressive resistance)
await client.setEccentric(10); // -195 to +195 (eccentric load %)
// 5. Subscribe to real-time telemetry
client.onFrame((frame: TelemetryFrame) => {
console.log(`Position: ${frame.position}, Velocity: ${frame.velocity}, Force: ${frame.force}`);
});
// 6. Start recording (engages motor)
await client.startRecording();
// ... user performs workout ...
// 7. Stop recording (disengages motor)
await client.stopRecording();
// 8. Cleanup
await manager.disconnectAll();
manager.dispose();Convenience Methods
For simpler scenarios, you can skip manual device selection:
// Connect to first available device
const client = await manager.connectFirst();
// Connect by device name
const client = await manager.connectByName('VTR-123456');Core Concepts
Resistance Settings
Control the device's resistance in three ways:
| Setting | Range | Description | |---------|-------|-------------| | Weight | 5-200 lbs | Primary resistance (any integer value) | | Chains | 0-100 lbs | Reverse resistance - reduces load as you extend | | Inverse Chains | 0-100 lbs | Progressive resistance - increases load as you extend | | Eccentric | -195% to +195% | Adjusts eccentric (lowering) phase relative to concentric |
// Set all resistance parameters
await client.setWeight(75); // 75 lbs primary resistance
await client.setChains(20); // 20 lbs chain reduction
await client.setInverseChains(10); // 10 lbs progressive resistance
await client.setEccentric(-25); // 25% less resistance on eccentric
// Query current settings
console.log(client.settings);
// { weight: 75, chains: 20, inverseChains: 10, eccentric: -25, mode: 1, battery: 85 }
// Get available values for each setting
const weights = client.getAvailableWeights(); // [5, 6, 7, ..., 200]
const chains = client.getAvailableChains(); // [0, 1, 2, ..., 100]
const inverseChains = client.getAvailableInverseChains(); // [0, 1, 2, ..., 100]
const eccentric = client.getAvailableEccentric(); // [-195, -190, ..., 195]Recording Lifecycle
Recording controls the motor engagement:
// Option 1: Simple start/stop (auto-prepares if needed)
await client.startRecording(); // Prepares then starts
// ... workout ...
await client.stopRecording(); // Stops and disengages motor
// Option 2: Manual prepare for lower latency between sets
await client.prepareRecording(); // Pre-engages motor (state: 'ready')
await client.startRecording(); // Instant start (state: 'active')
await client.endSet(); // End set but stay prepared (state: 'ready')
await client.startRecording(); // Next set instant start
await client.stopRecording(); // Fully disengage (state: 'idle')
// Monitor recording state
console.log(client.recordingState); // 'idle' | 'preparing' | 'ready' | 'active' | 'stopping'
console.log(client.isRecording); // true when state === 'active'Real-time Telemetry
Receive movement data at ~11 Hz when recording:
// Subscribe to telemetry frames
const unsubscribe = client.onFrame((frame) => {
console.log({
sequence: frame.sequence, // Packet sequence number
timestamp: frame.timestamp, // Unix ms when received
phase: frame.phase, // MovementPhase enum
position: frame.position, // Position in movement (0-600)
velocity: frame.velocity, // Current velocity
force: frame.force, // Force being applied
});
});
// Unsubscribe when done
unsubscribe();Events and Notifications
The SDK provides a comprehensive event system for real-time device notifications:
// Subscribe to all events with full type safety
client.subscribe((event) => {
switch (event.type) {
// Connection events
case 'connectionStateChanged':
console.log('State:', event.state);
// 'disconnected' | 'connecting' | 'authenticating' | 'connected'
break;
case 'connected':
console.log(`Connected to ${event.deviceName}`);
break;
case 'disconnected':
console.log(`Disconnected from ${event.deviceId}`);
break;
// Recording events
case 'recordingStateChanged':
console.log('Recording:', event.state);
break;
// Telemetry events
case 'frame':
console.log('Telemetry:', event.frame);
break;
// Typed vendor-frame events (0.6.0+)
case 'perRep':
// Fires twice per rep — pull start (event.event.phase === 'pull')
// and return start (event.event.phase === 'return')
console.log('Per-rep frame:', event.event);
break;
case 'inProgress':
// ~1 Hz heartbeat with peak/current force, velocity, target weight
console.log('In-progress beat:', event.event);
break;
case 'summary':
// Fires once at end-of-set
console.log('Set complete:', event.event);
break;
case 'preSummary':
// Fires ~3s before the final rep
console.log('Pre-summary:', event.event);
break;
// Device notification events
case 'modeConfirmed':
console.log('Mode confirmed:', event.mode);
break;
case 'settingsUpdate':
console.log('Device settings:', event.settings);
break;
case 'batteryUpdate':
console.log('Battery:', event.battery, '%');
break;
case 'error':
console.error('Error:', event.error);
break;
}
});Convenience Subscription Methods
Subscribe to specific event types with dedicated methods:
// Telemetry frames (~11 Hz during recording)
const unsubFrame = client.onFrame((frame) => {
updateUI(frame.position, frame.velocity, frame.force);
});
// Per-rep events (typed payload, fires twice per rep)
const unsubPerRep = client.onPerRep((event) => {
// event.phase: 'pull' | 'return'
// event.repCount, event.setCounter, event.targetWeightTenths
if (event.phase === 'pull') {
repCount++;
playRepSound();
}
});
// End-of-set summary (typed payload)
const unsubSummary = client.onSummary((event) => {
// event.schemaVersion, event.setCounter, event.repCount, event.raw
logSetComplete(event.repCount);
});
// In-progress heartbeat (~1 Hz, typed payload — use sparingly)
const unsubInProgress = client.onInProgress((event) => {
// event.peakForceTenths, event.currentForceTenths, event.velocityCmPerSec
updateForceGauge(event.currentForceTenths);
});
// Pre-summary (fires ~3s before final rep)
const unsubPreSummary = client.onPreSummary((event) => {
// event.repDurationMs, event.repCount, event.targetWeightTenths
showPreSetSummary(event.repDurationMs);
});
// Mode confirmations (after setMode())
const unsubMode = client.onModeConfirmed((mode) => {
console.log('Mode now active:', mode);
});
// Settings updates (device reports current state)
const unsubSettings = client.onSettingsUpdate((settings) => {
// settings: { baseWeight?, chains?, eccentric?, trainingMode? }
syncUIWithDevice(settings);
});
// Battery level updates
const unsubBattery = client.onBatteryUpdate((battery) => {
showBatteryIndicator(battery);
});
// Connection state changes
const unsubConnection = client.onConnectionStateChange((state) => {
updateConnectionUI(state);
});
// Unsubscribe when done
unsubFrame();
unsubPerRep();
// ... etcMode-config setters (0.6.0)
Beyond the four core resistance settings, 0.6.0 adds setters for the remaining mode-specific knobs the device exposes. All persist globally and resolve on the BLE write completing — they don't wait for a device echo.
await client.setDamperLevel(5); // Damper mode (UI shows N+1 → "6")
await client.setAssistMode('on'); // Assist on/off
await client.setBandMaxForce(40); // Resistance band max force (15-70 lbs)
await client.setIsokineticTargetSpeed(1500); // 1500 mm/s = 1.5 m/s
await client.setIsokineticEccMode('isokinetic');
await client.setIsokineticEccSpeedLimit(0); // 0 = auto
await client.setIsokineticEccConstWeight(50);
await client.setIsokineticEccOverloadWeight(75);
// damperLevel is reflected back from the device's settingsUpdate
// notifications and surfaced on client.settings:
console.log(client.settings.damperLevel); // 5
// @experimental — register-validated only
await client.setTelemetryRate(10);
await client.setTelemetrySubscribe('all');
await client.setCableTrigger('open');
await client.setResistanceExperience('intense');Platform-Specific Setup
React Native
import { VoltraManager } from '@voltras/node-sdk';
// Use forNative() to get React Native BLE support
const manager = VoltraManager.forNative();
// Rest of the API is identical
const devices = await manager.scan();
const client = await manager.connect(devices[0]);React Hooks
import { useMemo, useState } from 'react';
import { VoltraManager, type DiscoveredDevice, type VoltraClient } from '@voltras/node-sdk';
import { useVoltraScanner, useVoltraDevice } from '@voltras/node-sdk/react';
function WorkoutScreen() {
const manager = useMemo(() => VoltraManager.forNative(), []);
const [client, setClient] = useState<VoltraClient | null>(null);
// Scanner hook - manages scan state and discovered devices
const { devices, isScanning, scan, error: scanError } = useVoltraScanner(manager);
// Device hook - tracks connection state and telemetry
const { connectionState, isConnected, isRecording, currentFrame, settings } = useVoltraDevice(client);
const handleConnect = async (device: DiscoveredDevice) => {
const connected = await manager.connect(device);
await connected.setWeight(50);
setClient(connected);
};
return (
<View>
{/* Scanning */}
{!isConnected && (
<>
<Button onPress={() => scan({ timeout: 10000 })}>
{isScanning ? 'Scanning...' : 'Scan for Devices'}
</Button>
{devices.map((device) => (
<Button key={device.id} onPress={() => handleConnect(device)}>
{device.name}
</Button>
))}
</>
)}
{/* Connected */}
{isConnected && (
<>
<Text>Weight: {settings?.weight} lbs</Text>
<Text>Position: {currentFrame?.position ?? '--'}</Text>
<Button onPress={() => client?.startRecording()}>Start</Button>
<Button onPress={() => client?.stopRecording()}>Stop</Button>
</>
)}
</View>
);
}Multi-Device Support
Connect to and control multiple Voltra devices simultaneously:
const manager = new VoltraManager();
// Listen for connection events
manager.onDeviceConnected((client, deviceId, deviceName) => {
console.log(`Connected: ${deviceName}`);
// Configure each device
client.setWeight(50);
// Handle telemetry per device
client.onFrame((frame) => {
console.log(`[${deviceName}] pos=${frame.position}`);
});
});
manager.onDeviceDisconnected((deviceId) => {
console.log(`Disconnected: ${deviceId}`);
});
// Scan and connect to multiple devices
const devices = await manager.scan();
for (const device of devices) {
await manager.connect(device);
}
// Access specific client by ID
const client = manager.getClient(devices[0].id);
// Or iterate all connected clients
for (const client of manager.getAllClients()) {
await client.startRecording();
}
// Disconnect all when done
await manager.disconnectAll();Error Handling
import {
VoltraSDKError,
ConnectionError,
AuthenticationError,
NotConnectedError,
InvalidSettingError,
CommandError,
TimeoutError,
} from '@voltras/node-sdk';
try {
await manager.connect(device);
} catch (error) {
if (error instanceof ConnectionError) {
console.log('Connection failed:', error.code, error.message);
} else if (error instanceof AuthenticationError) {
console.log('Device authentication failed');
} else if (error instanceof TimeoutError) {
console.log('Operation timed out');
}
}
try {
await client.setWeight(999); // Invalid weight
} catch (error) {
if (error instanceof InvalidSettingError) {
console.log(`Invalid ${error.setting}: ${error.value}`);
console.log('Available values:', error.validValues);
}
}Examples
Complete working examples for each platform:
| Platform | Description | Code | |----------|-------------|------| | Node.js | CLI app with scanning, settings, and telemetry | examples/node/ | | Web | Interactive browser demo with UI | examples/web/ | | React Native | Expo app with hooks | examples/react-native/ |
API Reference
VoltraManager
Main entry point - handles device discovery and connection management.
// Create with auto-detection (Node resolves to the noble backend)
const manager = new VoltraManager();
// Or specify platform
const manager = VoltraManager.forWeb();
const manager = VoltraManager.forNodeNoble(); // recommended for Node
const manager = VoltraManager.forNode(); // legacy webbluetooth backend
const manager = VoltraManager.forNative();forNode() cannot enumerate — its requestDevice selects the first matching
device and stops scanning — and is not multi-peripheral-safe. Prefer the
noble backend, which auto-detection now selects for you.
| Method | Description |
|--------|-------------|
| scan(options?) | Scan for Voltra devices |
| connect(device) | Connect to a device, returns VoltraClient |
| connectFirst(options?) | Connect to first available device |
| connectByName(name, options?) | Scan and connect by device name |
| getClient(deviceId) | Get client for connected device |
| getAllClients() | Get all connected clients |
| disconnect(deviceId) | Disconnect specific device |
| disconnectAll() | Disconnect all devices |
| dispose() | Clean up all resources |
| resolvedPlatform | Platform actually selected, after auto-detection |
| resolvedDeviceNamePrefix | Effective name prefix, or undefined when unfiltered |
The last two are getters, useful when a scan returns nothing and you need to know what the SDK actually chose:
console.log(manager.resolvedPlatform, manager.resolvedDeviceNamePrefix);See examples/node/scan-diagnostics.ts for a ready-made version that also
prints everything the backend discovered.
Devices are identified by BLE service UUID, so scanning finds every Voltra
regardless of its advertised name (including one renamed from the vendor
app). Name-prefix filtering is opt-in — pass deviceNamePrefix to the
constructor or to scan() if you want to additionally restrict results to
names starting with a given string:
const manager = new VoltraManager({ deviceNamePrefix: 'VTR-' });
// or per scan:
const devices = await manager.scan({ deviceNamePrefix: 'VTR-' });VoltraClient
Controls a single connected device.
| Method | Description |
|--------|-------------|
| setWeight(lbs) | Set weight (5-200, any integer) |
| setChains(lbs) | Set chains (0-100) |
| setInverseChains(lbs) | Set inverse chains (0-100) |
| setEccentric(percent) | Set eccentric (-195 to +195) |
| setMode(mode) | Set training mode |
| prepareRecording() | Pre-engage motor for low-latency start |
| startRecording() | Start recording (engages motor) |
| stopRecording() | Stop recording (disengages motor) |
| endSet() | End set but stay prepared |
| subscribe(callback) | Subscribe to all events |
| onFrame(callback) | Subscribe to telemetry frames |
| onPerRep(callback) | Subscribe to typed per-rep events (0.6.0+) |
| onSummary(callback) | Subscribe to end-of-set summary events (0.6.0+) |
| onPreSummary(callback) | Subscribe to pre-summary events (0.6.0+) |
| onInProgress(callback) | Subscribe to ~1 Hz in-progress heartbeats (0.6.0+) |
| onModeConfirmed(callback) | Subscribe to mode confirmation events |
| onSettingsUpdate(callback) | Subscribe to device settings updates |
| onBatteryUpdate(callback) | Subscribe to battery level updates |
| onConnectionStateChange(callback) | Subscribe to connection state changes |
| disconnect() | Disconnect from device |
| dispose() | Clean up resources |
| Property | Type | Description |
|----------|------|-------------|
| connectionState | string | 'disconnected' | 'connecting' | 'authenticating' | 'connected' |
| isConnected | boolean | Whether connected |
| recordingState | string | 'idle' | 'preparing' | 'ready' | 'active' | 'stopping' |
| isRecording | boolean | Whether recording is active |
| settings | object | Current { weight, chains, inverseChains, eccentric, mode, battery } |
| connectedDeviceId | string | Connected device ID |
| connectedDeviceName | string | Connected device name |
TelemetryFrame
import { MovementPhase } from '@voltras/node-sdk';
interface TelemetryFrame {
sequence: number; // Packet sequence number
timestamp: number; // Unix ms when received
phase: MovementPhase; // Movement phase (see MovementPhase enum)
position: number; // Position in movement (0-600)
velocity: number; // Current velocity
force: number; // Force being applied (signed)
}
// MovementPhase enum values:
// IDLE = 0, CONCENTRIC = 1, HOLD = 2, ECCENTRIC = 3, UNKNOWN = -1Documentation
Getting Started Guides
Step-by-step tutorials for using the SDK in your app:
- Node.js - Build a CLI fitness app
- Web Browser - Build a web-based workout tracker
- React Native - Build a mobile fitness app
Technical Deep-Dives
- Platform Adapters - How adapters work across platforms
Other
- Troubleshooting - Common issues and solutions
- Roadmap - Planned features
License
MIT - see LICENSE
