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.
Install
Section titled “Install”npm install @deancochran/ftms# orpnpm add @deancochran/ftmsThe 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.
Parse measurements
Section titled “Parse measurements”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.
Named Indoor Bike reading
Section titled “Named Indoor Bike reading”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.
Decode features and supported ranges
Section titled “Decode features and supported ranges”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" }}Encode control requests
Section titled “Encode control requests”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.
Control safety and ownership
Section titled “Control safety and ownership”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.
Units and unavailable values
Section titled “Units and unavailable values”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.
Conformance corpus
Section titled “Conformance corpus”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.
Specification basis
Section titled “Specification basis”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.
API stability
Section titled “API stability”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.
Bidirectional protocol APIs
Section titled “Bidirectional protocol APIs”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/encodeFtmsFeaturesRawdecodeFtmsRangeRaw/encodeFtmsRangeRawinspectFtmsRangeRawdecodeFtmsControlRequestRaw/encodeFtmsControlRequestRawdecodeFtmsControlResponseRaw/encodeFtmsControlResponseRawdecodeFtmsMeasurementRaw/encodeFtmsMeasurementRawdecodeFtmsMachineStatusRaw/encodeFtmsMachineStatusRawdecodeFtmsTrainingStatusRaw/encodeFtmsTrainingStatusRawevaluateFtmsCapabilities
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 | IndicateOmit 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.
Cross-language packages
Section titled “Cross-language packages”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.
Development
Section titled “Development”From the repository root:
pnpm install --frozen-lockfilepnpm verifyThe 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.
Release
Section titled “Release”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.
Qualification checklist
Section titled “Qualification checklist”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.
License
Section titled “License”MIT © Dean Cochran.