From 7fb16205a6e5aea0f733e210e7be22d73acf9e68 Mon Sep 17 00:00:00 2001 From: Stephen Hidem Date: Sun, 12 Apr 2026 19:53:44 -0500 Subject: [PATCH 1/3] Add support for Stationary Bike fitness equipment type - Introduce StationaryBikeData and StationaryBike enum values - Implement StationaryBike class with cadence and power parsing - Update factory methods to instantiate StationaryBike as needed - Move lap toggle logic to HandleFEState for consistency --- .../FitnessEquipment/FitnessEquipment.cs | 26 ++++++--- .../FitnessEquipment/StationaryBike.cs | 57 +++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/FitnessEquipment.cs b/AntPlus/DeviceProfiles/FitnessEquipment/FitnessEquipment.cs index ce1b1a53..ac1af912 100644 --- a/AntPlus/DeviceProfiles/FitnessEquipment/FitnessEquipment.cs +++ b/AntPlus/DeviceProfiles/FitnessEquipment/FitnessEquipment.cs @@ -49,6 +49,8 @@ public enum DataPage TreadmillData = 0x13, /// Elliptical data page EllipticalData = 0x14, + /// Legacy stationary bike dAta page + StationaryBikeData = 0x15, /// Rower data page RowerData = 0x16, /// Climber data page @@ -82,6 +84,8 @@ public enum FitnessEquipmentType Treadmill = 0x13, /// Elliptical Elliptical = 0x14, + /// Legacy stationary bike + StationaryBike = 0x15, /// Rower Rower = 0x16, /// Climber @@ -328,13 +332,6 @@ public override void Parse(byte[] dataPage) case DataPage.GeneralFEData: HandleFEState(dataPage); GeneralData.Parse(dataPage); - - // check for lap toggle - if (lapToggleState != ((dataPage[7] & 0x80) == 0x80)) - { - lapToggleState = (dataPage[7] & 0x80) == 0x80; - LapToggled?.Invoke(this, EventArgs.Empty); - } break; case DataPage.GeneralSettings: HandleFEState(dataPage); @@ -344,7 +341,7 @@ public override void Parse(byte[] dataPage) HandleFEState(dataPage); GeneralMetabolic.Parse(dataPage); break; - // handle specific FE pages + // handle on demand pages case DataPage.FECapabilities: MaxTrainerResistance = BitConverter.ToUInt16(dataPage, 5); TrainingModes = (SupportedTrainingModes)dataPage[7]; @@ -355,10 +352,17 @@ public override void Parse(byte[] dataPage) } } - /// Handles the state of the fitness equipment. + /// Handles the state of the fitness equipment including the lap toggle. /// The data page being parsed. protected void HandleFEState(byte[] dataPage) { + // check for lap toggle + if (lapToggleState != ((dataPage[7] & 0x80) == 0x80)) + { + lapToggleState = (dataPage[7] & 0x80) == 0x80; + LapToggled?.Invoke(this, EventArgs.Empty); + } + var st = (dataPage[7] & 0x70) >> 4; // check for valid state if (Enum.IsDefined(typeof(FEState), st)) @@ -478,6 +482,8 @@ public async Task RequestFECapabilities() return new Treadmill(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case FitnessEquipmentType.Elliptical: return new Elliptical(channelId, antChannel, loggerFactory.CreateLogger(), timeout); + case FitnessEquipmentType.StationaryBike: + return new StationaryBike(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case FitnessEquipmentType.Rower: return new Rower(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case FitnessEquipmentType.Climber: @@ -495,6 +501,8 @@ public async Task RequestFECapabilities() return new Treadmill(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case DataPage.EllipticalData: return new Elliptical(channelId, antChannel, loggerFactory.CreateLogger(), timeout); + case DataPage.StationaryBikeData: + return new StationaryBike(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case DataPage.RowerData: return new Rower(channelId, antChannel, loggerFactory.CreateLogger(), timeout); case DataPage.ClimberData: diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs new file mode 100644 index 00000000..3305aac4 --- /dev/null +++ b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs @@ -0,0 +1,57 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using SmallEarthTech.AntRadioInterface; +using System; + +namespace SmallEarthTech.AntPlus.DeviceProfiles.FitnessEquipment +{ + public partial class StationaryBike : FitnessEquipment + { + /// Gets the instantaneous pedaling cadence in revolutions per minute. + /// The instantaneous cadence. + [ObservableProperty] + private byte cadence; + /// Gets the instantaneous power in watts. + /// The instantaneous power. + [ObservableProperty] + private int instantaneousPower; + + /// Initializes a new instance of the class. + /// + public StationaryBike(ChannelId channelId, IAntChannel antChannel, ILogger logger, int timeout) : base(channelId, antChannel, logger, timeout) + { + } + + /// Initializes a new instance of the class. + /// + public StationaryBike(ChannelId channelId, IAntChannel antChannel, ILogger logger, TimeoutOptions? timeoutOptions) : base(channelId, antChannel, logger, timeoutOptions) + { + } + + /// + /// Parses the specified data page and updates the relevant properties. + /// + /// If the data page represents stationary bike data, the method extracts cadence and + /// instantaneous power values. For other data page types, common data page parsing is delegated to the + /// CommonDataPages class. The method does not process the page if it has already been handled. + /// A byte array containing the data page to parse. + override public void Parse(byte[] dataPage) + { + base.Parse(dataPage); + if (handledPage) return; + + if ((DataPage)dataPage[0] == DataPage.StationaryBikeData) + { + Cadence = dataPage[4]; + InstantaneousPower = BitConverter.ToInt16(dataPage, 5); + } + else + { + CommonDataPages.ParseCommonDataPage(dataPage); + } + } + + /// + public override string ToString() => "Stationary Bike"; + } +} From 4c1b2710b333020841c50a6bb780e5d2c02cde41 Mon Sep 17 00:00:00 2001 From: Stephen Hidem Date: Mon, 13 Apr 2026 18:20:30 -0500 Subject: [PATCH 2/3] Add StationaryBike tests and improve FE state handling - Add unit tests for StationaryBike covering parsing, invalid values, and ToString(). - Update XML docs to clarify invalid cadence/power values. - Call HandleFEState in StationaryBike and Treadmill Parse methods. - Expand FitnessEquipmentTests to cover StationaryBike and LapToggled event for all equipment types. --- .../FitnessEquipment/FitnessEquipmentTests.cs | 45 ++++++++++++++ .../FitnessEquipment/StationaryBikeTests.cs | 62 +++++++++++++++++++ .../FitnessEquipment/StationaryBike.cs | 5 +- .../FitnessEquipment/Treadmill.cs | 1 + 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/StationaryBikeTests.cs diff --git a/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/FitnessEquipmentTests.cs b/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/FitnessEquipmentTests.cs index 6cb285b0..fa35d3d4 100644 --- a/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/FitnessEquipmentTests.cs +++ b/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/FitnessEquipmentTests.cs @@ -46,11 +46,49 @@ public void Parse_FEState_ExpectedFEState(int state, FEState expState) Assert.Equal(expState, fitnessEquipment.State); } + [Theory] + [InlineData(FitnessEquipmentType.Treadmill, DataPage.GeneralFEData)] + [InlineData(FitnessEquipmentType.Treadmill, DataPage.GeneralMetabolicData)] + [InlineData(FitnessEquipmentType.Treadmill, DataPage.GeneralSettings)] + [InlineData(FitnessEquipmentType.Treadmill, DataPage.TreadmillData)] + [InlineData(FitnessEquipmentType.Elliptical, DataPage.EllipticalData)] + [InlineData(FitnessEquipmentType.Rower, DataPage.RowerData)] + [InlineData(FitnessEquipmentType.Climber, DataPage.ClimberData)] + [InlineData(FitnessEquipmentType.StationaryBike, DataPage.StationaryBikeData)] + [InlineData(FitnessEquipmentType.NordicSkier, DataPage.NordicSkierData)] + [InlineData(FitnessEquipmentType.TrainerStationaryBike, DataPage.TrainerStationaryBikeData)] + public void Parse_DataPage_LapToggledEventRaised(FitnessEquipmentType equipmentType, DataPage page) + { + // Arrange + var fitnessEquipment = CreateFitnessEquipment(equipmentType); + byte[] dataPage = [(byte)page, 0, 0, 0, 0, 0, 0, 0x80]; + bool eventRaised = false; + bool lowHighToggle = false; + bool highLowToggle = false; + fitnessEquipment.LapToggled += (s, e) => eventRaised = true; + + // Act + fitnessEquipment.Parse( + dataPage); + lowHighToggle = eventRaised; + + eventRaised = false; + dataPage[7] = 0x00; // Toggle back to low + fitnessEquipment.Parse( + dataPage); + highLowToggle = eventRaised; + + // Assert + Assert.True(lowHighToggle); + Assert.True(highLowToggle); + } + [Theory] [InlineData(FitnessEquipmentType.Treadmill)] [InlineData(FitnessEquipmentType.Elliptical)] [InlineData(FitnessEquipmentType.Rower)] [InlineData(FitnessEquipmentType.Climber)] + [InlineData(FitnessEquipmentType.StationaryBike)] [InlineData(FitnessEquipmentType.NordicSkier)] [InlineData(FitnessEquipmentType.TrainerStationaryBike)] public void GetEquipment_GeneralDataPage_ExpectedEquipment(FitnessEquipmentType equipmentType) @@ -73,6 +111,9 @@ public void GetEquipment_GeneralDataPage_ExpectedEquipment(FitnessEquipmentType case FitnessEquipmentType.Climber: Assert.IsType(fitnessEquipment); break; + case FitnessEquipmentType.StationaryBike: + Assert.IsType(fitnessEquipment); + break; case FitnessEquipmentType.NordicSkier: Assert.IsType(fitnessEquipment); break; @@ -90,6 +131,7 @@ public void GetEquipment_GeneralDataPage_ExpectedEquipment(FitnessEquipmentType [InlineData(DataPage.EllipticalData, FitnessEquipmentType.Elliptical)] [InlineData(DataPage.RowerData, FitnessEquipmentType.Rower)] [InlineData(DataPage.ClimberData, FitnessEquipmentType.Climber)] + [InlineData(DataPage.StationaryBikeData, FitnessEquipmentType.StationaryBike)] [InlineData(DataPage.NordicSkierData, FitnessEquipmentType.NordicSkier)] [InlineData(DataPage.TrainerStationaryBikeData, FitnessEquipmentType.TrainerStationaryBike)] [InlineData(DataPage.TrainerTorqueData, FitnessEquipmentType.TrainerStationaryBike)] @@ -116,6 +158,9 @@ public void GetEquipment_SpecificPage_ExpectedEquipment(DataPage pageNumber, Fit case FitnessEquipmentType.Climber: Assert.IsType(fitnessEquipment); break; + case FitnessEquipmentType.StationaryBike: + Assert.IsType(fitnessEquipment); + break; case FitnessEquipmentType.NordicSkier: Assert.IsType(fitnessEquipment); break; diff --git a/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/StationaryBikeTests.cs b/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/StationaryBikeTests.cs new file mode 100644 index 00000000..0f61297d --- /dev/null +++ b/AntPlus.UnitTests/DeviceProfiles/FitnessEquipment/StationaryBikeTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SmallEarthTech.AntPlus.DeviceProfiles.FitnessEquipment; +using SmallEarthTech.AntRadioInterface; +using Xunit; + +namespace AntPlus.UnitTests.DeviceProfiles.FitnessEquipment +{ + public class StationaryBikeTests + { + private readonly StationaryBike _stationaryBike; + + public StationaryBikeTests() + { + _stationaryBike = new(new ChannelId(0), Mock.Of(), Mock.Of>(), It.IsAny()); + } + + [Fact] + public void Parse_InstantaneousCadenceAndPower_Matches() + { + // Arrange + byte[] dataPage = [0x15, 0xFF, 0xFF, 0xFF, 60, 150, 0, 0]; + + // Act + _stationaryBike.Parse(dataPage); + + // Assert + Assert.Equal(60, _stationaryBike.Cadence); + Assert.Equal(150, _stationaryBike.InstantaneousPower); + } + + [Fact] + public void Parse_PowerHighValue_Correct() + { + // Arrange - 274W = 0x0112 + byte[] dataPage = [0x15, 0xFF, 0xFF, 0xFF, 87, 0x12, 0x01, 0]; + + // Act + _stationaryBike.Parse(dataPage); + + // Assert + Assert.Equal(87, _stationaryBike.Cadence); + Assert.Equal(274, _stationaryBike.InstantaneousPower); + } + + [Fact] + public void Parse_CommonDataPage_Handled() + { + // Arrange - common page 0x50 (Manufacturer Info) + byte[] dataPage = [0x50, 0xFF, 0xFF, 16, 32, 0, 100, 0]; + + // Act & Assert - should not throw + _stationaryBike.Parse(dataPage); + } + + [Fact] + public void ToString_ReturnsExpected() + { + Assert.Equal("Stationary Bike", _stationaryBike.ToString()); + } + } +} \ No newline at end of file diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs index 3305aac4..1d568338 100644 --- a/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs +++ b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs @@ -8,11 +8,11 @@ namespace SmallEarthTech.AntPlus.DeviceProfiles.FitnessEquipment public partial class StationaryBike : FitnessEquipment { /// Gets the instantaneous pedaling cadence in revolutions per minute. - /// The instantaneous cadence. + /// The instantaneous cadence. 255 (0xFF) indicates invalid. [ObservableProperty] private byte cadence; /// Gets the instantaneous power in watts. - /// The instantaneous power. + /// The instantaneous power. 65535 (0xFFFF) indicates invalid. [ObservableProperty] private int instantaneousPower; @@ -42,6 +42,7 @@ override public void Parse(byte[] dataPage) if ((DataPage)dataPage[0] == DataPage.StationaryBikeData) { + HandleFEState(dataPage); Cadence = dataPage[4]; InstantaneousPower = BitConverter.ToInt16(dataPage, 5); } diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/Treadmill.cs b/AntPlus/DeviceProfiles/FitnessEquipment/Treadmill.cs index 5c5e2dde..edf7c6c9 100644 --- a/AntPlus/DeviceProfiles/FitnessEquipment/Treadmill.cs +++ b/AntPlus/DeviceProfiles/FitnessEquipment/Treadmill.cs @@ -65,6 +65,7 @@ public override void Parse(byte[] dataPage) if ((DataPage)dataPage[0] == DataPage.TreadmillData) { + HandleFEState(dataPage); if (isFirstDataMessage) { prevNeg = dataPage[5]; From 3747ab76ec428af57af3a9fe71330a29cc6731db Mon Sep 17 00:00:00 2001 From: Stephen Hidem Date: Tue, 14 Apr 2026 15:46:49 -0500 Subject: [PATCH 3/3] Add Legacy Stationary Bike profile, update to v6.1.0 - Bump version to 6.1.0 and update release notes/docs - Add Legacy Stationary Bike device profile to Fitness Equipment - Refactor: HandleFEState now handles lap toggle - Fix: Treadmill correctly handles fitness equipment state - Update NuGet dependencies - Improve XML docs for StationaryBike and TrainerStationaryBike --- AntPlus/AntPlus.csproj | 15 ++- .../FitnessEquipment/StationaryBike.cs | 3 + .../FitnessEquipment/TrainerStationaryBike.cs | 2 +- .../AntPlus/AntPllusVersionHistory.aml | 5 + .../VersionHistory/AntPlus/v6.1.0.0.aml | 103 ++++++++++++++++++ Documentation/ContentLayout.content | 7 +- Documentation/Documentation.shfbproj | 1 + 7 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 Documentation/Content/VersionHistory/AntPlus/v6.1.0.0.aml diff --git a/AntPlus/AntPlus.csproj b/AntPlus/AntPlus.csproj index eeb5b43d..b60f8f7f 100644 --- a/AntPlus/AntPlus.csproj +++ b/AntPlus/AntPlus.csproj @@ -5,7 +5,7 @@ True SmallEarthTech.$(MSBuildProjectName.Replace(" ", "_")) SmallEarthTech.$(AssemblyName) - 6.0.4 + 6.1.0 ANT+ Class Library https://stephenhidem.github.io/AntPlus Stephen Hidem @@ -23,16 +23,19 @@ PackageLogo.png readme.md - 1. Updated NuGet dependencies. + 1. New device profile: Legacy Stationary Bike added to Fitness Equipment category. + 2. Refactor: HandleFEState handles lap toggle. + 3. Bug fix: Treadmill now correctly handles fitness equipment state. + 4. Updated NuGet dependencies. Debug;Release - 9.0 - enable - OSMFEULA.txt - True + 9.0 + enable + OSMFEULA.txt + True diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs index 1d568338..eaf2e674 100644 --- a/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs +++ b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs @@ -5,6 +5,9 @@ namespace SmallEarthTech.AntPlus.DeviceProfiles.FitnessEquipment { + /// + /// This class supports the ANT+ Legacy Stationary Bike device profile. It extends the FitnessEquipment base class to provide specific properties and parsing logic for stationary bike data pages, including cadence and instantaneous power. + /// public partial class StationaryBike : FitnessEquipment { /// Gets the instantaneous pedaling cadence in revolutions per minute. diff --git a/AntPlus/DeviceProfiles/FitnessEquipment/TrainerStationaryBike.cs b/AntPlus/DeviceProfiles/FitnessEquipment/TrainerStationaryBike.cs index 15ff93ee..757761f8 100644 --- a/AntPlus/DeviceProfiles/FitnessEquipment/TrainerStationaryBike.cs +++ b/AntPlus/DeviceProfiles/FitnessEquipment/TrainerStationaryBike.cs @@ -7,7 +7,7 @@ namespace SmallEarthTech.AntPlus.DeviceProfiles.FitnessEquipment { /// - /// This class supports the stationary bike fitness equipment type. + /// This class supports the trainer/stationary bike fitness equipment type. /// public partial class TrainerStationaryBike : FitnessEquipment { diff --git a/Documentation/Content/VersionHistory/AntPlus/AntPllusVersionHistory.aml b/Documentation/Content/VersionHistory/AntPlus/AntPllusVersionHistory.aml index a2ea52c5..bcb12f19 100644 --- a/Documentation/Content/VersionHistory/AntPlus/AntPllusVersionHistory.aml +++ b/Documentation/Content/VersionHistory/AntPlus/AntPllusVersionHistory.aml @@ -111,6 +111,11 @@ + + + + + + + + + + + Release Date: April 14, 2026 + + + +
+ Changes + + + + + New device profile: Legacy Stationary Bike added to Fitness Equipment category. + + + Refactor: HandleFEState handles lap toggle. + + + Bug fix: Treadmill now correctly handles fitness equipment state. + + + Updated NuGet dependencies to latest version. + + + + +
+ + + + + + + diff --git a/Documentation/ContentLayout.content b/Documentation/ContentLayout.content index 62244185..9cb1b65b 100644 --- a/Documentation/ContentLayout.content +++ b/Documentation/ContentLayout.content @@ -46,7 +46,7 @@ - + @@ -64,6 +64,7 @@ + @@ -78,7 +79,7 @@ - + @@ -89,7 +90,7 @@ - + \ No newline at end of file diff --git a/Documentation/Documentation.shfbproj b/Documentation/Documentation.shfbproj index bba6c4c6..0830a669 100644 --- a/Documentation/Documentation.shfbproj +++ b/Documentation/Documentation.shfbproj @@ -137,6 +137,7 @@ +