Skip to content

TypeScript guide

Runtime-neutral TypeScript codecs for the Bluetooth Fitness Machine Service (FTMS).

Replace handwritten packet parsing while keeping your existing Bluetooth stack. Start with the runnable quickstart, then the integration cookbook, API index, and troubleshooting. Repository links describe main; use a matching release tag for older packages.

Support profile: FullWire, with CapabilityEvidence, RangeInspection and NormalizedViews. This names codec directions, not BLE transport or permission to control equipment.

Integrate through the consumer adapter seam: transport conversion, BLE/session lifecycle, retries, subscriptions, UI/application policy and control safety remain outside this protocol package.

The package accepts Uint8Array or ArrayBuffer values and returns typed, normalized data. It does not create BLE connections, own GATT subscriptions, schedule command timeouts, log, or depend on React Native.

Release status: 0.x. The protocol codecs are comprehensively unit tested, but the package does not claim Bluetooth qualification, PTS verification, or compatibility with every fitness machine.

Terminal window
npm install @deancochran/ftms
# or
pnpm add @deancochran/ftms

The package publishes ESM and CommonJS JavaScript from the same TypeScript sources, with matching declarations (import resolves dist/; require resolves dist/cjs/). Its legacy main and module entries remain available. It is intended for Node.js 20+, modern bundlers, and modern React Native/Metro projects.

import { parseFtmsIndoorBikeMeasurement } from "@deancochran/ftms";
const notificationBytes = Uint8Array.of(0x44, 0x00, 0x10, 0x0e, 0xb4, 0x00, 0xfa, 0x00);
const reading = parseFtmsIndoorBikeMeasurement(notificationBytes);
if (reading.diagnostics.truncated) {
// The notification ended before every advertised field could be read.
}
console.log(reading.metrics.cadenceRpm);
console.log(reading.metrics.powerWatts);
console.log(reading.metrics.speedMps);

Parsers are available for:

  • Treadmill Data
  • Cross Trainer Data
  • Step Climber Data
  • Stair Climber Data
  • Rower Data
  • Indoor Bike Data
  • Training Status
  • Fitness Machine Status

Use parseRegisteredFtmsPayload(characteristicUuid, bytes, formatOptions) when dispatching by characteristic UUID. Measurement parsers accept explicit caller-owned format options; legacy treadmill pace is retained only by raw decoding because its unit is unknown, so normalized pace remains null.

decodeIndoorBikeData(bytes, options?) is an additive Indoor Bike-only view for consumers that need stable named fields rather than the generic metric bag. It returns a measurement object in physical units, a raw object retaining unscaled wire integers, and diagnostics. All named fields are present in each object; null means the field was not selected by flags, was truncated, or used its FTMS unavailable sentinel. diagnostics.unavailableFields distinguishes the last case. Zero remains a valid value.

import { decodeIndoorBikeData } from "@deancochran/ftms";
const reading = decodeIndoorBikeData(notificationBytes);
console.log(reading.measurement.speedKph, reading.measurement.speedMps);
console.log(reading.raw.cadenceHalfRpm);

Speed is exposed directly from the wire value as hundredths of km/h and as m/s; cadence is half-rpm; MET is tenths; times are seconds. The default resistance layout is the existing unsigned whole-level format. Select the alternate layout only from caller-owned evidence: { resistanceFormat: "signed16Tenths" } makes raw.resistance a signed integer in tenths and measurement.resistanceLevel a scaled value. The decoder never guesses a layout from packet length or device identity, and it does not assemble moreData fragments.

Feature and range decoders return explicit result unions rather than throwing for malformed payload lengths or invalid ranges.

import { decodeFtmsFeatures, decodeSupportedPowerRange } from "@deancochran/ftms";
const features = decodeFtmsFeatures(featureBytes);
if (!features.ok) {
throw new Error(features.error.message);
}
const powerRange = decodeSupportedPowerRange(powerRangeBytes);
if (powerRange.ok) {
console.log(powerRange.value); // { min, max, increment, unit: "watts" }
}

All FTMS 1.0 Fitness Machine Control Point request opcodes are represented by the FtmsControlRequest union.

import { decodeFtmsControlResponse, tryEncodeFtmsControlRequest } from "@deancochran/ftms";
const encoded = tryEncodeFtmsControlRequest({
op: "setTargetPower",
powerWatts: 250,
});
if (!encoded.ok) {
throw new RangeError(encoded.error.message);
}
// Consumer adapter function; this package does not provide transport I/O.
await writeControlPoint(encoded.value);
const response = decodeFtmsControlResponse(indicationBytes);
if (!response.ok || !response.value.success) {
// Treat the operation as rejected or failed.
}

encodeFtmsControlRequest is the throwing convenience variant. tryEncodeFtmsControlRequest is recommended at untrusted boundaries. writeControlPoint above is supplied by the consumer adapter, which also owns procedure serialization, response correlation and timeout/disconnect policy.

Both normalized encoders accept an optional second FtmsControlFormatOptions argument, as do encodeFtmsControlRequestRaw and decodeFtmsControlRequestRaw:

const resistance = tryEncodeFtmsControlRequest(
{ op: "setTargetResistance", resistanceLevel: 12.3 },
{ resistanceFormat: "uint8Tenths" },
); // successful value is Uint8Array.of(0x04, 0x7b)

Omitted/empty options preserve the ESR11 E8991 signed16-tenths default (04 7b 00 for 12.3). The explicit uint8Tenths alternative permits 0–25.5 normalized levels, or integer 0–255 raw tenths. Version 0.4.0 checks original normalized bounds before grid alignment, tolerating only binary64 representation noise: 2 * Number.EPSILON * max(1, abs(value * scale)) in raw units. There is no arbitrary rounding or clamping. Raw codecs reject fractional operands. The source-only shared/protocol/numeric-inputs.md specifies the policy. Invalid options, including null, are rejected (a result error from tryEncode, otherwise a thrown error). Only resistance opcode 0x04 changes; other requests are unaffected. Selection is never inferred from packet length, measurements, ranges, status or device identity. Status resistance remains signed16 tenths. The literal 1.0.1 table conflicts with E8991; this alternative is not a claim that the signed correction was revoked. The source-checkout-only docs/specification-audit.md records the reconciliation; it is not part of the installed npm package.

Version 0.4.0 requires format selections and C.7 evidence to be own data properties. Inherited selections and getters are rejected rather than silently changing defaults or manufacturing evidence. Empty, null-prototype and foreign-realm records remain supported. Configuration is not a sandbox for untrusted Proxy objects.

For simulation opcode 0x11, the legacy cwKgPerM property keeps its public name, but E10187 / FTMS 1.0.1 Table 4.20 defines Cw as unitless. The wire representation remains UINT8 with resolution 0.01; no new physical-unit conversion is performed. Do not infer current normative units from that legacy property name.

This package only encodes and decodes protocol values. Callers must:

  • inspect the Feature characteristic before exposing a control;
  • read and enforce the machine’s supported range and increment;
  • request control and wait for the matching indication;
  • serialize Control Point procedures;
  • handle timeouts, disconnects, and Control Permission Lost (0xff);
  • require appropriate user confirmation for movement or resistance changes.

FTMS responses do not contain transaction identifiers. Correlating responses, handling delayed indications, and deciding whether a connection remains safe are transport/application responsibilities.

Public metric names carry normalized units where practical:

  • speed: metres per second (*Mps)
  • distance and elevation: metres (*Meters)
  • cadence, stroke rate, and step rate: per minute (*Rpm/*Spm)
  • power: watts (*Watts)
  • heart rate: beats per minute (*Bpm)
  • energy: kilocalories (*Kcal)
  • duration: seconds (*Seconds)
  • inclination and grade: percent (*Percent)

Wire-level unavailable sentinels become null. Truncation, reserved values, unknown status opcodes, trailing bytes, reserved flags, and More Data are reported through ParsedFtmsPayload.diagnostics.

The package deliberately does not reassemble notifications marked More Data; the caller owns fragment buffering and lifecycle policy.

Versioned, language-neutral regression vectors and their JSON Schema are published at:

  • @deancochran/ftms/conformance/v1
  • @deancochran/ftms/conformance/v1/schema
  • @deancochran/ftms/conformance/schema

Use the JSON loading mechanism appropriate to your runtime or tooling. The corpus is regression evidence, not a Bluetooth qualification certificate. Its language-neutral comparison and reporting rules are in the repository’s conformance runner contract.

Characteristic layouts follow the Bluetooth SIG GATT Specification Supplement YAML at public repository revision 3b58acd4d2446e68f5539acac46c3b4941a34747. The adopted FTMS v1.0 service text supplies service semantics where GSS does not. ESR11 and its FTMS errata override older text, including the signed 16-bit, 0.1-resolution resistance Control Point correction.

Mandatory Errata Correction 23224 replaces the general conformance language in FTMS 1.0 Section 1.1: each capability, and each supported implementation option, must be supported as specified. It does not change FTMS wire layouts. The corpus records the correction as governing provenance, while qualification and caller-owned GATT behavior remain outside this codec package. No Bluetooth compliance or interoperability claim is made here.

The package follows semantic versioning. During 0.x, protocol corrections and API cleanup may be released as minor versions. Compatibility projections such as parseFtmsIndoorBikeData remain available. Application policy, transport lifecycle, machine inference, and presentation contracts intentionally remain outside this package. Prefer complete ParsedFtmsPayload parsers for new code.

The working source now matches the C port’s protocol directions. Existing normalized parsers, human-unit control encoders and validated response decoder remain compatible. Additive raw APIs are exported from the package root:

  • decodeFtmsFeaturesRaw / encodeFtmsFeaturesRaw
  • decodeFtmsRangeRaw / encodeFtmsRangeRaw
  • inspectFtmsRangeRaw
  • decodeFtmsControlRequestRaw / encodeFtmsControlRequestRaw
  • decodeFtmsControlResponseRaw / encodeFtmsControlResponseRaw
  • decodeFtmsMeasurementRaw / encodeFtmsMeasurementRaw
  • decodeFtmsMachineStatusRaw / encodeFtmsMachineStatusRaw
  • decodeFtmsTrainingStatusRaw / encodeFtmsTrainingStatusRaw
  • evaluateFtmsCapabilities

Explicit resistance/pace compatibility options apply to raw codecs and normalized measurement parsers, including registry dispatch. Resistance-range options also apply to normalized range decoding and capability evaluation. Each call requires its own explicit selection; no setting changes another API’s defaults. Legacy treadmill pace values remain available through raw decoding, but normalized seconds-per-500m fields stay null because the legacy units are unresolved. Omit options or pass {} for defaults; malformed option values are rejected. See the repository’s docs/device-compatibility.md for field applicability.

The bidirectional codecs and capability evaluator were published in 0.3.0; inspectFtmsRangeRaw followed in 0.4.0. Exact release identities and artifact evidence are recorded in the repository’s docs/released-packages.md.

Raw codecs use integer wire units and explicit diagnostics, matching the shared bidirectional contracts. They accept Uint8Array/ArrayBuffer inputs, including offset views and cross-realm inputs. Invalid arguments or unrepresentable values throw RawCodecError with a null, length, kind or range category. Decoded partial/unknown evidence can instead carry diagnostics: successful decoding does not mean a complete or conformant packet. Encoder inputs are canonical values, not permission to send controls. Unknown-response evidence is available through the new raw decoder without changing the old decoder’s stricter behavior.

Measurements use kinds 0–5 (Treadmill, Cross Trainer, Step Climber, Stair Climber, Rower, Indoor Bike), 30 raw fields and explicit present/unavailable masks. See the shared measurement contract for field order and per-kind units. Training text is a Uint8Array, retaining invalid UTF-8 evidence on decode and requiring valid UTF-8 on encode. Raw APIs intentionally do not silently convert display units or aggregate More Data fragments.

inspectFtmsRangeRaw(kind, bytes, options?) is additive diagnostic evidence for Supported Ranges. It returns the selected caller-owned profile, actual/expected length, selected valid/length/range status, and bounded structural candidates (both resistance layouts; one canonical layout for other ranges). It never selects an alternative from bytes: an alternate candidate can be valid while the selected result remains malformed. Candidate success is neither proof of a physical unit or device conformance nor permission to control equipment.

Capability evaluation takes a pure protocol snapshot: uint32 generation, numeric discovery/scope/read states, canonical 32-lowercase-hex UUIDs, uint16 properties, and byte read evidence. Its report follows the shared capability contract, including all 21 operations, duplicates, missing/failed reads and contradictions. Optional caller-owned C.7 evidence distinguishes false from unknown:

evaluateFtmsCapabilities({
...snapshot,
c7: { bondingSupported: true, featureMayChangeOverLifetime: true },
}); // Feature must have Read | Indicate

Omit either C.7 fact when it is unknown; the evaluator does not invent false. That leaves applicable prerequisites incomplete with an insufficient-evidence diagnostic, while non-Indicate extra Feature properties remain contradictory. It does not perform discovery or return execution authorization. Full state-code definitions and examples are in shared/conformance/capabilities/v1/schema.json and shared/protocol/capability-discovery.md in the repository.

TypeScript is distributed through npm; C is independently distributed as a versioned source archive through GitHub releases. The repository places them in language-owned packages. Swift 0.1.0 is distributed through a revision-pinned SwiftPM Git dependency, Kotlin/JVM 0.1.0 through Maven Central, and Python 0.1.0a2 as a PyPI alpha with static capability evidence. Rust 0.1.1, Dart 0.1.0, Go v0.1.0 and C# 0.1.0-alpha.1 are also published independently; consult the repository’s release matrix for exact artifact identities and the distinction between released and branch source. The role-based support profiles distinguish wire directions from optional convenience modules. Limited passive KICKR CORE telemetry evidence is documented in the repository; it does not establish compatibility with every device, physical accuracy or safe control execution.

The cross-language architecture preserves the npm package identity and the canonical shared/conformance/v1 corpus. The capability-discovery design covers all six FTMS machine-data families, not only indoor bikes. Implementations evaluate caller-supplied feature, characteristic, and range evidence without owning BLE discovery or inferring a machine’s identity.

The repository coverage matrix distinguishes the current protocol parity from platform-specific APIs and unverified device behavior; its versioning boundaries keep package releases independent from FTMS and corpus revisions.

From the repository root:

Terminal window
pnpm install --frozen-lockfile
pnpm verify

The root is a private pnpm orchestration workspace; its build, test, type-check, and verification commands forward to packages/typescript. Biome and Lefthook remain root tooling. C uses its own native compiler/test commands; Swift and Kotlin use their native toolchains, and Python uses its package-owned uv checks. None is a pnpm workspace package.

Lefthook is installed by pnpm install and runs pnpm test before every push. Run one file with pnpm --filter @deancochran/ftms exec vitest run test/control.test.ts.

From packages/typescript, use pnpm test, pnpm check-types, pnpm build, and pnpm verify:package directly, or pnpm verify for all gates. Package scripts own npm build/staging and verification; lint and format use root Biome configuration. This README and CHANGELOG.md are canonical package-owned documents, not generated copies of repository documentation.

Update the source-controlled version and changelog together, merge the verified change, then push the matching tag: vVERSION for the 0.x line (for example, v0.4.0) and typescript-vVERSION for 1.0 and later, including prereleases. The 1.0 milestone targets typescript-v1.0.0; it is not yet a published version. Publishing rejects a tag that does not exactly match packages/typescript/package.json or lacks a packages/typescript/CHANGELOG.md entry.

Before making Bluetooth interoperability, PTS, or qualification claims:

  • test supported measurements, statuses, and controls on representative machines; record model, firmware, transport traces, and results;
  • run the adopted FTMS v1.0 PTS suite with Mandatory Errata Correction 23224 and all applicable errata, retaining the PTS version and reports;
  • validate caller-owned GATT behavior, including discovery, characteristic properties, indications, procedure serialization, timeouts, disconnects, and permission loss;
  • convert failures into regression vectors and complete any required Bluetooth SIG qualification or listing process.

pnpm verify and the conformance corpus are release gates, not substitutes for these steps.

Security reports should follow the repository’s security policy.

MIT © Dean Cochran.