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/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/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..eaf2e674 --- /dev/null +++ b/AntPlus/DeviceProfiles/FitnessEquipment/StationaryBike.cs @@ -0,0 +1,61 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Extensions.Logging; +using SmallEarthTech.AntRadioInterface; +using System; + +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. + /// The instantaneous cadence. 255 (0xFF) indicates invalid. + [ObservableProperty] + private byte cadence; + /// Gets the instantaneous power in watts. + /// The instantaneous power. 65535 (0xFFFF) indicates invalid. + [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) + { + HandleFEState(dataPage); + Cadence = dataPage[4]; + InstantaneousPower = BitConverter.ToInt16(dataPage, 5); + } + else + { + CommonDataPages.ParseCommonDataPage(dataPage); + } + } + + /// + public override string ToString() => "Stationary Bike"; + } +} 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/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]; 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 @@ +