Skip to content
Merged
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
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# AGENTS.md

FTMS.NET is a .NET client library for the Bluetooth LE **Fitness Machine Service (FTMS)**. The Bluetooth spec PDFs live in `docs/` (`FTMS_v1.0-1.pdf` is the authoritative FTMS spec; `Assigned_Numbers.pdf` and `GATT_Specification_Supplement.pdf` are context).

## Layout

- `FTMS.NET/` — the library. Subfolders mirror the FTMS feature areas: `Control/`, `Data/`, `Features/`, `State/`, `Exceptions/`, `Utils/`.
- `FTMS.NET.Tests/` — xUnit v3 + Moq + Microsoft.Reactive.Testing + coverlet. One test class file per source area.
- `docs/` — the Bluetooth FTMS spec PDFs; check these before changing parsing/format code.

## Build & test

Both projects multi-target `net8.0;net9.0;net10.0` (set in `Directory.Build.props`), so a bare `dotnet test` runs the suite three times.

- Fast, focused verification: `dotnet test -f net10.0` (or `dotnet build -f net10.0`).
- No lint/format script exists; style is enforced by `.editorconfig` via `dotnet format`.
- `dotnet pack` produces the NuGet package; the version is derived from git by GitVersion (GitHubFlow, `main` label `alpha`) — never hand-edit version numbers.

## Architecture (read this before touching the public surface)

- The library does **not** do BLE. Callers supply an `IFitnessMachineServiceConnection` wrapping their BLE library; `FitnessMachineServiceFactory` extension methods on that interface assemble `IFitnessMachineService` (`FitnessMachineServiceFactory.cs`).
- `FitnessMachineService` itself is `internal`; the public API is the `IFitnessMachineService*` interfaces.
- Optional characteristics (Control Point, Machine/Training State) are swapped for `ThrowingCharacteristic` when absent; required ones throw `NeededCharacteristicNotAvailableException` via `EnsureAvailableCharacteristic`.
- `FtmsUuids.cs` builds a UUID→name dictionary with source-generated reflection (`[SourceReflection]`, `SourceGeneration.Reflection`, kept AOT-friendly). To register a new UUID, add it as a `public static readonly Guid` field — it is picked up automatically.
- Live data flows out as a DynamicData `IChangeSet<IFitnessMachineValue, Guid>` (`IFitnessMachineService.Connect()`).

## Domain gotchas (all were real bugs)

- Bit indexing is **standard 8-bit, LSB-first** (`IsBitSet(pos)` with pos 0 = LSB). A previous bug used 7-bit indexing — do not reintroduce it.
- FTMS feature-flag and frame byte offsets have been wrong before (e.g. Target Setting Features offset, UInt24 field encoding). Verify offsets against `docs/FTMS_v1.0-1.pdf` before changing.
- Little-endian values; some fields are `UInt24` (`FTMS.NET/Utils/UInt24.cs`).
- **No range validation is intentional**: control requests do not validate against the machine's advertised feature ranges (see README "Remarks"). Do not add it.

## Testing conventions

- Tests run through a real `IFitnessMachineServiceConnection` fake (`FakeConnection`/`FakeCharacteristic` in `FitnessMachineServiceFactory.Tests.cs`) or Moq for internals — `InternalsVisibleTo("FTMS.NET.Tests")` grants access.
- Reactive streams are tested with `Microsoft.Reactive.Testing` (ReactiveUI's TestScheduler).
- Run the full `dotnet test` (all TFMs) before finishing; the single-TFM run is only for fast iteration.

## Release / CI

`.github/workflows/cicd.yml`: build+test (with coverage) → pack → upload artifacts; publishes to NuGet (secret `NUGET_API_KEY`) from `release/*` branches, `main`, and on GitHub releases. Local debug builds are fine for iteration.
43 changes: 43 additions & 0 deletions FTMS.NET.Tests/Control/ControlExtensions.Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace FTMS.NET.Tests.Control;

using FTMS.NET.Control;
using FTMS.NET.Utils;

public sealed class ControlExtensionsTests
{
[Fact]
public async Task SetTargetedDistance_SendsOpCodeWithLittleEndianThreeByteParameter()
{
var control = new FakeControl();

await control.SetTargetedDistance(new UInt24(1000)); // 0x0003E8

Assert.NotNull(control.Request);
Assert.Equal(EControlOpCode.SetTargetedDistance, control.Request!.OpCode);
Assert.Equal(new byte[] { 0xE8, 0x03, 0x00 }, control.Request!.Parameter);
}

[Fact]
public async Task SetTargetedDistance_MaxValue_SendsThreeByteLittleEndian()
{
var control = new FakeControl();

await control.SetTargetedDistance(UInt24.MaxValue); // 0xFFFFFF

Assert.NotNull(control.Request);
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF }, control.Request!.Parameter);
}

private sealed class FakeControl : IFitnessMachineControl
{
public ControlRequest? Request { get; private set; }

public Task<ControlResponse> Execute(ControlRequest request)
{
this.Request = request;
return Task.FromResult(new ControlResponse(request.OpCode, EControlResultCode.Success, []));
}

public void Dispose() { }
}
}
76 changes: 76 additions & 0 deletions FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace FTMS.NET.Tests.Control;

using FTMS.NET.Control;
using FTMS.NET.Exceptions;
using Microsoft.Reactive.Testing;
using System;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading.Tasks;

public sealed class FitnessMachineControlTests
{
[Fact(Timeout = 10_000)]
public async Task Execute_NoResponseWithinTimeout_ThrowsControlRequestException()
{
var scheduler = new TestScheduler();
var controlPoint = new Subject<byte[]>();
var control = new FitnessMachineControl(
controlPoint,
_ => Task.CompletedTask,
responseTimeout: TimeSpan.FromSeconds(5),
scheduler);

var executeTask = control.Execute(new ControlRequest(EControlOpCode.RequestControl, []));

scheduler.AdvanceBy(TimeSpan.FromSeconds(5).Ticks);

await Assert.ThrowsAsync<ControlRequestException>(() => executeTask);
}

[Fact]
public async Task Execute_ValidResponseIndicated_ReturnsResponse()
{
var controlPoint = new Subject<byte[]>();
var control = new FitnessMachineControl(
controlPoint,
_ => Task.CompletedTask,
responseTimeout: TimeSpan.FromSeconds(5));

var executeTask = control.Execute(new ControlRequest(EControlOpCode.RequestControl, []));

controlPoint.OnNext([0x80, 0x00, 0x01]);

var response = await executeTask;
Assert.Equal(EControlOpCode.RequestControl, response.RequestedOpCode);
Assert.Equal(EControlResultCode.Success, response.ResultCode);
}

[Fact]
public async Task Execute_NonSuccessResultCode_ThrowsControlRequestException()
{
var controlPoint = new Subject<byte[]>();
var control = new FitnessMachineControl(
controlPoint,
_ => Task.CompletedTask,
responseTimeout: TimeSpan.FromSeconds(5));

var executeTask = control.Execute(new ControlRequest(EControlOpCode.RequestControl, []));

controlPoint.OnNext([0x80, 0x00, 0x03]);

await Assert.ThrowsAsync<ControlRequestException>(() => executeTask);
}

[Fact]
public async Task Execute_WriteThrows_WrapsInControlRequestException()
{
var control = new FitnessMachineControl(
Observable.Never<byte[]>(),
_ => throw new Exception("simulated ATT error"),
responseTimeout: TimeSpan.FromSeconds(5));

await Assert.ThrowsAsync<ControlRequestException>(() =>
control.Execute(new ControlRequest(EControlOpCode.RequestControl, [])));
}
}
32 changes: 32 additions & 0 deletions FTMS.NET.Tests/Data/FitnessMachineDataReaderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace FTMS.NET.Tests.Data;

using FTMS.NET.Data;
using System.Collections.Generic;
using System.Linq;

public sealed class FitnessMachineDataReaderTests
{
/// <summary>
/// Tests that a treadmill frame with Heart Rate present (bit 8 of the 2-octet flag field)
/// is parsed correctly using standard bit numbering.
/// Expected: Both Instantaneous Speed and Heart Rate are parsed from the frame.
/// </summary>
[Fact]
public void Read_TreadmillFrameWithHeartRate_ParsesHeartRate()
{
// Flags: bit 0 = 0 (Instantaneous Speed present), bit 8 = 1 (Heart Rate present)
// LE: byte0 = 0x00, byte1 = 0x01
byte[] frame = [0x00, 0x01, 0xD2, 0x04, 0x48]; // speed raw 1234, HR 72 bpm

FitnessMachineDataReader reader = new(
SingleFrameStrategies.GetFor(EFitnessMachineType.Threadmill));

List<IFitnessMachineValue> values = reader.Read(frame).ToList();

IFitnessMachineValue speed = values.Single(v => v.Uuid == FtmsUuids.InstantaneousSpeed);
Assert.Equal(12.34, speed.Value, precision: 2);

IFitnessMachineValue heartRate = values.Single(v => v.Uuid == FtmsUuids.HeartRate);
Assert.Equal(72, heartRate.Value);
}
}
1 change: 1 addition & 0 deletions FTMS.NET.Tests/FTMS.NET.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="Microsoft.Reactive.Testing" Version="6.1.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
Expand Down
47 changes: 47 additions & 0 deletions FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace FTMS.NET.Tests;

using System.Reactive;
using System.Reactive.Linq;

public sealed class FitnessMachineServiceFactoryTests
{
[Fact]
public async Task ReadFitnessMachineFeaturesAsync_SpeedTargetSettingInByte4_ReportsSupported()
{
byte[] value = new byte[8];
value[4] = 0x01; // Target Setting Features bit 0 -> Speed Target Setting Supported

var features = await new FakeConnection(value).ReadFitnessMachineFeaturesAsync();

Assert.True(features.SpeedTargetSettingSupported);
Assert.False(features.AverageSpeedSupported);
}

[Fact]
public async Task ReadFitnessMachineFeaturesAsync_DistanceTargetSettingInByte5_ReportsSupported()
{
byte[] value = new byte[8];
value[5] = 0x01; // Target Setting Features bit 8 -> Targeted Distance Configuration Supported

var features = await new FakeConnection(value).ReadFitnessMachineFeaturesAsync();

Assert.True(features.TargetedDistanceConfigurationSupported);
Assert.False(features.SpeedTargetSettingSupported);
}

private sealed class FakeCharacteristic(byte[] value) : IFitnessMachineCharacteristic
{
public Guid Id { get; } = Guid.NewGuid();
public Task<byte[]> ReadValueAsync() => Task.FromResult(value);
public Task WriteValueAsync(byte[] value) => Task.CompletedTask;
public IObservable<byte[]> ObserveValue() => Observable.Empty<byte[]>();
}

private sealed class FakeConnection(byte[] featureValue) : IFitnessMachineServiceConnection
{
public byte[] ServiceData { get; } = [];
public Task<IFitnessMachineCharacteristic?> GetCharacteristicAsync(Guid id)
=> Task.FromResult<IFitnessMachineCharacteristic?>(
id == FtmsUuids.Feature ? new FakeCharacteristic(featureValue) : null);
}
}
124 changes: 62 additions & 62 deletions FTMS.NET.Tests/FtmsUuids.Tests.cs
Original file line number Diff line number Diff line change
@@ -1,62 +1,62 @@
namespace FTMS.NET.Tests;

using System;

public sealed class FtmsUuids_Tests
{
public static readonly TheoryData<Guid, string> UuidTestData = new()
{
{ FtmsUuids.Service, nameof(FtmsUuids.Service) },
{ FtmsUuids.Feature, nameof(FtmsUuids.Feature) },
{ FtmsUuids.MachineState, nameof(FtmsUuids.MachineState) },
{ FtmsUuids.TrainingState, nameof(FtmsUuids.TrainingState) },
{ FtmsUuids.ControlPoint, nameof(FtmsUuids.ControlPoint) },
{ FtmsUuids.SupportedSpeedRange, nameof(FtmsUuids.SupportedSpeedRange) },
{ FtmsUuids.SupportedInclinationRange, nameof(FtmsUuids.SupportedInclinationRange) },
{ FtmsUuids.SupportedResistanceLevelRange, nameof(FtmsUuids.SupportedResistanceLevelRange) },
{ FtmsUuids.SupportedPowerRange, nameof(FtmsUuids.SupportedPowerRange) },
{ FtmsUuids.SupportedHeartRateRange, nameof(FtmsUuids.SupportedHeartRateRange) },
{ FtmsUuids.TreadmillData, nameof(FtmsUuids.TreadmillData) },
{ FtmsUuids.CrossTrainerData, nameof(FtmsUuids.CrossTrainerData) },
{ FtmsUuids.StepClimberData, nameof(FtmsUuids.StepClimberData) },
{ FtmsUuids.StairClimberData, nameof(FtmsUuids.StairClimberData) },
{ FtmsUuids.RowerData, nameof(FtmsUuids.RowerData) },
{ FtmsUuids.IndoorBikeData, nameof(FtmsUuids.IndoorBikeData) },

// Indoor Bike specific
{ FtmsUuids.InstantaneousSpeed, nameof(FtmsUuids.InstantaneousSpeed) },
{ FtmsUuids.AverageSpeed, nameof(FtmsUuids.AverageSpeed) },
{ FtmsUuids.InstantaneousCadence, nameof(FtmsUuids.InstantaneousCadence) },
{ FtmsUuids.AverageCadence, nameof(FtmsUuids.AverageCadence) },
{ FtmsUuids.TotalDistance, nameof(FtmsUuids.TotalDistance) },
{ FtmsUuids.ResistantLevel, nameof(FtmsUuids.ResistantLevel) },
{ FtmsUuids.InstantaneousPower, nameof(FtmsUuids.InstantaneousPower) },
{ FtmsUuids.AveragePower, nameof(FtmsUuids.AveragePower) },
{ FtmsUuids.TotalEnergy, nameof(FtmsUuids.TotalEnergy) },
{ FtmsUuids.EnergyPerHour, nameof(FtmsUuids.EnergyPerHour) },
{ FtmsUuids.EnergyPerMinute, nameof(FtmsUuids.EnergyPerMinute) },
};

[Theory]
[MemberData(nameof(UuidTestData))]
public void FtmsUuids_GetName_ReturnsCorrectName(Guid uuid, string nameOfUuid)
{
var name = FtmsUuids.GetName(uuid);
Assert.Equal(nameOfUuid, name);
}

[Fact]
public void FtmsUuids_GetName_WithUnknownUuid_ReturnsEmptyString()
{
var unknownUuid = Guid.NewGuid();
var name = FtmsUuids.GetName(unknownUuid);
Assert.Equal(string.Empty, name);
}

[Fact]
public void FtmsUuids_GetName_WithEmptyGuid_ReturnsEmptyString()
{
var name = FtmsUuids.GetName(Guid.Empty);
Assert.Equal(string.Empty, name);
}
}
namespace FTMS.NET.Tests;
using System;
public sealed class FtmsUuids_Tests
{
public static readonly TheoryData<Guid, string> UuidTestData = new()
{
{ FtmsUuids.Service, nameof(FtmsUuids.Service) },
{ FtmsUuids.Feature, nameof(FtmsUuids.Feature) },
{ FtmsUuids.MachineState, nameof(FtmsUuids.MachineState) },
{ FtmsUuids.TrainingState, nameof(FtmsUuids.TrainingState) },
{ FtmsUuids.ControlPoint, nameof(FtmsUuids.ControlPoint) },
{ FtmsUuids.SupportedSpeedRange, nameof(FtmsUuids.SupportedSpeedRange) },
{ FtmsUuids.SupportedInclinationRange, nameof(FtmsUuids.SupportedInclinationRange) },
{ FtmsUuids.SupportedResistanceLevelRange, nameof(FtmsUuids.SupportedResistanceLevelRange) },
{ FtmsUuids.SupportedPowerRange, nameof(FtmsUuids.SupportedPowerRange) },
{ FtmsUuids.SupportedHeartRateRange, nameof(FtmsUuids.SupportedHeartRateRange) },
{ FtmsUuids.TreadmillData, nameof(FtmsUuids.TreadmillData) },
{ FtmsUuids.CrossTrainerData, nameof(FtmsUuids.CrossTrainerData) },
{ FtmsUuids.StepClimberData, nameof(FtmsUuids.StepClimberData) },
{ FtmsUuids.StairClimberData, nameof(FtmsUuids.StairClimberData) },
{ FtmsUuids.RowerData, nameof(FtmsUuids.RowerData) },
{ FtmsUuids.IndoorBikeData, nameof(FtmsUuids.IndoorBikeData) },
// Indoor Bike specific
{ FtmsUuids.InstantaneousSpeed, nameof(FtmsUuids.InstantaneousSpeed) },
{ FtmsUuids.AverageSpeed, nameof(FtmsUuids.AverageSpeed) },
{ FtmsUuids.InstantaneousCadence, nameof(FtmsUuids.InstantaneousCadence) },
{ FtmsUuids.AverageCadence, nameof(FtmsUuids.AverageCadence) },
{ FtmsUuids.TotalDistance, nameof(FtmsUuids.TotalDistance) },
{ FtmsUuids.ResistantLevel, nameof(FtmsUuids.ResistantLevel) },
{ FtmsUuids.InstantaneousPower, nameof(FtmsUuids.InstantaneousPower) },
{ FtmsUuids.AveragePower, nameof(FtmsUuids.AveragePower) },
{ FtmsUuids.TotalEnergy, nameof(FtmsUuids.TotalEnergy) },
{ FtmsUuids.EnergyPerHour, nameof(FtmsUuids.EnergyPerHour) },
{ FtmsUuids.EnergyPerMinute, nameof(FtmsUuids.EnergyPerMinute) },
};
[Theory]
[MemberData(nameof(UuidTestData))]
public void FtmsUuids_GetName_ReturnsCorrectName(Guid uuid, string nameOfUuid)
{
var name = FtmsUuids.GetName(uuid);
Assert.Equal(nameOfUuid, name);
}
[Fact]
public void FtmsUuids_GetName_WithUnknownUuid_ReturnsEmptyString()
{
var unknownUuid = Guid.NewGuid();
var name = FtmsUuids.GetName(unknownUuid);
Assert.Equal(string.Empty, name);
}
[Fact]
public void FtmsUuids_GetName_WithEmptyGuid_ReturnsEmptyString()
{
var name = FtmsUuids.GetName(Guid.Empty);
Assert.Equal(string.Empty, name);
}
}
Loading