From 845a2967912bf46fb6324b2ba06f4fe110670b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 21:35:53 +0200 Subject: [PATCH 01/10] Fix IsBitSet bit indexing from 7-bit to standard 8-bit packing Bit positions were mapped with pos/7 and pos%7, misreading every flag at bit position >= 7 (e.g. treadmill Heart Rate at bit 8, Fitness Machine Feature bits 7-16). Map with pos/8 and pos%8 in both the byte[] and ReadOnlySpan overloads. Replace the tests that encoded the 7-bit packing with standard bit numbering, and add an integration test proving a treadmill frame with Heart Rate present (bit 8) parses correctly. --- .../Data/FitnessMachineDataReaderTests.cs | 32 + FTMS.NET.Tests/Utils/ByteExtensionsTests.cs | 1398 +++++++++-------- FTMS.NET/Utils/ByteExtensions.cs | 8 +- 3 files changed, 755 insertions(+), 683 deletions(-) create mode 100644 FTMS.NET.Tests/Data/FitnessMachineDataReaderTests.cs diff --git a/FTMS.NET.Tests/Data/FitnessMachineDataReaderTests.cs b/FTMS.NET.Tests/Data/FitnessMachineDataReaderTests.cs new file mode 100644 index 0000000..5c38114 --- /dev/null +++ b/FTMS.NET.Tests/Data/FitnessMachineDataReaderTests.cs @@ -0,0 +1,32 @@ +namespace FTMS.NET.Tests.Data; + +using FTMS.NET.Data; +using System.Collections.Generic; +using System.Linq; + +public sealed class FitnessMachineDataReaderTests +{ + /// + /// 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. + /// + [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 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); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/Utils/ByteExtensionsTests.cs b/FTMS.NET.Tests/Utils/ByteExtensionsTests.cs index 387d791..04870f9 100644 --- a/FTMS.NET.Tests/Utils/ByteExtensionsTests.cs +++ b/FTMS.NET.Tests/Utils/ByteExtensionsTests.cs @@ -1,680 +1,720 @@ -namespace FTMS.NET.Tests.Utils; - -using FTMS.NET.Utils; - -public sealed class ByteExtensionsTests -{ - /// - /// Tests that IsBitSet correctly identifies set and unset bits for all positions 0-7 - /// in a byte with a known bit pattern (0b10100101). - /// Expected: Returns true for positions where bit is 1, false where bit is 0. - /// - [Fact] - public void IsBitSet_ByteWithMixedBitPattern_ReturnsCorrectStateForEachPosition() - { - // Arrange - // 0b10100101 == 0xA5 == 165 - byte b = 0b1010_0101; - - // Act & Assert - Assert.True(b.IsBitSet(0)); // LSB = 1 - Assert.False(b.IsBitSet(1)); // bit 1 = 0 - Assert.True(b.IsBitSet(2)); // bit 2 = 1 - Assert.False(b.IsBitSet(3)); // bit 3 = 0 - Assert.False(b.IsBitSet(4)); // bit 4 = 0 - Assert.True(b.IsBitSet(5)); // bit 5 = 1 - Assert.False(b.IsBitSet(6)); // bit 6 = 0 - Assert.True(b.IsBitSet(7)); // MSB = 1 - } - - /// - /// Tests that IsBitSet returns false for all bit positions when the byte value is 0x00 (all bits unset). - /// Expected: Returns false for all positions 0-7. - /// - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] - [InlineData(6)] - [InlineData(7)] - public void IsBitSet_ByteWithAllBitsUnset_ReturnsFalseForAllPositions(int pos) - { - // Arrange - byte b = 0x00; - - // Act - bool result = b.IsBitSet(pos); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsBitSet returns true for all bit positions when the byte value is 0xFF (all bits set). - /// Expected: Returns true for all positions 0-7. - /// - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - [InlineData(3)] - [InlineData(4)] - [InlineData(5)] - [InlineData(6)] - [InlineData(7)] - public void IsBitSet_ByteWithAllBitsSet_ReturnsTrueForAllPositions(int pos) - { - // Arrange - byte b = 0xFF; - - // Act - bool result = b.IsBitSet(pos); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsBitSet correctly identifies a single set bit at various positions. - /// Expected: Returns true only for the position where the bit is set, false for all others. - /// - [Theory] - [InlineData(0, 0b0000_0001)] - [InlineData(1, 0b0000_0010)] - [InlineData(2, 0b0000_0100)] - [InlineData(3, 0b0000_1000)] - [InlineData(4, 0b0001_0000)] - [InlineData(5, 0b0010_0000)] - [InlineData(6, 0b0100_0000)] - [InlineData(7, 0b1000_0000)] - public void IsBitSet_ByteWithSingleBitSet_ReturnsTrueOnlyForThatPosition(int pos, byte byteValue) - { - // Arrange - byte b = byteValue; - - // Act - bool result = b.IsBitSet(pos); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsBitSet returns false when checking a position that is not set in a byte with a single bit set. - /// Expected: Returns false for all positions except the one that is set. - /// - [Theory] - [InlineData(0, 0b0000_0010)] // bit 0 unset, bit 1 set - [InlineData(1, 0b0000_0001)] // bit 1 unset, bit 0 set - [InlineData(3, 0b1000_0000)] // bit 3 unset, bit 7 set - [InlineData(7, 0b0000_0001)] // bit 7 unset, bit 0 set - public void IsBitSet_ByteWithSingleBitSet_ReturnsFalseForOtherPositions(int pos, byte byteValue) - { - // Arrange - byte b = byteValue; - - // Act - bool result = b.IsBitSet(pos); - - // Assert - Assert.False(result); - } - - /// - /// Tests behavior of IsBitSet when position is negative. - /// Expected: Due to bit shift behavior, negative positions may produce unexpected results or exceptions. - /// This test documents the actual behavior. - /// - [Theory] - [InlineData(-1)] - [InlineData(-8)] - public void IsBitSet_NegativePosition_ProducesResult(int pos) - { - // Arrange - byte b = 0xFF; - - // Act & Assert - Assert.Throws(() => b.IsBitSet(pos)); - } - - /// - /// Tests behavior of IsBitSet when position is greater than 7 (beyond byte boundary). - /// Expected: Positions beyond 7 should produce results based on bit shift wrapping behavior. - /// This test documents the actual behavior for out-of-range positions. - /// - [Theory] - [InlineData(8)] - [InlineData(9)] - [InlineData(16)] - [InlineData(31)] - public void IsBitSet_PositionBeyondByteBoundary_ProducesResult(int pos) - { - // Arrange - byte b = 0b1010_0101; - - // Act & Assert - Assert.Throws(() => b.IsBitSet(pos)); - } - - /// - /// Tests behavior of IsBitSet with extreme position values. - /// Expected: Documents behavior with int.MaxValue as position. - /// - [Fact] - public void IsBitSet_PositionMaxValue_ProducesResult() - { - // Arrange - byte b = 0xFF; - int pos = int.MaxValue; - - // Act - Assert.Throws(() => b.IsBitSet(pos)); - } - - /// - /// Tests behavior of IsBitSet with extreme negative position value. - /// Expected: Documents behavior with int.MinValue as position. - /// - [Fact] - public void IsBitSet_PositionMinValue_ProducesResult() - { - // Arrange - byte b = 0xFF; - int pos = int.MinValue; - - // Act - Assert.Throws(() => b.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet correctly handles boundary byte values (byte.MinValue and byte.MaxValue). - /// Expected: byte.MinValue (0) should return false for all positions, byte.MaxValue (255) should return true. - /// - [Theory] - [InlineData(byte.MinValue, 0, false)] - [InlineData(byte.MinValue, 7, false)] - [InlineData(byte.MaxValue, 0, true)] - [InlineData(byte.MaxValue, 7, true)] - public void IsBitSet_BoundaryByteValues_ReturnsExpectedResult(byte b, int pos, bool expected) - { - // Act - Assert.Equal(expected, b.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet returns false when the ReadOnlySpan is empty, regardless of position. - /// Input: Empty span, position 0. - /// Expected: Returns false. - /// - [Fact] - public void IsBitSet_EmptySpan_ReturnsFalse() - { - // Arrange - ReadOnlySpan data = []; - int pos = 0; - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet returns false when position is far beyond the span length. - /// Input: Single byte span, position 100. - /// Expected: Returns false. - /// - [Fact] - public void IsBitSet_PositionBeyondLength_ReturnsFalse() - { - // Arrange - byte[] backing = [0b1111_1111]; - ReadOnlySpan data = new(backing); - int pos = 100; - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet returns false when position is at exact boundary where byteIndex equals data.Length. - /// Input: Single byte span, position 7 (byteIndex = 7/7 = 1, which equals length). - /// Expected: Returns false. - /// - [Fact] - public void IsBitSet_PositionAtExactBoundary_ReturnsFalse() - { - // Arrange - byte[] backing = [0b1111_1111]; - ReadOnlySpan data = new(backing); - int pos = 7; // byteIndex = 1, equals data.Length - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet returns false when position is int.MaxValue. - /// Input: Single byte span, position int.MaxValue. - /// Expected: Returns false. - /// - [Fact] - public void IsBitSet_PositionIntMaxValue_ReturnsFalse() - { - // Arrange - byte[] backing = [0xFF]; - ReadOnlySpan data = new(backing); - int pos = int.MaxValue; - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet handles negative positions correctly. - /// Input: Single byte span with all bits set, position -1. - /// Expected: Behavior depends on implementation (negative byteIndex/bitIndex). - /// Note: -1 / 7 = 0, -1 % 7 = -1, which will call byte.IsBitSet(-1). - /// - [Fact] - public void IsBitSet_NegativePosition_ReturnsExpectedResult() - { - // Arrange - byte[] backing = [0b1111_1111]; - ReadOnlySpan data = new(backing); - int pos = -1; - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet handles int.MinValue position correctly. - /// Input: Single byte span, position int.MinValue. - /// Expected: Returns false (extreme negative value). - /// - [Fact] - public void IsBitSet_PositionIntMinValue_ReturnsFalse() - { - // Arrange - byte[] backing = [0xFF]; - ReadOnlySpan data = new(backing); - int pos = int.MinValue; - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet correctly checks bits in the first byte using 7-bit packing. - /// Input: Various positions 0-6 mapping to first byte. - /// Expected: Returns true/false based on bit pattern. - /// - [Theory] - [InlineData(0, false)] // Bit 0 of 0b0100_0000 is not set - [InlineData(1, false)] // Bit 1 is not set - [InlineData(2, false)] // Bit 2 is not set - [InlineData(3, false)] // Bit 3 is not set - [InlineData(4, false)] // Bit 4 is not set - [InlineData(5, false)] // Bit 5 is not set - [InlineData(6, true)] // Bit 6 is set - public void IsBitSet_PositionsInFirstByte_ReturnsCorrectResult(int pos, bool expected) - { - // Arrange - byte[] backing = [0b0100_0000]; // Only bit 6 is set - ReadOnlySpan data = new(backing); - Assert.Equal(expected, data.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet correctly uses 7-bit packing to map positions across multiple bytes. - /// Input: Positions 6 and 7 to verify transition from first to second byte. - /// Expected: Position 6 maps to byte 0 bit 6, position 7 maps to byte 1 bit 0. - /// - [Fact] - public void IsBitSet_PositionsCrossingByteBoundary_MapsCorrectly() - { - // Arrange - // First byte: bit 6 set (0b0100_0000) - // Second byte: bit 0 set (0b0000_0001) - byte[] backing = [0b0100_0000, 0b0000_0001]; - ReadOnlySpan data = new(backing); - - // Act & Assert - // pos = 6 -> byteIndex = 0, bitIndex = 6 -> first byte, bit 6 - Assert.True(data.IsBitSet(6)); - - // pos = 7 -> byteIndex = 1, bitIndex = 0 -> second byte, bit 0 - Assert.True(data.IsBitSet(7)); - - // pos = 0 -> byteIndex = 0, bitIndex = 0 -> first byte, bit 0 (not set) - Assert.False(data.IsBitSet(0)); - - // pos = 8 -> byteIndex = 1, bitIndex = 1 -> second byte, bit 1 (not set) - Assert.False(data.IsBitSet(8)); - } - - /// - /// Tests that IsBitSet correctly handles positions mapping to the second byte. - /// Input: Positions 7-13 mapping to second byte using 7-bit packing. - /// Expected: Returns correct bit values from second byte. - /// - [Theory] - [InlineData(7, true)] // pos 7 -> byte 1, bit 0 (set) - [InlineData(8, false)] // pos 8 -> byte 1, bit 1 (not set) - [InlineData(9, true)] // pos 9 -> byte 1, bit 2 (set) - [InlineData(10, false)] // pos 10 -> byte 1, bit 3 (not set) - [InlineData(11, true)] // pos 11 -> byte 1, bit 4 (set) - [InlineData(12, false)] // pos 12 -> byte 1, bit 5 (not set) - [InlineData(13, true)] // pos 13 -> byte 1, bit 6 (set) - public void IsBitSet_PositionsInSecondByte_ReturnsCorrectResult(int pos, bool expected) - { - // Arrange - // First byte: all zeros - // Second byte: 0b0101_0101 (bits 0, 2, 4, 6 set) - byte[] backing = [0b0000_0000, 0b0101_0101]; - ReadOnlySpan data = new(backing); - Assert.Equal(expected, data.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet works correctly with all bits set in multiple bytes. - /// Input: Two bytes with all bits set, various positions. - /// Expected: All positions within valid range return true. - /// - [Theory] - [InlineData(0, true)] - [InlineData(3, true)] - [InlineData(6, true)] - [InlineData(7, true)] - [InlineData(10, true)] - [InlineData(13, true)] - public void IsBitSet_AllBitsSet_ReturnsTrue(int pos, bool expected) - { - // Arrange - byte[] backing = [0xFF, 0xFF]; - ReadOnlySpan data = new(backing); - Assert.Equal(expected, data.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet works correctly with no bits set. - /// Input: Multiple bytes with all bits cleared, various positions. - /// Expected: All positions return false. - /// - [Theory] - [InlineData(0)] - [InlineData(3)] - [InlineData(6)] - [InlineData(7)] - [InlineData(10)] - [InlineData(13)] - public void IsBitSet_NoBitsSet_ReturnsFalse(int pos) - { - // Arrange - byte[] backing = [0x00, 0x00, 0x00]; - ReadOnlySpan data = new(backing); - Assert.False(data.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet returns false when position maps to third byte but only two bytes exist. - /// Input: Two byte span, position 14 (maps to third byte). - /// Expected: Returns false. - /// - [Fact] - public void IsBitSet_PositionMapsToNonExistentThirdByte_ReturnsFalse() - { - // Arrange - byte[] backing = [0xFF, 0xFF]; - ReadOnlySpan data = new(backing); - int pos = 14; // byteIndex = 14/7 = 2, but only indices 0-1 exist - - // Act - AssertThrowsIndexOutOfRange(data, pos); - } - - /// - /// Tests that IsBitSet handles position zero correctly with single byte. - /// Input: Single byte with specific pattern, position 0. - /// Expected: Returns correct bit value at position 0. - /// - [Theory] - [InlineData(0b0000_0001, true)] // Bit 0 set - [InlineData(0b0000_0000, false)] // Bit 0 not set - [InlineData(0b1111_1110, false)] // Bit 0 not set - public void IsBitSet_PositionZero_ReturnsCorrectResult(byte byteValue, bool expected) - { - // Arrange - byte[] backing = [byteValue]; - ReadOnlySpan data = new(backing); - int pos = 0; - - // Act - bool result = data.IsBitSet(pos); - Assert.Equal(expected, result); - } - - /// - /// Tests that IsBitSet returns false when the position is out of bounds of the array. - /// Uses 7-bit packing, so position is mapped to byteIndex = pos / 7. - /// - [Theory] - [InlineData(7, 1)] // pos=7 maps to byteIndex=1, array has only 1 byte - [InlineData(8, 1)] // pos=8 maps to byteIndex=1, array has only 1 byte - [InlineData(14, 2)] // pos=14 maps to byteIndex=2, array has only 2 bytes - [InlineData(100, 3)] // pos=100 maps to byteIndex=14, array has only 3 bytes - [InlineData(int.MaxValue, 10)] // Very large position, array has 10 bytes - public void IsBitSet_PositionOutOfBounds_ReturnsFalse(int pos, int arrayLength) - { - // Arrange - byte[] data = new byte[arrayLength]; - - // Act - Assert.Throws(() => data.IsBitSet(pos)); - } - - /// - /// Tests that IsBitSet throws NullReferenceException when the array is null. - /// - [Fact] - public void IsBitSet_NullArray_ThrowsNullReferenceException() - { - // Arrange - byte[]? data = null; - - // Act & Assert - Assert.Throws(() => data!.IsBitSet(0)); - } - - /// - /// Tests that IsBitSet returns false for any valid position when the array is empty. - /// - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(10)] - [InlineData(int.MaxValue)] - public void IsBitSet_EmptyArray_ReturnsFalse(int pos) - { - // Arrange - byte[] data = []; - - // Act - Assert.Throws(() => data.IsBitSet(pos)); - } - - /// - /// Tests IsBitSet with various positions and bit patterns using 7-bit packing. - /// Verifies correct mapping: pos/7 gives byte index, pos%7 gives bit index within that byte. - /// - [Theory] - [InlineData(0, new byte[] { 0b0000_0001 }, true)] // pos=0: byte[0] bit 0 is set - [InlineData(0, new byte[] { 0b0000_0000 }, false)] // pos=0: byte[0] bit 0 is not set - [InlineData(1, new byte[] { 0b0000_0010 }, true)] // pos=1: byte[0] bit 1 is set - [InlineData(6, new byte[] { 0b0100_0000 }, true)] // pos=6: byte[0] bit 6 is set - [InlineData(6, new byte[] { 0b0000_0000 }, false)] // pos=6: byte[0] bit 6 is not set - [InlineData(7, new byte[] { 0b0000_0000, 0b0000_0001 }, true)] // pos=7: byte[1] bit 0 is set - [InlineData(7, new byte[] { 0b1111_1111, 0b0000_0000 }, false)] // pos=7: byte[1] bit 0 is not set - [InlineData(8, new byte[] { 0b0000_0000, 0b0000_0010 }, true)] // pos=8: byte[1] bit 1 is set - [InlineData(13, new byte[] { 0b0000_0000, 0b0100_0000 }, true)] // pos=13: byte[1] bit 6 is set - [InlineData(14, new byte[] { 0b0000_0000, 0b0000_0000, 0b0000_0001 }, true)] // pos=14: byte[2] bit 0 is set - public void IsBitSet_ValidPositionWithBitPattern_ReturnsExpectedResult(int pos, byte[] data, bool expectedResult) - { - // Act - Assert.Equal(expectedResult, data.IsBitSet(pos)); - } - - /// - /// Tests IsBitSet at byte boundary positions (where position transitions from one byte to the next). - /// With 7-bit packing: positions 6→7, 13→14, etc. are boundaries. - /// - [Fact] - public void IsBitSet_BoundaryPositions_MapsCorrectlyAcrossBytes() - { - // Arrange - // byte[0] has bit 6 set, byte[1] has bit 0 set, byte[2] has bit 6 set - byte[] data = [0b0100_0000, 0b0000_0001, 0b0100_0000]; - - // Act & Assert - Assert.True(data.IsBitSet(6)); - Assert.True(data.IsBitSet(7)); - Assert.False(data.IsBitSet(13)); - Assert.False(data.IsBitSet(14)); - Assert.True(data.IsBitSet(20)); - } - - /// - /// Tests IsBitSet with negative positions where byteIndex becomes negative, - /// which should throw IndexOutOfRangeException when accessing the array. - /// - [Theory] - [InlineData(-7)] - [InlineData(-8)] - [InlineData(-14)] - [InlineData(int.MinValue)] - public void IsBitSet_NegativePositionWithNegativeByteIndex_ThrowsIndexOutOfRangeException(int pos) - { - // Arrange - byte[] data = [0xFF]; - - // Act & Assert - Assert.Throws(() => data.IsBitSet(pos)); - } - - /// - /// Tests IsBitSet with negative positions in range [-6, -1] where byteIndex = 0 but bitIndex is negative. - /// The behavior depends on the byte.IsBitSet implementation with negative bit indices. - /// This tests the actual behavior of the bit shift operation with negative indices. - /// - [Theory] - [InlineData(-1)] - [InlineData(-2)] - [InlineData(-6)] - public void IsBitSet_NegativePositionWithZeroByteIndex_CallsByteIsBitSetWithNegativeBitIndex(int pos) - { - // Arrange - byte[] data = [0xFF]; - - // Act - Assert.Throws(() => data.IsBitSet(pos)); - } - - /// - /// Tests IsBitSet with position 0 on various byte patterns. - /// Position 0 maps to byte[0] bit 0 (LSB). - /// - [Theory] - [InlineData(0b0000_0001, true)] // LSB set - [InlineData(0b0000_0000, false)] // LSB not set - [InlineData(0b1111_1110, false)] // All bits except LSB set - [InlineData(0b1111_1111, true)] // All bits set - public void IsBitSet_Position0_ChecksFirstBitOfFirstByte(byte firstByte, bool expectedResult) - { - // Arrange - byte[] data = [firstByte]; - - // Act - bool result = data.IsBitSet(0); - - // Assert - Assert.Equal(expectedResult, result); - } - - /// - /// Tests IsBitSet with a comprehensive bit pattern across multiple bytes - /// to verify correct 7-bit packing logic throughout the array. - /// - [Fact] - public void IsBitSet_MultipleBytes_Uses7BitPackingCorrectly() - { - // Arrange - // Create a pattern where specific positions are set - byte[] data = - [ - 0b0101_0101, // byte[0]: bits 0,2,4,6 set - 0b1010_1010, // byte[1]: bits 1,3,5,7 set - 0b0000_1111 // byte[2]: bits 0,1,2,3 set - ]; - - // Act & Assert - // Positions 0-6 map to byte[0] - Assert.True(data.IsBitSet(0)); // bit 0 set - Assert.False(data.IsBitSet(1)); // bit 1 not set - Assert.True(data.IsBitSet(2)); // bit 2 set - Assert.False(data.IsBitSet(3)); // bit 3 not set - Assert.True(data.IsBitSet(4)); // bit 4 set - Assert.False(data.IsBitSet(5)); // bit 5 not set - Assert.True(data.IsBitSet(6)); // bit 6 set - - // Positions 7-13 map to byte[1] - Assert.False(data.IsBitSet(7)); // bit 0 not set - Assert.True(data.IsBitSet(8)); // bit 1 set - Assert.False(data.IsBitSet(9)); // bit 2 not set - Assert.True(data.IsBitSet(10)); // bit 3 set - Assert.False(data.IsBitSet(11)); // bit 4 not set - Assert.True(data.IsBitSet(12)); // bit 5 set - Assert.False(data.IsBitSet(13)); // bit 6 not set - - // Positions 14-20 map to byte[2] - Assert.True(data.IsBitSet(14)); // bit 0 set - Assert.True(data.IsBitSet(15)); // bit 1 set - Assert.True(data.IsBitSet(16)); // bit 2 set - Assert.True(data.IsBitSet(17)); // bit 3 set - Assert.False(data.IsBitSet(18)); // bit 4 not set - Assert.False(data.IsBitSet(19)); // bit 5 not set - Assert.False(data.IsBitSet(20)); // bit 6 not set - } - - /// - /// Tests IsBitSet with position exactly at the upper boundary that maps to the last valid byte. - /// Verifies behavior when position maps exactly to the last byte's last usable bit (bit 6). - /// - [Fact] - public void IsBitSet_PositionAtExactArrayBoundary_ReturnsCorrectResult() - { - // Arrange - // Array with 3 bytes, so valid positions are 0-20 - // Position 20 = byteIndex 2, bitIndex 6 (last valid position) - byte[] data = [0x00, 0x00, 0b0100_0000]; - - // Act & Assert - Assert.True(data.IsBitSet(20)); // Exactly at boundary, bit is set - AssertThrowsIndexOutOfRange(data, 21); // Beyond boundary, throws - } - - // Helper for ReadOnlySpan exception assertion - private static void AssertThrowsIndexOutOfRange(ReadOnlySpan span, int pos) - { - try - { - span.IsBitSet(pos); - Assert.Fail("Expected IndexOutOfRangeException was not thrown."); - } - catch (IndexOutOfRangeException) - { - // Expected - } - } +namespace FTMS.NET.Tests.Utils; + +using FTMS.NET.Utils; + +public sealed class ByteExtensionsTests +{ + /// + /// Tests that IsBitSet correctly identifies set and unset bits for all positions 0-7 + /// in a byte with a known bit pattern (0b10100101). + /// Expected: Returns true for positions where bit is 1, false where bit is 0. + /// + [Fact] + public void IsBitSet_ByteWithMixedBitPattern_ReturnsCorrectStateForEachPosition() + { + // Arrange + // 0b10100101 == 0xA5 == 165 + byte b = 0b1010_0101; + + // Act & Assert + Assert.True(b.IsBitSet(0)); // LSB = 1 + Assert.False(b.IsBitSet(1)); // bit 1 = 0 + Assert.True(b.IsBitSet(2)); // bit 2 = 1 + Assert.False(b.IsBitSet(3)); // bit 3 = 0 + Assert.False(b.IsBitSet(4)); // bit 4 = 0 + Assert.True(b.IsBitSet(5)); // bit 5 = 1 + Assert.False(b.IsBitSet(6)); // bit 6 = 0 + Assert.True(b.IsBitSet(7)); // MSB = 1 + } + + /// + /// Tests that IsBitSet returns false for all bit positions when the byte value is 0x00 (all bits unset). + /// Expected: Returns false for all positions 0-7. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void IsBitSet_ByteWithAllBitsUnset_ReturnsFalseForAllPositions(int pos) + { + // Arrange + byte b = 0x00; + + // Act + bool result = b.IsBitSet(pos); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsBitSet returns true for all bit positions when the byte value is 0xFF (all bits set). + /// Expected: Returns true for all positions 0-7. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void IsBitSet_ByteWithAllBitsSet_ReturnsTrueForAllPositions(int pos) + { + // Arrange + byte b = 0xFF; + + // Act + bool result = b.IsBitSet(pos); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsBitSet correctly identifies a single set bit at various positions. + /// Expected: Returns true only for the position where the bit is set, false for all others. + /// + [Theory] + [InlineData(0, 0b0000_0001)] + [InlineData(1, 0b0000_0010)] + [InlineData(2, 0b0000_0100)] + [InlineData(3, 0b0000_1000)] + [InlineData(4, 0b0001_0000)] + [InlineData(5, 0b0010_0000)] + [InlineData(6, 0b0100_0000)] + [InlineData(7, 0b1000_0000)] + public void IsBitSet_ByteWithSingleBitSet_ReturnsTrueOnlyForThatPosition(int pos, byte byteValue) + { + // Arrange + byte b = byteValue; + + // Act + bool result = b.IsBitSet(pos); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsBitSet returns false when checking a position that is not set in a byte with a single bit set. + /// Expected: Returns false for all positions except the one that is set. + /// + [Theory] + [InlineData(0, 0b0000_0010)] // bit 0 unset, bit 1 set + [InlineData(1, 0b0000_0001)] // bit 1 unset, bit 0 set + [InlineData(3, 0b1000_0000)] // bit 3 unset, bit 7 set + [InlineData(7, 0b0000_0001)] // bit 7 unset, bit 0 set + public void IsBitSet_ByteWithSingleBitSet_ReturnsFalseForOtherPositions(int pos, byte byteValue) + { + // Arrange + byte b = byteValue; + + // Act + bool result = b.IsBitSet(pos); + + // Assert + Assert.False(result); + } + + /// + /// Tests behavior of IsBitSet when position is negative. + /// Expected: Negative positions throw IndexOutOfRangeException. + /// + [Theory] + [InlineData(-1)] + [InlineData(-8)] + public void IsBitSet_NegativeBytePosition_ThrowsIndexOutOfRange(int pos) + { + // Arrange + byte b = 0xFF; + + // Act & Assert + Assert.Throws(() => b.IsBitSet(pos)); + } + + /// + /// Tests behavior of IsBitSet when position is greater than 7 (beyond byte boundary). + /// Expected: Positions beyond 7 throw IndexOutOfRangeException. + /// + [Theory] + [InlineData(8)] + [InlineData(9)] + [InlineData(16)] + [InlineData(31)] + public void IsBitSet_PositionBeyondByteBoundary_ThrowsIndexOutOfRange(int pos) + { + // Arrange + byte b = 0b1010_0101; + + // Act & Assert + Assert.Throws(() => b.IsBitSet(pos)); + } + + /// + /// Tests behavior of IsBitSet with extreme position values. + /// Expected: int.MaxValue throws IndexOutOfRangeException. + /// + [Fact] + public void IsBitSet_PositionMaxValue_ThrowsIndexOutOfRange() + { + // Arrange + byte b = 0xFF; + int pos = int.MaxValue; + + // Act + Assert.Throws(() => b.IsBitSet(pos)); + } + + /// + /// Tests behavior of IsBitSet with extreme negative position value. + /// Expected: int.MinValue throws IndexOutOfRangeException. + /// + [Fact] + public void IsBitSet_PositionMinValue_ThrowsIndexOutOfRange() + { + // Arrange + byte b = 0xFF; + int pos = int.MinValue; + + // Act + Assert.Throws(() => b.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet correctly handles boundary byte values (byte.MinValue and byte.MaxValue). + /// Expected: byte.MinValue (0) should return false for all positions, byte.MaxValue (255) should return true. + /// + [Theory] + [InlineData(byte.MinValue, 0, false)] + [InlineData(byte.MinValue, 7, false)] + [InlineData(byte.MaxValue, 0, true)] + [InlineData(byte.MaxValue, 7, true)] + public void IsBitSet_BoundaryByteValues_ReturnsExpectedResult(byte b, int pos, bool expected) + { + // Act + Assert.Equal(expected, b.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException when the ReadOnlySpan is empty, regardless of position. + /// Input: Empty span, position 0. + /// + [Fact] + public void IsBitSet_EmptySpan_ThrowsIndexOutOfRange() + { + // Arrange + ReadOnlySpan data = []; + int pos = 0; + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException when position is far beyond the span length. + /// Input: Single byte span, position 100. + /// + [Fact] + public void IsBitSet_PositionBeyondLength_ThrowsIndexOutOfRange() + { + // Arrange + byte[] backing = [0b1111_1111]; + ReadOnlySpan data = new(backing); + int pos = 100; + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet reads the MSB of a single byte at position 7 and throws beyond it. + /// Input: Single byte span, positions 7 and 8. + /// Expected: Position 7 returns true (byteIndex = 0, bitIndex = 7); position 8 throws (byteIndex = 1, equals length). + /// + [Fact] + public void IsBitSet_PositionAtExactBoundary_ReturnsCorrectResult() + { + // Arrange + byte[] backing = [0b1111_1111]; + ReadOnlySpan data = new(backing); + + // Act & Assert + Assert.True(data.IsBitSet(7)); // byte[0] bit 7 (MSB), valid + AssertThrowsIndexOutOfRange(data, 8); // byteIndex = 1, equals data.Length + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException when position is int.MaxValue. + /// Input: Single byte span, position int.MaxValue. + /// + [Fact] + public void IsBitSet_PositionIntMaxValue_ThrowsIndexOutOfRange() + { + // Arrange + byte[] backing = [0xFF]; + ReadOnlySpan data = new(backing); + int pos = int.MaxValue; + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet handles negative positions correctly. + /// Input: Single byte span with all bits set, position -1. + /// Note: -1 / 8 = 0, -1 % 8 = -1, which will call byte.IsBitSet(-1) and throw. + /// + [Fact] + public void IsBitSet_SpanNegativePosition_ThrowsIndexOutOfRange() + { + // Arrange + byte[] backing = [0b1111_1111]; + ReadOnlySpan data = new(backing); + int pos = -1; + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet handles int.MinValue position correctly. + /// Input: Single byte span, position int.MinValue. + /// Expected: Throws IndexOutOfRangeException (extreme negative value). + /// + [Fact] + public void IsBitSet_PositionIntMinValue_ThrowsIndexOutOfRange() + { + // Arrange + byte[] backing = [0xFF]; + ReadOnlySpan data = new(backing); + int pos = int.MinValue; + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet correctly checks bits in the first byte using standard bit numbering. + /// Input: Various positions 0-6 mapping to the first byte. + /// Expected: Returns true/false based on bit pattern. + /// + [Theory] + [InlineData(0, false)] // Bit 0 of 0b0100_0000 is not set + [InlineData(1, false)] // Bit 1 is not set + [InlineData(2, false)] // Bit 2 is not set + [InlineData(3, false)] // Bit 3 is not set + [InlineData(4, false)] // Bit 4 is not set + [InlineData(5, false)] // Bit 5 is not set + [InlineData(6, true)] // Bit 6 is set + public void IsBitSet_PositionsInFirstByte_ReturnsCorrectResult(int pos, bool expected) + { + // Arrange + byte[] backing = [0b0100_0000]; // Only bit 6 is set + ReadOnlySpan data = new(backing); + Assert.Equal(expected, data.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet correctly uses standard bit numbering to map positions across multiple bytes. + /// Input: Positions 7 and 8 to verify transition from first to second byte. + /// Expected: Position 7 maps to byte 0 bit 7 (MSB), position 8 maps to byte 1 bit 0 (LSB). + /// + [Fact] + public void IsBitSet_PositionsCrossingByteBoundary_MapsCorrectly() + { + // Arrange + // First byte: bit 7 set (0b1000_0000) + // Second byte: bit 0 set (0b0000_0001) + byte[] backing = [0b1000_0000, 0b0000_0001]; + ReadOnlySpan data = new(backing); + + // Act & Assert + // pos = 7 -> byteIndex = 0, bitIndex = 7 -> first byte, bit 7 + Assert.True(data.IsBitSet(7)); + + // pos = 8 -> byteIndex = 1, bitIndex = 0 -> second byte, bit 0 + Assert.True(data.IsBitSet(8)); + + // pos = 6 -> byteIndex = 0, bitIndex = 6 -> first byte, bit 6 (not set) + Assert.False(data.IsBitSet(6)); + + // pos = 9 -> byteIndex = 1, bitIndex = 1 -> second byte, bit 1 (not set) + Assert.False(data.IsBitSet(9)); + } + + /// + /// Tests that IsBitSet correctly handles positions mapping to the second byte. + /// Input: Positions 8-15 mapping to second byte using standard bit numbering. + /// Expected: Returns correct bit values from second byte. + /// + [Theory] + [InlineData(8, true)] // pos 8 -> byte 1, bit 0 (set) + [InlineData(9, false)] // pos 9 -> byte 1, bit 1 (not set) + [InlineData(10, true)] // pos 10 -> byte 1, bit 2 (set) + [InlineData(11, false)] // pos 11 -> byte 1, bit 3 (not set) + [InlineData(12, true)] // pos 12 -> byte 1, bit 4 (set) + [InlineData(13, false)] // pos 13 -> byte 1, bit 5 (not set) + [InlineData(14, true)] // pos 14 -> byte 1, bit 6 (set) + [InlineData(15, false)] // pos 15 -> byte 1, bit 7 (not set) + public void IsBitSet_PositionsInSecondByte_ReturnsCorrectResult(int pos, bool expected) + { + // Arrange + // First byte: all zeros + // Second byte: 0b0101_0101 (bits 0, 2, 4, 6 set) + byte[] backing = [0b0000_0000, 0b0101_0101]; + ReadOnlySpan data = new(backing); + Assert.Equal(expected, data.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet works correctly with all bits set in multiple bytes. + /// Input: Two bytes with all bits set, various positions. + /// Expected: All positions within valid range return true. + /// + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(10)] + [InlineData(13)] + [InlineData(15)] + public void IsBitSet_AllBitsSet_ReturnsTrue(int pos) + { + // Arrange + byte[] backing = [0xFF, 0xFF]; + ReadOnlySpan data = new(backing); + Assert.True(data.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet works correctly with no bits set. + /// Input: Multiple bytes with all bits cleared, various positions. + /// Expected: All positions return false. + /// + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(10)] + [InlineData(13)] + public void IsBitSet_NoBitsSet_ReturnsFalse(int pos) + { + // Arrange + byte[] backing = [0x00, 0x00, 0x00]; + ReadOnlySpan data = new(backing); + Assert.False(data.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException when position maps to a non-existent byte. + /// Input: Two byte span, position 16 (maps to third byte). + /// + [Fact] + public void IsBitSet_PositionMapsToNonExistentThirdByte_ThrowsIndexOutOfRange() + { + // Arrange + byte[] backing = [0xFF, 0xFF]; + ReadOnlySpan data = new(backing); + int pos = 16; // byteIndex = 16/8 = 2, but only indices 0-1 exist + + // Act + AssertThrowsIndexOutOfRange(data, pos); + } + + /// + /// Tests that IsBitSet handles position zero correctly with single byte. + /// Input: Single byte with specific pattern, position 0. + /// Expected: Returns correct bit value at position 0. + /// + [Theory] + [InlineData(0b0000_0001, true)] // Bit 0 set + [InlineData(0b0000_0000, false)] // Bit 0 not set + [InlineData(0b1111_1110, false)] // Bit 0 not set + public void IsBitSet_PositionZero_ReturnsCorrectResult(byte byteValue, bool expected) + { + // Arrange + byte[] backing = [byteValue]; + ReadOnlySpan data = new(backing); + int pos = 0; + + // Act + bool result = data.IsBitSet(pos); + Assert.Equal(expected, result); + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException when the position is out of bounds of the array. + /// Uses standard bit numbering, so position is mapped to byteIndex = pos / 8. + /// + [Theory] + [InlineData(8, 1)] // pos=8 maps to byteIndex=1, array has only 1 byte + [InlineData(9, 1)] // pos=9 maps to byteIndex=1, array has only 1 byte + [InlineData(16, 2)] // pos=16 maps to byteIndex=2, array has only 2 bytes + [InlineData(100, 3)] // pos=100 maps to byteIndex=12, array has only 3 bytes + [InlineData(int.MaxValue, 10)] // Very large position, array has 10 bytes + public void IsBitSet_PositionOutOfBounds_ThrowsIndexOutOfRange(int pos, int arrayLength) + { + // Arrange + byte[] data = new byte[arrayLength]; + + // Act + Assert.Throws(() => data.IsBitSet(pos)); + } + + /// + /// Tests that IsBitSet throws NullReferenceException when the array is null. + /// + [Fact] + public void IsBitSet_NullArray_ThrowsNullReferenceException() + { + // Arrange + byte[]? data = null; + + // Act & Assert + Assert.Throws(() => data!.IsBitSet(0)); + } + + /// + /// Tests that IsBitSet throws IndexOutOfRangeException for any valid position when the array is empty. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(10)] + [InlineData(int.MaxValue)] + public void IsBitSet_EmptyArray_ThrowsIndexOutOfRange(int pos) + { + // Arrange + byte[] data = []; + + // Act + Assert.Throws(() => data.IsBitSet(pos)); + } + + /// + /// Tests IsBitSet with various positions and bit patterns using standard bit numbering. + /// Verifies correct mapping: pos/8 gives byte index, pos%8 gives bit index within that byte. + /// + [Theory] + [InlineData(0, new byte[] { 0b0000_0001 }, true)] // pos=0: byte[0] bit 0 is set + [InlineData(0, new byte[] { 0b0000_0000 }, false)] // pos=0: byte[0] bit 0 is not set + [InlineData(1, new byte[] { 0b0000_0010 }, true)] // pos=1: byte[0] bit 1 is set + [InlineData(6, new byte[] { 0b0100_0000 }, true)] // pos=6: byte[0] bit 6 is set + [InlineData(6, new byte[] { 0b0000_0000 }, false)] // pos=6: byte[0] bit 6 is not set + [InlineData(7, new byte[] { 0b1000_0000 }, true)] // pos=7: byte[0] bit 7 is set + [InlineData(7, new byte[] { 0b1111_1111, 0b0000_0000 }, true)] // pos=7: byte[0] bit 7 is set, byte[1] bit 0 is not relevant + [InlineData(8, new byte[] { 0b0000_0000, 0b0000_0010 }, false)] // pos=8: byte[1] bit 1 is set, not bit 0 + [InlineData(8, new byte[] { 0b0000_0000, 0b0000_0001 }, true)] // pos=8: byte[1] bit 0 is set + [InlineData(13, new byte[] { 0b0000_0000, 0b0010_0000 }, true)] // pos=13: byte[1] bit 5 is set + [InlineData(16, new byte[] { 0b0000_0000, 0b0000_0000, 0b0000_0001 }, true)] // pos=16: byte[2] bit 0 is set + public void IsBitSet_ValidPositionWithBitPattern_ReturnsExpectedResult(int pos, byte[] data, bool expectedResult) + { + // Act + Assert.Equal(expectedResult, data.IsBitSet(pos)); + } + + /// + /// Tests IsBitSet at byte boundary positions (where position transitions from one byte to the next). + /// With standard bit numbering: positions 7→8, 15→16, etc. are boundaries. + /// + [Fact] + public void IsBitSet_BoundaryPositions_MapsCorrectlyAcrossBytes() + { + // Arrange + // byte[0] has bit 6 set, byte[1] has bit 0 set, byte[2] has bit 6 set + byte[] data = [0b0100_0000, 0b0000_0001, 0b0100_0000]; + + // Act & Assert + Assert.True(data.IsBitSet(6)); // byte[0] bit 6 + Assert.False(data.IsBitSet(7)); // byte[0] bit 7 not set + Assert.True(data.IsBitSet(8)); // byte[1] bit 0 + Assert.False(data.IsBitSet(13)); // byte[1] bit 5 not set + Assert.False(data.IsBitSet(14)); // byte[1] bit 6 not set + Assert.False(data.IsBitSet(16)); // byte[2] bit 0 not set + Assert.True(data.IsBitSet(22)); // byte[2] bit 6 + } + + /// + /// Tests IsBitSet with negative positions where byteIndex becomes negative, + /// which should throw IndexOutOfRangeException when accessing the array. + /// + [Theory] + [InlineData(-8)] + [InlineData(-9)] + [InlineData(-16)] + [InlineData(int.MinValue)] + public void IsBitSet_NegativePositionWithNegativeByteIndex_ThrowsIndexOutOfRangeException(int pos) + { + // Arrange + byte[] data = [0xFF]; + + // Act & Assert + Assert.Throws(() => data.IsBitSet(pos)); + } + + /// + /// Tests IsBitSet with negative positions in range [-7, -1] where byteIndex = 0 but bitIndex is negative. + /// The byte.IsBitSet implementation throws for negative bit indices. + /// + [Theory] + [InlineData(-1)] + [InlineData(-2)] + [InlineData(-7)] + public void IsBitSet_NegativePositionWithZeroByteIndex_ThrowsIndexOutOfRangeException(int pos) + { + // Arrange + byte[] data = [0xFF]; + + // Act + Assert.Throws(() => data.IsBitSet(pos)); + } + + /// + /// Tests IsBitSet with position 0 on various byte patterns. + /// Position 0 maps to byte[0] bit 0 (LSB). + /// + [Theory] + [InlineData(0b0000_0001, true)] // LSB set + [InlineData(0b0000_0000, false)] // LSB not set + [InlineData(0b1111_1110, false)] // All bits except LSB set + [InlineData(0b1111_1111, true)] // All bits set + public void IsBitSet_Position0_ChecksFirstBitOfFirstByte(byte firstByte, bool expectedResult) + { + // Arrange + byte[] data = [firstByte]; + + // Act + bool result = data.IsBitSet(0); + + // Assert + Assert.Equal(expectedResult, result); + } + + /// + /// Tests IsBitSet with a comprehensive bit pattern across multiple bytes + /// to verify correct standard bit numbering throughout the array. + /// + [Fact] + public void IsBitSet_MultipleBytes_UsesStandardBitMappingCorrectly() + { + // Arrange + // Create a pattern where specific positions are set + byte[] data = + [ + 0b0101_0101, // byte[0]: bits 0,2,4,6 set + 0b1010_1010, // byte[1]: bits 1,3,5,7 set + 0b0000_1111 // byte[2]: bits 0,1,2,3 set + ]; + + // Act & Assert + // Positions 0-7 map to byte[0] + Assert.True(data.IsBitSet(0)); // bit 0 set + Assert.False(data.IsBitSet(1)); // bit 1 not set + Assert.True(data.IsBitSet(2)); // bit 2 set + Assert.False(data.IsBitSet(3)); // bit 3 not set + Assert.True(data.IsBitSet(4)); // bit 4 set + Assert.False(data.IsBitSet(5)); // bit 5 not set + Assert.True(data.IsBitSet(6)); // bit 6 set + Assert.False(data.IsBitSet(7)); // bit 7 not set + + // Positions 8-15 map to byte[1] + Assert.False(data.IsBitSet(8)); // bit 0 not set + Assert.True(data.IsBitSet(9)); // bit 1 set + Assert.False(data.IsBitSet(10)); // bit 2 not set + Assert.True(data.IsBitSet(11)); // bit 3 set + Assert.False(data.IsBitSet(12)); // bit 4 not set + Assert.True(data.IsBitSet(13)); // bit 5 set + Assert.False(data.IsBitSet(14)); // bit 6 not set + Assert.True(data.IsBitSet(15)); // bit 7 set + + // Positions 16-23 map to byte[2] + Assert.True(data.IsBitSet(16)); // bit 0 set + Assert.True(data.IsBitSet(17)); // bit 1 set + Assert.True(data.IsBitSet(18)); // bit 2 set + Assert.True(data.IsBitSet(19)); // bit 3 set + Assert.False(data.IsBitSet(20)); // bit 4 not set + Assert.False(data.IsBitSet(21)); // bit 5 not set + Assert.False(data.IsBitSet(22)); // bit 6 not set + Assert.False(data.IsBitSet(23)); // bit 7 not set + } + + /// + /// Tests IsBitSet with position exactly at the upper boundary that maps to the last valid byte. + /// Verifies behavior when position maps exactly to the last byte's last usable bit (bit 7). + /// + [Fact] + public void IsBitSet_PositionAtExactArrayBoundary_ReturnsCorrectResult() + { + // Arrange + // Array with 3 bytes, so valid positions are 0-23 + // Position 23 = byteIndex 2, bitIndex 7 (last valid position) + byte[] data = [0x00, 0x00, 0b1000_0000]; + + // Act & Assert + Assert.True(data.IsBitSet(23)); // Exactly at boundary, bit is set + AssertThrowsIndexOutOfRange(data, 24); // Beyond boundary, throws + } + + /// + /// Tests that the MSB of the first byte is bit 7 using standard bit numbering. + /// Expected: pos 7 reads byte[0] bit 7, pos 15 reads byte[1] bit 7. + /// + [Fact] + public void IsBitSet_StandardBitNumbering_MsbOfFirstByteIsBit7() + { + byte[] data = [0b1000_0000, 0b0000_0000]; + + Assert.True(data.IsBitSet(7)); + Assert.False(data.IsBitSet(15)); // byte[1] bit 7, not set + } + + /// + /// Tests that the LSB of the second byte is bit 8 using standard bit numbering. + /// Expected: pos 8 reads byte[1] bit 0, pos 7 reads byte[0] bit 7. + /// + [Fact] + public void IsBitSet_StandardBitNumbering_LsbOfSecondByteIsBit8() + { + byte[] data = [0b0000_0000, 0b0000_0001]; + + Assert.True(data.IsBitSet(8)); + Assert.False(data.IsBitSet(7)); // byte[0] bit 7, not set + } + + /// + /// Tests that a single byte with its MSB set does not overflow at position 7. + /// Expected: pos 7 returns true instead of throwing IndexOutOfRangeException. + /// + [Fact] + public void IsBitSet_SingleByte_MsbDoesNotOverflow() + { + byte[] data = [0b1000_0000]; + + Assert.True(data.IsBitSet(7)); // byte[0] bit 7 (MSB) + } + + // Helper for ReadOnlySpan exception assertion + private static void AssertThrowsIndexOutOfRange(ReadOnlySpan span, int pos) + { + try + { + span.IsBitSet(pos); + Assert.Fail("Expected IndexOutOfRangeException was not thrown."); + } + catch (IndexOutOfRangeException) + { + // Expected + } + } } \ No newline at end of file diff --git a/FTMS.NET/Utils/ByteExtensions.cs b/FTMS.NET/Utils/ByteExtensions.cs index 399723e..fadd44e 100644 --- a/FTMS.NET/Utils/ByteExtensions.cs +++ b/FTMS.NET/Utils/ByteExtensions.cs @@ -7,8 +7,8 @@ public static bool IsBitSet(this byte[] data, int pos) if (pos < 0) throw new IndexOutOfRangeException(); - int byteIndex = pos / 7; - int bitIndex = pos % 7; + int byteIndex = pos / 8; + int bitIndex = pos % 8; if (byteIndex >= data.Length) throw new IndexOutOfRangeException(); @@ -21,8 +21,8 @@ public static bool IsBitSet(this ReadOnlySpan data, int pos) if (pos < 0) throw new IndexOutOfRangeException(); - int byteIndex = pos / 7; - int bitIndex = pos % 7; + int byteIndex = pos / 8; + int bitIndex = pos % 8; if (byteIndex >= data.Length) throw new IndexOutOfRangeException(); From 2e1b38c4ca3460a7a992c90d5a609fcee959174c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 21:42:29 +0200 Subject: [PATCH 02/10] Fix Target Setting Features byte offset in fitness machine features --- .../FitnessMachineServiceFactory.Tests.cs | 47 +++++++++++++++++++ FTMS.NET/FitnessMachineServiceFactory.cs | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs diff --git a/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs b/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs new file mode 100644 index 0000000..afa8371 --- /dev/null +++ b/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs @@ -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 ReadValueAsync() => Task.FromResult(value); + public Task WriteValueAsync(byte[] value) => Task.CompletedTask; + public IObservable ObserveValue() => Observable.Empty(); + } + + private sealed class FakeConnection(byte[] featureValue) : IFitnessMachineServiceConnection + { + public byte[] ServiceData { get; } = []; + public Task GetCharacteristicAsync(Guid id) + => Task.FromResult( + id == FtmsUuids.Feature ? new FakeCharacteristic(featureValue) : null); + } +} diff --git a/FTMS.NET/FitnessMachineServiceFactory.cs b/FTMS.NET/FitnessMachineServiceFactory.cs index f9eb188..2759018 100644 --- a/FTMS.NET/FitnessMachineServiceFactory.cs +++ b/FTMS.NET/FitnessMachineServiceFactory.cs @@ -90,7 +90,7 @@ public static async Task ReadFitnessMachineFeaturesAsyn var featureData = await featureCharacteristic.ReadValueAsync(); var featureDataSpan = featureData.AsSpan(); - return new FitnessMachineFeatures(featureDataSpan[..4], featureDataSpan[5..]) + return new FitnessMachineFeatures(featureDataSpan[..4], featureDataSpan[4..]) { SpeedRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedSpeedRange, SupportedRange.ReadSpeed), InclinationRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedInclinationRange, SupportedRange.ReadInclination), From 37b85516efb579d160a18a591a5d7adc3f61275d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 21:50:16 +0200 Subject: [PATCH 03/10] Fix SetTargetedDistance throwing InvalidOperationException UInt24 is not a primitive type, so ExecuteWithValue rejected it before writing anything to the device. Handle UInt24 explicitly in GetBytes(), writing the 3-byte little-endian (LSO...MSO) encoding required by the FTMS spec (op code 0x0C, UINT24 meters, resolution 1). --- .../Control/ControlExtensions.Tests.cs | 43 +++++++++++++++++++ FTMS.NET/Control/ControlExtensions.cs | 3 ++ 2 files changed, 46 insertions(+) create mode 100644 FTMS.NET.Tests/Control/ControlExtensions.Tests.cs diff --git a/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs b/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs new file mode 100644 index 0000000..d92e1fd --- /dev/null +++ b/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs @@ -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 Execute(ControlRequest request) + { + this.Request = request; + return Task.FromResult(new ControlResponse(request.OpCode, EControlResultCode.Success, [])); + } + + public void Dispose() { } + } +} diff --git a/FTMS.NET/Control/ControlExtensions.cs b/FTMS.NET/Control/ControlExtensions.cs index c21df59..937790c 100644 --- a/FTMS.NET/Control/ControlExtensions.cs +++ b/FTMS.NET/Control/ControlExtensions.cs @@ -112,6 +112,9 @@ private static Task ExecuteWithValue(this IFitnessMachineCon byte[] GetBytes() { + if (value is UInt24 u24) + return [(byte)u24.Value, (byte)(u24.Value >> 8), (byte)(u24.Value >> 16)]; + if (typeof(T).IsPrimitive) { int size = Marshal.SizeOf(); From 4e21dadd26ebe7e573ac1a3d3d03d5a9bb496bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 22:01:23 +0200 Subject: [PATCH 04/10] Move UInt24 byte encoding into UInt24.GetBytes() ControlExtensions.GetBytes() re-derived the 3-byte little-endian (LSO...MSO) split that UInt24 already owns, duplicating the byte-order logic and reaching into another type's data. UInt24 now exposes the encoding directly and the control path delegates to it. --- FTMS.NET.Tests/Utils/UInt24Tests.cs | 37 +++++++++++++++++++++++++++ FTMS.NET/Control/ControlExtensions.cs | 2 +- FTMS.NET/Utils/UInt24.cs | 2 ++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 FTMS.NET.Tests/Utils/UInt24Tests.cs diff --git a/FTMS.NET.Tests/Utils/UInt24Tests.cs b/FTMS.NET.Tests/Utils/UInt24Tests.cs new file mode 100644 index 0000000..6aa1454 --- /dev/null +++ b/FTMS.NET.Tests/Utils/UInt24Tests.cs @@ -0,0 +1,37 @@ +namespace FTMS.NET.Tests.Utils; + +using FTMS.NET.Utils; + +/// +/// Unit tests for the struct. +/// +public sealed class UInt24Tests +{ + /// + /// Tests that GetBytes returns the value encoded as three little-endian bytes (LSO...MSO). + /// + [Theory] + [InlineData(0u, new byte[] { 0x00, 0x00, 0x00 })] + [InlineData(1000u, new byte[] { 0xE8, 0x03, 0x00 })] // 0x0003E8 + [InlineData(0x010203u, new byte[] { 0x03, 0x02, 0x01 })] + [InlineData(0xFFFFFFu, new byte[] { 0xFF, 0xFF, 0xFF })] + public void GetBytes_ReturnsLittleEndianBytes(uint value, byte[] expected) + { + var u24 = new UInt24(value); + + byte[] actual = u24.GetBytes(); + + Assert.Equal(expected, actual); + } + + /// + /// Tests that GetBytes on MaxValue returns three bytes of 0xFF. + /// + [Fact] + public void GetBytes_MaxValue_ReturnsThreeBytesOfFF() + { + byte[] actual = UInt24.MaxValue.GetBytes(); + + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF }, actual); + } +} diff --git a/FTMS.NET/Control/ControlExtensions.cs b/FTMS.NET/Control/ControlExtensions.cs index 937790c..faef188 100644 --- a/FTMS.NET/Control/ControlExtensions.cs +++ b/FTMS.NET/Control/ControlExtensions.cs @@ -113,7 +113,7 @@ private static Task ExecuteWithValue(this IFitnessMachineCon byte[] GetBytes() { if (value is UInt24 u24) - return [(byte)u24.Value, (byte)(u24.Value >> 8), (byte)(u24.Value >> 16)]; + return u24.GetBytes(); if (typeof(T).IsPrimitive) { diff --git a/FTMS.NET/Utils/UInt24.cs b/FTMS.NET/Utils/UInt24.cs index 1463d12..3f9d4ad 100644 --- a/FTMS.NET/Utils/UInt24.cs +++ b/FTMS.NET/Utils/UInt24.cs @@ -52,6 +52,8 @@ public UInt24(uint value) private int SignedValue => this.b0 | this.b1 << 8 | this.b2 << 16; public uint Value => (uint)this.SignedValue; + public byte[] GetBytes() => [this.b0, this.b1, this.b2]; + // #region Struct Tedium + IEquatable + IComparable From 542fde6d0b306339925bd6f39de489b95349ca75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 22:06:38 +0200 Subject: [PATCH 05/10] use Unsafe instead of Marshal --- FTMS.NET/Control/ControlExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FTMS.NET/Control/ControlExtensions.cs b/FTMS.NET/Control/ControlExtensions.cs index faef188..77b27e2 100644 --- a/FTMS.NET/Control/ControlExtensions.cs +++ b/FTMS.NET/Control/ControlExtensions.cs @@ -117,7 +117,7 @@ byte[] GetBytes() if (typeof(T).IsPrimitive) { - int size = Marshal.SizeOf(); + int size = Unsafe.SizeOf(); byte[] bytes = new byte[size]; Unsafe.As(ref bytes[0]) = value; return bytes; From 8dcf17341ca948f35d1bebddc88b1f59cb4b880e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 22:32:08 +0200 Subject: [PATCH 06/10] Fix Target Incline and Targeted Distance parameter parsing Decode Target Incline as SINT16 instead of UINT16 so negative (downhill) values are read correctly, and decode Targeted Distance as the full UINT24 using the UInt24 byte constructor instead of truncating to 2 octets via ToUInt16. --- ...tnessMachineStateParameterFactory.Tests.cs | 75 +++++++++++++++++++ .../FitnessMachineStateParameterFactory.cs | 4 +- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs diff --git a/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs b/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs new file mode 100644 index 0000000..daec1c5 --- /dev/null +++ b/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs @@ -0,0 +1,75 @@ +namespace FTMS.NET.Tests.State; + +using FTMS.NET.State; +using System.Collections.Generic; +using System.Linq; + +public sealed class FitnessMachineStateParameterFactoryTests +{ + /// + /// Tests that a negative target incline (SINT16) is decoded as a negative percent value. + /// + [Fact] + public void ReadParameters_TargetInclineChanged_NegativeIncline_ReturnsNegativePercent() + { + // -20 as SINT16, little-endian + byte[] rawData = [0xEC, 0xFF]; + + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) + .Cast(); + + var parameter = Assert.Single(parameters); + Assert.Equal(FitnessMachineUnit.Percent, parameter.Unit); + Assert.Equal(-2.0, parameter.Value, precision: 1); // -20 * 0.1 + } + + /// + /// Tests that a targeted distance larger than 65,535 m (UINT24) is decoded fully. + /// + [Fact] + public void ReadParameters_TargetedDistanceChanged_ThreeByteDistance_ReturnsFullValue() + { + // UINT24 little-endian: 0x0F4240 = 1,000,000 meters + byte[] rawData = [0x40, 0x42, 0x0F]; + + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) + .Cast(); + + var parameter = Assert.Single(parameters); + Assert.Equal(FitnessMachineUnit.Meters, parameter.Unit); + Assert.Equal(1_000_000.0, parameter.Value); + } + + /// + /// Tests that target incline decodes positive, zero and negative SINT16 values correctly. + /// + [Theory] + [InlineData(new byte[] { 0x14, 0x00 }, 2.0)] // +20 -> 2.0 % + [InlineData(new byte[] { 0x00, 0x00 }, 0.0)] + [InlineData(new byte[] { 0xFF, 0xFF }, -0.1)] // -1 -> -0.1 % + public void ReadParameters_TargetInclineChanged_PositiveAndNegative_ReturnsCorrectPercent(byte[] rawData, double expected) + { + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) + .Cast(); + + Assert.Equal(expected, Assert.Single(parameters).Value, precision: 1); + } + + /// + /// Tests that targeted distance decodes three-byte UINT24 values, including the maximum. + /// + [Theory] + [InlineData(new byte[] { 0x64, 0x00, 0x00 }, 100.0)] + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF }, 16_777_215.0)] + public void ReadParameters_TargetedDistanceChanged_EncodesThreeByteValue(byte[] rawData, double expected) + { + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) + .Cast(); + + Assert.Equal(expected, Assert.Single(parameters).Value); + } +} diff --git a/FTMS.NET/State/FitnessMachineStateParameterFactory.cs b/FTMS.NET/State/FitnessMachineStateParameterFactory.cs index 309373c..fe658af 100644 --- a/FTMS.NET/State/FitnessMachineStateParameterFactory.cs +++ b/FTMS.NET/State/FitnessMachineStateParameterFactory.cs @@ -49,7 +49,7 @@ private static FitnessMachineStateParameter ReadTargetSpeed(byte[] rawData) => new("Target Speed", FitnessMachineUnit.KilometersPerHour, BitConverter.ToUInt16(rawData) * 0.01); private static FitnessMachineStateParameter ReadTargetIncline(byte[] rawData) - => new("Target Incline", FitnessMachineUnit.Percent, BitConverter.ToUInt16(rawData) * 0.1); + => new("Target Incline", FitnessMachineUnit.Percent, BitConverter.ToInt16(rawData) * 0.1); private static FitnessMachineStateParameter ReadTargetResistanceLevel(byte[] rawData) => new("Target Resistance Level", FitnessMachineUnit.None, rawData[0]); @@ -70,7 +70,7 @@ private static FitnessMachineStateParameter ReadTargetedNumberOfStrides(byte[] r => new("Targeted Number Of Strides", FitnessMachineUnit.Stride, BitConverter.ToUInt16(rawData)); private static FitnessMachineStateParameter ReadTargetedDistance(byte[] rawData) - => new("Targeted Distance", FitnessMachineUnit.Meters, new UInt24(BitConverter.ToUInt16(rawData))); + => new("Targeted Distance", FitnessMachineUnit.Meters, new UInt24(rawData[0], rawData[1], rawData[2])); private static FitnessMachineStateParameter ReadTargetedTrainingTime(byte[] rawData) => new("Targeted Training Time", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(rawData)); From 467a9a8146a5fd3c528d0a8752a3de6eb369c724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 23:02:48 +0200 Subject: [PATCH 07/10] Add procedure timeout to Fitness Machine Control Point Execute awaited a matching response with no bound; a server that never indicates a response caused Execute to hang forever. Add an injectable response timeout (default 5s) applied via the Rx Timeout operator after FirstAsync, scheduled on the injected scheduler so tests drive it with a TestScheduler. Timeout surfaces as ControlRequestException, keeping the public contract unchanged. --- .../Control/FitnessMachineControl.Tests.cs | 76 +++++++++++++++++++ FTMS.NET.Tests/FTMS.NET.Tests.csproj | 1 + FTMS.NET/Control/FitnessMachineControl.cs | 8 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs diff --git a/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs b/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs new file mode 100644 index 0000000..a0fd7ed --- /dev/null +++ b/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs @@ -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(); + 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(() => executeTask); + } + + [Fact] + public async Task Execute_ValidResponseIndicated_ReturnsResponse() + { + var controlPoint = new Subject(); + 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(); + 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(() => executeTask); + } + + [Fact] + public async Task Execute_WriteThrows_WrapsInControlRequestException() + { + var control = new FitnessMachineControl( + Observable.Never(), + _ => throw new Exception("simulated ATT error"), + responseTimeout: TimeSpan.FromSeconds(5)); + + await Assert.ThrowsAsync(() => + control.Execute(new ControlRequest(EControlOpCode.RequestControl, []))); + } +} diff --git a/FTMS.NET.Tests/FTMS.NET.Tests.csproj b/FTMS.NET.Tests/FTMS.NET.Tests.csproj index e46964d..df59ce7 100644 --- a/FTMS.NET.Tests/FTMS.NET.Tests.csproj +++ b/FTMS.NET.Tests/FTMS.NET.Tests.csproj @@ -10,6 +10,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/FTMS.NET/Control/FitnessMachineControl.cs b/FTMS.NET/Control/FitnessMachineControl.cs index efe4323..a4f20c9 100644 --- a/FTMS.NET/Control/FitnessMachineControl.cs +++ b/FTMS.NET/Control/FitnessMachineControl.cs @@ -14,18 +14,23 @@ internal sealed class FitnessMachineControl : IFitnessMachineControl private readonly Func writeControlPoint; private readonly CancellationDisposable cancellationDisposable = new(); private readonly IObservable responseObservable; + private readonly TimeSpan responseTimeout; + private readonly IScheduler scheduler; internal FitnessMachineControl( IObservable observeControlPoint, Func writeControlPoint, + TimeSpan? responseTimeout = null, IScheduler? scheduler = null) { this.writeControlPoint = writeControlPoint; + this.scheduler = scheduler ?? DefaultScheduler.Instance; + this.responseTimeout = responseTimeout ?? TimeSpan.FromSeconds(5); this.responseObservable = observeControlPoint .TakeUntil(this.cancellationDisposable.Token) .Select(this.ReadResponseData) .Publish() - .RefCount(TimeSpan.FromSeconds(5), scheduler ?? DefaultScheduler.Instance); + .RefCount(TimeSpan.FromSeconds(5), this.scheduler); } public async Task Execute(ControlRequest request) @@ -34,6 +39,7 @@ public async Task Execute(ControlRequest request) var responseTask = this.responseObservable .FirstAsync(response => response.RequestedOpCode == request.OpCode) + .Timeout(this.responseTimeout, this.scheduler) .ToTask(); byte[] writeValue = [(byte)request.OpCode, .. request.Parameter]; From ae491048754fa3e2272ecfef12bc3898b14ceba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 23:08:40 +0200 Subject: [PATCH 08/10] format files --- .../Control/ControlExtensions.Tests.cs | 86 +- .../Control/FitnessMachineControl.Tests.cs | 152 +- .../FitnessMachineServiceFactory.Tests.cs | 94 +- FTMS.NET.Tests/FtmsUuids.Tests.cs | 124 +- ...tnessMachineStateParameterFactory.Tests.cs | 150 +- FTMS.NET.Tests/Utils/GenericMathTests.cs | 1498 ++++++++--------- FTMS.NET.Tests/Utils/UInt24Tests.cs | 74 +- FTMS.NET.Tests/Utils/ValueCalculationTests.cs | 512 +++--- FTMS.NET/Control/ControlExtensions.cs | 290 ++-- FTMS.NET/Control/ControlRequest.cs | 8 +- FTMS.NET/Control/ControlResponse.cs | 10 +- FTMS.NET/Control/EControlOpCode.cs | 51 +- FTMS.NET/Control/EControlResultCode.cs | 19 +- FTMS.NET/Control/EStopOrPauseCode.cs | 13 +- FTMS.NET/Control/FitnessMachineControl.cs | 164 +- FTMS.NET/Control/IFitnessMachineControl.cs | 16 +- FTMS.NET/Data/CrossTrainerData.cs | 64 +- FTMS.NET/Data/FitnessMachineData.cs | 48 +- FTMS.NET/Data/FitnessMachineDataReader.cs | 28 +- FTMS.NET/Data/FitnessMachineValue.cs | 9 +- FTMS.NET/Data/ICrossTrainerData.cs | 50 +- FTMS.NET/Data/IFitnessMachineData.cs | 20 +- FTMS.NET/Data/IFitnessMachineValue.cs | 20 +- FTMS.NET/Data/IIndoorBikeData.cs | 39 +- FTMS.NET/Data/IRowerData.cs | 42 +- FTMS.NET/Data/IStairClimberData.cs | 34 +- FTMS.NET/Data/IStepClimberData.cs | 34 +- FTMS.NET/Data/IThreadmillData.cs | 46 +- FTMS.NET/Data/IndoorBikeData.cs | 54 +- FTMS.NET/Data/RowerData.cs | 56 +- FTMS.NET/Data/SingleFrameReader.cs | 136 +- FTMS.NET/Data/SingleFrameStrategies.cs | 316 ++-- FTMS.NET/Data/SingleFrameStrategy.cs | 68 +- FTMS.NET/Data/StairClimberData.cs | 48 +- FTMS.NET/Data/StepClimberData.cs | 48 +- FTMS.NET/Data/ThreadmillData.cs | 60 +- FTMS.NET/EFitnessMachineType.cs | 72 +- .../Exceptions/ControlRequestException.cs | 38 +- .../FitnessMachineNotAvailableException.cs | 9 +- .../FitnessMachineTypeNotDefinedException.cs | 18 +- ...ededCharacteristicNotAvailableException.cs | 22 +- FTMS.NET/Features/FitnessMachineFeatures.cs | 112 +- FTMS.NET/Features/IFitnessMachineFeatures.cs | 91 +- FTMS.NET/Features/ISupportedRange.cs | 17 +- .../Features/ISupportedRangeExtensions.cs | 30 +- FTMS.NET/Features/RangeCalculations.cs | 24 +- FTMS.NET/Features/SupportedRange.cs | 92 +- FTMS.NET/FitnessMachineService.cs | 90 +- FTMS.NET/FitnessMachineServiceFactory.cs | 246 +-- FTMS.NET/FitnessMachineUnit.cs | 37 +- FTMS.NET/FtmsUuids.cs | 158 +- FTMS.NET/IFitnessMachineCharacteristic.cs | 24 +- FTMS.NET/IFitnessMachineService.cs | 32 +- FTMS.NET/IFitnessMachineServiceConnection.cs | 20 +- FTMS.NET/State/ESpinDownState.cs | 17 +- FTMS.NET/State/EStateOpCode.cs | 55 +- FTMS.NET/State/ETrainingState.cs | 41 +- FTMS.NET/State/FitnessMachineState.cs | 28 +- .../State/FitnessMachineStateParameter.cs | 10 +- .../FitnessMachineStateParameterFactory.cs | 256 +-- FTMS.NET/State/FitnessMachineStateProvider.cs | 146 +- FTMS.NET/State/IFitnessMachineState.cs | 20 +- .../State/IFitnessMachineStateProvider.cs | 18 +- FTMS.NET/State/ITrainingState.cs | 13 +- FTMS.NET/State/TrainingState.cs | 4 +- FTMS.NET/ThrowingCharacteristic.cs | 38 +- FTMS.NET/Utils/ByteExtensions.cs | 78 +- FTMS.NET/Utils/GenericMath.cs | 56 +- FTMS.NET/Utils/UInt24.cs | 162 +- FTMS.NET/Utils/ValueCalculation.cs | 19 +- 70 files changed, 3294 insertions(+), 3280 deletions(-) diff --git a/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs b/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs index d92e1fd..8388d5e 100644 --- a/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs +++ b/FTMS.NET.Tests/Control/ControlExtensions.Tests.cs @@ -1,43 +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 Execute(ControlRequest request) - { - this.Request = request; - return Task.FromResult(new ControlResponse(request.OpCode, EControlResultCode.Success, [])); - } - - public void Dispose() { } - } -} +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 Execute(ControlRequest request) + { + this.Request = request; + return Task.FromResult(new ControlResponse(request.OpCode, EControlResultCode.Success, [])); + } + + public void Dispose() { } + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs b/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs index a0fd7ed..701f797 100644 --- a/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs +++ b/FTMS.NET.Tests/Control/FitnessMachineControl.Tests.cs @@ -1,76 +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(); - 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(() => executeTask); - } - - [Fact] - public async Task Execute_ValidResponseIndicated_ReturnsResponse() - { - var controlPoint = new Subject(); - 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(); - 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(() => executeTask); - } - - [Fact] - public async Task Execute_WriteThrows_WrapsInControlRequestException() - { - var control = new FitnessMachineControl( - Observable.Never(), - _ => throw new Exception("simulated ATT error"), - responseTimeout: TimeSpan.FromSeconds(5)); - - await Assert.ThrowsAsync(() => - control.Execute(new ControlRequest(EControlOpCode.RequestControl, []))); - } -} +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(); + 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(() => executeTask); + } + + [Fact] + public async Task Execute_ValidResponseIndicated_ReturnsResponse() + { + var controlPoint = new Subject(); + 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(); + 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(() => executeTask); + } + + [Fact] + public async Task Execute_WriteThrows_WrapsInControlRequestException() + { + var control = new FitnessMachineControl( + Observable.Never(), + _ => throw new Exception("simulated ATT error"), + responseTimeout: TimeSpan.FromSeconds(5)); + + await Assert.ThrowsAsync(() => + control.Execute(new ControlRequest(EControlOpCode.RequestControl, []))); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs b/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs index afa8371..81a6816 100644 --- a/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs +++ b/FTMS.NET.Tests/FitnessMachineServiceFactory.Tests.cs @@ -1,47 +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 ReadValueAsync() => Task.FromResult(value); - public Task WriteValueAsync(byte[] value) => Task.CompletedTask; - public IObservable ObserveValue() => Observable.Empty(); - } - - private sealed class FakeConnection(byte[] featureValue) : IFitnessMachineServiceConnection - { - public byte[] ServiceData { get; } = []; - public Task GetCharacteristicAsync(Guid id) - => Task.FromResult( - id == FtmsUuids.Feature ? new FakeCharacteristic(featureValue) : null); - } -} +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 ReadValueAsync() => Task.FromResult(value); + public Task WriteValueAsync(byte[] value) => Task.CompletedTask; + public IObservable ObserveValue() => Observable.Empty(); + } + + private sealed class FakeConnection(byte[] featureValue) : IFitnessMachineServiceConnection + { + public byte[] ServiceData { get; } = []; + public Task GetCharacteristicAsync(Guid id) + => Task.FromResult( + id == FtmsUuids.Feature ? new FakeCharacteristic(featureValue) : null); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/FtmsUuids.Tests.cs b/FTMS.NET.Tests/FtmsUuids.Tests.cs index 3e941c9..8507662 100644 --- a/FTMS.NET.Tests/FtmsUuids.Tests.cs +++ b/FTMS.NET.Tests/FtmsUuids.Tests.cs @@ -1,62 +1,62 @@ -namespace FTMS.NET.Tests; - -using System; - -public sealed class FtmsUuids_Tests -{ - public static readonly TheoryData 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 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); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs b/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs index daec1c5..1be1915 100644 --- a/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs +++ b/FTMS.NET.Tests/State/FitnessMachineStateParameterFactory.Tests.cs @@ -1,75 +1,75 @@ -namespace FTMS.NET.Tests.State; - -using FTMS.NET.State; -using System.Collections.Generic; -using System.Linq; - -public sealed class FitnessMachineStateParameterFactoryTests -{ - /// - /// Tests that a negative target incline (SINT16) is decoded as a negative percent value. - /// - [Fact] - public void ReadParameters_TargetInclineChanged_NegativeIncline_ReturnsNegativePercent() - { - // -20 as SINT16, little-endian - byte[] rawData = [0xEC, 0xFF]; - - var parameters = FitnessMachineStateParameterFactory - .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) - .Cast(); - - var parameter = Assert.Single(parameters); - Assert.Equal(FitnessMachineUnit.Percent, parameter.Unit); - Assert.Equal(-2.0, parameter.Value, precision: 1); // -20 * 0.1 - } - - /// - /// Tests that a targeted distance larger than 65,535 m (UINT24) is decoded fully. - /// - [Fact] - public void ReadParameters_TargetedDistanceChanged_ThreeByteDistance_ReturnsFullValue() - { - // UINT24 little-endian: 0x0F4240 = 1,000,000 meters - byte[] rawData = [0x40, 0x42, 0x0F]; - - var parameters = FitnessMachineStateParameterFactory - .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) - .Cast(); - - var parameter = Assert.Single(parameters); - Assert.Equal(FitnessMachineUnit.Meters, parameter.Unit); - Assert.Equal(1_000_000.0, parameter.Value); - } - - /// - /// Tests that target incline decodes positive, zero and negative SINT16 values correctly. - /// - [Theory] - [InlineData(new byte[] { 0x14, 0x00 }, 2.0)] // +20 -> 2.0 % - [InlineData(new byte[] { 0x00, 0x00 }, 0.0)] - [InlineData(new byte[] { 0xFF, 0xFF }, -0.1)] // -1 -> -0.1 % - public void ReadParameters_TargetInclineChanged_PositiveAndNegative_ReturnsCorrectPercent(byte[] rawData, double expected) - { - var parameters = FitnessMachineStateParameterFactory - .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) - .Cast(); - - Assert.Equal(expected, Assert.Single(parameters).Value, precision: 1); - } - - /// - /// Tests that targeted distance decodes three-byte UINT24 values, including the maximum. - /// - [Theory] - [InlineData(new byte[] { 0x64, 0x00, 0x00 }, 100.0)] - [InlineData(new byte[] { 0xFF, 0xFF, 0xFF }, 16_777_215.0)] - public void ReadParameters_TargetedDistanceChanged_EncodesThreeByteValue(byte[] rawData, double expected) - { - var parameters = FitnessMachineStateParameterFactory - .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) - .Cast(); - - Assert.Equal(expected, Assert.Single(parameters).Value); - } -} +namespace FTMS.NET.Tests.State; + +using FTMS.NET.State; +using System.Collections.Generic; +using System.Linq; + +public sealed class FitnessMachineStateParameterFactoryTests +{ + /// + /// Tests that a negative target incline (SINT16) is decoded as a negative percent value. + /// + [Fact] + public void ReadParameters_TargetInclineChanged_NegativeIncline_ReturnsNegativePercent() + { + // -20 as SINT16, little-endian + byte[] rawData = [0xEC, 0xFF]; + + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) + .Cast(); + + var parameter = Assert.Single(parameters); + Assert.Equal(FitnessMachineUnit.Percent, parameter.Unit); + Assert.Equal(-2.0, parameter.Value, precision: 1); // -20 * 0.1 + } + + /// + /// Tests that a targeted distance larger than 65,535 m (UINT24) is decoded fully. + /// + [Fact] + public void ReadParameters_TargetedDistanceChanged_ThreeByteDistance_ReturnsFullValue() + { + // UINT24 little-endian: 0x0F4240 = 1,000,000 meters + byte[] rawData = [0x40, 0x42, 0x0F]; + + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) + .Cast(); + + var parameter = Assert.Single(parameters); + Assert.Equal(FitnessMachineUnit.Meters, parameter.Unit); + Assert.Equal(1_000_000.0, parameter.Value); + } + + /// + /// Tests that target incline decodes positive, zero and negative SINT16 values correctly. + /// + [Theory] + [InlineData(new byte[] { 0x14, 0x00 }, 2.0)] // +20 -> 2.0 % + [InlineData(new byte[] { 0x00, 0x00 }, 0.0)] + [InlineData(new byte[] { 0xFF, 0xFF }, -0.1)] // -1 -> -0.1 % + public void ReadParameters_TargetInclineChanged_PositiveAndNegative_ReturnsCorrectPercent(byte[] rawData, double expected) + { + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetInclineChanged, rawData) + .Cast(); + + Assert.Equal(expected, Assert.Single(parameters).Value, precision: 1); + } + + /// + /// Tests that targeted distance decodes three-byte UINT24 values, including the maximum. + /// + [Theory] + [InlineData(new byte[] { 0x64, 0x00, 0x00 }, 100.0)] + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF }, 16_777_215.0)] + public void ReadParameters_TargetedDistanceChanged_EncodesThreeByteValue(byte[] rawData, double expected) + { + var parameters = FitnessMachineStateParameterFactory + .ReadParameters(EStateOpCode.TargetedDistanceChanged, rawData) + .Cast(); + + Assert.Equal(expected, Assert.Single(parameters).Value); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/Utils/GenericMathTests.cs b/FTMS.NET.Tests/Utils/GenericMathTests.cs index 1a73a76..1e4461d 100644 --- a/FTMS.NET.Tests/Utils/GenericMathTests.cs +++ b/FTMS.NET.Tests/Utils/GenericMathTests.cs @@ -1,750 +1,750 @@ -namespace FTMS.NET.Tests.Utils; - -using FTMS.NET.Utils; - -/// -/// Unit tests for the class. -/// -public sealed class GenericMathTests -{ - /// - /// Tests that Clamp returns the minimum value when the input value is below the minimum. - /// - [Theory] - [InlineData(5, 10, 20, 10)] - [InlineData(-10, 0, 100, 0)] - [InlineData(int.MinValue, 0, 100, 0)] - public void Clamp_ValueBelowMin_ReturnsMin(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp returns the maximum value when the input value is above the maximum. - /// - [Theory] - [InlineData(25, 10, 20, 20)] - [InlineData(150, 0, 100, 100)] - [InlineData(int.MaxValue, 0, 100, 100)] - public void Clamp_ValueAboveMax_ReturnsMax(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp returns the original value when it is within the valid range. - /// - [Theory] - [InlineData(15, 10, 20, 15)] - [InlineData(50, 0, 100, 50)] - [InlineData(0, -100, 100, 0)] - public void Clamp_ValueWithinRange_ReturnsValue(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp returns the value when it equals the minimum boundary. - /// - [Theory] - [InlineData(10, 10, 20, 10)] - [InlineData(0, 0, 100, 0)] - [InlineData(int.MinValue, int.MinValue, int.MaxValue, int.MinValue)] - public void Clamp_ValueEqualsMin_ReturnsValue(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp returns the value when it equals the maximum boundary. - /// - [Theory] - [InlineData(20, 10, 20, 20)] - [InlineData(100, 0, 100, 100)] - [InlineData(int.MaxValue, int.MinValue, int.MaxValue, int.MaxValue)] - public void Clamp_ValueEqualsMax_ReturnsValue(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp handles extreme integer boundary values correctly. - /// - [Fact] - public void Clamp_IntegerBoundaryValues_HandlesCorrectly() - { - // Arrange & Act - int result1 = GenericMath.Clamp(int.MinValue, int.MinValue, int.MaxValue); - int result2 = GenericMath.Clamp(int.MaxValue, int.MinValue, int.MaxValue); - int result3 = GenericMath.Clamp(0, int.MinValue, int.MaxValue); - - // Assert - Assert.Equal(int.MinValue, result1); - Assert.Equal(int.MaxValue, result2); - Assert.Equal(0, result3); - } - - /// - /// Tests that Clamp works with different numeric types using saturating conversion. - /// - [Fact] - public void Clamp_CrossTypeConversion_UsesCreateSaturating() - { - // Arrange - int value with byte min and short max - int value = 150; - byte min = 50; - short max = 200; - - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(150, result); - } - - /// - /// Tests that Clamp saturates when max value exceeds TValue's representable range. - /// - [Fact] - public void Clamp_MaxExceedsByteRange_SaturatesToByteMax() - { - // Arrange - byte value with int max that exceeds byte range - byte value = 250; - byte min = 0; - int max = 1000; // Exceeds byte.MaxValue (255), should saturate to 255 - - // Act - byte result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(250, result); - } - - /// - /// Tests that Clamp saturates when min value exceeds TValue's representable range. - /// - [Fact] - public void Clamp_MinExceedsByteRange_SaturatesToByteMax() - { - // Arrange - byte value with int min that exceeds byte range - byte value = 10; - int min = 300; // Exceeds byte.MaxValue (255), should saturate to 255 - byte max = 255; - - // Act - byte result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(255, result); // value (10) < saturated min (255), returns saturated min - } - - /// - /// Tests that Clamp handles negative minimum values with unsigned types. - /// - [Fact] - public void Clamp_NegativeMinWithUnsignedType_SaturatesToZero() - { - // Arrange - byte value with negative min (saturates to 0 for unsigned byte) - byte value = 50; - int min = -100; // Negative, saturates to 0 for byte - byte max = 200; - - // Act - byte result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(50, result); - } - - /// - /// Tests that Clamp works correctly with double precision floating-point values. - /// - [Theory] - [InlineData(5.5, 1.0, 10.0, 5.5)] - [InlineData(0.5, 1.0, 10.0, 1.0)] - [InlineData(15.5, 1.0, 10.0, 10.0)] - public void Clamp_DoubleValues_ClampsCorrectly(double value, double min, double max, double expected) - { - // Act - double result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp handles positive infinity by clamping to max. - /// - [Fact] - public void Clamp_PositiveInfinity_ClampsToMax() - { - // Arrange - double value = double.PositiveInfinity; - double min = 0.0; - double max = 100.0; - - // Act - double result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(100.0, result); - } - - /// - /// Tests that Clamp handles negative infinity by clamping to min. - /// - [Fact] - public void Clamp_NegativeInfinity_ClampsToMin() - { - // Arrange - double value = double.NegativeInfinity; - double min = -100.0; - double max = 100.0; - - // Act - double result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(-100.0, result); - } - - /// - /// Tests that Clamp handles NaN values, which have special comparison behavior. - /// NaN comparisons always return false, so NaN > tMin is false and NaN < tMax is false. - /// - [Fact] - public void Clamp_NaNValue_ReturnsNaN() - { - // Arrange - double value = double.NaN; - double min = 0.0; - double max = 100.0; - - // Act - double result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.True(double.IsNaN(result)); - } - - /// - /// Tests that Clamp works correctly with long integer values at boundaries. - /// - [Fact] - public void Clamp_LongBoundaryValues_HandlesCorrectly() - { - // Arrange & Act - long result1 = GenericMath.Clamp(long.MinValue, long.MinValue, long.MaxValue); - long result2 = GenericMath.Clamp(long.MaxValue, long.MinValue, long.MaxValue); - long result3 = GenericMath.Clamp(0L, long.MinValue, long.MaxValue); - - // Assert - Assert.Equal(long.MinValue, result1); - Assert.Equal(long.MaxValue, result2); - Assert.Equal(0L, result3); - } - - /// - /// Tests that Clamp works correctly with float values. - /// - [Theory] - [InlineData(5.5f, 1.0f, 10.0f, 5.5f)] - [InlineData(0.5f, 1.0f, 10.0f, 1.0f)] - [InlineData(15.5f, 1.0f, 10.0f, 10.0f)] - public void Clamp_FloatValues_ClampsCorrectly(float value, float min, float max, float expected) - { - // Act - float result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Clamp works correctly with decimal values. - /// - [Fact] - public void Clamp_DecimalValues_ClampsCorrectly() - { - // Arrange - decimal value1 = 5.5m; - decimal value2 = 0.5m; - decimal value3 = 15.5m; - decimal min = 1.0m; - decimal max = 10.0m; - - // Act - decimal result1 = GenericMath.Clamp(value1, min, max); - decimal result2 = GenericMath.Clamp(value2, min, max); - decimal result3 = GenericMath.Clamp(value3, min, max); - - // Assert - Assert.Equal(5.5m, result1); - Assert.Equal(1.0m, result2); - Assert.Equal(10.0m, result3); - } - - /// - /// Tests that Clamp handles the scenario where min is greater than max. - /// In this case, tMin > value is checked first, so if value < min, min is returned. - /// If value >= min (which is > max), then tMax < value will be true, so max is returned. - /// - [Fact] - public void Clamp_MinGreaterThanMax_ReturnsBasedOnFirstCheck() - { - // Arrange - int value1 = 5; - int value2 = 20; - int value3 = 25; - int min = 20; - int max = 10; - - // Act - int result1 = GenericMath.Clamp(value1, min, max); - int result2 = GenericMath.Clamp(value2, min, max); - int result3 = GenericMath.Clamp(value3, min, max); - - // Assert - Assert.Equal(20, result1); // value < min, returns min - Assert.Equal(10, result2); // min check fails, value > max, returns max - Assert.Equal(10, result3); // min check fails, value > max, returns max - } - - /// - /// Tests that Clamp works with short integer values. - /// - [Fact] - public void Clamp_ShortValues_ClampsCorrectly() - { - // Arrange - short value = 150; - short min = 100; - short max = 200; - - // Act - short result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal((short)150, result); - } - - /// - /// Tests that Clamp works with short values at boundaries. - /// - [Fact] - public void Clamp_ShortBoundaryValues_HandlesCorrectly() - { - // Arrange & Act - short result1 = GenericMath.Clamp(short.MinValue, short.MinValue, short.MaxValue); - short result2 = GenericMath.Clamp(short.MaxValue, short.MinValue, short.MaxValue); - - // Assert - Assert.Equal(short.MinValue, result1); - Assert.Equal(short.MaxValue, result2); - } - - /// - /// Tests that Clamp works correctly with zero values for all parameters. - /// - [Fact] - public void Clamp_AllZeroValues_ReturnsZero() - { - // Arrange - int value = 0; - int min = 0; - int max = 0; - - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(0, result); - } - - /// - /// Tests that Clamp works with negative values for all parameters. - /// - [Theory] - [InlineData(-15, -20, -10, -15)] - [InlineData(-25, -20, -10, -20)] - [InlineData(-5, -20, -10, -10)] - public void Clamp_NegativeValues_ClampsCorrectly(int value, int min, int max, int expected) - { - // Act - int result = GenericMath.Clamp(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange returns true when value is within the inclusive range [min, max]. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - [Theory] - [InlineData(5, 1, 10)] - [InlineData(0, -10, 10)] - [InlineData(-5, -10, 0)] - [InlineData(100, 100, 200)] - [InlineData(200, 100, 200)] - [InlineData(0, 0, 0)] - public void IsInRange_ValueWithinRange_ReturnsTrue(int value, int min, int max) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsInRange returns false when value is outside the range [min, max]. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - [Theory] - [InlineData(0, 1, 10)] - [InlineData(11, 1, 10)] - [InlineData(-11, -10, 0)] - [InlineData(1, 2, 10)] - [InlineData(99, 100, 200)] - [InlineData(201, 100, 200)] - public void IsInRange_ValueOutsideRange_ReturnsFalse(int value, int min, int max) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsInRange correctly handles boundary values including int.MinValue and int.MaxValue. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(int.MinValue, int.MinValue, int.MaxValue, true)] - [InlineData(int.MaxValue, int.MinValue, int.MaxValue, true)] - [InlineData(0, int.MinValue, int.MaxValue, true)] - [InlineData(int.MinValue, int.MinValue, int.MinValue, true)] - [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, true)] - [InlineData(int.MinValue, 0, int.MaxValue, false)] - [InlineData(int.MaxValue, int.MinValue, 0, false)] - public void IsInRange_BoundaryValues_ReturnsExpected(int value, int min, int max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange works correctly with double precision floating point values. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(5.5, 1.0, 10.0, true)] - [InlineData(1.0, 1.0, 10.0, true)] - [InlineData(10.0, 1.0, 10.0, true)] - [InlineData(0.5, 1.0, 10.0, false)] - [InlineData(10.5, 1.0, 10.0, false)] - [InlineData(-0.001, -0.001, 0.001, true)] - public void IsInRange_DoubleValues_ReturnsExpected(double value, double min, double max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange handles double.NaN correctly. NaN comparisons always return false. - /// - [Fact] - public void IsInRange_ValueIsNaN_ReturnsFalse() - { - // Arrange - double value = double.NaN; - double min = 0.0; - double max = 10.0; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsInRange handles double.PositiveInfinity correctly. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(double.PositiveInfinity, 0.0, double.PositiveInfinity, true)] - [InlineData(double.PositiveInfinity, 0.0, 100.0, false)] - [InlineData(5.0, 0.0, double.PositiveInfinity, true)] - public void IsInRange_PositiveInfinity_ReturnsExpected(double value, double min, double max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange handles double.NegativeInfinity correctly. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(double.NegativeInfinity, double.NegativeInfinity, 0.0, true)] - [InlineData(double.NegativeInfinity, -100.0, 0.0, false)] - [InlineData(5.0, double.NegativeInfinity, 10.0, true)] - public void IsInRange_NegativeInfinity_ReturnsExpected(double value, double min, double max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange works correctly with mixed numeric types, relying on CreateSaturating conversions. - /// - [Fact] - public void IsInRange_MixedNumericTypes_IntValueShortMinLongMax_ReturnsTrue() - { - // Arrange - int value = 1000; - short min = 100; - long max = 10000L; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsInRange with mixed types returns false when value is outside range. - /// - [Fact] - public void IsInRange_MixedNumericTypes_IntValueOutsideRange_ReturnsFalse() - { - // Arrange - int value = 50; - short min = 100; - long max = 10000L; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsInRange handles saturation when min/max types have larger range than TValue. - /// When converting a value larger than byte.MaxValue to byte, it saturates to 255. - /// - [Fact] - public void IsInRange_TypeSaturation_MaxExceedsByteRange_Saturates() - { - // Arrange - byte value = 200; - int min = -100; // Saturates to 0 when converted to byte - int max = 300; // Saturates to 255 when converted to byte - - // Act - // After saturation: IsInRange(200, 0, 255) - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsInRange handles saturation when min is negative but TValue is unsigned. - /// - [Fact] - public void IsInRange_TypeSaturation_NegativeMinWithUnsignedValue_Saturates() - { - // Arrange - byte value = 5; - int min = -100; // Saturates to 0 when converted to byte - int max = 10; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsInRange returns false for all values when min > max (invalid range). - /// - /// The value to test. - [Theory] - [InlineData(0)] - [InlineData(5)] - [InlineData(10)] - [InlineData(15)] - [InlineData(-5)] - public void IsInRange_MinGreaterThanMax_ReturnsFalse(int value) - { - // Arrange - int min = 10; - int max = 5; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsInRange works correctly with decimal type values. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(5.5, 1.0, 10.0, true)] - [InlineData(0.5, 1.0, 10.0, false)] - [InlineData(10.5, 1.0, 10.0, false)] - public void IsInRange_DecimalValues_ReturnsExpected(double valueDouble, double minDouble, double maxDouble, bool expected) - { - // Arrange - decimal value = (decimal)valueDouble; - decimal min = (decimal)minDouble; - decimal max = (decimal)maxDouble; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange works correctly with float type values. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(5.5f, 1.0f, 10.0f, true)] - [InlineData(1.0f, 1.0f, 10.0f, true)] - [InlineData(10.0f, 1.0f, 10.0f, true)] - [InlineData(0.5f, 1.0f, 10.0f, false)] - [InlineData(10.5f, 1.0f, 10.0f, false)] - public void IsInRange_FloatValues_ReturnsExpected(float value, float min, float max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that IsInRange handles float.NaN correctly. NaN comparisons always return false. - /// - [Fact] - public void IsInRange_FloatNaN_ReturnsFalse() - { - // Arrange - float value = float.NaN; - float min = 0.0f; - float max = 10.0f; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.False(result); - } - - /// - /// Tests that IsInRange works with long type values and boundaries. - /// - [Fact] - public void IsInRange_LongBoundaryValues_ReturnsTrue() - { - // Arrange - long value = long.MaxValue; - long min = long.MinValue; - long max = long.MaxValue; - - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.True(result); - } - - /// - /// Tests that IsInRange works with negative values across the range. - /// - /// The value to test. - /// The minimum bound. - /// The maximum bound. - /// The expected result. - [Theory] - [InlineData(-5, -10, -1, true)] - [InlineData(-10, -10, -1, true)] - [InlineData(-1, -10, -1, true)] - [InlineData(0, -10, -1, false)] - [InlineData(-11, -10, -1, false)] - public void IsInRange_NegativeValues_ReturnsExpected(int value, int min, int max, bool expected) - { - // Act - bool result = GenericMath.IsInRange(value, min, max); - - // Assert - Assert.Equal(expected, result); - } +namespace FTMS.NET.Tests.Utils; + +using FTMS.NET.Utils; + +/// +/// Unit tests for the class. +/// +public sealed class GenericMathTests +{ + /// + /// Tests that Clamp returns the minimum value when the input value is below the minimum. + /// + [Theory] + [InlineData(5, 10, 20, 10)] + [InlineData(-10, 0, 100, 0)] + [InlineData(int.MinValue, 0, 100, 0)] + public void Clamp_ValueBelowMin_ReturnsMin(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp returns the maximum value when the input value is above the maximum. + /// + [Theory] + [InlineData(25, 10, 20, 20)] + [InlineData(150, 0, 100, 100)] + [InlineData(int.MaxValue, 0, 100, 100)] + public void Clamp_ValueAboveMax_ReturnsMax(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp returns the original value when it is within the valid range. + /// + [Theory] + [InlineData(15, 10, 20, 15)] + [InlineData(50, 0, 100, 50)] + [InlineData(0, -100, 100, 0)] + public void Clamp_ValueWithinRange_ReturnsValue(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp returns the value when it equals the minimum boundary. + /// + [Theory] + [InlineData(10, 10, 20, 10)] + [InlineData(0, 0, 100, 0)] + [InlineData(int.MinValue, int.MinValue, int.MaxValue, int.MinValue)] + public void Clamp_ValueEqualsMin_ReturnsValue(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp returns the value when it equals the maximum boundary. + /// + [Theory] + [InlineData(20, 10, 20, 20)] + [InlineData(100, 0, 100, 100)] + [InlineData(int.MaxValue, int.MinValue, int.MaxValue, int.MaxValue)] + public void Clamp_ValueEqualsMax_ReturnsValue(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp handles extreme integer boundary values correctly. + /// + [Fact] + public void Clamp_IntegerBoundaryValues_HandlesCorrectly() + { + // Arrange & Act + int result1 = GenericMath.Clamp(int.MinValue, int.MinValue, int.MaxValue); + int result2 = GenericMath.Clamp(int.MaxValue, int.MinValue, int.MaxValue); + int result3 = GenericMath.Clamp(0, int.MinValue, int.MaxValue); + + // Assert + Assert.Equal(int.MinValue, result1); + Assert.Equal(int.MaxValue, result2); + Assert.Equal(0, result3); + } + + /// + /// Tests that Clamp works with different numeric types using saturating conversion. + /// + [Fact] + public void Clamp_CrossTypeConversion_UsesCreateSaturating() + { + // Arrange - int value with byte min and short max + int value = 150; + byte min = 50; + short max = 200; + + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(150, result); + } + + /// + /// Tests that Clamp saturates when max value exceeds TValue's representable range. + /// + [Fact] + public void Clamp_MaxExceedsByteRange_SaturatesToByteMax() + { + // Arrange - byte value with int max that exceeds byte range + byte value = 250; + byte min = 0; + int max = 1000; // Exceeds byte.MaxValue (255), should saturate to 255 + + // Act + byte result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(250, result); + } + + /// + /// Tests that Clamp saturates when min value exceeds TValue's representable range. + /// + [Fact] + public void Clamp_MinExceedsByteRange_SaturatesToByteMax() + { + // Arrange - byte value with int min that exceeds byte range + byte value = 10; + int min = 300; // Exceeds byte.MaxValue (255), should saturate to 255 + byte max = 255; + + // Act + byte result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(255, result); // value (10) < saturated min (255), returns saturated min + } + + /// + /// Tests that Clamp handles negative minimum values with unsigned types. + /// + [Fact] + public void Clamp_NegativeMinWithUnsignedType_SaturatesToZero() + { + // Arrange - byte value with negative min (saturates to 0 for unsigned byte) + byte value = 50; + int min = -100; // Negative, saturates to 0 for byte + byte max = 200; + + // Act + byte result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(50, result); + } + + /// + /// Tests that Clamp works correctly with double precision floating-point values. + /// + [Theory] + [InlineData(5.5, 1.0, 10.0, 5.5)] + [InlineData(0.5, 1.0, 10.0, 1.0)] + [InlineData(15.5, 1.0, 10.0, 10.0)] + public void Clamp_DoubleValues_ClampsCorrectly(double value, double min, double max, double expected) + { + // Act + double result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp handles positive infinity by clamping to max. + /// + [Fact] + public void Clamp_PositiveInfinity_ClampsToMax() + { + // Arrange + double value = double.PositiveInfinity; + double min = 0.0; + double max = 100.0; + + // Act + double result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(100.0, result); + } + + /// + /// Tests that Clamp handles negative infinity by clamping to min. + /// + [Fact] + public void Clamp_NegativeInfinity_ClampsToMin() + { + // Arrange + double value = double.NegativeInfinity; + double min = -100.0; + double max = 100.0; + + // Act + double result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(-100.0, result); + } + + /// + /// Tests that Clamp handles NaN values, which have special comparison behavior. + /// NaN comparisons always return false, so NaN > tMin is false and NaN < tMax is false. + /// + [Fact] + public void Clamp_NaNValue_ReturnsNaN() + { + // Arrange + double value = double.NaN; + double min = 0.0; + double max = 100.0; + + // Act + double result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.True(double.IsNaN(result)); + } + + /// + /// Tests that Clamp works correctly with long integer values at boundaries. + /// + [Fact] + public void Clamp_LongBoundaryValues_HandlesCorrectly() + { + // Arrange & Act + long result1 = GenericMath.Clamp(long.MinValue, long.MinValue, long.MaxValue); + long result2 = GenericMath.Clamp(long.MaxValue, long.MinValue, long.MaxValue); + long result3 = GenericMath.Clamp(0L, long.MinValue, long.MaxValue); + + // Assert + Assert.Equal(long.MinValue, result1); + Assert.Equal(long.MaxValue, result2); + Assert.Equal(0L, result3); + } + + /// + /// Tests that Clamp works correctly with float values. + /// + [Theory] + [InlineData(5.5f, 1.0f, 10.0f, 5.5f)] + [InlineData(0.5f, 1.0f, 10.0f, 1.0f)] + [InlineData(15.5f, 1.0f, 10.0f, 10.0f)] + public void Clamp_FloatValues_ClampsCorrectly(float value, float min, float max, float expected) + { + // Act + float result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Clamp works correctly with decimal values. + /// + [Fact] + public void Clamp_DecimalValues_ClampsCorrectly() + { + // Arrange + decimal value1 = 5.5m; + decimal value2 = 0.5m; + decimal value3 = 15.5m; + decimal min = 1.0m; + decimal max = 10.0m; + + // Act + decimal result1 = GenericMath.Clamp(value1, min, max); + decimal result2 = GenericMath.Clamp(value2, min, max); + decimal result3 = GenericMath.Clamp(value3, min, max); + + // Assert + Assert.Equal(5.5m, result1); + Assert.Equal(1.0m, result2); + Assert.Equal(10.0m, result3); + } + + /// + /// Tests that Clamp handles the scenario where min is greater than max. + /// In this case, tMin > value is checked first, so if value < min, min is returned. + /// If value >= min (which is > max), then tMax < value will be true, so max is returned. + /// + [Fact] + public void Clamp_MinGreaterThanMax_ReturnsBasedOnFirstCheck() + { + // Arrange + int value1 = 5; + int value2 = 20; + int value3 = 25; + int min = 20; + int max = 10; + + // Act + int result1 = GenericMath.Clamp(value1, min, max); + int result2 = GenericMath.Clamp(value2, min, max); + int result3 = GenericMath.Clamp(value3, min, max); + + // Assert + Assert.Equal(20, result1); // value < min, returns min + Assert.Equal(10, result2); // min check fails, value > max, returns max + Assert.Equal(10, result3); // min check fails, value > max, returns max + } + + /// + /// Tests that Clamp works with short integer values. + /// + [Fact] + public void Clamp_ShortValues_ClampsCorrectly() + { + // Arrange + short value = 150; + short min = 100; + short max = 200; + + // Act + short result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal((short)150, result); + } + + /// + /// Tests that Clamp works with short values at boundaries. + /// + [Fact] + public void Clamp_ShortBoundaryValues_HandlesCorrectly() + { + // Arrange & Act + short result1 = GenericMath.Clamp(short.MinValue, short.MinValue, short.MaxValue); + short result2 = GenericMath.Clamp(short.MaxValue, short.MinValue, short.MaxValue); + + // Assert + Assert.Equal(short.MinValue, result1); + Assert.Equal(short.MaxValue, result2); + } + + /// + /// Tests that Clamp works correctly with zero values for all parameters. + /// + [Fact] + public void Clamp_AllZeroValues_ReturnsZero() + { + // Arrange + int value = 0; + int min = 0; + int max = 0; + + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(0, result); + } + + /// + /// Tests that Clamp works with negative values for all parameters. + /// + [Theory] + [InlineData(-15, -20, -10, -15)] + [InlineData(-25, -20, -10, -20)] + [InlineData(-5, -20, -10, -10)] + public void Clamp_NegativeValues_ClampsCorrectly(int value, int min, int max, int expected) + { + // Act + int result = GenericMath.Clamp(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange returns true when value is within the inclusive range [min, max]. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + [Theory] + [InlineData(5, 1, 10)] + [InlineData(0, -10, 10)] + [InlineData(-5, -10, 0)] + [InlineData(100, 100, 200)] + [InlineData(200, 100, 200)] + [InlineData(0, 0, 0)] + public void IsInRange_ValueWithinRange_ReturnsTrue(int value, int min, int max) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsInRange returns false when value is outside the range [min, max]. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + [Theory] + [InlineData(0, 1, 10)] + [InlineData(11, 1, 10)] + [InlineData(-11, -10, 0)] + [InlineData(1, 2, 10)] + [InlineData(99, 100, 200)] + [InlineData(201, 100, 200)] + public void IsInRange_ValueOutsideRange_ReturnsFalse(int value, int min, int max) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsInRange correctly handles boundary values including int.MinValue and int.MaxValue. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(int.MinValue, int.MinValue, int.MaxValue, true)] + [InlineData(int.MaxValue, int.MinValue, int.MaxValue, true)] + [InlineData(0, int.MinValue, int.MaxValue, true)] + [InlineData(int.MinValue, int.MinValue, int.MinValue, true)] + [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, true)] + [InlineData(int.MinValue, 0, int.MaxValue, false)] + [InlineData(int.MaxValue, int.MinValue, 0, false)] + public void IsInRange_BoundaryValues_ReturnsExpected(int value, int min, int max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange works correctly with double precision floating point values. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(5.5, 1.0, 10.0, true)] + [InlineData(1.0, 1.0, 10.0, true)] + [InlineData(10.0, 1.0, 10.0, true)] + [InlineData(0.5, 1.0, 10.0, false)] + [InlineData(10.5, 1.0, 10.0, false)] + [InlineData(-0.001, -0.001, 0.001, true)] + public void IsInRange_DoubleValues_ReturnsExpected(double value, double min, double max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange handles double.NaN correctly. NaN comparisons always return false. + /// + [Fact] + public void IsInRange_ValueIsNaN_ReturnsFalse() + { + // Arrange + double value = double.NaN; + double min = 0.0; + double max = 10.0; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsInRange handles double.PositiveInfinity correctly. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(double.PositiveInfinity, 0.0, double.PositiveInfinity, true)] + [InlineData(double.PositiveInfinity, 0.0, 100.0, false)] + [InlineData(5.0, 0.0, double.PositiveInfinity, true)] + public void IsInRange_PositiveInfinity_ReturnsExpected(double value, double min, double max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange handles double.NegativeInfinity correctly. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(double.NegativeInfinity, double.NegativeInfinity, 0.0, true)] + [InlineData(double.NegativeInfinity, -100.0, 0.0, false)] + [InlineData(5.0, double.NegativeInfinity, 10.0, true)] + public void IsInRange_NegativeInfinity_ReturnsExpected(double value, double min, double max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange works correctly with mixed numeric types, relying on CreateSaturating conversions. + /// + [Fact] + public void IsInRange_MixedNumericTypes_IntValueShortMinLongMax_ReturnsTrue() + { + // Arrange + int value = 1000; + short min = 100; + long max = 10000L; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsInRange with mixed types returns false when value is outside range. + /// + [Fact] + public void IsInRange_MixedNumericTypes_IntValueOutsideRange_ReturnsFalse() + { + // Arrange + int value = 50; + short min = 100; + long max = 10000L; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsInRange handles saturation when min/max types have larger range than TValue. + /// When converting a value larger than byte.MaxValue to byte, it saturates to 255. + /// + [Fact] + public void IsInRange_TypeSaturation_MaxExceedsByteRange_Saturates() + { + // Arrange + byte value = 200; + int min = -100; // Saturates to 0 when converted to byte + int max = 300; // Saturates to 255 when converted to byte + + // Act + // After saturation: IsInRange(200, 0, 255) + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsInRange handles saturation when min is negative but TValue is unsigned. + /// + [Fact] + public void IsInRange_TypeSaturation_NegativeMinWithUnsignedValue_Saturates() + { + // Arrange + byte value = 5; + int min = -100; // Saturates to 0 when converted to byte + int max = 10; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsInRange returns false for all values when min > max (invalid range). + /// + /// The value to test. + [Theory] + [InlineData(0)] + [InlineData(5)] + [InlineData(10)] + [InlineData(15)] + [InlineData(-5)] + public void IsInRange_MinGreaterThanMax_ReturnsFalse(int value) + { + // Arrange + int min = 10; + int max = 5; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsInRange works correctly with decimal type values. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(5.5, 1.0, 10.0, true)] + [InlineData(0.5, 1.0, 10.0, false)] + [InlineData(10.5, 1.0, 10.0, false)] + public void IsInRange_DecimalValues_ReturnsExpected(double valueDouble, double minDouble, double maxDouble, bool expected) + { + // Arrange + decimal value = (decimal)valueDouble; + decimal min = (decimal)minDouble; + decimal max = (decimal)maxDouble; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange works correctly with float type values. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(5.5f, 1.0f, 10.0f, true)] + [InlineData(1.0f, 1.0f, 10.0f, true)] + [InlineData(10.0f, 1.0f, 10.0f, true)] + [InlineData(0.5f, 1.0f, 10.0f, false)] + [InlineData(10.5f, 1.0f, 10.0f, false)] + public void IsInRange_FloatValues_ReturnsExpected(float value, float min, float max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that IsInRange handles float.NaN correctly. NaN comparisons always return false. + /// + [Fact] + public void IsInRange_FloatNaN_ReturnsFalse() + { + // Arrange + float value = float.NaN; + float min = 0.0f; + float max = 10.0f; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.False(result); + } + + /// + /// Tests that IsInRange works with long type values and boundaries. + /// + [Fact] + public void IsInRange_LongBoundaryValues_ReturnsTrue() + { + // Arrange + long value = long.MaxValue; + long min = long.MinValue; + long max = long.MaxValue; + + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.True(result); + } + + /// + /// Tests that IsInRange works with negative values across the range. + /// + /// The value to test. + /// The minimum bound. + /// The maximum bound. + /// The expected result. + [Theory] + [InlineData(-5, -10, -1, true)] + [InlineData(-10, -10, -1, true)] + [InlineData(-1, -10, -1, true)] + [InlineData(0, -10, -1, false)] + [InlineData(-11, -10, -1, false)] + public void IsInRange_NegativeValues_ReturnsExpected(int value, int min, int max, bool expected) + { + // Act + bool result = GenericMath.IsInRange(value, min, max); + + // Assert + Assert.Equal(expected, result); + } } \ No newline at end of file diff --git a/FTMS.NET.Tests/Utils/UInt24Tests.cs b/FTMS.NET.Tests/Utils/UInt24Tests.cs index 6aa1454..8035e85 100644 --- a/FTMS.NET.Tests/Utils/UInt24Tests.cs +++ b/FTMS.NET.Tests/Utils/UInt24Tests.cs @@ -1,37 +1,37 @@ -namespace FTMS.NET.Tests.Utils; - -using FTMS.NET.Utils; - -/// -/// Unit tests for the struct. -/// -public sealed class UInt24Tests -{ - /// - /// Tests that GetBytes returns the value encoded as three little-endian bytes (LSO...MSO). - /// - [Theory] - [InlineData(0u, new byte[] { 0x00, 0x00, 0x00 })] - [InlineData(1000u, new byte[] { 0xE8, 0x03, 0x00 })] // 0x0003E8 - [InlineData(0x010203u, new byte[] { 0x03, 0x02, 0x01 })] - [InlineData(0xFFFFFFu, new byte[] { 0xFF, 0xFF, 0xFF })] - public void GetBytes_ReturnsLittleEndianBytes(uint value, byte[] expected) - { - var u24 = new UInt24(value); - - byte[] actual = u24.GetBytes(); - - Assert.Equal(expected, actual); - } - - /// - /// Tests that GetBytes on MaxValue returns three bytes of 0xFF. - /// - [Fact] - public void GetBytes_MaxValue_ReturnsThreeBytesOfFF() - { - byte[] actual = UInt24.MaxValue.GetBytes(); - - Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF }, actual); - } -} +namespace FTMS.NET.Tests.Utils; + +using FTMS.NET.Utils; + +/// +/// Unit tests for the struct. +/// +public sealed class UInt24Tests +{ + /// + /// Tests that GetBytes returns the value encoded as three little-endian bytes (LSO...MSO). + /// + [Theory] + [InlineData(0u, new byte[] { 0x00, 0x00, 0x00 })] + [InlineData(1000u, new byte[] { 0xE8, 0x03, 0x00 })] // 0x0003E8 + [InlineData(0x010203u, new byte[] { 0x03, 0x02, 0x01 })] + [InlineData(0xFFFFFFu, new byte[] { 0xFF, 0xFF, 0xFF })] + public void GetBytes_ReturnsLittleEndianBytes(uint value, byte[] expected) + { + var u24 = new UInt24(value); + + byte[] actual = u24.GetBytes(); + + Assert.Equal(expected, actual); + } + + /// + /// Tests that GetBytes on MaxValue returns three bytes of 0xFF. + /// + [Fact] + public void GetBytes_MaxValue_ReturnsThreeBytesOfFF() + { + byte[] actual = UInt24.MaxValue.GetBytes(); + + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF }, actual); + } +} \ No newline at end of file diff --git a/FTMS.NET.Tests/Utils/ValueCalculationTests.cs b/FTMS.NET.Tests/Utils/ValueCalculationTests.cs index 418c848..08475a5 100644 --- a/FTMS.NET.Tests/Utils/ValueCalculationTests.cs +++ b/FTMS.NET.Tests/Utils/ValueCalculationTests.cs @@ -1,257 +1,257 @@ -namespace FTMS.NET.Tests.Utils; - -using FTMS.NET.Utils; - -/// -/// Unit tests for the record's Calculate method. -/// -public sealed partial class ValueCalculationTests -{ - /// - /// Tests that Calculate returns the correct result with default parameters (multiplier=1, exponents=0). - /// The constant multiplier should be 1, so the result should equal the raw value. - /// - [Theory] - [InlineData(0L, 0.0)] - [InlineData(1L, 1.0)] - [InlineData(-1L, -1.0)] - [InlineData(100L, 100.0)] - [InlineData(-100L, -100.0)] - [InlineData(long.MaxValue, 9223372036854775807.0)] - [InlineData(long.MinValue, -9223372036854775808.0)] - public void Calculate_WithDefaultParameters_ReturnsRawValue(long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate returns zero when the raw value is zero, regardless of the multiplier. - /// - [Theory] - [InlineData(1, 0, 0)] - [InlineData(5, 0, 0)] - [InlineData(-5, 0, 0)] - [InlineData(100, 2, 3)] - [InlineData(int.MaxValue, 10, 10)] - public void Calculate_WithZeroRawValue_ReturnsZero(int multiplier, int decimalExponent, int binaryExponent) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(0L); - - // Assert - Assert.Equal(0.0, result); - } - - /// - /// Tests that Calculate correctly applies the multiplier to the raw value. - /// With decimal and binary exponents at 0, the constant multiplier equals the multiplier parameter. - /// - [Theory] - [InlineData(2, 10L, 20.0)] - [InlineData(5, 3L, 15.0)] - [InlineData(-2, 10L, -20.0)] - [InlineData(-3, -5L, 15.0)] - [InlineData(0, 100L, 0.0)] - [InlineData(1, long.MaxValue, 9223372036854775807.0)] - [InlineData(10, 100L, 1000.0)] - public void Calculate_WithMultiplier_ReturnsScaledValue(int multiplier, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(Multiplier: multiplier, DecimalExponent: 0, BinaryExponent: 0); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate correctly applies the decimal exponent (power of 10). - /// The constant multiplier should be multiplied by 10^decimalExponent. - /// - [Theory] - [InlineData(1, 2, 0, 5L, 500.0)] // 5 * (1 * 10^2 * 2^0) = 5 * 100 = 500 - [InlineData(1, 3, 0, 2L, 2000.0)] // 2 * (1 * 10^3 * 2^0) = 2 * 1000 = 2000 - [InlineData(1, -2, 0, 100L, 1.0)] // 100 * (1 * 10^-2 * 2^0) = 100 * 0.01 = 1 - [InlineData(2, 1, 0, 5L, 100.0)] // 5 * (2 * 10^1 * 2^0) = 5 * 20 = 100 - [InlineData(1, 0, 0, 10L, 10.0)] // 10 * (1 * 10^0 * 2^0) = 10 * 1 = 10 - public void Calculate_WithDecimalExponent_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate correctly applies the binary exponent (power of 2). - /// The constant multiplier should be multiplied by 2^binaryExponent. - /// - [Theory] - [InlineData(1, 0, 3, 5L, 40.0)] // 5 * (1 * 10^0 * 2^3) = 5 * 8 = 40 - [InlineData(1, 0, 4, 2L, 32.0)] // 2 * (1 * 10^0 * 2^4) = 2 * 16 = 32 - [InlineData(1, 0, -2, 16L, 4.0)] // 16 * (1 * 10^0 * 2^-2) = 16 * 0.25 = 4 - [InlineData(2, 0, 2, 5L, 40.0)] // 5 * (2 * 10^0 * 2^2) = 5 * 8 = 40 - [InlineData(1, 0, 0, 10L, 10.0)] // 10 * (1 * 10^0 * 2^0) = 10 * 1 = 10 - public void Calculate_WithBinaryExponent_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate correctly combines all three parameters (multiplier, decimal exponent, binary exponent). - /// - [Theory] - [InlineData(2, 1, 2, 5L, 400.0)] // 5 * (2 * 10^1 * 2^2) = 5 * (2 * 10 * 4) = 5 * 80 = 400 - [InlineData(3, 2, 3, 1L, 2400.0)] // 1 * (3 * 10^2 * 2^3) = 3 * 100 * 8 = 2400 - [InlineData(5, -1, 1, 10L, 10.0)] // 10 * (5 * 10^-1 * 2^1) = 10 * (0.5 * 2) = 10 * 1 = 10 - [InlineData(1, 1, 1, 50L, 1000.0)] // 50 * (1 * 10^1 * 2^1) = 50 * 20 = 1000 - [InlineData(-2, 1, 1, 5L, -200.0)] // 5 * (-2 * 10^1 * 2^1) = 5 * (-40) = -200 - public void Calculate_WithCombinedParameters_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate handles negative raw values correctly. - /// The sign of the raw value should be preserved in the result. - /// - [Theory] - [InlineData(1, 0, 0, -10L, -10.0)] - [InlineData(2, 0, 0, -5L, -10.0)] - [InlineData(1, 2, 0, -1L, -100.0)] - [InlineData(-2, 0, 0, -10L, 20.0)] // Negative multiplier with negative rawValue = positive - [InlineData(5, 1, 1, -2L, -200.0)] - public void Calculate_WithNegativeRawValue_ReturnsCorrectSignedResult(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result); - } - - /// - /// Tests that Calculate handles extreme raw values (long.MinValue and long.MaxValue) correctly. - /// These tests verify that the calculation doesn't cause unexpected overflow behavior. - /// - [Theory] - [InlineData(2, 0, 0, long.MaxValue, 1.8446744073709552E+19)] // long.MaxValue * 2 - [InlineData(2, 0, 0, long.MinValue, -1.8446744073709552E+19)] // long.MinValue * 2 - [InlineData(1, 1, 0, long.MaxValue, 9.223372036854776E+19)] // long.MaxValue * 10 - [InlineData(1, -10, 0, long.MaxValue, 922337203.6854776)] // long.MaxValue * 10^-10 - public void Calculate_WithExtremeRawValues_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(expected, result, precision: 10); - } - - /// - /// Tests that Calculate can produce positive infinity when the result exceeds double's maximum value. - /// - [Fact] - public void Calculate_WithVeryLargeMultiplier_ReturnsPositiveInfinity() - { - // Arrange - var calculation = new ValueCalculation(Multiplier: 1, DecimalExponent: 300, BinaryExponent: 10); - - // Act - double result = calculation.Calculate(long.MaxValue); - - // Assert - Assert.Equal(double.PositiveInfinity, result); - } - - /// - /// Tests that Calculate can produce negative infinity when the result is below double's minimum value. - /// - [Fact] - public void Calculate_WithVeryLargeMultiplierAndNegativeValue_ReturnsNegativeInfinity() - { - // Arrange - var calculation = new ValueCalculation(Multiplier: 1, DecimalExponent: 300, BinaryExponent: 10); - - // Act - double result = calculation.Calculate(long.MinValue); - - // Assert - Assert.Equal(double.NegativeInfinity, result); - } - - /// - /// Tests that Calculate returns values very close to zero when using large negative exponents. - /// - [Theory] - [InlineData(1, -300, 0, 100L)] - [InlineData(1, 0, -1000, 100L)] - [InlineData(1, -150, -150, 1000L)] - public void Calculate_WithVerySmallMultiplier_ReturnsValueCloseToZero(int multiplier, int decimalExponent, int binaryExponent, long rawValue) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.True(result >= 0.0 && result < 1e-100); - } - - /// - /// Tests that Calculate with a zero multiplier always returns zero. - /// - [Theory] - [InlineData(0, 0, 0, 100L)] - [InlineData(0, 5, 3, long.MaxValue)] - [InlineData(0, -5, -3, long.MinValue)] - [InlineData(0, 10, 10, -1000L)] - public void Calculate_WithZeroMultiplier_ReturnsZero(int multiplier, int decimalExponent, int binaryExponent, long rawValue) - { - // Arrange - var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); - - // Act - double result = calculation.Calculate(rawValue); - - // Assert - Assert.Equal(0.0, result); - } +namespace FTMS.NET.Tests.Utils; + +using FTMS.NET.Utils; + +/// +/// Unit tests for the record's Calculate method. +/// +public sealed partial class ValueCalculationTests +{ + /// + /// Tests that Calculate returns the correct result with default parameters (multiplier=1, exponents=0). + /// The constant multiplier should be 1, so the result should equal the raw value. + /// + [Theory] + [InlineData(0L, 0.0)] + [InlineData(1L, 1.0)] + [InlineData(-1L, -1.0)] + [InlineData(100L, 100.0)] + [InlineData(-100L, -100.0)] + [InlineData(long.MaxValue, 9223372036854775807.0)] + [InlineData(long.MinValue, -9223372036854775808.0)] + public void Calculate_WithDefaultParameters_ReturnsRawValue(long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate returns zero when the raw value is zero, regardless of the multiplier. + /// + [Theory] + [InlineData(1, 0, 0)] + [InlineData(5, 0, 0)] + [InlineData(-5, 0, 0)] + [InlineData(100, 2, 3)] + [InlineData(int.MaxValue, 10, 10)] + public void Calculate_WithZeroRawValue_ReturnsZero(int multiplier, int decimalExponent, int binaryExponent) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(0L); + + // Assert + Assert.Equal(0.0, result); + } + + /// + /// Tests that Calculate correctly applies the multiplier to the raw value. + /// With decimal and binary exponents at 0, the constant multiplier equals the multiplier parameter. + /// + [Theory] + [InlineData(2, 10L, 20.0)] + [InlineData(5, 3L, 15.0)] + [InlineData(-2, 10L, -20.0)] + [InlineData(-3, -5L, 15.0)] + [InlineData(0, 100L, 0.0)] + [InlineData(1, long.MaxValue, 9223372036854775807.0)] + [InlineData(10, 100L, 1000.0)] + public void Calculate_WithMultiplier_ReturnsScaledValue(int multiplier, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(Multiplier: multiplier, DecimalExponent: 0, BinaryExponent: 0); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate correctly applies the decimal exponent (power of 10). + /// The constant multiplier should be multiplied by 10^decimalExponent. + /// + [Theory] + [InlineData(1, 2, 0, 5L, 500.0)] // 5 * (1 * 10^2 * 2^0) = 5 * 100 = 500 + [InlineData(1, 3, 0, 2L, 2000.0)] // 2 * (1 * 10^3 * 2^0) = 2 * 1000 = 2000 + [InlineData(1, -2, 0, 100L, 1.0)] // 100 * (1 * 10^-2 * 2^0) = 100 * 0.01 = 1 + [InlineData(2, 1, 0, 5L, 100.0)] // 5 * (2 * 10^1 * 2^0) = 5 * 20 = 100 + [InlineData(1, 0, 0, 10L, 10.0)] // 10 * (1 * 10^0 * 2^0) = 10 * 1 = 10 + public void Calculate_WithDecimalExponent_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate correctly applies the binary exponent (power of 2). + /// The constant multiplier should be multiplied by 2^binaryExponent. + /// + [Theory] + [InlineData(1, 0, 3, 5L, 40.0)] // 5 * (1 * 10^0 * 2^3) = 5 * 8 = 40 + [InlineData(1, 0, 4, 2L, 32.0)] // 2 * (1 * 10^0 * 2^4) = 2 * 16 = 32 + [InlineData(1, 0, -2, 16L, 4.0)] // 16 * (1 * 10^0 * 2^-2) = 16 * 0.25 = 4 + [InlineData(2, 0, 2, 5L, 40.0)] // 5 * (2 * 10^0 * 2^2) = 5 * 8 = 40 + [InlineData(1, 0, 0, 10L, 10.0)] // 10 * (1 * 10^0 * 2^0) = 10 * 1 = 10 + public void Calculate_WithBinaryExponent_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate correctly combines all three parameters (multiplier, decimal exponent, binary exponent). + /// + [Theory] + [InlineData(2, 1, 2, 5L, 400.0)] // 5 * (2 * 10^1 * 2^2) = 5 * (2 * 10 * 4) = 5 * 80 = 400 + [InlineData(3, 2, 3, 1L, 2400.0)] // 1 * (3 * 10^2 * 2^3) = 3 * 100 * 8 = 2400 + [InlineData(5, -1, 1, 10L, 10.0)] // 10 * (5 * 10^-1 * 2^1) = 10 * (0.5 * 2) = 10 * 1 = 10 + [InlineData(1, 1, 1, 50L, 1000.0)] // 50 * (1 * 10^1 * 2^1) = 50 * 20 = 1000 + [InlineData(-2, 1, 1, 5L, -200.0)] // 5 * (-2 * 10^1 * 2^1) = 5 * (-40) = -200 + public void Calculate_WithCombinedParameters_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate handles negative raw values correctly. + /// The sign of the raw value should be preserved in the result. + /// + [Theory] + [InlineData(1, 0, 0, -10L, -10.0)] + [InlineData(2, 0, 0, -5L, -10.0)] + [InlineData(1, 2, 0, -1L, -100.0)] + [InlineData(-2, 0, 0, -10L, 20.0)] // Negative multiplier with negative rawValue = positive + [InlineData(5, 1, 1, -2L, -200.0)] + public void Calculate_WithNegativeRawValue_ReturnsCorrectSignedResult(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Tests that Calculate handles extreme raw values (long.MinValue and long.MaxValue) correctly. + /// These tests verify that the calculation doesn't cause unexpected overflow behavior. + /// + [Theory] + [InlineData(2, 0, 0, long.MaxValue, 1.8446744073709552E+19)] // long.MaxValue * 2 + [InlineData(2, 0, 0, long.MinValue, -1.8446744073709552E+19)] // long.MinValue * 2 + [InlineData(1, 1, 0, long.MaxValue, 9.223372036854776E+19)] // long.MaxValue * 10 + [InlineData(1, -10, 0, long.MaxValue, 922337203.6854776)] // long.MaxValue * 10^-10 + public void Calculate_WithExtremeRawValues_ReturnsCorrectValue(int multiplier, int decimalExponent, int binaryExponent, long rawValue, double expected) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(expected, result, precision: 10); + } + + /// + /// Tests that Calculate can produce positive infinity when the result exceeds double's maximum value. + /// + [Fact] + public void Calculate_WithVeryLargeMultiplier_ReturnsPositiveInfinity() + { + // Arrange + var calculation = new ValueCalculation(Multiplier: 1, DecimalExponent: 300, BinaryExponent: 10); + + // Act + double result = calculation.Calculate(long.MaxValue); + + // Assert + Assert.Equal(double.PositiveInfinity, result); + } + + /// + /// Tests that Calculate can produce negative infinity when the result is below double's minimum value. + /// + [Fact] + public void Calculate_WithVeryLargeMultiplierAndNegativeValue_ReturnsNegativeInfinity() + { + // Arrange + var calculation = new ValueCalculation(Multiplier: 1, DecimalExponent: 300, BinaryExponent: 10); + + // Act + double result = calculation.Calculate(long.MinValue); + + // Assert + Assert.Equal(double.NegativeInfinity, result); + } + + /// + /// Tests that Calculate returns values very close to zero when using large negative exponents. + /// + [Theory] + [InlineData(1, -300, 0, 100L)] + [InlineData(1, 0, -1000, 100L)] + [InlineData(1, -150, -150, 1000L)] + public void Calculate_WithVerySmallMultiplier_ReturnsValueCloseToZero(int multiplier, int decimalExponent, int binaryExponent, long rawValue) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.True(result >= 0.0 && result < 1e-100); + } + + /// + /// Tests that Calculate with a zero multiplier always returns zero. + /// + [Theory] + [InlineData(0, 0, 0, 100L)] + [InlineData(0, 5, 3, long.MaxValue)] + [InlineData(0, -5, -3, long.MinValue)] + [InlineData(0, 10, 10, -1000L)] + public void Calculate_WithZeroMultiplier_ReturnsZero(int multiplier, int decimalExponent, int binaryExponent, long rawValue) + { + // Arrange + var calculation = new ValueCalculation(multiplier, decimalExponent, binaryExponent); + + // Act + double result = calculation.Calculate(rawValue); + + // Assert + Assert.Equal(0.0, result); + } } \ No newline at end of file diff --git a/FTMS.NET/Control/ControlExtensions.cs b/FTMS.NET/Control/ControlExtensions.cs index 77b27e2..67c8483 100644 --- a/FTMS.NET/Control/ControlExtensions.cs +++ b/FTMS.NET/Control/ControlExtensions.cs @@ -1,145 +1,145 @@ -namespace FTMS.NET.Control; - -using FTMS.NET.Utils; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Threading.Tasks; - -public static class ControlExtensions -{ - public static Task RequestControl(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.RequestControl); - - public static Task Reset(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.Reset); - - public static Task SetTargetSpeed(this IFitnessMachineControl fitnessMachineControl, ushort speed) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetSpeed, speed); - - public static Task SetTargetInclination(this IFitnessMachineControl fitnessMachineControl, short inclination) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetInclination, inclination); - - public static Task SetTargetResistanceLevel(this IFitnessMachineControl fitnessMachineControl, byte resistanceLevel) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetResistanceLevel, resistanceLevel); - - public static Task SetTargetPower(this IFitnessMachineControl fitnessMachineControl, short power) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetPower, power); - - public static Task SetTargetHeartRate(this IFitnessMachineControl fitnessMachineControl, byte beatsPerMinute) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetHeartRate, beatsPerMinute); - - public static Task StartOrResume(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.StartOrResume); - - public static Task Stop(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.StopOrPause(0x01); - - public static Task Pause(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.StopOrPause(0x02); - - public static Task SetTargetedExpendedEnergy(this IFitnessMachineControl fitnessMachineControl, ushort expendedEnergy) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedExpendedEnergy, expendedEnergy); - - public static Task SetTargetedNumberOfSteps(this IFitnessMachineControl fitnessMachineControl, ushort steps) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedNumberOfSteps, steps); - - public static Task SetTargetedNumberOfStrides(this IFitnessMachineControl fitnessMachineControl, ushort strides) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedNumberOfStrides, strides); - - public static Task SetTargetedDistance(this IFitnessMachineControl fitnessMachineControl, UInt24 distance) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedDistance, distance); - - public static Task SetTargetedTrainingTime(this IFitnessMachineControl fitnessMachineControl, ushort time) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedTrainingTime, time); - - public static Task SetTargetedTimeInTwoHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInFatBurnZone, ushort timeInFitnessZone) - => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInTwoHeartRateZones, timeInFatBurnZone, timeInFitnessZone); - - public static Task SetTargetedTimeInThreeHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInLightZone, ushort timeInModerateZone, ushort timeInHardZone) - => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInThreeHeartRateZones, timeInLightZone, timeInModerateZone, timeInHardZone); - - public static Task SetTargetedTimeInFiveHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInVeryLightZone, ushort timeInLightZone, ushort timeInModerateZone, ushort timeInHardZone, ushort timeInMaximumZone) - => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInFiveHeartRateZones, timeInVeryLightZone, timeInLightZone, timeInModerateZone, timeInHardZone, timeInMaximumZone); - - public static Task SetIndoorBikeSimulationParameters(this IFitnessMachineControl fitnessMachineControl, short windspeed, short grade, byte crr, byte cw) - { - var windspeedBytes = BitConverter.GetBytes(windspeed); - var gradeBytes = BitConverter.GetBytes(grade); - - byte[] parameterBytes = [.. windspeedBytes, .. gradeBytes, crr, cw]; - var request = new ControlRequest(EControlOpCode.SetIndoorBikeSimulationParameters, parameterBytes); - return fitnessMachineControl.Execute(request); - } - - public static Task SetWheelCircumference(this IFitnessMachineControl fitnessMachineControl, ushort wheelCircumference) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetWheelCircumference, wheelCircumference); - - public static async Task<(ushort targetSpeedLow, ushort targetSpeedHigh)> StartSpinDownControl(this IFitnessMachineControl fitnessMachineControl) - { - var response = await fitnessMachineControl.SendSpinDownControl(0x01); - using MemoryStream responseStream = new(response.ResponseParameter); - using BinaryReader responseReader = new(responseStream); - - ushort targetSpeedLow = responseReader.ReadUInt16(); - ushort targetSpeedHigh = responseReader.ReadUInt16(); - - return (targetSpeedLow, targetSpeedHigh); - } - - public static Task IgnoreSpinDownControl(this IFitnessMachineControl fitnessMachineControl) - => fitnessMachineControl.SendSpinDownControl(0x02); - - private static Task SendSpinDownControl(this IFitnessMachineControl fitnessMachineControl, byte startOrIgnore) - { - var request = new ControlRequest(EControlOpCode.SpinDownControl, [startOrIgnore]); - return fitnessMachineControl.Execute(request); - } - - public static Task SetTargetedCadence(this IFitnessMachineControl fitnessMachineControl, ushort cadence) - => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedCadence, cadence); - - private static Task ExecuteWithoutValue(this IFitnessMachineControl fitnessMachineControl, EControlOpCode opCode) - { - var request = new ControlRequest(opCode, []); - return fitnessMachineControl.Execute(request); - } - - private static Task ExecuteWithValue(this IFitnessMachineControl fitnessMachineControl, EControlOpCode opCode, T value) - { - var valueBytes = GetBytes(); - var request = new ControlRequest(opCode, valueBytes); - return fitnessMachineControl.Execute(request); - - byte[] GetBytes() - { - if (value is UInt24 u24) - return u24.GetBytes(); - - if (typeof(T).IsPrimitive) - { - int size = Unsafe.SizeOf(); - byte[] bytes = new byte[size]; - Unsafe.As(ref bytes[0]) = value; - return bytes; - } - - throw new InvalidOperationException(); - } - } - - private static Task ExecuteWithMultipleValues( - this IFitnessMachineControl fitnessMachineControl, - EControlOpCode opCode, - params ushort[] values) - { - var valuesBytes = values.SelectMany(BitConverter.GetBytes).ToArray(); - var request = new ControlRequest(opCode, valuesBytes); - return fitnessMachineControl.Execute(request); - } - - private static Task StopOrPause(this IFitnessMachineControl fitnessMachineControl, byte stopOrPause) - { - var request = new ControlRequest(EControlOpCode.StopOrPause, [stopOrPause]); - return fitnessMachineControl.Execute(request); - } -} +namespace FTMS.NET.Control; + +using FTMS.NET.Utils; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +public static class ControlExtensions +{ + public static Task RequestControl(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.RequestControl); + + public static Task Reset(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.Reset); + + public static Task SetTargetSpeed(this IFitnessMachineControl fitnessMachineControl, ushort speed) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetSpeed, speed); + + public static Task SetTargetInclination(this IFitnessMachineControl fitnessMachineControl, short inclination) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetInclination, inclination); + + public static Task SetTargetResistanceLevel(this IFitnessMachineControl fitnessMachineControl, byte resistanceLevel) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetResistanceLevel, resistanceLevel); + + public static Task SetTargetPower(this IFitnessMachineControl fitnessMachineControl, short power) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetPower, power); + + public static Task SetTargetHeartRate(this IFitnessMachineControl fitnessMachineControl, byte beatsPerMinute) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetHeartRate, beatsPerMinute); + + public static Task StartOrResume(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.ExecuteWithoutValue(EControlOpCode.StartOrResume); + + public static Task Stop(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.StopOrPause(0x01); + + public static Task Pause(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.StopOrPause(0x02); + + public static Task SetTargetedExpendedEnergy(this IFitnessMachineControl fitnessMachineControl, ushort expendedEnergy) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedExpendedEnergy, expendedEnergy); + + public static Task SetTargetedNumberOfSteps(this IFitnessMachineControl fitnessMachineControl, ushort steps) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedNumberOfSteps, steps); + + public static Task SetTargetedNumberOfStrides(this IFitnessMachineControl fitnessMachineControl, ushort strides) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedNumberOfStrides, strides); + + public static Task SetTargetedDistance(this IFitnessMachineControl fitnessMachineControl, UInt24 distance) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedDistance, distance); + + public static Task SetTargetedTrainingTime(this IFitnessMachineControl fitnessMachineControl, ushort time) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedTrainingTime, time); + + public static Task SetTargetedTimeInTwoHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInFatBurnZone, ushort timeInFitnessZone) + => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInTwoHeartRateZones, timeInFatBurnZone, timeInFitnessZone); + + public static Task SetTargetedTimeInThreeHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInLightZone, ushort timeInModerateZone, ushort timeInHardZone) + => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInThreeHeartRateZones, timeInLightZone, timeInModerateZone, timeInHardZone); + + public static Task SetTargetedTimeInFiveHeartRateZones(this IFitnessMachineControl fitnessMachineControl, ushort timeInVeryLightZone, ushort timeInLightZone, ushort timeInModerateZone, ushort timeInHardZone, ushort timeInMaximumZone) + => fitnessMachineControl.ExecuteWithMultipleValues(EControlOpCode.SetTargetedTimeInFiveHeartRateZones, timeInVeryLightZone, timeInLightZone, timeInModerateZone, timeInHardZone, timeInMaximumZone); + + public static Task SetIndoorBikeSimulationParameters(this IFitnessMachineControl fitnessMachineControl, short windspeed, short grade, byte crr, byte cw) + { + var windspeedBytes = BitConverter.GetBytes(windspeed); + var gradeBytes = BitConverter.GetBytes(grade); + + byte[] parameterBytes = [.. windspeedBytes, .. gradeBytes, crr, cw]; + var request = new ControlRequest(EControlOpCode.SetIndoorBikeSimulationParameters, parameterBytes); + return fitnessMachineControl.Execute(request); + } + + public static Task SetWheelCircumference(this IFitnessMachineControl fitnessMachineControl, ushort wheelCircumference) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetWheelCircumference, wheelCircumference); + + public static async Task<(ushort targetSpeedLow, ushort targetSpeedHigh)> StartSpinDownControl(this IFitnessMachineControl fitnessMachineControl) + { + var response = await fitnessMachineControl.SendSpinDownControl(0x01); + using MemoryStream responseStream = new(response.ResponseParameter); + using BinaryReader responseReader = new(responseStream); + + ushort targetSpeedLow = responseReader.ReadUInt16(); + ushort targetSpeedHigh = responseReader.ReadUInt16(); + + return (targetSpeedLow, targetSpeedHigh); + } + + public static Task IgnoreSpinDownControl(this IFitnessMachineControl fitnessMachineControl) + => fitnessMachineControl.SendSpinDownControl(0x02); + + private static Task SendSpinDownControl(this IFitnessMachineControl fitnessMachineControl, byte startOrIgnore) + { + var request = new ControlRequest(EControlOpCode.SpinDownControl, [startOrIgnore]); + return fitnessMachineControl.Execute(request); + } + + public static Task SetTargetedCadence(this IFitnessMachineControl fitnessMachineControl, ushort cadence) + => fitnessMachineControl.ExecuteWithValue(EControlOpCode.SetTargetedCadence, cadence); + + private static Task ExecuteWithoutValue(this IFitnessMachineControl fitnessMachineControl, EControlOpCode opCode) + { + var request = new ControlRequest(opCode, []); + return fitnessMachineControl.Execute(request); + } + + private static Task ExecuteWithValue(this IFitnessMachineControl fitnessMachineControl, EControlOpCode opCode, T value) + { + var valueBytes = GetBytes(); + var request = new ControlRequest(opCode, valueBytes); + return fitnessMachineControl.Execute(request); + + byte[] GetBytes() + { + if (value is UInt24 u24) + return u24.GetBytes(); + + if (typeof(T).IsPrimitive) + { + int size = Unsafe.SizeOf(); + byte[] bytes = new byte[size]; + Unsafe.As(ref bytes[0]) = value; + return bytes; + } + + throw new InvalidOperationException(); + } + } + + private static Task ExecuteWithMultipleValues( + this IFitnessMachineControl fitnessMachineControl, + EControlOpCode opCode, + params ushort[] values) + { + var valuesBytes = values.SelectMany(BitConverter.GetBytes).ToArray(); + var request = new ControlRequest(opCode, valuesBytes); + return fitnessMachineControl.Execute(request); + } + + private static Task StopOrPause(this IFitnessMachineControl fitnessMachineControl, byte stopOrPause) + { + var request = new ControlRequest(EControlOpCode.StopOrPause, [stopOrPause]); + return fitnessMachineControl.Execute(request); + } +} \ No newline at end of file diff --git a/FTMS.NET/Control/ControlRequest.cs b/FTMS.NET/Control/ControlRequest.cs index 137ad4b..4c5e882 100644 --- a/FTMS.NET/Control/ControlRequest.cs +++ b/FTMS.NET/Control/ControlRequest.cs @@ -1,5 +1,5 @@ -namespace FTMS.NET.Control; - -public sealed record ControlRequest( - EControlOpCode OpCode, +namespace FTMS.NET.Control; + +public sealed record ControlRequest( + EControlOpCode OpCode, byte[] Parameter); \ No newline at end of file diff --git a/FTMS.NET/Control/ControlResponse.cs b/FTMS.NET/Control/ControlResponse.cs index 2c20611..f0f9e84 100644 --- a/FTMS.NET/Control/ControlResponse.cs +++ b/FTMS.NET/Control/ControlResponse.cs @@ -1,6 +1,6 @@ -namespace FTMS.NET.Control; - -public sealed record ControlResponse( - EControlOpCode RequestedOpCode, - EControlResultCode ResultCode, +namespace FTMS.NET.Control; + +public sealed record ControlResponse( + EControlOpCode RequestedOpCode, + EControlResultCode ResultCode, byte[] ResponseParameter); \ No newline at end of file diff --git a/FTMS.NET/Control/EControlOpCode.cs b/FTMS.NET/Control/EControlOpCode.cs index 15804bd..d3af188 100644 --- a/FTMS.NET/Control/EControlOpCode.cs +++ b/FTMS.NET/Control/EControlOpCode.cs @@ -1,25 +1,26 @@ -namespace FTMS.NET.Control; -public enum EControlOpCode : byte -{ - RequestControl = 0x00, - Reset = 0x01, - SetTargetSpeed = 0x02, - SetTargetInclination = 0x03, - SetTargetResistanceLevel = 0x04, - SetTargetPower = 0x05, - SetTargetHeartRate = 0x06, - StartOrResume = 0x07, - StopOrPause = 0x08, - SetTargetedExpendedEnergy = 0x09, - SetTargetedNumberOfSteps = 0x0A, - SetTargetedNumberOfStrides = 0x0B, - SetTargetedDistance = 0x0C, - SetTargetedTrainingTime = 0x0D, - SetTargetedTimeInTwoHeartRateZones = 0x0E, - SetTargetedTimeInThreeHeartRateZones = 0x0F, - SetTargetedTimeInFiveHeartRateZones = 0x10, - SetIndoorBikeSimulationParameters = 0x11, - SetWheelCircumference = 0x12, - SpinDownControl = 0x13, - SetTargetedCadence = 0x14, -} +namespace FTMS.NET.Control; + +public enum EControlOpCode : byte +{ + RequestControl = 0x00, + Reset = 0x01, + SetTargetSpeed = 0x02, + SetTargetInclination = 0x03, + SetTargetResistanceLevel = 0x04, + SetTargetPower = 0x05, + SetTargetHeartRate = 0x06, + StartOrResume = 0x07, + StopOrPause = 0x08, + SetTargetedExpendedEnergy = 0x09, + SetTargetedNumberOfSteps = 0x0A, + SetTargetedNumberOfStrides = 0x0B, + SetTargetedDistance = 0x0C, + SetTargetedTrainingTime = 0x0D, + SetTargetedTimeInTwoHeartRateZones = 0x0E, + SetTargetedTimeInThreeHeartRateZones = 0x0F, + SetTargetedTimeInFiveHeartRateZones = 0x10, + SetIndoorBikeSimulationParameters = 0x11, + SetWheelCircumference = 0x12, + SpinDownControl = 0x13, + SetTargetedCadence = 0x14, +} \ No newline at end of file diff --git a/FTMS.NET/Control/EControlResultCode.cs b/FTMS.NET/Control/EControlResultCode.cs index bc34714..65d77a1 100644 --- a/FTMS.NET/Control/EControlResultCode.cs +++ b/FTMS.NET/Control/EControlResultCode.cs @@ -1,9 +1,10 @@ -namespace FTMS.NET.Control; -public enum EControlResultCode : byte -{ - Success = 0x01, - OpCodeNotSupported = 0x02, - InvalidParameter = 0x03, - OperationFailed = 0x04, - ControlNotPermitted = 0x05 -} +namespace FTMS.NET.Control; + +public enum EControlResultCode : byte +{ + Success = 0x01, + OpCodeNotSupported = 0x02, + InvalidParameter = 0x03, + OperationFailed = 0x04, + ControlNotPermitted = 0x05 +} \ No newline at end of file diff --git a/FTMS.NET/Control/EStopOrPauseCode.cs b/FTMS.NET/Control/EStopOrPauseCode.cs index 0e4f35d..7a82f39 100644 --- a/FTMS.NET/Control/EStopOrPauseCode.cs +++ b/FTMS.NET/Control/EStopOrPauseCode.cs @@ -1,6 +1,7 @@ -namespace FTMS.NET.Control; -public enum EStopOrPauseCode : byte -{ - Stop = 0x01, - Pause = 0x02 -} +namespace FTMS.NET.Control; + +public enum EStopOrPauseCode : byte +{ + Stop = 0x01, + Pause = 0x02 +} \ No newline at end of file diff --git a/FTMS.NET/Control/FitnessMachineControl.cs b/FTMS.NET/Control/FitnessMachineControl.cs index a4f20c9..f841629 100644 --- a/FTMS.NET/Control/FitnessMachineControl.cs +++ b/FTMS.NET/Control/FitnessMachineControl.cs @@ -1,82 +1,82 @@ -namespace FTMS.NET.Control; - -using FTMS.NET.Exceptions; -using System; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Threading.Tasks; - -internal sealed class FitnessMachineControl : IFitnessMachineControl -{ - private readonly Func writeControlPoint; - private readonly CancellationDisposable cancellationDisposable = new(); - private readonly IObservable responseObservable; - private readonly TimeSpan responseTimeout; - private readonly IScheduler scheduler; - - internal FitnessMachineControl( - IObservable observeControlPoint, - Func writeControlPoint, - TimeSpan? responseTimeout = null, - IScheduler? scheduler = null) - { - this.writeControlPoint = writeControlPoint; - this.scheduler = scheduler ?? DefaultScheduler.Instance; - this.responseTimeout = responseTimeout ?? TimeSpan.FromSeconds(5); - this.responseObservable = observeControlPoint - .TakeUntil(this.cancellationDisposable.Token) - .Select(this.ReadResponseData) - .Publish() - .RefCount(TimeSpan.FromSeconds(5), this.scheduler); - } - - public async Task Execute(ControlRequest request) - { - ArgumentNullException.ThrowIfNull(request); - - var responseTask = this.responseObservable - .FirstAsync(response => response.RequestedOpCode == request.OpCode) - .Timeout(this.responseTimeout, this.scheduler) - .ToTask(); - - byte[] writeValue = [(byte)request.OpCode, .. request.Parameter]; - - try - { - await this.writeControlPoint(writeValue); - - var response = await responseTask; - - if (response.ResultCode == EControlResultCode.Success) - return response; - - throw new ControlRequestException(request.OpCode, response.ResultCode); - } - catch (Exception ex) - { - throw new ControlRequestException(request.OpCode, ex); - } - } - - private ControlResponse ReadResponseData(byte[] data) - { - var responseCode = data[0]; - - if (responseCode != 0x80) - throw new InvalidOperationException(); - - var requestedOpCode = (EControlOpCode)data[1]; - var resultCode = (EControlResultCode)data[2]; - byte[] parameter = [.. data.Skip(3)]; - - return new ControlResponse(requestedOpCode, resultCode, parameter); - } - - public void Dispose() - { - this.cancellationDisposable.Dispose(); - } -} +namespace FTMS.NET.Control; + +using FTMS.NET.Exceptions; +using System; +using System.Linq; +using System.Reactive.Concurrency; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; + +internal sealed class FitnessMachineControl : IFitnessMachineControl +{ + private readonly Func writeControlPoint; + private readonly CancellationDisposable cancellationDisposable = new(); + private readonly IObservable responseObservable; + private readonly TimeSpan responseTimeout; + private readonly IScheduler scheduler; + + internal FitnessMachineControl( + IObservable observeControlPoint, + Func writeControlPoint, + TimeSpan? responseTimeout = null, + IScheduler? scheduler = null) + { + this.writeControlPoint = writeControlPoint; + this.scheduler = scheduler ?? DefaultScheduler.Instance; + this.responseTimeout = responseTimeout ?? TimeSpan.FromSeconds(5); + this.responseObservable = observeControlPoint + .TakeUntil(this.cancellationDisposable.Token) + .Select(this.ReadResponseData) + .Publish() + .RefCount(TimeSpan.FromSeconds(5), this.scheduler); + } + + public async Task Execute(ControlRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var responseTask = this.responseObservable + .FirstAsync(response => response.RequestedOpCode == request.OpCode) + .Timeout(this.responseTimeout, this.scheduler) + .ToTask(); + + byte[] writeValue = [(byte)request.OpCode, .. request.Parameter]; + + try + { + await this.writeControlPoint(writeValue); + + var response = await responseTask; + + if (response.ResultCode == EControlResultCode.Success) + return response; + + throw new ControlRequestException(request.OpCode, response.ResultCode); + } + catch (Exception ex) + { + throw new ControlRequestException(request.OpCode, ex); + } + } + + private ControlResponse ReadResponseData(byte[] data) + { + var responseCode = data[0]; + + if (responseCode != 0x80) + throw new InvalidOperationException(); + + var requestedOpCode = (EControlOpCode)data[1]; + var resultCode = (EControlResultCode)data[2]; + byte[] parameter = [.. data.Skip(3)]; + + return new ControlResponse(requestedOpCode, resultCode, parameter); + } + + public void Dispose() + { + this.cancellationDisposable.Dispose(); + } +} \ No newline at end of file diff --git a/FTMS.NET/Control/IFitnessMachineControl.cs b/FTMS.NET/Control/IFitnessMachineControl.cs index 6f8aac7..e265a0b 100644 --- a/FTMS.NET/Control/IFitnessMachineControl.cs +++ b/FTMS.NET/Control/IFitnessMachineControl.cs @@ -1,8 +1,8 @@ -namespace FTMS.NET.Control; - -using System.Threading.Tasks; - -public interface IFitnessMachineControl : IDisposable -{ - Task Execute(ControlRequest request); -} +namespace FTMS.NET.Control; + +using System.Threading.Tasks; + +public interface IFitnessMachineControl : IDisposable +{ + Task Execute(ControlRequest request); +} \ No newline at end of file diff --git a/FTMS.NET/Data/CrossTrainerData.cs b/FTMS.NET/Data/CrossTrainerData.cs index ddede3c..64e30ee 100644 --- a/FTMS.NET/Data/CrossTrainerData.cs +++ b/FTMS.NET/Data/CrossTrainerData.cs @@ -1,32 +1,32 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class CrossTrainerData(IFitnessMachineData data) : ICrossTrainerData -{ - public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); - public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); - public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); - public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); - public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); - public IFitnessMachineValue? StrideCount => data.GetValue(FtmsUuids.StrideCount); - public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); - public IFitnessMachineValue? NegativeElevationGain => data.GetValue(FtmsUuids.NegativeElevationGain); - public IFitnessMachineValue? Inclination => data.GetValue(FtmsUuids.Inclination); - public IFitnessMachineValue? RampAngleSetting => data.GetValue(FtmsUuids.RampAngleSetting); - public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); - public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); - public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class CrossTrainerData(IFitnessMachineData data) : ICrossTrainerData +{ + public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); + public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); + public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); + public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); + public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); + public IFitnessMachineValue? StrideCount => data.GetValue(FtmsUuids.StrideCount); + public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); + public IFitnessMachineValue? NegativeElevationGain => data.GetValue(FtmsUuids.NegativeElevationGain); + public IFitnessMachineValue? Inclination => data.GetValue(FtmsUuids.Inclination); + public IFitnessMachineValue? RampAngleSetting => data.GetValue(FtmsUuids.RampAngleSetting); + public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); + public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); + public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/FitnessMachineData.cs b/FTMS.NET/Data/FitnessMachineData.cs index 9ade6a1..dade2d3 100644 --- a/FTMS.NET/Data/FitnessMachineData.cs +++ b/FTMS.NET/Data/FitnessMachineData.cs @@ -1,24 +1,24 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; -using System.Reactive.Linq; - -internal sealed class FitnessMachineData( - IObservable observeData, - FitnessMachineDataReader dataReader) - : IFitnessMachineData -{ - private readonly IObservableCache valueCache = observeData - .Select(dataReader.Read) - .ToObservableChangeSet(v => v.Uuid) - .AsObservableCache(); - - public IObservable> Connect() - => this.valueCache.Connect(); - - public IFitnessMachineValue? GetValue(Guid uuid) - => this.valueCache.KeyValues.GetValueOrDefault(uuid); - - public void Dispose() => this.valueCache.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; +using System.Reactive.Linq; + +internal sealed class FitnessMachineData( + IObservable observeData, + FitnessMachineDataReader dataReader) + : IFitnessMachineData +{ + private readonly IObservableCache valueCache = observeData + .Select(dataReader.Read) + .ToObservableChangeSet(v => v.Uuid) + .AsObservableCache(); + + public IObservable> Connect() + => this.valueCache.Connect(); + + public IFitnessMachineValue? GetValue(Guid uuid) + => this.valueCache.KeyValues.GetValueOrDefault(uuid); + + public void Dispose() => this.valueCache.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/FitnessMachineDataReader.cs b/FTMS.NET/Data/FitnessMachineDataReader.cs index 1f1d8c6..ce43286 100644 --- a/FTMS.NET/Data/FitnessMachineDataReader.cs +++ b/FTMS.NET/Data/FitnessMachineDataReader.cs @@ -1,15 +1,15 @@ -namespace FTMS.NET.Data; - -using System.Collections.Generic; - -internal sealed class FitnessMachineDataReader(SingleFrameStrategy singleFrameStrategy) -{ - public IEnumerable Read(byte[] dataFrame) - { - if (dataFrame.Length is 0) - return []; - - using var singleFrameReader = new SingleFrameReader(dataFrame, singleFrameStrategy); - return singleFrameReader.ReadFrame(); - } +namespace FTMS.NET.Data; + +using System.Collections.Generic; + +internal sealed class FitnessMachineDataReader(SingleFrameStrategy singleFrameStrategy) +{ + public IEnumerable Read(byte[] dataFrame) + { + if (dataFrame.Length is 0) + return []; + + using var singleFrameReader = new SingleFrameReader(dataFrame, singleFrameStrategy); + return singleFrameReader.ReadFrame(); + } } \ No newline at end of file diff --git a/FTMS.NET/Data/FitnessMachineValue.cs b/FTMS.NET/Data/FitnessMachineValue.cs index d6903f0..555b2fb 100644 --- a/FTMS.NET/Data/FitnessMachineValue.cs +++ b/FTMS.NET/Data/FitnessMachineValue.cs @@ -1,4 +1,5 @@ -namespace FTMS.NET.Data; -using System; - -public sealed record FitnessMachineValue(Guid Uuid, double Value, string Name) : IFitnessMachineValue; +namespace FTMS.NET.Data; + +using System; + +public sealed record FitnessMachineValue(Guid Uuid, double Value, string Name) : IFitnessMachineValue; \ No newline at end of file diff --git a/FTMS.NET/Data/ICrossTrainerData.cs b/FTMS.NET/Data/ICrossTrainerData.cs index 0bf0536..5d8cdda 100644 --- a/FTMS.NET/Data/ICrossTrainerData.cs +++ b/FTMS.NET/Data/ICrossTrainerData.cs @@ -1,25 +1,25 @@ -namespace FTMS.NET.Data; - -public interface ICrossTrainerData : IFitnessMachineData -{ - IFitnessMachineValue? InstantaneousSpeed { get; } - IFitnessMachineValue? AverageSpeed { get; } - IFitnessMachineValue? TotalDistance { get; } - IFitnessMachineValue? StepsPerMinute { get; } - IFitnessMachineValue? AverageStepRate { get; } - IFitnessMachineValue? StrideCount { get; } - IFitnessMachineValue? PositiveElevationGain { get; } - IFitnessMachineValue? NegativeElevationGain { get; } - IFitnessMachineValue? Inclination { get; } - IFitnessMachineValue? RampAngleSetting { get; } - IFitnessMachineValue? ResistantLevel { get; } - IFitnessMachineValue? InstantaneousPower { get; } - IFitnessMachineValue? AveragePower { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } -} +namespace FTMS.NET.Data; + +public interface ICrossTrainerData : IFitnessMachineData +{ + IFitnessMachineValue? InstantaneousSpeed { get; } + IFitnessMachineValue? AverageSpeed { get; } + IFitnessMachineValue? TotalDistance { get; } + IFitnessMachineValue? StepsPerMinute { get; } + IFitnessMachineValue? AverageStepRate { get; } + IFitnessMachineValue? StrideCount { get; } + IFitnessMachineValue? PositiveElevationGain { get; } + IFitnessMachineValue? NegativeElevationGain { get; } + IFitnessMachineValue? Inclination { get; } + IFitnessMachineValue? RampAngleSetting { get; } + IFitnessMachineValue? ResistantLevel { get; } + IFitnessMachineValue? InstantaneousPower { get; } + IFitnessMachineValue? AveragePower { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IFitnessMachineData.cs b/FTMS.NET/Data/IFitnessMachineData.cs index d806191..ac9aa99 100644 --- a/FTMS.NET/Data/IFitnessMachineData.cs +++ b/FTMS.NET/Data/IFitnessMachineData.cs @@ -1,10 +1,10 @@ -namespace FTMS.NET.Data; - -using DynamicData; - -public interface IFitnessMachineData : IDisposable -{ - IObservable> Connect(); - - IFitnessMachineValue? GetValue(Guid uuid); -} +namespace FTMS.NET.Data; + +using DynamicData; + +public interface IFitnessMachineData : IDisposable +{ + IObservable> Connect(); + + IFitnessMachineValue? GetValue(Guid uuid); +} \ No newline at end of file diff --git a/FTMS.NET/Data/IFitnessMachineValue.cs b/FTMS.NET/Data/IFitnessMachineValue.cs index 87b83a2..75ae4c4 100644 --- a/FTMS.NET/Data/IFitnessMachineValue.cs +++ b/FTMS.NET/Data/IFitnessMachineValue.cs @@ -1,10 +1,10 @@ -namespace FTMS.NET.Data; - -using System; - -public interface IFitnessMachineValue -{ - Guid Uuid { get; } - double Value { get; } - string Name { get; } -} +namespace FTMS.NET.Data; + +using System; + +public interface IFitnessMachineValue +{ + Guid Uuid { get; } + double Value { get; } + string Name { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IIndoorBikeData.cs b/FTMS.NET/Data/IIndoorBikeData.cs index abdf15c..2b2c5a3 100644 --- a/FTMS.NET/Data/IIndoorBikeData.cs +++ b/FTMS.NET/Data/IIndoorBikeData.cs @@ -1,19 +1,20 @@ -namespace FTMS.NET.Data; -public interface IIndoorBikeData : IFitnessMachineData -{ - IFitnessMachineValue? InstantaneousSpeed { get; } - IFitnessMachineValue? AverageSpeed { get; } - IFitnessMachineValue? InstantaneousCadence { get; } - IFitnessMachineValue? AverageCadence { get; } - IFitnessMachineValue? TotalDistance { get; } - IFitnessMachineValue? ResistantLevel { get; } - IFitnessMachineValue? InstantaneousPower { get; } - IFitnessMachineValue? AveragePower { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } -} +namespace FTMS.NET.Data; + +public interface IIndoorBikeData : IFitnessMachineData +{ + IFitnessMachineValue? InstantaneousSpeed { get; } + IFitnessMachineValue? AverageSpeed { get; } + IFitnessMachineValue? InstantaneousCadence { get; } + IFitnessMachineValue? AverageCadence { get; } + IFitnessMachineValue? TotalDistance { get; } + IFitnessMachineValue? ResistantLevel { get; } + IFitnessMachineValue? InstantaneousPower { get; } + IFitnessMachineValue? AveragePower { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IRowerData.cs b/FTMS.NET/Data/IRowerData.cs index c85bbb4..4f91ec2 100644 --- a/FTMS.NET/Data/IRowerData.cs +++ b/FTMS.NET/Data/IRowerData.cs @@ -1,21 +1,21 @@ -namespace FTMS.NET.Data; - -public interface IRowerData : IFitnessMachineData -{ - IFitnessMachineValue? StrokeRate { get; } - IFitnessMachineValue? StrokeCount { get; } - IFitnessMachineValue? AverageStrokeRate { get; } - IFitnessMachineValue? TotalDistance { get; } - IFitnessMachineValue? InstantaneousPace { get; } - IFitnessMachineValue? AveragePace { get; } - IFitnessMachineValue? InstantaneousPower { get; } - IFitnessMachineValue? AveragePower { get; } - IFitnessMachineValue? ResistantLevel { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } -} +namespace FTMS.NET.Data; + +public interface IRowerData : IFitnessMachineData +{ + IFitnessMachineValue? StrokeRate { get; } + IFitnessMachineValue? StrokeCount { get; } + IFitnessMachineValue? AverageStrokeRate { get; } + IFitnessMachineValue? TotalDistance { get; } + IFitnessMachineValue? InstantaneousPace { get; } + IFitnessMachineValue? AveragePace { get; } + IFitnessMachineValue? InstantaneousPower { get; } + IFitnessMachineValue? AveragePower { get; } + IFitnessMachineValue? ResistantLevel { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IStairClimberData.cs b/FTMS.NET/Data/IStairClimberData.cs index db6a9a9..97a64ae 100644 --- a/FTMS.NET/Data/IStairClimberData.cs +++ b/FTMS.NET/Data/IStairClimberData.cs @@ -1,17 +1,17 @@ -namespace FTMS.NET.Data; - -public interface IStairClimberData : IFitnessMachineData -{ - IFitnessMachineValue? Floors { get; } - IFitnessMachineValue? StepsPerMinute { get; } - IFitnessMachineValue? AverageStepRate { get; } - IFitnessMachineValue? PositiveElevationGain { get; } - IFitnessMachineValue? StrideCount { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } -} +namespace FTMS.NET.Data; + +public interface IStairClimberData : IFitnessMachineData +{ + IFitnessMachineValue? Floors { get; } + IFitnessMachineValue? StepsPerMinute { get; } + IFitnessMachineValue? AverageStepRate { get; } + IFitnessMachineValue? PositiveElevationGain { get; } + IFitnessMachineValue? StrideCount { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IStepClimberData.cs b/FTMS.NET/Data/IStepClimberData.cs index e44a3f4..35e8b7f 100644 --- a/FTMS.NET/Data/IStepClimberData.cs +++ b/FTMS.NET/Data/IStepClimberData.cs @@ -1,17 +1,17 @@ -namespace FTMS.NET.Data; - -public interface IStepClimberData : IFitnessMachineData -{ - IFitnessMachineValue? Floors { get; } - IFitnessMachineValue? StepCount { get; } - IFitnessMachineValue? StepsPerMinute { get; } - IFitnessMachineValue? AverageStepRate { get; } - IFitnessMachineValue? PositiveElevationGain { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } -} +namespace FTMS.NET.Data; + +public interface IStepClimberData : IFitnessMachineData +{ + IFitnessMachineValue? Floors { get; } + IFitnessMachineValue? StepCount { get; } + IFitnessMachineValue? StepsPerMinute { get; } + IFitnessMachineValue? AverageStepRate { get; } + IFitnessMachineValue? PositiveElevationGain { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IThreadmillData.cs b/FTMS.NET/Data/IThreadmillData.cs index 3c961a9..d542062 100644 --- a/FTMS.NET/Data/IThreadmillData.cs +++ b/FTMS.NET/Data/IThreadmillData.cs @@ -1,23 +1,23 @@ -namespace FTMS.NET.Data; - -public interface IThreadmillData : IFitnessMachineData -{ - IFitnessMachineValue? InstantaneousSpeed { get; } - IFitnessMachineValue? AverageSpeed { get; } - IFitnessMachineValue? TotalDistance { get; } - IFitnessMachineValue? Inclination { get; } - IFitnessMachineValue? RampAngleSetting { get; } - IFitnessMachineValue? PositiveElevationGain { get; } - IFitnessMachineValue? NegativeElevationGain { get; } - IFitnessMachineValue? InstantaneousPace { get; } - IFitnessMachineValue? AveragePace { get; } - IFitnessMachineValue? TotalEnergy { get; } - IFitnessMachineValue? EnergyPerHour { get; } - IFitnessMachineValue? EnergyPerMinute { get; } - IFitnessMachineValue? HeartRate { get; } - IFitnessMachineValue? MetabolicEquivalent { get; } - IFitnessMachineValue? ElapsedTime { get; } - IFitnessMachineValue? RemainingTime { get; } - IFitnessMachineValue? ForceOnBelt { get; } - IFitnessMachineValue? PowerOutput { get; } -} +namespace FTMS.NET.Data; + +public interface IThreadmillData : IFitnessMachineData +{ + IFitnessMachineValue? InstantaneousSpeed { get; } + IFitnessMachineValue? AverageSpeed { get; } + IFitnessMachineValue? TotalDistance { get; } + IFitnessMachineValue? Inclination { get; } + IFitnessMachineValue? RampAngleSetting { get; } + IFitnessMachineValue? PositiveElevationGain { get; } + IFitnessMachineValue? NegativeElevationGain { get; } + IFitnessMachineValue? InstantaneousPace { get; } + IFitnessMachineValue? AveragePace { get; } + IFitnessMachineValue? TotalEnergy { get; } + IFitnessMachineValue? EnergyPerHour { get; } + IFitnessMachineValue? EnergyPerMinute { get; } + IFitnessMachineValue? HeartRate { get; } + IFitnessMachineValue? MetabolicEquivalent { get; } + IFitnessMachineValue? ElapsedTime { get; } + IFitnessMachineValue? RemainingTime { get; } + IFitnessMachineValue? ForceOnBelt { get; } + IFitnessMachineValue? PowerOutput { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Data/IndoorBikeData.cs b/FTMS.NET/Data/IndoorBikeData.cs index bdfc344..ae86116 100644 --- a/FTMS.NET/Data/IndoorBikeData.cs +++ b/FTMS.NET/Data/IndoorBikeData.cs @@ -1,27 +1,27 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class IndoorBikeData(IFitnessMachineData data) : IIndoorBikeData -{ - public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); - public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); - public IFitnessMachineValue? InstantaneousCadence => data.GetValue(FtmsUuids.InstantaneousCadence); - public IFitnessMachineValue? AverageCadence => data.GetValue(FtmsUuids.AverageCadence); - public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); - public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); - public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); - public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class IndoorBikeData(IFitnessMachineData data) : IIndoorBikeData +{ + public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); + public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); + public IFitnessMachineValue? InstantaneousCadence => data.GetValue(FtmsUuids.InstantaneousCadence); + public IFitnessMachineValue? AverageCadence => data.GetValue(FtmsUuids.AverageCadence); + public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); + public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); + public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); + public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/RowerData.cs b/FTMS.NET/Data/RowerData.cs index 6df0e3d..29bd7cf 100644 --- a/FTMS.NET/Data/RowerData.cs +++ b/FTMS.NET/Data/RowerData.cs @@ -1,28 +1,28 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class RowerData(IFitnessMachineData data) : IRowerData -{ - public IFitnessMachineValue? StrokeRate => data.GetValue(FtmsUuids.StrokeRate); - public IFitnessMachineValue? StrokeCount => data.GetValue(FtmsUuids.StrokeCount); - public IFitnessMachineValue? AverageStrokeRate => data.GetValue(FtmsUuids.AverageStrokeRate); - public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); - public IFitnessMachineValue? InstantaneousPace => data.GetValue(FtmsUuids.InstantaneousPace); - public IFitnessMachineValue? AveragePace => data.GetValue(FtmsUuids.AveragePace); - public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); - public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); - public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class RowerData(IFitnessMachineData data) : IRowerData +{ + public IFitnessMachineValue? StrokeRate => data.GetValue(FtmsUuids.StrokeRate); + public IFitnessMachineValue? StrokeCount => data.GetValue(FtmsUuids.StrokeCount); + public IFitnessMachineValue? AverageStrokeRate => data.GetValue(FtmsUuids.AverageStrokeRate); + public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); + public IFitnessMachineValue? InstantaneousPace => data.GetValue(FtmsUuids.InstantaneousPace); + public IFitnessMachineValue? AveragePace => data.GetValue(FtmsUuids.AveragePace); + public IFitnessMachineValue? InstantaneousPower => data.GetValue(FtmsUuids.InstantaneousPower); + public IFitnessMachineValue? AveragePower => data.GetValue(FtmsUuids.AveragePower); + public IFitnessMachineValue? ResistantLevel => data.GetValue(FtmsUuids.ResistantLevel); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/SingleFrameReader.cs b/FTMS.NET/Data/SingleFrameReader.cs index 191aa3b..ebfd3e5 100644 --- a/FTMS.NET/Data/SingleFrameReader.cs +++ b/FTMS.NET/Data/SingleFrameReader.cs @@ -1,68 +1,68 @@ -namespace FTMS.NET.Data; - -using FTMS.NET.Utils; -using System; -using System.Data; - -internal sealed class SingleFrameReader : IDisposable -{ - private readonly MemoryStream dataStream; - private readonly BinaryReader dataReader; - private readonly SingleFrameStrategy singleFrameStrategy; - - public SingleFrameReader(byte[] dataFrame, SingleFrameStrategy singleFrameStrategy) - { - this.dataStream = new(dataFrame); - this.dataReader = new(this.dataStream); - this.singleFrameStrategy = singleFrameStrategy; - } - - public IReadOnlyList ReadFrame() - { - byte[] flagFields = this.dataReader.ReadBytes(this.singleFrameStrategy.FlagFieldLength); - - return [.. this.singleFrameStrategy - .SingleValueRules - .Where(rule => flagFields.IsBitSet(rule.BitPosition) == rule.CheckIfBitIsSet) - .Select(rule => - { - long readValue = ReadValue(rule.RawValueType); - double calculatedValue = rule.Calculation.Calculate(readValue); - return new FitnessMachineValue( - rule.ValueUuid, - calculatedValue, - FtmsUuids.GetName(rule.ValueUuid)); - })]; - - long ReadValue(RawValueType rawValueType) => rawValueType switch - { - RawValueType.Byte => this.dataReader.ReadByte(), - RawValueType.SByte => this.dataReader.ReadSByte(), - RawValueType.Short => this.dataReader.ReadInt16(), - RawValueType.UShort => this.dataReader.ReadUInt16(), - RawValueType.Int => this.dataReader.ReadInt32(), - RawValueType.UInt => this.dataReader.ReadUInt32(), - //RawValueType.Long => this.dataReader.ReadInt64(), - //RawValueType.ULong => (long)this.dataReader.ReadUInt64(), - //RawValueType.Float => (long)this.dataReader.ReadSingle(), - //RawValueType.Double => (long)this.dataReader.ReadDouble(), - RawValueType.UInt24 => this.ReadUInt24(), - _ => throw new NotSupportedException($"Unsupported RawValueType: {rawValueType}") - }; - } - - private UInt24 ReadUInt24() - { - byte b0 = this.dataReader.ReadByte(); - byte b1 = this.dataReader.ReadByte(); - byte b2 = this.dataReader.ReadByte(); - return new UInt24(b0, b1, b2); - } - - public void Dispose() - { - this.dataReader.Dispose(); - this.dataStream.Dispose(); - GC.SuppressFinalize(this); - } -} +namespace FTMS.NET.Data; + +using FTMS.NET.Utils; +using System; +using System.Data; + +internal sealed class SingleFrameReader : IDisposable +{ + private readonly MemoryStream dataStream; + private readonly BinaryReader dataReader; + private readonly SingleFrameStrategy singleFrameStrategy; + + public SingleFrameReader(byte[] dataFrame, SingleFrameStrategy singleFrameStrategy) + { + this.dataStream = new(dataFrame); + this.dataReader = new(this.dataStream); + this.singleFrameStrategy = singleFrameStrategy; + } + + public IReadOnlyList ReadFrame() + { + byte[] flagFields = this.dataReader.ReadBytes(this.singleFrameStrategy.FlagFieldLength); + + return [.. this.singleFrameStrategy + .SingleValueRules + .Where(rule => flagFields.IsBitSet(rule.BitPosition) == rule.CheckIfBitIsSet) + .Select(rule => + { + long readValue = ReadValue(rule.RawValueType); + double calculatedValue = rule.Calculation.Calculate(readValue); + return new FitnessMachineValue( + rule.ValueUuid, + calculatedValue, + FtmsUuids.GetName(rule.ValueUuid)); + })]; + + long ReadValue(RawValueType rawValueType) => rawValueType switch + { + RawValueType.Byte => this.dataReader.ReadByte(), + RawValueType.SByte => this.dataReader.ReadSByte(), + RawValueType.Short => this.dataReader.ReadInt16(), + RawValueType.UShort => this.dataReader.ReadUInt16(), + RawValueType.Int => this.dataReader.ReadInt32(), + RawValueType.UInt => this.dataReader.ReadUInt32(), + //RawValueType.Long => this.dataReader.ReadInt64(), + //RawValueType.ULong => (long)this.dataReader.ReadUInt64(), + //RawValueType.Float => (long)this.dataReader.ReadSingle(), + //RawValueType.Double => (long)this.dataReader.ReadDouble(), + RawValueType.UInt24 => this.ReadUInt24(), + _ => throw new NotSupportedException($"Unsupported RawValueType: {rawValueType}") + }; + } + + private UInt24 ReadUInt24() + { + byte b0 = this.dataReader.ReadByte(); + byte b1 = this.dataReader.ReadByte(); + byte b2 = this.dataReader.ReadByte(); + return new UInt24(b0, b1, b2); + } + + public void Dispose() + { + this.dataReader.Dispose(); + this.dataStream.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/FTMS.NET/Data/SingleFrameStrategies.cs b/FTMS.NET/Data/SingleFrameStrategies.cs index 2cf290a..2eb81b7 100644 --- a/FTMS.NET/Data/SingleFrameStrategies.cs +++ b/FTMS.NET/Data/SingleFrameStrategies.cs @@ -1,158 +1,158 @@ -namespace FTMS.NET.Data; - -using System; - -internal static class SingleFrameStrategies -{ - public static SingleFrameStrategy GetFor(EFitnessMachineType type) => type switch - { - EFitnessMachineType.Threadmill => CreateThreadmillStrategy(), - EFitnessMachineType.CrossTrainer => CreateCrossTrainerStrategy(), - EFitnessMachineType.StepClimber => CreateStepClimberStrategy(), - EFitnessMachineType.StairClimber => CreateStairClimberStrategy(), - EFitnessMachineType.Rower => CreateRowerStrategy(), - EFitnessMachineType.IndoorBike => CreateIndoorBikeStrategy(), - _ => throw new ArgumentException($"Unsupported fitness machine type: {type}") - }; - - private static SingleFrameStrategy CreateThreadmillStrategy() => new() - { - FlagFieldLength = 2, - SingleValueRules = - [ - new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), - new(FtmsUuids.Inclination, true, 3, RawValueType.Short, new(1, -1, 0)), - new(FtmsUuids.RampAngleSetting, true, 3, RawValueType.Short, new(1, -1, 0)), - new(FtmsUuids.PositiveElevationGain, true, 4, RawValueType.UShort, new(1, -1, 0)), - new(FtmsUuids.NegativeElevationGain, true, 4, RawValueType.UShort, new(1, -1, 0)), - new(FtmsUuids.InstantaneousPace, true, 5, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AveragePace, true, 6, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.TotalEnergy, true, 7, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 7, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 7, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 8, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 9, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.ElapsedTime, true, 10, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 11, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.ForceOnBelt, true, 12, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.PowerOutput, true, 12, RawValueType.Short, new(1, 0, 0)), - ] - }; - - private static SingleFrameStrategy CreateCrossTrainerStrategy() => new() - { - FlagFieldLength = 3, - SingleValueRules = - [ - new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), - new(FtmsUuids.StepsPerMinute, true, 3, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AverageStepRate, true, 3, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.StrideCount, true, 4, RawValueType.UShort, new(1, -1, 0)), - new(FtmsUuids.PositiveElevationGain, true, 5, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.NegativeElevationGain, true, 5, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.Inclination, true, 6, RawValueType.Short, new(1, -1, 0)), - new(FtmsUuids.RampAngleSetting, true, 6, RawValueType.Short, new(1, -1, 0)), - new(FtmsUuids.ResistantLevel, true, 7, RawValueType.Byte, new(1, 1, 0)), - new(FtmsUuids.InstantaneousPower, true, 8, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.AveragePower, true, 9, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.TotalEnergy, true, 10, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 10, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 10, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 11, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 12, RawValueType.Byte, new(1, -1, 0)), - new(FtmsUuids.ElapsedTime, true, 13, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 14, RawValueType.UShort, new(1, 0, 0)), - ] - }; - - private static SingleFrameStrategy CreateStepClimberStrategy() => new() - { - FlagFieldLength = 2, - SingleValueRules = - [ - new(FtmsUuids.Floors, false, 0, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.StepCount, false, 0, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.StepsPerMinute, true, 1, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AverageStepRate, true, 2, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.PositiveElevationGain, true, 3, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.TotalEnergy, true, 4, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 4, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 4, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 5, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 6, RawValueType.Byte, new(1, -1, 0)), - new(FtmsUuids.ElapsedTime, true, 7, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 8, RawValueType.UShort, new(1, 0, 0)) - ] - }; - - private static SingleFrameStrategy CreateStairClimberStrategy() => new() - { - FlagFieldLength = 2, - SingleValueRules = - [ - new(FtmsUuids.Floors, false, 0, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.StepsPerMinute, true, 1, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AverageStepRate, true, 2, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.PositiveElevationGain, true, 3, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.StrideCount, true, 4, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.TotalEnergy, true, 5, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 5, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 5, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 6, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 7, RawValueType.Byte, new(1, -1, 0)), - new(FtmsUuids.ElapsedTime, true, 8, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 9, RawValueType.UShort, new(1, 0, 0)) - ] - }; - - private static SingleFrameStrategy CreateRowerStrategy() => new() - { - FlagFieldLength = 2, - SingleValueRules = - [ - new(FtmsUuids.StrokeRate, false, 0, RawValueType.Byte, new(1, 0, -1)), - new(FtmsUuids.StrokeCount, false, 0, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AverageStrokeRate, true, 1, RawValueType.Byte, new(1, 0, -1)), - new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), - new(FtmsUuids.InstantaneousPace, true, 3, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.AveragePace, true, 4, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.InstantaneousPower, true, 5, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.AveragePower, true, 6, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.ResistantLevel, true, 7, RawValueType.Byte, new(1, 1, 0)), - new(FtmsUuids.TotalEnergy, true, 8, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 8, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 8, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 9, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 10, RawValueType.Byte, new(1, -1, 0)), - new(FtmsUuids.ElapsedTime, true, 11, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 12, RawValueType.UShort, new(1, 0, 0)) - ] - }; - - private static SingleFrameStrategy CreateIndoorBikeStrategy() => new() - { - FlagFieldLength = 2, - SingleValueRules = - [ - new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), - new(FtmsUuids.InstantaneousCadence, true, 2, RawValueType.UShort, new(1, 0, -1)), - new(FtmsUuids.AverageCadence, true, 3, RawValueType.UShort, new(1, 0, -1)), - new(FtmsUuids.TotalDistance, true, 4, RawValueType.UInt24, new(1, 0, 0)), - new(FtmsUuids.ResistantLevel, true, 5, RawValueType.Byte, new(1, 1, 0)), - new(FtmsUuids.InstantaneousPower, true, 6, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.AveragePower, true, 7, RawValueType.Short, new(1, 0, 0)), - new(FtmsUuids.TotalEnergy, true, 8, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerHour, true, 8, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.EnergyPerMinute, true, 8, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.HeartRate, true, 9, RawValueType.Byte, new(1, 0, 0)), - new(FtmsUuids.MetabolicEquivalent, true, 10, RawValueType.Byte, new(1, -1, 0)), - new(FtmsUuids.ElapsedTime, true, 11, RawValueType.UShort, new(1, 0, 0)), - new(FtmsUuids.RemainingTime, true, 12, RawValueType.UShort, new(1, 0, 0)) - ] - }; -} +namespace FTMS.NET.Data; + +using System; + +internal static class SingleFrameStrategies +{ + public static SingleFrameStrategy GetFor(EFitnessMachineType type) => type switch + { + EFitnessMachineType.Threadmill => CreateThreadmillStrategy(), + EFitnessMachineType.CrossTrainer => CreateCrossTrainerStrategy(), + EFitnessMachineType.StepClimber => CreateStepClimberStrategy(), + EFitnessMachineType.StairClimber => CreateStairClimberStrategy(), + EFitnessMachineType.Rower => CreateRowerStrategy(), + EFitnessMachineType.IndoorBike => CreateIndoorBikeStrategy(), + _ => throw new ArgumentException($"Unsupported fitness machine type: {type}") + }; + + private static SingleFrameStrategy CreateThreadmillStrategy() => new() + { + FlagFieldLength = 2, + SingleValueRules = + [ + new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), + new(FtmsUuids.Inclination, true, 3, RawValueType.Short, new(1, -1, 0)), + new(FtmsUuids.RampAngleSetting, true, 3, RawValueType.Short, new(1, -1, 0)), + new(FtmsUuids.PositiveElevationGain, true, 4, RawValueType.UShort, new(1, -1, 0)), + new(FtmsUuids.NegativeElevationGain, true, 4, RawValueType.UShort, new(1, -1, 0)), + new(FtmsUuids.InstantaneousPace, true, 5, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AveragePace, true, 6, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.TotalEnergy, true, 7, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 7, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 7, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 8, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 9, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.ElapsedTime, true, 10, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 11, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.ForceOnBelt, true, 12, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.PowerOutput, true, 12, RawValueType.Short, new(1, 0, 0)), + ] + }; + + private static SingleFrameStrategy CreateCrossTrainerStrategy() => new() + { + FlagFieldLength = 3, + SingleValueRules = + [ + new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), + new(FtmsUuids.StepsPerMinute, true, 3, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AverageStepRate, true, 3, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.StrideCount, true, 4, RawValueType.UShort, new(1, -1, 0)), + new(FtmsUuids.PositiveElevationGain, true, 5, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.NegativeElevationGain, true, 5, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.Inclination, true, 6, RawValueType.Short, new(1, -1, 0)), + new(FtmsUuids.RampAngleSetting, true, 6, RawValueType.Short, new(1, -1, 0)), + new(FtmsUuids.ResistantLevel, true, 7, RawValueType.Byte, new(1, 1, 0)), + new(FtmsUuids.InstantaneousPower, true, 8, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.AveragePower, true, 9, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.TotalEnergy, true, 10, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 10, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 10, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 11, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 12, RawValueType.Byte, new(1, -1, 0)), + new(FtmsUuids.ElapsedTime, true, 13, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 14, RawValueType.UShort, new(1, 0, 0)), + ] + }; + + private static SingleFrameStrategy CreateStepClimberStrategy() => new() + { + FlagFieldLength = 2, + SingleValueRules = + [ + new(FtmsUuids.Floors, false, 0, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.StepCount, false, 0, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.StepsPerMinute, true, 1, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AverageStepRate, true, 2, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.PositiveElevationGain, true, 3, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.TotalEnergy, true, 4, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 4, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 4, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 5, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 6, RawValueType.Byte, new(1, -1, 0)), + new(FtmsUuids.ElapsedTime, true, 7, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 8, RawValueType.UShort, new(1, 0, 0)) + ] + }; + + private static SingleFrameStrategy CreateStairClimberStrategy() => new() + { + FlagFieldLength = 2, + SingleValueRules = + [ + new(FtmsUuids.Floors, false, 0, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.StepsPerMinute, true, 1, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AverageStepRate, true, 2, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.PositiveElevationGain, true, 3, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.StrideCount, true, 4, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.TotalEnergy, true, 5, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 5, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 5, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 6, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 7, RawValueType.Byte, new(1, -1, 0)), + new(FtmsUuids.ElapsedTime, true, 8, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 9, RawValueType.UShort, new(1, 0, 0)) + ] + }; + + private static SingleFrameStrategy CreateRowerStrategy() => new() + { + FlagFieldLength = 2, + SingleValueRules = + [ + new(FtmsUuids.StrokeRate, false, 0, RawValueType.Byte, new(1, 0, -1)), + new(FtmsUuids.StrokeCount, false, 0, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AverageStrokeRate, true, 1, RawValueType.Byte, new(1, 0, -1)), + new(FtmsUuids.TotalDistance, true, 2, RawValueType.UInt24, new(1, 0, 0)), + new(FtmsUuids.InstantaneousPace, true, 3, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.AveragePace, true, 4, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.InstantaneousPower, true, 5, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.AveragePower, true, 6, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.ResistantLevel, true, 7, RawValueType.Byte, new(1, 1, 0)), + new(FtmsUuids.TotalEnergy, true, 8, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 8, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 8, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 9, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 10, RawValueType.Byte, new(1, -1, 0)), + new(FtmsUuids.ElapsedTime, true, 11, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 12, RawValueType.UShort, new(1, 0, 0)) + ] + }; + + private static SingleFrameStrategy CreateIndoorBikeStrategy() => new() + { + FlagFieldLength = 2, + SingleValueRules = + [ + new(FtmsUuids.InstantaneousSpeed, false, 0, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.AverageSpeed, true, 1, RawValueType.UShort, new(1, -2, 0)), + new(FtmsUuids.InstantaneousCadence, true, 2, RawValueType.UShort, new(1, 0, -1)), + new(FtmsUuids.AverageCadence, true, 3, RawValueType.UShort, new(1, 0, -1)), + new(FtmsUuids.TotalDistance, true, 4, RawValueType.UInt24, new(1, 0, 0)), + new(FtmsUuids.ResistantLevel, true, 5, RawValueType.Byte, new(1, 1, 0)), + new(FtmsUuids.InstantaneousPower, true, 6, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.AveragePower, true, 7, RawValueType.Short, new(1, 0, 0)), + new(FtmsUuids.TotalEnergy, true, 8, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerHour, true, 8, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.EnergyPerMinute, true, 8, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.HeartRate, true, 9, RawValueType.Byte, new(1, 0, 0)), + new(FtmsUuids.MetabolicEquivalent, true, 10, RawValueType.Byte, new(1, -1, 0)), + new(FtmsUuids.ElapsedTime, true, 11, RawValueType.UShort, new(1, 0, 0)), + new(FtmsUuids.RemainingTime, true, 12, RawValueType.UShort, new(1, 0, 0)) + ] + }; +} \ No newline at end of file diff --git a/FTMS.NET/Data/SingleFrameStrategy.cs b/FTMS.NET/Data/SingleFrameStrategy.cs index 8c16af3..4347e60 100644 --- a/FTMS.NET/Data/SingleFrameStrategy.cs +++ b/FTMS.NET/Data/SingleFrameStrategy.cs @@ -1,34 +1,34 @@ -namespace FTMS.NET.Data; - -using FTMS.NET.Utils; -using System; -using System.Collections.Immutable; - -internal sealed class SingleFrameStrategy -{ - public required int FlagFieldLength { get; init; } - - public required IImmutableList SingleValueRules { get; init; } -} - -internal enum RawValueType -{ - Byte, - SByte, - Short, - UShort, - Int, - UInt, - UInt24, - Long, - ULong, - Float, - Double -} - -internal sealed record SingleValueRule( - Guid ValueUuid, - bool CheckIfBitIsSet, - int BitPosition, - RawValueType RawValueType, - ValueCalculation Calculation); +namespace FTMS.NET.Data; + +using FTMS.NET.Utils; +using System; +using System.Collections.Immutable; + +internal sealed class SingleFrameStrategy +{ + public required int FlagFieldLength { get; init; } + + public required IImmutableList SingleValueRules { get; init; } +} + +internal enum RawValueType +{ + Byte, + SByte, + Short, + UShort, + Int, + UInt, + UInt24, + Long, + ULong, + Float, + Double +} + +internal sealed record SingleValueRule( + Guid ValueUuid, + bool CheckIfBitIsSet, + int BitPosition, + RawValueType RawValueType, + ValueCalculation Calculation); \ No newline at end of file diff --git a/FTMS.NET/Data/StairClimberData.cs b/FTMS.NET/Data/StairClimberData.cs index eb08f97..a1858bb 100644 --- a/FTMS.NET/Data/StairClimberData.cs +++ b/FTMS.NET/Data/StairClimberData.cs @@ -1,24 +1,24 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class StairClimberData(IFitnessMachineData data) : IStairClimberData -{ - public IFitnessMachineValue? Floors => data.GetValue(FtmsUuids.Floors); - public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); - public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); - public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); - public IFitnessMachineValue? StrideCount => data.GetValue(FtmsUuids.StrideCount); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class StairClimberData(IFitnessMachineData data) : IStairClimberData +{ + public IFitnessMachineValue? Floors => data.GetValue(FtmsUuids.Floors); + public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); + public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); + public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); + public IFitnessMachineValue? StrideCount => data.GetValue(FtmsUuids.StrideCount); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/StepClimberData.cs b/FTMS.NET/Data/StepClimberData.cs index 2087e5d..df2be88 100644 --- a/FTMS.NET/Data/StepClimberData.cs +++ b/FTMS.NET/Data/StepClimberData.cs @@ -1,24 +1,24 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class StepClimberData(IFitnessMachineData data) : IStepClimberData -{ - public IFitnessMachineValue? Floors => data.GetValue(FtmsUuids.Floors); - public IFitnessMachineValue? StepCount => data.GetValue(FtmsUuids.StepCount); - public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); - public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); - public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class StepClimberData(IFitnessMachineData data) : IStepClimberData +{ + public IFitnessMachineValue? Floors => data.GetValue(FtmsUuids.Floors); + public IFitnessMachineValue? StepCount => data.GetValue(FtmsUuids.StepCount); + public IFitnessMachineValue? StepsPerMinute => data.GetValue(FtmsUuids.StepsPerMinute); + public IFitnessMachineValue? AverageStepRate => data.GetValue(FtmsUuids.AverageStepRate); + public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/Data/ThreadmillData.cs b/FTMS.NET/Data/ThreadmillData.cs index a07364e..ea9ba74 100644 --- a/FTMS.NET/Data/ThreadmillData.cs +++ b/FTMS.NET/Data/ThreadmillData.cs @@ -1,30 +1,30 @@ -namespace FTMS.NET.Data; - -using DynamicData; -using System; - -public sealed class ThreadmillData(IFitnessMachineData data) : IThreadmillData -{ - public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); - public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); - public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); - public IFitnessMachineValue? Inclination => data.GetValue(FtmsUuids.Inclination); - public IFitnessMachineValue? RampAngleSetting => data.GetValue(FtmsUuids.RampAngleSetting); - public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); - public IFitnessMachineValue? NegativeElevationGain => data.GetValue(FtmsUuids.NegativeElevationGain); - public IFitnessMachineValue? InstantaneousPace => data.GetValue(FtmsUuids.InstantaneousPace); - public IFitnessMachineValue? AveragePace => data.GetValue(FtmsUuids.AveragePace); - public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); - public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); - public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); - public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); - public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); - public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); - public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); - public IFitnessMachineValue? ForceOnBelt => data.GetValue(FtmsUuids.ForceOnBelt); - public IFitnessMachineValue? PowerOutput => data.GetValue(FtmsUuids.PowerOutput); - - public IObservable> Connect() => data.Connect(); - public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); - public void Dispose() => data.Dispose(); -} +namespace FTMS.NET.Data; + +using DynamicData; +using System; + +public sealed class ThreadmillData(IFitnessMachineData data) : IThreadmillData +{ + public IFitnessMachineValue? InstantaneousSpeed => data.GetValue(FtmsUuids.InstantaneousSpeed); + public IFitnessMachineValue? AverageSpeed => data.GetValue(FtmsUuids.AverageSpeed); + public IFitnessMachineValue? TotalDistance => data.GetValue(FtmsUuids.TotalDistance); + public IFitnessMachineValue? Inclination => data.GetValue(FtmsUuids.Inclination); + public IFitnessMachineValue? RampAngleSetting => data.GetValue(FtmsUuids.RampAngleSetting); + public IFitnessMachineValue? PositiveElevationGain => data.GetValue(FtmsUuids.PositiveElevationGain); + public IFitnessMachineValue? NegativeElevationGain => data.GetValue(FtmsUuids.NegativeElevationGain); + public IFitnessMachineValue? InstantaneousPace => data.GetValue(FtmsUuids.InstantaneousPace); + public IFitnessMachineValue? AveragePace => data.GetValue(FtmsUuids.AveragePace); + public IFitnessMachineValue? TotalEnergy => data.GetValue(FtmsUuids.TotalEnergy); + public IFitnessMachineValue? EnergyPerHour => data.GetValue(FtmsUuids.EnergyPerHour); + public IFitnessMachineValue? EnergyPerMinute => data.GetValue(FtmsUuids.EnergyPerMinute); + public IFitnessMachineValue? HeartRate => data.GetValue(FtmsUuids.HeartRate); + public IFitnessMachineValue? MetabolicEquivalent => data.GetValue(FtmsUuids.MetabolicEquivalent); + public IFitnessMachineValue? ElapsedTime => data.GetValue(FtmsUuids.ElapsedTime); + public IFitnessMachineValue? RemainingTime => data.GetValue(FtmsUuids.RemainingTime); + public IFitnessMachineValue? ForceOnBelt => data.GetValue(FtmsUuids.ForceOnBelt); + public IFitnessMachineValue? PowerOutput => data.GetValue(FtmsUuids.PowerOutput); + + public IObservable> Connect() => data.Connect(); + public IFitnessMachineValue? GetValue(Guid uuid) => data.GetValue(uuid); + public void Dispose() => data.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/EFitnessMachineType.cs b/FTMS.NET/EFitnessMachineType.cs index 1970770..8b4cc5d 100644 --- a/FTMS.NET/EFitnessMachineType.cs +++ b/FTMS.NET/EFitnessMachineType.cs @@ -1,36 +1,36 @@ -namespace FTMS.NET; - -using FTMS.NET.Exceptions; - -public enum EFitnessMachineType -{ - Threadmill, - CrossTrainer, - StepClimber, - StairClimber, - Rower, - IndoorBike -} - -public static class EFitnessMachineTypeExtensions -{ - public static EFitnessMachineType EnsureType(this EFitnessMachineType fitnessMachineType) - { - if (Enum.IsDefined(fitnessMachineType) == false) - throw new FitnessMachineTypeNotDefinedException(fitnessMachineType); - - return fitnessMachineType; - } - - public static Guid GetDataCharacteristicId(this EFitnessMachineType fitnessMachineType) - => fitnessMachineType switch - { - EFitnessMachineType.Threadmill => FtmsUuids.TreadmillData, - EFitnessMachineType.CrossTrainer => FtmsUuids.CrossTrainerData, - EFitnessMachineType.StepClimber => FtmsUuids.StepClimberData, - EFitnessMachineType.StairClimber => FtmsUuids.StairClimberData, - EFitnessMachineType.Rower => FtmsUuids.RowerData, - EFitnessMachineType.IndoorBike => FtmsUuids.IndoorBikeData, - _ => throw new InvalidOperationException() - }; -} +namespace FTMS.NET; + +using FTMS.NET.Exceptions; + +public enum EFitnessMachineType +{ + Threadmill, + CrossTrainer, + StepClimber, + StairClimber, + Rower, + IndoorBike +} + +public static class EFitnessMachineTypeExtensions +{ + public static EFitnessMachineType EnsureType(this EFitnessMachineType fitnessMachineType) + { + if (Enum.IsDefined(fitnessMachineType) == false) + throw new FitnessMachineTypeNotDefinedException(fitnessMachineType); + + return fitnessMachineType; + } + + public static Guid GetDataCharacteristicId(this EFitnessMachineType fitnessMachineType) + => fitnessMachineType switch + { + EFitnessMachineType.Threadmill => FtmsUuids.TreadmillData, + EFitnessMachineType.CrossTrainer => FtmsUuids.CrossTrainerData, + EFitnessMachineType.StepClimber => FtmsUuids.StepClimberData, + EFitnessMachineType.StairClimber => FtmsUuids.StairClimberData, + EFitnessMachineType.Rower => FtmsUuids.RowerData, + EFitnessMachineType.IndoorBike => FtmsUuids.IndoorBikeData, + _ => throw new InvalidOperationException() + }; +} \ No newline at end of file diff --git a/FTMS.NET/Exceptions/ControlRequestException.cs b/FTMS.NET/Exceptions/ControlRequestException.cs index 7a529c0..ce9d2eb 100644 --- a/FTMS.NET/Exceptions/ControlRequestException.cs +++ b/FTMS.NET/Exceptions/ControlRequestException.cs @@ -1,19 +1,19 @@ -namespace FTMS.NET.Exceptions; - -using FTMS.NET.Control; -using System; - -public sealed class ControlRequestException( - EControlOpCode opCode, - Exception? innerException = null) - : Exception("Control Request could not be executed.", innerException) -{ - public EControlOpCode OpCode { get; } = opCode; - public EControlResultCode? ResultCode { get; } - - public ControlRequestException(EControlOpCode opCode, EControlResultCode resultCode) - : this(opCode) - { - this.ResultCode = resultCode; - } -} +namespace FTMS.NET.Exceptions; + +using FTMS.NET.Control; +using System; + +public sealed class ControlRequestException( + EControlOpCode opCode, + Exception? innerException = null) + : Exception("Control Request could not be executed.", innerException) +{ + public EControlOpCode OpCode { get; } = opCode; + public EControlResultCode? ResultCode { get; } + + public ControlRequestException(EControlOpCode opCode, EControlResultCode resultCode) + : this(opCode) + { + this.ResultCode = resultCode; + } +} \ No newline at end of file diff --git a/FTMS.NET/Exceptions/FitnessMachineNotAvailableException.cs b/FTMS.NET/Exceptions/FitnessMachineNotAvailableException.cs index 94a7f8d..c3b943c 100644 --- a/FTMS.NET/Exceptions/FitnessMachineNotAvailableException.cs +++ b/FTMS.NET/Exceptions/FitnessMachineNotAvailableException.cs @@ -1,5 +1,6 @@ -namespace FTMS.NET.Exceptions; -using System; - -public sealed class FitnessMachineNotAvailableException() +namespace FTMS.NET.Exceptions; + +using System; + +public sealed class FitnessMachineNotAvailableException() : Exception("The service data indicate, that the fitness machine service is not available."); \ No newline at end of file diff --git a/FTMS.NET/Exceptions/FitnessMachineTypeNotDefinedException.cs b/FTMS.NET/Exceptions/FitnessMachineTypeNotDefinedException.cs index a7c03c2..a84eed4 100644 --- a/FTMS.NET/Exceptions/FitnessMachineTypeNotDefinedException.cs +++ b/FTMS.NET/Exceptions/FitnessMachineTypeNotDefinedException.cs @@ -1,9 +1,9 @@ -namespace FTMS.NET.Exceptions; - -using System; - -public sealed class FitnessMachineTypeNotDefinedException(EFitnessMachineType machineType) - : Exception("The fitness machine type sent from the server is not defined.") -{ - public EFitnessMachineType MachineType { get; } = machineType; -} +namespace FTMS.NET.Exceptions; + +using System; + +public sealed class FitnessMachineTypeNotDefinedException(EFitnessMachineType machineType) + : Exception("The fitness machine type sent from the server is not defined.") +{ + public EFitnessMachineType MachineType { get; } = machineType; +} \ No newline at end of file diff --git a/FTMS.NET/Exceptions/NeededCharacteristicNotAvailableException.cs b/FTMS.NET/Exceptions/NeededCharacteristicNotAvailableException.cs index 91b107e..a6011ce 100644 --- a/FTMS.NET/Exceptions/NeededCharacteristicNotAvailableException.cs +++ b/FTMS.NET/Exceptions/NeededCharacteristicNotAvailableException.cs @@ -1,11 +1,11 @@ -namespace FTMS.NET.Exceptions; - -using System; - -public sealed class NeededCharacteristicNotAvailableException( - Guid characteristicUuid) - : Exception("The needed characteristic is not available.") -{ - public string CharacteristicName { get; } = FtmsUuids.GetName(characteristicUuid); - public Guid CharacteristicUuid { get; } = characteristicUuid; -} +namespace FTMS.NET.Exceptions; + +using System; + +public sealed class NeededCharacteristicNotAvailableException( + Guid characteristicUuid) + : Exception("The needed characteristic is not available.") +{ + public string CharacteristicName { get; } = FtmsUuids.GetName(characteristicUuid); + public Guid CharacteristicUuid { get; } = characteristicUuid; +} \ No newline at end of file diff --git a/FTMS.NET/Features/FitnessMachineFeatures.cs b/FTMS.NET/Features/FitnessMachineFeatures.cs index 5c292a3..ddbd56f 100644 --- a/FTMS.NET/Features/FitnessMachineFeatures.cs +++ b/FTMS.NET/Features/FitnessMachineFeatures.cs @@ -1,56 +1,56 @@ -namespace FTMS.NET.Features; - -using FTMS.NET.Utils; -using System; - -internal sealed class FitnessMachineFeatures( - ReadOnlySpan featuresField, - ReadOnlySpan targetSettingsField) - : IFitnessMachineFeatures -{ - public bool AverageSpeedSupported { get; } = featuresField.IsBitSet(0); - public bool CadenceSupported { get; } = featuresField.IsBitSet(1); - public bool TotalDistanceSupported { get; } = featuresField.IsBitSet(2); - public bool InclinationSupported { get; } = featuresField.IsBitSet(3); - public bool ElevationGainSupported { get; } = featuresField.IsBitSet(4); - public bool PaceSupported { get; } = featuresField.IsBitSet(5); - public bool StepCountSupported { get; } = featuresField.IsBitSet(6); - public bool ResistanceLevelSupported { get; } = featuresField.IsBitSet(7); - public bool StrideCountSupported { get; } = featuresField.IsBitSet(8); - public bool ExpendedEnergySupported { get; } = featuresField.IsBitSet(9); - public bool HeartRateMeasurementSupported { get; } = featuresField.IsBitSet(10); - public bool MetabolicEquivalentSupported { get; } = featuresField.IsBitSet(11); - public bool ElapsedTimeSupported { get; } = featuresField.IsBitSet(12); - public bool RemainingTimeSupported { get; } = featuresField.IsBitSet(13); - public bool PowerMeasurementSupported { get; } = featuresField.IsBitSet(14); - public bool ForceOnBeltAndPowerOutputSupported { get; } = featuresField.IsBitSet(15); - public bool UserDataRetentionSupported { get; } = featuresField.IsBitSet(16); - - public bool SpeedTargetSettingSupported { get; } = targetSettingsField.IsBitSet(0); - public bool InclinationTargetSettingSupported { get; } = targetSettingsField.IsBitSet(1); - public bool ResistanceTargetSettingSupported { get; } = targetSettingsField.IsBitSet(2); - public bool PowerTargetSettingSupported { get; } = targetSettingsField.IsBitSet(3); - public bool HeartRateTargetSettingSupported { get; } = targetSettingsField.IsBitSet(4); - public bool TargetedExpendedEnergyConfigurationSupported { get; } = targetSettingsField.IsBitSet(5); - public bool TargetedStepNumberConfigurationSupported { get; } = targetSettingsField.IsBitSet(6); - public bool TargetedStrideNumberConfigurationSupported { get; } = targetSettingsField.IsBitSet(7); - public bool TargetedDistanceConfigurationSupported { get; } = targetSettingsField.IsBitSet(8); - public bool TargetedTrainingTimeConfigurationSupported { get; } = targetSettingsField.IsBitSet(9); - public bool TargetedTimeInTwoHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(10); - public bool TargetedTimeInThreeHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(11); - public bool TargetedTimeInFiveHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(12); - public bool IndoorBikeSimulationParametersSupported { get; } = targetSettingsField.IsBitSet(13); - public bool WheelCircumferenceConfigurationSupported { get; } = targetSettingsField.IsBitSet(14); - public bool SpinDownControlSupported { get; } = targetSettingsField.IsBitSet(15); - public bool TargetedCadenceConfigurationSupported { get; } = targetSettingsField.IsBitSet(16); - - public ISupportedRange? SpeedRange { get; init; } - - public ISupportedRange? InclinationRange { get; init; } - - public ISupportedRange? ResistanceLevelRange { get; init; } - - public ISupportedRange? PowerRange { get; init; } - - public ISupportedRange? HeartRateRange { get; init; } -} +namespace FTMS.NET.Features; + +using FTMS.NET.Utils; +using System; + +internal sealed class FitnessMachineFeatures( + ReadOnlySpan featuresField, + ReadOnlySpan targetSettingsField) + : IFitnessMachineFeatures +{ + public bool AverageSpeedSupported { get; } = featuresField.IsBitSet(0); + public bool CadenceSupported { get; } = featuresField.IsBitSet(1); + public bool TotalDistanceSupported { get; } = featuresField.IsBitSet(2); + public bool InclinationSupported { get; } = featuresField.IsBitSet(3); + public bool ElevationGainSupported { get; } = featuresField.IsBitSet(4); + public bool PaceSupported { get; } = featuresField.IsBitSet(5); + public bool StepCountSupported { get; } = featuresField.IsBitSet(6); + public bool ResistanceLevelSupported { get; } = featuresField.IsBitSet(7); + public bool StrideCountSupported { get; } = featuresField.IsBitSet(8); + public bool ExpendedEnergySupported { get; } = featuresField.IsBitSet(9); + public bool HeartRateMeasurementSupported { get; } = featuresField.IsBitSet(10); + public bool MetabolicEquivalentSupported { get; } = featuresField.IsBitSet(11); + public bool ElapsedTimeSupported { get; } = featuresField.IsBitSet(12); + public bool RemainingTimeSupported { get; } = featuresField.IsBitSet(13); + public bool PowerMeasurementSupported { get; } = featuresField.IsBitSet(14); + public bool ForceOnBeltAndPowerOutputSupported { get; } = featuresField.IsBitSet(15); + public bool UserDataRetentionSupported { get; } = featuresField.IsBitSet(16); + + public bool SpeedTargetSettingSupported { get; } = targetSettingsField.IsBitSet(0); + public bool InclinationTargetSettingSupported { get; } = targetSettingsField.IsBitSet(1); + public bool ResistanceTargetSettingSupported { get; } = targetSettingsField.IsBitSet(2); + public bool PowerTargetSettingSupported { get; } = targetSettingsField.IsBitSet(3); + public bool HeartRateTargetSettingSupported { get; } = targetSettingsField.IsBitSet(4); + public bool TargetedExpendedEnergyConfigurationSupported { get; } = targetSettingsField.IsBitSet(5); + public bool TargetedStepNumberConfigurationSupported { get; } = targetSettingsField.IsBitSet(6); + public bool TargetedStrideNumberConfigurationSupported { get; } = targetSettingsField.IsBitSet(7); + public bool TargetedDistanceConfigurationSupported { get; } = targetSettingsField.IsBitSet(8); + public bool TargetedTrainingTimeConfigurationSupported { get; } = targetSettingsField.IsBitSet(9); + public bool TargetedTimeInTwoHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(10); + public bool TargetedTimeInThreeHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(11); + public bool TargetedTimeInFiveHeartRateZonesConfigurationSupported { get; } = targetSettingsField.IsBitSet(12); + public bool IndoorBikeSimulationParametersSupported { get; } = targetSettingsField.IsBitSet(13); + public bool WheelCircumferenceConfigurationSupported { get; } = targetSettingsField.IsBitSet(14); + public bool SpinDownControlSupported { get; } = targetSettingsField.IsBitSet(15); + public bool TargetedCadenceConfigurationSupported { get; } = targetSettingsField.IsBitSet(16); + + public ISupportedRange? SpeedRange { get; init; } + + public ISupportedRange? InclinationRange { get; init; } + + public ISupportedRange? ResistanceLevelRange { get; init; } + + public ISupportedRange? PowerRange { get; init; } + + public ISupportedRange? HeartRateRange { get; init; } +} \ No newline at end of file diff --git a/FTMS.NET/Features/IFitnessMachineFeatures.cs b/FTMS.NET/Features/IFitnessMachineFeatures.cs index e47da3d..65889c1 100644 --- a/FTMS.NET/Features/IFitnessMachineFeatures.cs +++ b/FTMS.NET/Features/IFitnessMachineFeatures.cs @@ -1,45 +1,46 @@ -namespace FTMS.NET.Features; -public interface IFitnessMachineFeatures -{ - bool AverageSpeedSupported { get; } - bool CadenceSupported { get; } - bool TotalDistanceSupported { get; } - bool InclinationSupported { get; } - bool ElevationGainSupported { get; } - bool PaceSupported { get; } - bool StepCountSupported { get; } - bool ResistanceLevelSupported { get; } - bool StrideCountSupported { get; } - bool ExpendedEnergySupported { get; } - bool HeartRateMeasurementSupported { get; } - bool MetabolicEquivalentSupported { get; } - bool ElapsedTimeSupported { get; } - bool RemainingTimeSupported { get; } - bool PowerMeasurementSupported { get; } - bool ForceOnBeltAndPowerOutputSupported { get; } - bool UserDataRetentionSupported { get; } - - bool SpeedTargetSettingSupported { get; } - bool InclinationTargetSettingSupported { get; } - bool ResistanceTargetSettingSupported { get; } - bool PowerTargetSettingSupported { get; } - bool HeartRateTargetSettingSupported { get; } - bool TargetedExpendedEnergyConfigurationSupported { get; } - bool TargetedStepNumberConfigurationSupported { get; } - bool TargetedStrideNumberConfigurationSupported { get; } - bool TargetedDistanceConfigurationSupported { get; } - bool TargetedTrainingTimeConfigurationSupported { get; } - bool TargetedTimeInTwoHeartRateZonesConfigurationSupported { get; } - bool TargetedTimeInThreeHeartRateZonesConfigurationSupported { get; } - bool TargetedTimeInFiveHeartRateZonesConfigurationSupported { get; } - bool IndoorBikeSimulationParametersSupported { get; } - bool WheelCircumferenceConfigurationSupported { get; } - bool SpinDownControlSupported { get; } - bool TargetedCadenceConfigurationSupported { get; } - - ISupportedRange? SpeedRange { get; } - ISupportedRange? InclinationRange { get; } - ISupportedRange? ResistanceLevelRange { get; } - ISupportedRange? PowerRange { get; } - ISupportedRange? HeartRateRange { get; } -} +namespace FTMS.NET.Features; + +public interface IFitnessMachineFeatures +{ + bool AverageSpeedSupported { get; } + bool CadenceSupported { get; } + bool TotalDistanceSupported { get; } + bool InclinationSupported { get; } + bool ElevationGainSupported { get; } + bool PaceSupported { get; } + bool StepCountSupported { get; } + bool ResistanceLevelSupported { get; } + bool StrideCountSupported { get; } + bool ExpendedEnergySupported { get; } + bool HeartRateMeasurementSupported { get; } + bool MetabolicEquivalentSupported { get; } + bool ElapsedTimeSupported { get; } + bool RemainingTimeSupported { get; } + bool PowerMeasurementSupported { get; } + bool ForceOnBeltAndPowerOutputSupported { get; } + bool UserDataRetentionSupported { get; } + + bool SpeedTargetSettingSupported { get; } + bool InclinationTargetSettingSupported { get; } + bool ResistanceTargetSettingSupported { get; } + bool PowerTargetSettingSupported { get; } + bool HeartRateTargetSettingSupported { get; } + bool TargetedExpendedEnergyConfigurationSupported { get; } + bool TargetedStepNumberConfigurationSupported { get; } + bool TargetedStrideNumberConfigurationSupported { get; } + bool TargetedDistanceConfigurationSupported { get; } + bool TargetedTrainingTimeConfigurationSupported { get; } + bool TargetedTimeInTwoHeartRateZonesConfigurationSupported { get; } + bool TargetedTimeInThreeHeartRateZonesConfigurationSupported { get; } + bool TargetedTimeInFiveHeartRateZonesConfigurationSupported { get; } + bool IndoorBikeSimulationParametersSupported { get; } + bool WheelCircumferenceConfigurationSupported { get; } + bool SpinDownControlSupported { get; } + bool TargetedCadenceConfigurationSupported { get; } + + ISupportedRange? SpeedRange { get; } + ISupportedRange? InclinationRange { get; } + ISupportedRange? ResistanceLevelRange { get; } + ISupportedRange? PowerRange { get; } + ISupportedRange? HeartRateRange { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Features/ISupportedRange.cs b/FTMS.NET/Features/ISupportedRange.cs index 149604f..2fad88d 100644 --- a/FTMS.NET/Features/ISupportedRange.cs +++ b/FTMS.NET/Features/ISupportedRange.cs @@ -1,8 +1,9 @@ -namespace FTMS.NET.Features; -public interface ISupportedRange -{ - double MinimumValue { get; } - double MaximumValue { get; } - double MinimumIncrement { get; } - FitnessMachineUnit Unit { get; } -} +namespace FTMS.NET.Features; + +public interface ISupportedRange +{ + double MinimumValue { get; } + double MaximumValue { get; } + double MinimumIncrement { get; } + FitnessMachineUnit Unit { get; } +} \ No newline at end of file diff --git a/FTMS.NET/Features/ISupportedRangeExtensions.cs b/FTMS.NET/Features/ISupportedRangeExtensions.cs index 504aab2..f9ae5c1 100644 --- a/FTMS.NET/Features/ISupportedRangeExtensions.cs +++ b/FTMS.NET/Features/ISupportedRangeExtensions.cs @@ -1,15 +1,15 @@ -namespace FTMS.NET.Features; - -using FTMS.NET.Utils; -using System.Numerics; - -public static class ISupportedRangeExtensions -{ - public static bool IsInRange(this ISupportedRange range, TValue value) - where TValue : struct, INumber - => GenericMath.IsInRange(value, range.MinimumValue, range.MaximumValue); - - public static TValue Clamp(this ISupportedRange range, TValue value) - where TValue : struct, INumber - => GenericMath.Clamp(value, range.MinimumValue, range.MaximumValue); -} +namespace FTMS.NET.Features; + +using FTMS.NET.Utils; +using System.Numerics; + +public static class ISupportedRangeExtensions +{ + public static bool IsInRange(this ISupportedRange range, TValue value) + where TValue : struct, INumber + => GenericMath.IsInRange(value, range.MinimumValue, range.MaximumValue); + + public static TValue Clamp(this ISupportedRange range, TValue value) + where TValue : struct, INumber + => GenericMath.Clamp(value, range.MinimumValue, range.MaximumValue); +} \ No newline at end of file diff --git a/FTMS.NET/Features/RangeCalculations.cs b/FTMS.NET/Features/RangeCalculations.cs index 393f78e..25162b8 100644 --- a/FTMS.NET/Features/RangeCalculations.cs +++ b/FTMS.NET/Features/RangeCalculations.cs @@ -1,12 +1,12 @@ -namespace FTMS.NET.Features; - -using FTMS.NET.Utils; - -internal static class RangeCalculations -{ - public static ValueCalculation Speed => new(1, -2, 0); - public static ValueCalculation Inclination => new(1, -1, 0); - public static ValueCalculation ResistanceLevel => new(1, 1, 0); - public static ValueCalculation Power => new(1, 0, 0); - public static ValueCalculation HeartRange => new(1, 0, 0); -} +namespace FTMS.NET.Features; + +using FTMS.NET.Utils; + +internal static class RangeCalculations +{ + public static ValueCalculation Speed => new(1, -2, 0); + public static ValueCalculation Inclination => new(1, -1, 0); + public static ValueCalculation ResistanceLevel => new(1, 1, 0); + public static ValueCalculation Power => new(1, 0, 0); + public static ValueCalculation HeartRange => new(1, 0, 0); +} \ No newline at end of file diff --git a/FTMS.NET/Features/SupportedRange.cs b/FTMS.NET/Features/SupportedRange.cs index 02ac9bf..a117656 100644 --- a/FTMS.NET/Features/SupportedRange.cs +++ b/FTMS.NET/Features/SupportedRange.cs @@ -1,46 +1,46 @@ -namespace FTMS.NET.Features; - -using FTMS.NET; -using FTMS.NET.Utils; - -internal sealed record SupportedRange( - double MinimumValue, - double MaximumValue, - double MinimumIncrement, - FitnessMachineUnit Unit) - : ISupportedRange -{ - public static ISupportedRange ReadSpeed(byte[] data) => - Read(data, RangeCalculations.Speed, FitnessMachineUnit.KilometersPerHour, r => r.ReadUInt16()); - - public static ISupportedRange ReadInclination(byte[] data) => - Read(data, RangeCalculations.Inclination, FitnessMachineUnit.Percent, r => r.ReadInt16(), r => r.ReadInt16(), r => r.ReadUInt16()); - - public static ISupportedRange ReadResistanceLevel(byte[] data) => - Read(data, RangeCalculations.ResistanceLevel, FitnessMachineUnit.None, r => r.ReadByte()); - - public static ISupportedRange ReadPower(byte[] data) => - Read(data, RangeCalculations.Power, FitnessMachineUnit.Watt, r => r.ReadInt16(), r => r.ReadInt16(), r => r.ReadUInt16()); - - public static ISupportedRange ReadHeartRate(byte[] data) => - Read(data, RangeCalculations.HeartRange, FitnessMachineUnit.BeatsPerMinute, r => r.ReadByte()); - - private static SupportedRange Read( - byte[] data, - ValueCalculation calculation, - FitnessMachineUnit unit, - Func readMinimum, - Func? readMaximum = null, - Func? readIncrement = null) - { - readMaximum ??= readMinimum; - readIncrement ??= readMinimum; - - using MemoryStream stream = new(data); - using BinaryReader reader = new(stream); - var minimumValue = calculation.Calculate(readMinimum(reader)); - var maximumValue = calculation.Calculate(readMaximum(reader)); - var minimumIncrement = calculation.Calculate(readIncrement(reader)); - return new(minimumValue, maximumValue, minimumIncrement, unit); - } -} +namespace FTMS.NET.Features; + +using FTMS.NET; +using FTMS.NET.Utils; + +internal sealed record SupportedRange( + double MinimumValue, + double MaximumValue, + double MinimumIncrement, + FitnessMachineUnit Unit) + : ISupportedRange +{ + public static ISupportedRange ReadSpeed(byte[] data) => + Read(data, RangeCalculations.Speed, FitnessMachineUnit.KilometersPerHour, r => r.ReadUInt16()); + + public static ISupportedRange ReadInclination(byte[] data) => + Read(data, RangeCalculations.Inclination, FitnessMachineUnit.Percent, r => r.ReadInt16(), r => r.ReadInt16(), r => r.ReadUInt16()); + + public static ISupportedRange ReadResistanceLevel(byte[] data) => + Read(data, RangeCalculations.ResistanceLevel, FitnessMachineUnit.None, r => r.ReadByte()); + + public static ISupportedRange ReadPower(byte[] data) => + Read(data, RangeCalculations.Power, FitnessMachineUnit.Watt, r => r.ReadInt16(), r => r.ReadInt16(), r => r.ReadUInt16()); + + public static ISupportedRange ReadHeartRate(byte[] data) => + Read(data, RangeCalculations.HeartRange, FitnessMachineUnit.BeatsPerMinute, r => r.ReadByte()); + + private static SupportedRange Read( + byte[] data, + ValueCalculation calculation, + FitnessMachineUnit unit, + Func readMinimum, + Func? readMaximum = null, + Func? readIncrement = null) + { + readMaximum ??= readMinimum; + readIncrement ??= readMinimum; + + using MemoryStream stream = new(data); + using BinaryReader reader = new(stream); + var minimumValue = calculation.Calculate(readMinimum(reader)); + var maximumValue = calculation.Calculate(readMaximum(reader)); + var minimumIncrement = calculation.Calculate(readIncrement(reader)); + return new(minimumValue, maximumValue, minimumIncrement, unit); + } +} \ No newline at end of file diff --git a/FTMS.NET/FitnessMachineService.cs b/FTMS.NET/FitnessMachineService.cs index 34c0078..d1e1bca 100644 --- a/FTMS.NET/FitnessMachineService.cs +++ b/FTMS.NET/FitnessMachineService.cs @@ -1,45 +1,45 @@ -namespace FTMS.NET; - -using DynamicData; -using FTMS.NET.Control; -using FTMS.NET.Data; -using FTMS.NET.Features; -using FTMS.NET.State; -using System.Threading.Tasks; - -internal sealed class FitnessMachineService( - EFitnessMachineType type, - IFitnessMachineData data, - IFitnessMachineControl control, - IFitnessMachineStateProvider state, - IFitnessMachineFeatures features) - : IFitnessMachineService -{ - public EFitnessMachineType Type { get; } = type; - public IFitnessMachineData Data { get; } = data; - public IFitnessMachineControl Control { get; } = control; - public IFitnessMachineStateProvider State { get; } = state; - public IFitnessMachineFeatures Features { get; } = features; - - public Task Execute(ControlRequest request) - => this.Control.Execute(request); - - public IObservable ObserveMachineState() - => this.State.ObserveMachineState(); - - public IObservable ObserveTrainingState() - => this.State.ObserveTrainingState(); - - public IObservable> Connect() - => this.Data.Connect(); - - public IFitnessMachineValue? GetValue(Guid uuid) - => this.Data.GetValue(uuid); - - public void Dispose() - { - this.Data.Dispose(); - this.Control.Dispose(); - this.State.Dispose(); - } -} +namespace FTMS.NET; + +using DynamicData; +using FTMS.NET.Control; +using FTMS.NET.Data; +using FTMS.NET.Features; +using FTMS.NET.State; +using System.Threading.Tasks; + +internal sealed class FitnessMachineService( + EFitnessMachineType type, + IFitnessMachineData data, + IFitnessMachineControl control, + IFitnessMachineStateProvider state, + IFitnessMachineFeatures features) + : IFitnessMachineService +{ + public EFitnessMachineType Type { get; } = type; + public IFitnessMachineData Data { get; } = data; + public IFitnessMachineControl Control { get; } = control; + public IFitnessMachineStateProvider State { get; } = state; + public IFitnessMachineFeatures Features { get; } = features; + + public Task Execute(ControlRequest request) + => this.Control.Execute(request); + + public IObservable ObserveMachineState() + => this.State.ObserveMachineState(); + + public IObservable ObserveTrainingState() + => this.State.ObserveTrainingState(); + + public IObservable> Connect() + => this.Data.Connect(); + + public IFitnessMachineValue? GetValue(Guid uuid) + => this.Data.GetValue(uuid); + + public void Dispose() + { + this.Data.Dispose(); + this.Control.Dispose(); + this.State.Dispose(); + } +} \ No newline at end of file diff --git a/FTMS.NET/FitnessMachineServiceFactory.cs b/FTMS.NET/FitnessMachineServiceFactory.cs index 2759018..2f94259 100644 --- a/FTMS.NET/FitnessMachineServiceFactory.cs +++ b/FTMS.NET/FitnessMachineServiceFactory.cs @@ -1,123 +1,123 @@ -namespace FTMS.NET; - -using FTMS.NET.Control; -using FTMS.NET.Data; -using FTMS.NET.Exceptions; -using FTMS.NET.Features; -using FTMS.NET.State; -using FTMS.NET.Utils; -using System; -using System.Diagnostics.CodeAnalysis; -using System.Numerics; - -public static class FitnessMachineServiceFactory -{ - public static async Task CreateFitnessMachineServiceAsync( - this IFitnessMachineServiceConnection connection) - { - connection.EnsureAvailability(); - var fitnessMachineType = connection.ReadType(); - - IFitnessMachineFeatures features = await connection.ReadFitnessMachineFeaturesAsync(); - IFitnessMachineData data = await connection.CreateFitnessMachineDataAsync(); - IFitnessMachineControl control = await connection.CreateFitnessMachineControlAsync(); - IFitnessMachineStateProvider stateProvider = await connection.CreateFitnessMachineStateProviderAsync(); - - return new FitnessMachineService(fitnessMachineType, data, control, stateProvider, features); - } - - public static void EnsureAvailability(this IFitnessMachineServiceConnection connection) - { - var available = connection.ServiceData[2].IsBitSet(0); - if (available is false) - throw new FitnessMachineNotAvailableException(); - } - - public static async Task CreateFitnessMachineDataAsync( - this IFitnessMachineServiceConnection connection) - { - var fitnessMachineType = connection.ReadType(); - var fitnessMachineDataReader = fitnessMachineType.GetDataReader(); - - var dataCharacteristicId = fitnessMachineType.GetDataCharacteristicId(); - var dataCharacteristic = await connection.GetCharacteristicAsync(dataCharacteristicId); - - dataCharacteristic.EnsureAvailabieCharacteristic(dataCharacteristicId); - return new FitnessMachineData(dataCharacteristic.ObserveValue(), fitnessMachineDataReader); - } - - public static EFitnessMachineType ReadType(this IFitnessMachineServiceConnection connection) - => ((EFitnessMachineType)BitOperations.TrailingZeroCount( - BitConverter.ToUInt16( - connection.ServiceData.AsSpan()[3..]))) - .EnsureType(); - - private static FitnessMachineDataReader GetDataReader(this EFitnessMachineType fitnessMachineType) - => new(SingleFrameStrategies.GetFor(fitnessMachineType)); - - public static async Task CreateFitnessMachineControlAsync( - this IFitnessMachineServiceConnection connection) - { - var controlPointCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.ControlPoint); - controlPointCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.ControlPoint); - - return new FitnessMachineControl( - controlPointCharacteristic.ObserveValue(), - controlPointCharacteristic.WriteValueAsync); - } - - public static async Task CreateFitnessMachineStateProviderAsync( - this IFitnessMachineServiceConnection connection) - { - var machineStateCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.MachineState); - var trainingStateCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.TrainingState); - - machineStateCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.MachineState); - trainingStateCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.TrainingState); - - return new FitnessMachineStateProvider( - machineStateCharacteristic.ObserveValue(), - trainingStateCharacteristic.ObserveValue(), - trainingStateCharacteristic.ReadValueAsync); - } - - public static async Task ReadFitnessMachineFeaturesAsync( - this IFitnessMachineServiceConnection connection) - { - var featureCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.Feature); - featureCharacteristic.EnsureAvailabieCharacteristic(FtmsUuids.Feature); - - var featureData = await featureCharacteristic.ReadValueAsync(); - var featureDataSpan = featureData.AsSpan(); - - return new FitnessMachineFeatures(featureDataSpan[..4], featureDataSpan[4..]) - { - SpeedRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedSpeedRange, SupportedRange.ReadSpeed), - InclinationRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedInclinationRange, SupportedRange.ReadInclination), - ResistanceLevelRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedResistanceLevelRange, SupportedRange.ReadResistanceLevel), - PowerRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedPowerRange, SupportedRange.ReadPower), - HeartRateRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedHeartRateRange, SupportedRange.ReadHeartRate) - }; - - async Task ReadSupportedRangeAsync(Guid characteristicId, Func createRange) - { - var characteristic = await connection.GetCharacteristicAsync(characteristicId); - if (characteristic is null) - return null; - - var data = await characteristic.ReadValueAsync(); - if (data is null) - return null; - - return createRange(data); - } - } - - public static void EnsureAvailabieCharacteristic( - [NotNull] this IFitnessMachineCharacteristic? characteristic, - Guid characteristicUuid) - { - if (characteristic is null) - throw new NeededCharacteristicNotAvailableException(characteristicUuid); - } -} +namespace FTMS.NET; + +using FTMS.NET.Control; +using FTMS.NET.Data; +using FTMS.NET.Exceptions; +using FTMS.NET.Features; +using FTMS.NET.State; +using FTMS.NET.Utils; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; + +public static class FitnessMachineServiceFactory +{ + public static async Task CreateFitnessMachineServiceAsync( + this IFitnessMachineServiceConnection connection) + { + connection.EnsureAvailability(); + var fitnessMachineType = connection.ReadType(); + + IFitnessMachineFeatures features = await connection.ReadFitnessMachineFeaturesAsync(); + IFitnessMachineData data = await connection.CreateFitnessMachineDataAsync(); + IFitnessMachineControl control = await connection.CreateFitnessMachineControlAsync(); + IFitnessMachineStateProvider stateProvider = await connection.CreateFitnessMachineStateProviderAsync(); + + return new FitnessMachineService(fitnessMachineType, data, control, stateProvider, features); + } + + public static void EnsureAvailability(this IFitnessMachineServiceConnection connection) + { + var available = connection.ServiceData[2].IsBitSet(0); + if (available is false) + throw new FitnessMachineNotAvailableException(); + } + + public static async Task CreateFitnessMachineDataAsync( + this IFitnessMachineServiceConnection connection) + { + var fitnessMachineType = connection.ReadType(); + var fitnessMachineDataReader = fitnessMachineType.GetDataReader(); + + var dataCharacteristicId = fitnessMachineType.GetDataCharacteristicId(); + var dataCharacteristic = await connection.GetCharacteristicAsync(dataCharacteristicId); + + dataCharacteristic.EnsureAvailabieCharacteristic(dataCharacteristicId); + return new FitnessMachineData(dataCharacteristic.ObserveValue(), fitnessMachineDataReader); + } + + public static EFitnessMachineType ReadType(this IFitnessMachineServiceConnection connection) + => ((EFitnessMachineType)BitOperations.TrailingZeroCount( + BitConverter.ToUInt16( + connection.ServiceData.AsSpan()[3..]))) + .EnsureType(); + + private static FitnessMachineDataReader GetDataReader(this EFitnessMachineType fitnessMachineType) + => new(SingleFrameStrategies.GetFor(fitnessMachineType)); + + public static async Task CreateFitnessMachineControlAsync( + this IFitnessMachineServiceConnection connection) + { + var controlPointCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.ControlPoint); + controlPointCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.ControlPoint); + + return new FitnessMachineControl( + controlPointCharacteristic.ObserveValue(), + controlPointCharacteristic.WriteValueAsync); + } + + public static async Task CreateFitnessMachineStateProviderAsync( + this IFitnessMachineServiceConnection connection) + { + var machineStateCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.MachineState); + var trainingStateCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.TrainingState); + + machineStateCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.MachineState); + trainingStateCharacteristic ??= new ThrowingCharacteristic(FtmsUuids.TrainingState); + + return new FitnessMachineStateProvider( + machineStateCharacteristic.ObserveValue(), + trainingStateCharacteristic.ObserveValue(), + trainingStateCharacteristic.ReadValueAsync); + } + + public static async Task ReadFitnessMachineFeaturesAsync( + this IFitnessMachineServiceConnection connection) + { + var featureCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.Feature); + featureCharacteristic.EnsureAvailabieCharacteristic(FtmsUuids.Feature); + + var featureData = await featureCharacteristic.ReadValueAsync(); + var featureDataSpan = featureData.AsSpan(); + + return new FitnessMachineFeatures(featureDataSpan[..4], featureDataSpan[4..]) + { + SpeedRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedSpeedRange, SupportedRange.ReadSpeed), + InclinationRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedInclinationRange, SupportedRange.ReadInclination), + ResistanceLevelRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedResistanceLevelRange, SupportedRange.ReadResistanceLevel), + PowerRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedPowerRange, SupportedRange.ReadPower), + HeartRateRange = await ReadSupportedRangeAsync(FtmsUuids.SupportedHeartRateRange, SupportedRange.ReadHeartRate) + }; + + async Task ReadSupportedRangeAsync(Guid characteristicId, Func createRange) + { + var characteristic = await connection.GetCharacteristicAsync(characteristicId); + if (characteristic is null) + return null; + + var data = await characteristic.ReadValueAsync(); + if (data is null) + return null; + + return createRange(data); + } + } + + public static void EnsureAvailabieCharacteristic( + [NotNull] this IFitnessMachineCharacteristic? characteristic, + Guid characteristicUuid) + { + if (characteristic is null) + throw new NeededCharacteristicNotAvailableException(characteristicUuid); + } +} \ No newline at end of file diff --git a/FTMS.NET/FitnessMachineUnit.cs b/FTMS.NET/FitnessMachineUnit.cs index 51a23c4..73e0fa7 100644 --- a/FTMS.NET/FitnessMachineUnit.cs +++ b/FTMS.NET/FitnessMachineUnit.cs @@ -1,18 +1,19 @@ -namespace FTMS.NET; -public enum FitnessMachineUnit -{ - None, - KilometersPerHour, - Percent, - Watt, - BeatsPerMinute, - Calories, - Steps, - Stride, - Millimeters, - Meters, - Seconds, - PerMinute, - MetersPerSecond, - KilogramPerMeter, -} +namespace FTMS.NET; + +public enum FitnessMachineUnit +{ + None, + KilometersPerHour, + Percent, + Watt, + BeatsPerMinute, + Calories, + Steps, + Stride, + Millimeters, + Meters, + Seconds, + PerMinute, + MetersPerSecond, + KilogramPerMeter, +} \ No newline at end of file diff --git a/FTMS.NET/FtmsUuids.cs b/FTMS.NET/FtmsUuids.cs index 64bad69..2800ad0 100644 --- a/FTMS.NET/FtmsUuids.cs +++ b/FTMS.NET/FtmsUuids.cs @@ -1,79 +1,79 @@ -namespace FTMS.NET; - -using SourceGeneration.Reflection; -using System; -using System.Collections.Frozen; - -[SourceReflection] -public static class FtmsUuids -{ - private static readonly FrozenDictionary allFieldsByUuid; - - public readonly static Guid Service = Guid.ParseExact("00001826-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid Feature = Guid.ParseExact("00002acc-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid MachineState = Guid.ParseExact("00002ada-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid TrainingState = Guid.ParseExact("00002ad3-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid ControlPoint = Guid.ParseExact("00002ad9-0000-1000-8000-00805f9b34fb", "d"); - - public readonly static Guid SupportedSpeedRange = Guid.ParseExact("00002ad4-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid SupportedInclinationRange = Guid.ParseExact("00002ad5-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid SupportedResistanceLevelRange = Guid.ParseExact("00002ad6-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid SupportedPowerRange = Guid.ParseExact("00002ad8-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid SupportedHeartRateRange = Guid.ParseExact("00002ad7-0000-1000-8000-00805f9b34fb", "d"); - - public readonly static Guid TreadmillData = Guid.ParseExact("00002acd-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid CrossTrainerData = Guid.ParseExact("00002ace-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid StepClimberData = Guid.ParseExact("00002acf-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid StairClimberData = Guid.ParseExact("00002ad0-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid RowerData = Guid.ParseExact("00002ad1-0000-1000-8000-00805f9b34fb", "d"); - public readonly static Guid IndoorBikeData = Guid.ParseExact("00002ad2-0000-1000-8000-00805f9b34fb", "d"); - - public static readonly Guid InstantaneousSpeed = new("765050cd-a033-4972-a71b-29c013c0845b"); - public static readonly Guid AverageSpeed = new("fe4f0fda-caa8-4a91-b4f6-049a7c74a68f"); - public static readonly Guid InstantaneousCadence = new("d5f34315-184e-4d9d-a3dd-b70858475f1c"); - public static readonly Guid AverageCadence = new("549c3fa5-caea-4db5-8b08-8d621db5177c"); - public static readonly Guid TotalDistance = new("20cd1373-cb1c-40ac-90f2-9b2ff0c6e74b"); - public static readonly Guid ResistantLevel = new("acba72d3-4e64-4b36-8675-202bcf43e761"); - public static readonly Guid InstantaneousPower = new("a9b3e3f6-fe62-45e0-9f82-f7a26a33378a"); - public static readonly Guid AveragePower = new("183da544-af04-42b8-861d-07ce96e261ef"); - public static readonly Guid TotalEnergy = new("ed9a5962-6b12-4679-894a-379131e12122"); - public static readonly Guid EnergyPerHour = new("8d799ac5-9c50-4119-96c9-da99ae22f91f"); - public static readonly Guid EnergyPerMinute = new("73d9c26e-356b-4ebc-83da-0f2719fca421"); - public static readonly Guid HeartRate = new("636a7fef-dd35-49ec-83df-35d52a34d4cc"); - public static readonly Guid MetabolicEquivalent = new("166f2f4c-9cff-4050-985b-2fea8b1a95bc"); - public static readonly Guid ElapsedTime = new("1eb082c4-e3ae-46f9-b4cb-d55a2f81e27b"); - public static readonly Guid RemainingTime = new("02185a25-968e-40e0-a55c-b2acf71e50fb"); - public static readonly Guid Inclination = new("d31143f6-3808-4921-aa24-abc3e714e291"); - public static readonly Guid RampAngleSetting = new("fa0e1933-d7a3-4537-a496-0a7211da6ca0"); - public static readonly Guid PositiveElevationGain = new("ef8234ce-19bd-4596-860e-30e3e632dcc6"); - public static readonly Guid NegativeElevationGain = new("bcb222de-896f-4582-8308-c44fa2c05a0c"); - public static readonly Guid InstantaneousPace = new("cb813306-52ef-463b-bde6-be92e58706ab"); - public static readonly Guid AveragePace = new("4ced1b35-7d75-4911-9afd-9a8ba9ca9d4e"); - public static readonly Guid ForceOnBelt = new("9fa8ab4b-8382-4240-9f71-2a04c221aa7f"); - public static readonly Guid PowerOutput = new("7128776e-0991-44b9-8511-ef969a8ea418"); - public static readonly Guid StepsPerMinute = new("814c417b-95f2-4cea-b86f-30625b4c2777"); - public static readonly Guid AverageStepRate = new("654ecaf1-be99-4256-a764-ff8f556d4392"); - public static readonly Guid StrideCount = new("ecdfab6f-40f4-45ad-9be9-030982a563ff"); - public static readonly Guid Floors = new("ace47522-5ea0-459b-afe7-8b70020f9e15"); - public static readonly Guid StepCount = new("97984a03-ee3f-4fd0-857f-d17500ac9038"); - public static readonly Guid StrokeRate = new("c663b25a-abef-44fe-a852-2b7ccbdb0149"); - public static readonly Guid StrokeCount = new("0119eab6-298b-4c2f-a7c4-26a4933c8365"); - public static readonly Guid AverageStrokeRate = new("07d4df62-397b-4040-ac9a-8fef6ad8a44d"); - - static FtmsUuids() - { - var thisType = typeof(FtmsUuids); - var guidType = typeof(Guid); - allFieldsByUuid = SourceReflector.GetType(thisType)? - .GetFieldsAndProperties() - .Where(fop => fop.IsStatic && fop.Accessibility == SourceAccessibility.Public) - .Where(fop => fop.MemberType == guidType) - .ToFrozenDictionary(fop => (Guid)fop.GetValue(null)!, fop => fop.Name) - ?? throw new InvalidOperationException($"{nameof(FtmsUuids)} class reflection was not generated!"); - } - - public static string GetName(Guid uuid) - { - return allFieldsByUuid.GetValueOrDefault(uuid) ?? string.Empty; - } -} +namespace FTMS.NET; + +using SourceGeneration.Reflection; +using System; +using System.Collections.Frozen; + +[SourceReflection] +public static class FtmsUuids +{ + private static readonly FrozenDictionary allFieldsByUuid; + + public readonly static Guid Service = Guid.ParseExact("00001826-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid Feature = Guid.ParseExact("00002acc-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid MachineState = Guid.ParseExact("00002ada-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid TrainingState = Guid.ParseExact("00002ad3-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid ControlPoint = Guid.ParseExact("00002ad9-0000-1000-8000-00805f9b34fb", "d"); + + public readonly static Guid SupportedSpeedRange = Guid.ParseExact("00002ad4-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid SupportedInclinationRange = Guid.ParseExact("00002ad5-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid SupportedResistanceLevelRange = Guid.ParseExact("00002ad6-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid SupportedPowerRange = Guid.ParseExact("00002ad8-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid SupportedHeartRateRange = Guid.ParseExact("00002ad7-0000-1000-8000-00805f9b34fb", "d"); + + public readonly static Guid TreadmillData = Guid.ParseExact("00002acd-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid CrossTrainerData = Guid.ParseExact("00002ace-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid StepClimberData = Guid.ParseExact("00002acf-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid StairClimberData = Guid.ParseExact("00002ad0-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid RowerData = Guid.ParseExact("00002ad1-0000-1000-8000-00805f9b34fb", "d"); + public readonly static Guid IndoorBikeData = Guid.ParseExact("00002ad2-0000-1000-8000-00805f9b34fb", "d"); + + public static readonly Guid InstantaneousSpeed = new("765050cd-a033-4972-a71b-29c013c0845b"); + public static readonly Guid AverageSpeed = new("fe4f0fda-caa8-4a91-b4f6-049a7c74a68f"); + public static readonly Guid InstantaneousCadence = new("d5f34315-184e-4d9d-a3dd-b70858475f1c"); + public static readonly Guid AverageCadence = new("549c3fa5-caea-4db5-8b08-8d621db5177c"); + public static readonly Guid TotalDistance = new("20cd1373-cb1c-40ac-90f2-9b2ff0c6e74b"); + public static readonly Guid ResistantLevel = new("acba72d3-4e64-4b36-8675-202bcf43e761"); + public static readonly Guid InstantaneousPower = new("a9b3e3f6-fe62-45e0-9f82-f7a26a33378a"); + public static readonly Guid AveragePower = new("183da544-af04-42b8-861d-07ce96e261ef"); + public static readonly Guid TotalEnergy = new("ed9a5962-6b12-4679-894a-379131e12122"); + public static readonly Guid EnergyPerHour = new("8d799ac5-9c50-4119-96c9-da99ae22f91f"); + public static readonly Guid EnergyPerMinute = new("73d9c26e-356b-4ebc-83da-0f2719fca421"); + public static readonly Guid HeartRate = new("636a7fef-dd35-49ec-83df-35d52a34d4cc"); + public static readonly Guid MetabolicEquivalent = new("166f2f4c-9cff-4050-985b-2fea8b1a95bc"); + public static readonly Guid ElapsedTime = new("1eb082c4-e3ae-46f9-b4cb-d55a2f81e27b"); + public static readonly Guid RemainingTime = new("02185a25-968e-40e0-a55c-b2acf71e50fb"); + public static readonly Guid Inclination = new("d31143f6-3808-4921-aa24-abc3e714e291"); + public static readonly Guid RampAngleSetting = new("fa0e1933-d7a3-4537-a496-0a7211da6ca0"); + public static readonly Guid PositiveElevationGain = new("ef8234ce-19bd-4596-860e-30e3e632dcc6"); + public static readonly Guid NegativeElevationGain = new("bcb222de-896f-4582-8308-c44fa2c05a0c"); + public static readonly Guid InstantaneousPace = new("cb813306-52ef-463b-bde6-be92e58706ab"); + public static readonly Guid AveragePace = new("4ced1b35-7d75-4911-9afd-9a8ba9ca9d4e"); + public static readonly Guid ForceOnBelt = new("9fa8ab4b-8382-4240-9f71-2a04c221aa7f"); + public static readonly Guid PowerOutput = new("7128776e-0991-44b9-8511-ef969a8ea418"); + public static readonly Guid StepsPerMinute = new("814c417b-95f2-4cea-b86f-30625b4c2777"); + public static readonly Guid AverageStepRate = new("654ecaf1-be99-4256-a764-ff8f556d4392"); + public static readonly Guid StrideCount = new("ecdfab6f-40f4-45ad-9be9-030982a563ff"); + public static readonly Guid Floors = new("ace47522-5ea0-459b-afe7-8b70020f9e15"); + public static readonly Guid StepCount = new("97984a03-ee3f-4fd0-857f-d17500ac9038"); + public static readonly Guid StrokeRate = new("c663b25a-abef-44fe-a852-2b7ccbdb0149"); + public static readonly Guid StrokeCount = new("0119eab6-298b-4c2f-a7c4-26a4933c8365"); + public static readonly Guid AverageStrokeRate = new("07d4df62-397b-4040-ac9a-8fef6ad8a44d"); + + static FtmsUuids() + { + var thisType = typeof(FtmsUuids); + var guidType = typeof(Guid); + allFieldsByUuid = SourceReflector.GetType(thisType)? + .GetFieldsAndProperties() + .Where(fop => fop.IsStatic && fop.Accessibility == SourceAccessibility.Public) + .Where(fop => fop.MemberType == guidType) + .ToFrozenDictionary(fop => (Guid)fop.GetValue(null)!, fop => fop.Name) + ?? throw new InvalidOperationException($"{nameof(FtmsUuids)} class reflection was not generated!"); + } + + public static string GetName(Guid uuid) + { + return allFieldsByUuid.GetValueOrDefault(uuid) ?? string.Empty; + } +} \ No newline at end of file diff --git a/FTMS.NET/IFitnessMachineCharacteristic.cs b/FTMS.NET/IFitnessMachineCharacteristic.cs index 9576ec9..5a05fec 100644 --- a/FTMS.NET/IFitnessMachineCharacteristic.cs +++ b/FTMS.NET/IFitnessMachineCharacteristic.cs @@ -1,12 +1,12 @@ -namespace FTMS.NET; - -using System; -using System.Threading.Tasks; - -public interface IFitnessMachineCharacteristic -{ - public Guid Id { get; } - Task ReadValueAsync(); - Task WriteValueAsync(byte[] value); - IObservable ObserveValue(); -} +namespace FTMS.NET; + +using System; +using System.Threading.Tasks; + +public interface IFitnessMachineCharacteristic +{ + public Guid Id { get; } + Task ReadValueAsync(); + Task WriteValueAsync(byte[] value); + IObservable ObserveValue(); +} \ No newline at end of file diff --git a/FTMS.NET/IFitnessMachineService.cs b/FTMS.NET/IFitnessMachineService.cs index c5a9d45..04cd9b7 100644 --- a/FTMS.NET/IFitnessMachineService.cs +++ b/FTMS.NET/IFitnessMachineService.cs @@ -1,16 +1,16 @@ -namespace FTMS.NET; - -using FTMS.NET.Control; -using FTMS.NET.Data; -using FTMS.NET.Features; -using FTMS.NET.State; - -public interface IFitnessMachineService - : IFitnessMachineControl, IFitnessMachineStateProvider, IFitnessMachineData, IDisposable -{ - EFitnessMachineType Type { get; } - IFitnessMachineData Data { get; } - IFitnessMachineStateProvider State { get; } - IFitnessMachineControl Control { get; } - IFitnessMachineFeatures Features { get; } -} +namespace FTMS.NET; + +using FTMS.NET.Control; +using FTMS.NET.Data; +using FTMS.NET.Features; +using FTMS.NET.State; + +public interface IFitnessMachineService + : IFitnessMachineControl, IFitnessMachineStateProvider, IFitnessMachineData, IDisposable +{ + EFitnessMachineType Type { get; } + IFitnessMachineData Data { get; } + IFitnessMachineStateProvider State { get; } + IFitnessMachineControl Control { get; } + IFitnessMachineFeatures Features { get; } +} \ No newline at end of file diff --git a/FTMS.NET/IFitnessMachineServiceConnection.cs b/FTMS.NET/IFitnessMachineServiceConnection.cs index 64d69d9..abaf435 100644 --- a/FTMS.NET/IFitnessMachineServiceConnection.cs +++ b/FTMS.NET/IFitnessMachineServiceConnection.cs @@ -1,10 +1,10 @@ -namespace FTMS.NET; - -using System; -using System.Threading.Tasks; - -public interface IFitnessMachineServiceConnection -{ - byte[] ServiceData { get; } - Task GetCharacteristicAsync(Guid id); -} +namespace FTMS.NET; + +using System; +using System.Threading.Tasks; + +public interface IFitnessMachineServiceConnection +{ + byte[] ServiceData { get; } + Task GetCharacteristicAsync(Guid id); +} \ No newline at end of file diff --git a/FTMS.NET/State/ESpinDownState.cs b/FTMS.NET/State/ESpinDownState.cs index 992d61a..8385321 100644 --- a/FTMS.NET/State/ESpinDownState.cs +++ b/FTMS.NET/State/ESpinDownState.cs @@ -1,8 +1,9 @@ -namespace FTMS.NET.State; -public enum ESpinDownState : byte -{ - SpinDownRequested = 0x01, - Success = 0x02, - Error = 0x03, - StopPedaling = 0x04 -} +namespace FTMS.NET.State; + +public enum ESpinDownState : byte +{ + SpinDownRequested = 0x01, + Success = 0x02, + Error = 0x03, + StopPedaling = 0x04 +} \ No newline at end of file diff --git a/FTMS.NET/State/EStateOpCode.cs b/FTMS.NET/State/EStateOpCode.cs index 06496c9..05ef310 100644 --- a/FTMS.NET/State/EStateOpCode.cs +++ b/FTMS.NET/State/EStateOpCode.cs @@ -1,27 +1,28 @@ -namespace FTMS.NET.State; -public enum EStateOpCode : byte -{ - Reset = 0x01, - FitnessMachineStoppedOrPausedByTheUser = 0x02, - FitnessMachineStoppedBySafetyKey = 0x03, - FitnessMachineStartedOrResumedByTheUser = 0x04, - TargetSpeedChanged = 0x05, - TargetInclineChanged = 0x06, - TargetResistanceLevelChanged = 0x07, - TargetPowerChanged = 0x08, - TargetHeartRateChanged = 0x09, - TargetedExpendedEnergyChanged = 0x0A, - TargetedNumberOfStepsChanged = 0x0B, - TargetedNumberOfStridesChanged = 0x0C, - TargetedDistanceChanged = 0x0D, - TargetedTrainingTimeChanged = 0x0E, - TargetedTimeInTwoHeartRateZonesChanged = 0x0F, - TargetedTimeInThreeHeartRateZonesChanged = 0x10, - TargetedTimeInFiveHeartRateZonesChanged = 0x11, - IndoorBikeSimulationParametersChanged = 0x12, - WheelCircumferenceChanged = 0x13, - SpinDownState = 0x14, - TargetedCadenceChanged = 0x15, - - ControlPermissionLost = 0xFF -} +namespace FTMS.NET.State; + +public enum EStateOpCode : byte +{ + Reset = 0x01, + FitnessMachineStoppedOrPausedByTheUser = 0x02, + FitnessMachineStoppedBySafetyKey = 0x03, + FitnessMachineStartedOrResumedByTheUser = 0x04, + TargetSpeedChanged = 0x05, + TargetInclineChanged = 0x06, + TargetResistanceLevelChanged = 0x07, + TargetPowerChanged = 0x08, + TargetHeartRateChanged = 0x09, + TargetedExpendedEnergyChanged = 0x0A, + TargetedNumberOfStepsChanged = 0x0B, + TargetedNumberOfStridesChanged = 0x0C, + TargetedDistanceChanged = 0x0D, + TargetedTrainingTimeChanged = 0x0E, + TargetedTimeInTwoHeartRateZonesChanged = 0x0F, + TargetedTimeInThreeHeartRateZonesChanged = 0x10, + TargetedTimeInFiveHeartRateZonesChanged = 0x11, + IndoorBikeSimulationParametersChanged = 0x12, + WheelCircumferenceChanged = 0x13, + SpinDownState = 0x14, + TargetedCadenceChanged = 0x15, + + ControlPermissionLost = 0xFF +} \ No newline at end of file diff --git a/FTMS.NET/State/ETrainingState.cs b/FTMS.NET/State/ETrainingState.cs index 80da977..c4d0fd6 100644 --- a/FTMS.NET/State/ETrainingState.cs +++ b/FTMS.NET/State/ETrainingState.cs @@ -1,20 +1,21 @@ -namespace FTMS.NET.State; -public enum ETrainingState : byte -{ - Other = 0x00, - Idle = 0x01, - WarmingUp = 0x02, - LowIntensityInterval = 0x03, - HighIntensityInterval = 0x04, - RecoveryInterval = 0x05, - Isometric = 0x06, - HeartRateControl = 0x07, - FitnessTest = 0x08, - SpeedLowerOfControlRegion = 0x09, - SpeedHigherOfControlRegion = 0x0A, - CoolDown = 0x0B, - WattControl = 0x0C, - ManualMode = 0x0D, - PreWorkout = 0x0E, - PostWorkout = 0x0F, -} +namespace FTMS.NET.State; + +public enum ETrainingState : byte +{ + Other = 0x00, + Idle = 0x01, + WarmingUp = 0x02, + LowIntensityInterval = 0x03, + HighIntensityInterval = 0x04, + RecoveryInterval = 0x05, + Isometric = 0x06, + HeartRateControl = 0x07, + FitnessTest = 0x08, + SpeedLowerOfControlRegion = 0x09, + SpeedHigherOfControlRegion = 0x0A, + CoolDown = 0x0B, + WattControl = 0x0C, + ManualMode = 0x0D, + PreWorkout = 0x0E, + PostWorkout = 0x0F, +} \ No newline at end of file diff --git a/FTMS.NET/State/FitnessMachineState.cs b/FTMS.NET/State/FitnessMachineState.cs index 264ddbc..a18ecc3 100644 --- a/FTMS.NET/State/FitnessMachineState.cs +++ b/FTMS.NET/State/FitnessMachineState.cs @@ -1,14 +1,14 @@ -namespace FTMS.NET.State; - -using System.Collections.Immutable; - -internal sealed record FitnessMachineState( - EStateOpCode OpCode, - byte[] RawData) - : IFitnessMachineState -{ - private IImmutableList? parameters; - - public IImmutableList ReadParameters() - => this.parameters ??= FitnessMachineStateParameterFactory.ReadParameters(OpCode, RawData).ToImmutableList(); -} +namespace FTMS.NET.State; + +using System.Collections.Immutable; + +internal sealed record FitnessMachineState( + EStateOpCode OpCode, + byte[] RawData) + : IFitnessMachineState +{ + private IImmutableList? parameters; + + public IImmutableList ReadParameters() + => this.parameters ??= FitnessMachineStateParameterFactory.ReadParameters(OpCode, RawData).ToImmutableList(); +} \ No newline at end of file diff --git a/FTMS.NET/State/FitnessMachineStateParameter.cs b/FTMS.NET/State/FitnessMachineStateParameter.cs index 0fb6c49..462a1d1 100644 --- a/FTMS.NET/State/FitnessMachineStateParameter.cs +++ b/FTMS.NET/State/FitnessMachineStateParameter.cs @@ -1,6 +1,6 @@ -namespace FTMS.NET.State; - -public sealed record FitnessMachineStateParameter( - string Name, - FitnessMachineUnit Unit, +namespace FTMS.NET.State; + +public sealed record FitnessMachineStateParameter( + string Name, + FitnessMachineUnit Unit, double Value); \ No newline at end of file diff --git a/FTMS.NET/State/FitnessMachineStateParameterFactory.cs b/FTMS.NET/State/FitnessMachineStateParameterFactory.cs index fe658af..73c6785 100644 --- a/FTMS.NET/State/FitnessMachineStateParameterFactory.cs +++ b/FTMS.NET/State/FitnessMachineStateParameterFactory.cs @@ -1,128 +1,128 @@ -namespace FTMS.NET.State; - -using FTMS.NET.Control; -using FTMS.NET.Utils; -using System; - -public static class FitnessMachineStateParameterFactory -{ - private static readonly Dictionary> parameterCalculations = new() - { - { EStateOpCode.Reset, ReadEmpty }, - { EStateOpCode.FitnessMachineStoppedOrPausedByTheUser, ReadStopOrPauseCode }, - { EStateOpCode.FitnessMachineStoppedBySafetyKey, ReadEmpty }, - { EStateOpCode.FitnessMachineStartedOrResumedByTheUser, ReadEmpty }, - { EStateOpCode.TargetSpeedChanged, ReadTargetSpeed }, - { EStateOpCode.TargetInclineChanged, ReadTargetIncline }, - { EStateOpCode.TargetResistanceLevelChanged, ReadTargetResistanceLevel }, - { EStateOpCode.TargetPowerChanged, ReadTargetPower }, - { EStateOpCode.TargetHeartRateChanged, ReadTargetHeartRate }, - { EStateOpCode.TargetedExpendedEnergyChanged, ReadTargetedExpendedEnergy }, - { EStateOpCode.TargetedNumberOfStepsChanged, ReadTargetedNumberOfSteps }, - { EStateOpCode.TargetedNumberOfStridesChanged, ReadTargetedNumberOfStrides }, - { EStateOpCode.TargetedDistanceChanged, ReadTargetedDistance }, - { EStateOpCode.TargetedTrainingTimeChanged, ReadTargetedTrainingTime }, - { EStateOpCode.TargetedTimeInTwoHeartRateZonesChanged, ReadTargetedTimeInTwoHeartRateZones }, - { EStateOpCode.TargetedTimeInThreeHeartRateZonesChanged, ReadTargetedTimeInThreeHeartRateZones }, - { EStateOpCode.TargetedTimeInFiveHeartRateZonesChanged, ReadTargetedTimeInFiveHeartRateZones }, - { EStateOpCode.IndoorBikeSimulationParametersChanged, ReadIndoorBikeSimulationParameters }, - { EStateOpCode.WheelCircumferenceChanged, ReadWheelCircumference }, - { EStateOpCode.SpinDownState, ReadSpinDownState }, - { EStateOpCode.TargetedCadenceChanged, ReadTargetedCadence }, - { EStateOpCode.ControlPermissionLost, ReadEmpty }, - }; - - public static IEnumerable ReadParameters(EStateOpCode opCode, byte[] rawData) - { - var parameter = parameterCalculations[opCode].Invoke(rawData); - if (parameter is IEnumerable multipleParameters) - return multipleParameters; - return [parameter]; - } - - private static IEnumerable ReadEmpty(byte[] rawData) => []; - - private static object ReadStopOrPauseCode(byte[] rawData) - => (EStopOrPauseCode)rawData[0]; - - private static FitnessMachineStateParameter ReadTargetSpeed(byte[] rawData) - => new("Target Speed", FitnessMachineUnit.KilometersPerHour, BitConverter.ToUInt16(rawData) * 0.01); - - private static FitnessMachineStateParameter ReadTargetIncline(byte[] rawData) - => new("Target Incline", FitnessMachineUnit.Percent, BitConverter.ToInt16(rawData) * 0.1); - - private static FitnessMachineStateParameter ReadTargetResistanceLevel(byte[] rawData) - => new("Target Resistance Level", FitnessMachineUnit.None, rawData[0]); - - private static FitnessMachineStateParameter ReadTargetPower(byte[] rawData) - => new("Target Power", FitnessMachineUnit.Watt, BitConverter.ToInt16(rawData)); - - private static FitnessMachineStateParameter ReadTargetHeartRate(byte[] rawData) - => new("Target Heart Rate", FitnessMachineUnit.BeatsPerMinute, rawData[0]); - - private static FitnessMachineStateParameter ReadTargetedExpendedEnergy(byte[] rawData) - => new("Targeted Expended Energy", FitnessMachineUnit.Calories, BitConverter.ToUInt16(rawData)); - - private static FitnessMachineStateParameter ReadTargetedNumberOfSteps(byte[] rawData) - => new("Targeted Number Of Steps", FitnessMachineUnit.Steps, BitConverter.ToUInt16(rawData)); - - private static FitnessMachineStateParameter ReadTargetedNumberOfStrides(byte[] rawData) - => new("Targeted Number Of Strides", FitnessMachineUnit.Stride, BitConverter.ToUInt16(rawData)); - - private static FitnessMachineStateParameter ReadTargetedDistance(byte[] rawData) - => new("Targeted Distance", FitnessMachineUnit.Meters, new UInt24(rawData[0], rawData[1], rawData[2])); - - private static FitnessMachineStateParameter ReadTargetedTrainingTime(byte[] rawData) - => new("Targeted Training Time", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(rawData)); - - private static IEnumerable ReadTargetedTimeInTwoHeartRateZones(byte[] rawData) - { - var dataSpan = rawData.AsSpan(); - return [ - new("Targeted Time in Fat Burn Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), - new("Targeted Time in Fitness Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..])) - ]; - } - - private static IEnumerable ReadTargetedTimeInThreeHeartRateZones(byte[] rawData) - { - var dataSpan = rawData.AsSpan(); - return [ - new("Targeted Time in Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), - new("Targeted Time in Moderate Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..4])), - new("Targeted Time in Hard Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[4..])) - ]; - } - - private static IEnumerable ReadTargetedTimeInFiveHeartRateZones(byte[] rawData) - { - var dataSpan = rawData.AsSpan(); - return [ - new("Targeted Time in Very Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), - new("Targeted Time in Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..4])), - new("Targeted Time in Moderate Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[4..6])), - new("Targeted Time in Hard Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[6..8])), - new("Targeted Time in Maximum Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[8..])) - ]; - } - - private static IEnumerable ReadIndoorBikeSimulationParameters(byte[] rawData) - { - var dataSpan = rawData.AsSpan(); - return [ - new("Wind Speed", FitnessMachineUnit.MetersPerSecond, BitConverter.ToInt16(dataSpan[..2]) * 0.001), - new("Grade", FitnessMachineUnit.Percent, BitConverter.ToInt16(dataSpan[2..4]) * 0.01), - new("Coefficient of Rolling Resistance", FitnessMachineUnit.None, dataSpan[4] * 0.0001), - new("Wind Resistance Coefficient", FitnessMachineUnit.KilogramPerMeter, dataSpan[5] * 0.01) - ]; - } - - private static FitnessMachineStateParameter ReadWheelCircumference(byte[] rawData) - => new("Wheel Circumference", FitnessMachineUnit.Millimeters, BitConverter.ToUInt16(rawData) * 0.1); - - private static object ReadSpinDownState(byte[] rawData) - => (ESpinDownState)rawData[0]; - - private static FitnessMachineStateParameter ReadTargetedCadence(byte[] rawData) - => new("Targeted Cadence", FitnessMachineUnit.PerMinute, BitConverter.ToUInt16(rawData) * 0.5); -} +namespace FTMS.NET.State; + +using FTMS.NET.Control; +using FTMS.NET.Utils; +using System; + +public static class FitnessMachineStateParameterFactory +{ + private static readonly Dictionary> parameterCalculations = new() + { + { EStateOpCode.Reset, ReadEmpty }, + { EStateOpCode.FitnessMachineStoppedOrPausedByTheUser, ReadStopOrPauseCode }, + { EStateOpCode.FitnessMachineStoppedBySafetyKey, ReadEmpty }, + { EStateOpCode.FitnessMachineStartedOrResumedByTheUser, ReadEmpty }, + { EStateOpCode.TargetSpeedChanged, ReadTargetSpeed }, + { EStateOpCode.TargetInclineChanged, ReadTargetIncline }, + { EStateOpCode.TargetResistanceLevelChanged, ReadTargetResistanceLevel }, + { EStateOpCode.TargetPowerChanged, ReadTargetPower }, + { EStateOpCode.TargetHeartRateChanged, ReadTargetHeartRate }, + { EStateOpCode.TargetedExpendedEnergyChanged, ReadTargetedExpendedEnergy }, + { EStateOpCode.TargetedNumberOfStepsChanged, ReadTargetedNumberOfSteps }, + { EStateOpCode.TargetedNumberOfStridesChanged, ReadTargetedNumberOfStrides }, + { EStateOpCode.TargetedDistanceChanged, ReadTargetedDistance }, + { EStateOpCode.TargetedTrainingTimeChanged, ReadTargetedTrainingTime }, + { EStateOpCode.TargetedTimeInTwoHeartRateZonesChanged, ReadTargetedTimeInTwoHeartRateZones }, + { EStateOpCode.TargetedTimeInThreeHeartRateZonesChanged, ReadTargetedTimeInThreeHeartRateZones }, + { EStateOpCode.TargetedTimeInFiveHeartRateZonesChanged, ReadTargetedTimeInFiveHeartRateZones }, + { EStateOpCode.IndoorBikeSimulationParametersChanged, ReadIndoorBikeSimulationParameters }, + { EStateOpCode.WheelCircumferenceChanged, ReadWheelCircumference }, + { EStateOpCode.SpinDownState, ReadSpinDownState }, + { EStateOpCode.TargetedCadenceChanged, ReadTargetedCadence }, + { EStateOpCode.ControlPermissionLost, ReadEmpty }, + }; + + public static IEnumerable ReadParameters(EStateOpCode opCode, byte[] rawData) + { + var parameter = parameterCalculations[opCode].Invoke(rawData); + if (parameter is IEnumerable multipleParameters) + return multipleParameters; + return [parameter]; + } + + private static IEnumerable ReadEmpty(byte[] rawData) => []; + + private static object ReadStopOrPauseCode(byte[] rawData) + => (EStopOrPauseCode)rawData[0]; + + private static FitnessMachineStateParameter ReadTargetSpeed(byte[] rawData) + => new("Target Speed", FitnessMachineUnit.KilometersPerHour, BitConverter.ToUInt16(rawData) * 0.01); + + private static FitnessMachineStateParameter ReadTargetIncline(byte[] rawData) + => new("Target Incline", FitnessMachineUnit.Percent, BitConverter.ToInt16(rawData) * 0.1); + + private static FitnessMachineStateParameter ReadTargetResistanceLevel(byte[] rawData) + => new("Target Resistance Level", FitnessMachineUnit.None, rawData[0]); + + private static FitnessMachineStateParameter ReadTargetPower(byte[] rawData) + => new("Target Power", FitnessMachineUnit.Watt, BitConverter.ToInt16(rawData)); + + private static FitnessMachineStateParameter ReadTargetHeartRate(byte[] rawData) + => new("Target Heart Rate", FitnessMachineUnit.BeatsPerMinute, rawData[0]); + + private static FitnessMachineStateParameter ReadTargetedExpendedEnergy(byte[] rawData) + => new("Targeted Expended Energy", FitnessMachineUnit.Calories, BitConverter.ToUInt16(rawData)); + + private static FitnessMachineStateParameter ReadTargetedNumberOfSteps(byte[] rawData) + => new("Targeted Number Of Steps", FitnessMachineUnit.Steps, BitConverter.ToUInt16(rawData)); + + private static FitnessMachineStateParameter ReadTargetedNumberOfStrides(byte[] rawData) + => new("Targeted Number Of Strides", FitnessMachineUnit.Stride, BitConverter.ToUInt16(rawData)); + + private static FitnessMachineStateParameter ReadTargetedDistance(byte[] rawData) + => new("Targeted Distance", FitnessMachineUnit.Meters, new UInt24(rawData[0], rawData[1], rawData[2])); + + private static FitnessMachineStateParameter ReadTargetedTrainingTime(byte[] rawData) + => new("Targeted Training Time", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(rawData)); + + private static IEnumerable ReadTargetedTimeInTwoHeartRateZones(byte[] rawData) + { + var dataSpan = rawData.AsSpan(); + return [ + new("Targeted Time in Fat Burn Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), + new("Targeted Time in Fitness Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..])) + ]; + } + + private static IEnumerable ReadTargetedTimeInThreeHeartRateZones(byte[] rawData) + { + var dataSpan = rawData.AsSpan(); + return [ + new("Targeted Time in Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), + new("Targeted Time in Moderate Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..4])), + new("Targeted Time in Hard Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[4..])) + ]; + } + + private static IEnumerable ReadTargetedTimeInFiveHeartRateZones(byte[] rawData) + { + var dataSpan = rawData.AsSpan(); + return [ + new("Targeted Time in Very Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[..2])), + new("Targeted Time in Light Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[2..4])), + new("Targeted Time in Moderate Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[4..6])), + new("Targeted Time in Hard Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[6..8])), + new("Targeted Time in Maximum Zone", FitnessMachineUnit.Seconds, BitConverter.ToUInt16(dataSpan[8..])) + ]; + } + + private static IEnumerable ReadIndoorBikeSimulationParameters(byte[] rawData) + { + var dataSpan = rawData.AsSpan(); + return [ + new("Wind Speed", FitnessMachineUnit.MetersPerSecond, BitConverter.ToInt16(dataSpan[..2]) * 0.001), + new("Grade", FitnessMachineUnit.Percent, BitConverter.ToInt16(dataSpan[2..4]) * 0.01), + new("Coefficient of Rolling Resistance", FitnessMachineUnit.None, dataSpan[4] * 0.0001), + new("Wind Resistance Coefficient", FitnessMachineUnit.KilogramPerMeter, dataSpan[5] * 0.01) + ]; + } + + private static FitnessMachineStateParameter ReadWheelCircumference(byte[] rawData) + => new("Wheel Circumference", FitnessMachineUnit.Millimeters, BitConverter.ToUInt16(rawData) * 0.1); + + private static object ReadSpinDownState(byte[] rawData) + => (ESpinDownState)rawData[0]; + + private static FitnessMachineStateParameter ReadTargetedCadence(byte[] rawData) + => new("Targeted Cadence", FitnessMachineUnit.PerMinute, BitConverter.ToUInt16(rawData) * 0.5); +} \ No newline at end of file diff --git a/FTMS.NET/State/FitnessMachineStateProvider.cs b/FTMS.NET/State/FitnessMachineStateProvider.cs index 6ab5408..7cc9674 100644 --- a/FTMS.NET/State/FitnessMachineStateProvider.cs +++ b/FTMS.NET/State/FitnessMachineStateProvider.cs @@ -1,73 +1,73 @@ -namespace FTMS.NET.State; - -using FTMS.NET.Utils; -using System; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Text; - -internal sealed class FitnessMachineStateProvider : IFitnessMachineStateProvider -{ - private readonly IObservable machineStateObservable; - private readonly IObservable trainingStateObservable; - private readonly CancellationDisposable cancellationDisposable = new(); - private readonly Func> readTrainingStateAsync; - - public FitnessMachineStateProvider( - IObservable observeMachineState, - IObservable observeTrainingState, - Func> readTrainingStateAsync) - { - this.machineStateObservable = observeMachineState - .TakeUntil(this.cancellationDisposable.Token) - .Select(this.ReadMachineStateData) - .Publish() - .RefCount(); - - this.trainingStateObservable = observeTrainingState - .TakeUntil(this.cancellationDisposable.Token) - .SelectMany(this.ReadTrainingStateDataAsync) - .Publish() - .RefCount(); - - this.readTrainingStateAsync = readTrainingStateAsync; - } - - public IObservable ObserveMachineState() - => this.machineStateObservable.AsObservable(); - - public IObservable ObserveTrainingState() - => this.trainingStateObservable.AsObservable(); - - private IFitnessMachineState ReadMachineStateData(byte[] data) - { - var opCode = (EStateOpCode)data.First(); - byte[] parameter = [.. data.Skip(1)]; - - return new FitnessMachineState(opCode, parameter); - } - - private async Task ReadTrainingStateDataAsync(byte[] data) - { - var flags = data[0]; - var trainingStateValue = (ETrainingState)data[1]; - string? detailString = await ReadDetailString(); - - return new TrainingState(trainingStateValue, detailString); - - async Task ReadDetailString() - { - if (flags.IsBitSet(0)) - return Encoding.UTF8.GetString(data.AsSpan()[2..]); - else if (flags.IsBitSet(1)) - { - var detailBytes = await this.readTrainingStateAsync(); - return Encoding.UTF8.GetString(detailBytes.AsSpan()); - } - return null; - } - } - - public void Dispose() => this.cancellationDisposable.Dispose(); -} +namespace FTMS.NET.State; + +using FTMS.NET.Utils; +using System; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Text; + +internal sealed class FitnessMachineStateProvider : IFitnessMachineStateProvider +{ + private readonly IObservable machineStateObservable; + private readonly IObservable trainingStateObservable; + private readonly CancellationDisposable cancellationDisposable = new(); + private readonly Func> readTrainingStateAsync; + + public FitnessMachineStateProvider( + IObservable observeMachineState, + IObservable observeTrainingState, + Func> readTrainingStateAsync) + { + this.machineStateObservable = observeMachineState + .TakeUntil(this.cancellationDisposable.Token) + .Select(this.ReadMachineStateData) + .Publish() + .RefCount(); + + this.trainingStateObservable = observeTrainingState + .TakeUntil(this.cancellationDisposable.Token) + .SelectMany(this.ReadTrainingStateDataAsync) + .Publish() + .RefCount(); + + this.readTrainingStateAsync = readTrainingStateAsync; + } + + public IObservable ObserveMachineState() + => this.machineStateObservable.AsObservable(); + + public IObservable ObserveTrainingState() + => this.trainingStateObservable.AsObservable(); + + private IFitnessMachineState ReadMachineStateData(byte[] data) + { + var opCode = (EStateOpCode)data.First(); + byte[] parameter = [.. data.Skip(1)]; + + return new FitnessMachineState(opCode, parameter); + } + + private async Task ReadTrainingStateDataAsync(byte[] data) + { + var flags = data[0]; + var trainingStateValue = (ETrainingState)data[1]; + string? detailString = await ReadDetailString(); + + return new TrainingState(trainingStateValue, detailString); + + async Task ReadDetailString() + { + if (flags.IsBitSet(0)) + return Encoding.UTF8.GetString(data.AsSpan()[2..]); + else if (flags.IsBitSet(1)) + { + var detailBytes = await this.readTrainingStateAsync(); + return Encoding.UTF8.GetString(detailBytes.AsSpan()); + } + return null; + } + } + + public void Dispose() => this.cancellationDisposable.Dispose(); +} \ No newline at end of file diff --git a/FTMS.NET/State/IFitnessMachineState.cs b/FTMS.NET/State/IFitnessMachineState.cs index 234b262..651abd2 100644 --- a/FTMS.NET/State/IFitnessMachineState.cs +++ b/FTMS.NET/State/IFitnessMachineState.cs @@ -1,10 +1,10 @@ -namespace FTMS.NET.State; - -using System.Collections.Immutable; - -public interface IFitnessMachineState -{ - EStateOpCode OpCode { get; } - byte[] RawData { get; } - IImmutableList ReadParameters(); -} +namespace FTMS.NET.State; + +using System.Collections.Immutable; + +public interface IFitnessMachineState +{ + EStateOpCode OpCode { get; } + byte[] RawData { get; } + IImmutableList ReadParameters(); +} \ No newline at end of file diff --git a/FTMS.NET/State/IFitnessMachineStateProvider.cs b/FTMS.NET/State/IFitnessMachineStateProvider.cs index e8728e0..aedb7df 100644 --- a/FTMS.NET/State/IFitnessMachineStateProvider.cs +++ b/FTMS.NET/State/IFitnessMachineStateProvider.cs @@ -1,9 +1,9 @@ -namespace FTMS.NET.State; - -using System; - -public interface IFitnessMachineStateProvider : IDisposable -{ - IObservable ObserveMachineState(); - IObservable ObserveTrainingState(); -} +namespace FTMS.NET.State; + +using System; + +public interface IFitnessMachineStateProvider : IDisposable +{ + IObservable ObserveMachineState(); + IObservable ObserveTrainingState(); +} \ No newline at end of file diff --git a/FTMS.NET/State/ITrainingState.cs b/FTMS.NET/State/ITrainingState.cs index bde8785..2de5101 100644 --- a/FTMS.NET/State/ITrainingState.cs +++ b/FTMS.NET/State/ITrainingState.cs @@ -1,6 +1,7 @@ -namespace FTMS.NET.State; -public interface ITrainingState -{ - ETrainingState State { get; } - string? Details { get; } -} +namespace FTMS.NET.State; + +public interface ITrainingState +{ + ETrainingState State { get; } + string? Details { get; } +} \ No newline at end of file diff --git a/FTMS.NET/State/TrainingState.cs b/FTMS.NET/State/TrainingState.cs index 79f0aff..2097594 100644 --- a/FTMS.NET/State/TrainingState.cs +++ b/FTMS.NET/State/TrainingState.cs @@ -1,3 +1,3 @@ -namespace FTMS.NET.State; - +namespace FTMS.NET.State; + internal sealed record TrainingState(ETrainingState State, string? Details) : ITrainingState; \ No newline at end of file diff --git a/FTMS.NET/ThrowingCharacteristic.cs b/FTMS.NET/ThrowingCharacteristic.cs index b541716..eb9dd99 100644 --- a/FTMS.NET/ThrowingCharacteristic.cs +++ b/FTMS.NET/ThrowingCharacteristic.cs @@ -1,19 +1,19 @@ -namespace FTMS.NET; - -using FTMS.NET.Exceptions; -using System; -using System.Reactive.Linq; -using System.Threading.Tasks; - -internal sealed class ThrowingCharacteristic(Guid uuid) : IFitnessMachineCharacteristic -{ - private readonly NeededCharacteristicNotAvailableException exception = new(uuid); - - public Guid Id => throw this.exception; - - public IObservable ObserveValue() => Observable.Throw(this.exception); - - public Task ReadValueAsync() => throw this.exception; - - public Task WriteValueAsync(byte[] value) => throw this.exception; -} +namespace FTMS.NET; + +using FTMS.NET.Exceptions; +using System; +using System.Reactive.Linq; +using System.Threading.Tasks; + +internal sealed class ThrowingCharacteristic(Guid uuid) : IFitnessMachineCharacteristic +{ + private readonly NeededCharacteristicNotAvailableException exception = new(uuid); + + public Guid Id => throw this.exception; + + public IObservable ObserveValue() => Observable.Throw(this.exception); + + public Task ReadValueAsync() => throw this.exception; + + public Task WriteValueAsync(byte[] value) => throw this.exception; +} \ No newline at end of file diff --git a/FTMS.NET/Utils/ByteExtensions.cs b/FTMS.NET/Utils/ByteExtensions.cs index fadd44e..f36dc19 100644 --- a/FTMS.NET/Utils/ByteExtensions.cs +++ b/FTMS.NET/Utils/ByteExtensions.cs @@ -1,40 +1,40 @@ -namespace FTMS.NET.Utils; - -internal static class ByteExtensions -{ - public static bool IsBitSet(this byte[] data, int pos) - { - if (pos < 0) - throw new IndexOutOfRangeException(); - - int byteIndex = pos / 8; - int bitIndex = pos % 8; - - if (byteIndex >= data.Length) - throw new IndexOutOfRangeException(); - - return data[byteIndex].IsBitSet(bitIndex); - } - - public static bool IsBitSet(this ReadOnlySpan data, int pos) - { - if (pos < 0) - throw new IndexOutOfRangeException(); - - int byteIndex = pos / 8; - int bitIndex = pos % 8; - - if (byteIndex >= data.Length) - throw new IndexOutOfRangeException(); - - return data[byteIndex].IsBitSet(bitIndex); - } - - public static bool IsBitSet(this byte b, int pos) - { - if ((uint)pos > 7) - throw new IndexOutOfRangeException(); - - return ((b >> pos) & 1) != 0; - } +namespace FTMS.NET.Utils; + +internal static class ByteExtensions +{ + public static bool IsBitSet(this byte[] data, int pos) + { + if (pos < 0) + throw new IndexOutOfRangeException(); + + int byteIndex = pos / 8; + int bitIndex = pos % 8; + + if (byteIndex >= data.Length) + throw new IndexOutOfRangeException(); + + return data[byteIndex].IsBitSet(bitIndex); + } + + public static bool IsBitSet(this ReadOnlySpan data, int pos) + { + if (pos < 0) + throw new IndexOutOfRangeException(); + + int byteIndex = pos / 8; + int bitIndex = pos % 8; + + if (byteIndex >= data.Length) + throw new IndexOutOfRangeException(); + + return data[byteIndex].IsBitSet(bitIndex); + } + + public static bool IsBitSet(this byte b, int pos) + { + if ((uint)pos > 7) + throw new IndexOutOfRangeException(); + + return ((b >> pos) & 1) != 0; + } } \ No newline at end of file diff --git a/FTMS.NET/Utils/GenericMath.cs b/FTMS.NET/Utils/GenericMath.cs index de685e9..5e431f4 100644 --- a/FTMS.NET/Utils/GenericMath.cs +++ b/FTMS.NET/Utils/GenericMath.cs @@ -1,28 +1,28 @@ -namespace FTMS.NET.Utils; - -using System.Numerics; - -internal static class GenericMath -{ - public static TValue Clamp(TValue value, TMin min, TMax max) - where TValue : struct, INumber - where TMin : struct, INumber - where TMax : struct, INumber - { - var tMin = TValue.CreateSaturating(min); - if (tMin > value) - return tMin; - - var tMax = TValue.CreateSaturating(max); - if (tMax < value) - return tMax; - - return value; - } - - public static bool IsInRange(TValue value, TMin min, TMax max) - where TValue : struct, INumber - where TMin : struct, INumber - where TMax : struct, INumber - => value >= TValue.CreateSaturating(min) && value <= TValue.CreateSaturating(max); -} +namespace FTMS.NET.Utils; + +using System.Numerics; + +internal static class GenericMath +{ + public static TValue Clamp(TValue value, TMin min, TMax max) + where TValue : struct, INumber + where TMin : struct, INumber + where TMax : struct, INumber + { + var tMin = TValue.CreateSaturating(min); + if (tMin > value) + return tMin; + + var tMax = TValue.CreateSaturating(max); + if (tMax < value) + return tMax; + + return value; + } + + public static bool IsInRange(TValue value, TMin min, TMax max) + where TValue : struct, INumber + where TMin : struct, INumber + where TMax : struct, INumber + => value >= TValue.CreateSaturating(min) && value <= TValue.CreateSaturating(max); +} \ No newline at end of file diff --git a/FTMS.NET/Utils/UInt24.cs b/FTMS.NET/Utils/UInt24.cs index 3f9d4ad..3b5f7bb 100644 --- a/FTMS.NET/Utils/UInt24.cs +++ b/FTMS.NET/Utils/UInt24.cs @@ -1,82 +1,82 @@ -namespace FTMS.NET.Utils; - -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; - -/// Based on https://stackoverflow.com/a/12549260 -[StructLayout(LayoutKind.Sequential)] -public readonly struct UInt24 : IEquatable, IComparable/*, INumber*/ -{ - public static readonly UInt24 MaxValue = new(0xFF, 0xFF, 0xFF); - public static readonly UInt24 MinValue = new(0x00, 0x00, 0x00); - - private const uint _maxValue = 0x00FFFFFF; // 16,777,215 // This is the *inclusive* upper-bound (i.e. max-value). - - // - - public static implicit operator uint(UInt24 self) => self.Value; - public static implicit operator UInt24(ushort u16) => new(value: u16); - - // - - private readonly byte b0; - private readonly byte b1; - private readonly byte b2; - - public UInt24(byte b0, byte b1, byte b2) - { - this.b0 = b0; - this.b1 = b1; - this.b2 = b2; - } - - public UInt24(uint value) - { - if (value > _maxValue) - throw new ArgumentOutOfRangeException( - paramName: nameof(value), - actualValue: value, - message: $"Value {value:N0} must be between 0 and {_maxValue:N0} (inclusive)."); - - // - - this.b0 = (byte)(value & 0xFF); - this.b1 = (byte)(value >> 8 & 0xFF); - this.b2 = (byte)(value >> 16 & 0xFF); - } - -#if UNSAFE - public unsafe Byte* Byte0 => &_b0; -#endif - - private int SignedValue => this.b0 | this.b1 << 8 | this.b2 << 16; - public uint Value => (uint)this.SignedValue; - - public byte[] GetBytes() => [this.b0, this.b1, this.b2]; - - // - - #region Struct Tedium + IEquatable + IComparable - - public override string ToString() => this.Value.ToString(); - public override int GetHashCode() => this.SignedValue; - public override bool Equals([NotNullWhen(true)] object? obj) => obj is UInt24 other && this.Equals(other: other); - - public bool Equals(UInt24 other) => this.Value.Equals(other.Value); - public int CompareTo(UInt24 other) => this.Value.CompareTo(other.Value); - - public static int Compare(UInt24 left, UInt24 right) => left.Value.CompareTo(right.Value); - - // - - public static bool operator ==(UInt24 left, UInt24 right) => left.Equals(right); - public static bool operator !=(UInt24 left, UInt24 right) => !left.Equals(right); - - public static bool operator >(UInt24 left, UInt24 right) => Compare(left, right) > 0; - public static bool operator >=(UInt24 left, UInt24 right) => Compare(left, right) >= 0; - - public static bool operator <(UInt24 left, UInt24 right) => Compare(left, right) < 0; - public static bool operator <=(UInt24 left, UInt24 right) => Compare(left, right) <= 0; - - #endregion +namespace FTMS.NET.Utils; + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +/// Based on https://stackoverflow.com/a/12549260 +[StructLayout(LayoutKind.Sequential)] +public readonly struct UInt24 : IEquatable, IComparable/*, INumber*/ +{ + public static readonly UInt24 MaxValue = new(0xFF, 0xFF, 0xFF); + public static readonly UInt24 MinValue = new(0x00, 0x00, 0x00); + + private const uint _maxValue = 0x00FFFFFF; // 16,777,215 // This is the *inclusive* upper-bound (i.e. max-value). + + // + + public static implicit operator uint(UInt24 self) => self.Value; + public static implicit operator UInt24(ushort u16) => new(value: u16); + + // + + private readonly byte b0; + private readonly byte b1; + private readonly byte b2; + + public UInt24(byte b0, byte b1, byte b2) + { + this.b0 = b0; + this.b1 = b1; + this.b2 = b2; + } + + public UInt24(uint value) + { + if (value > _maxValue) + throw new ArgumentOutOfRangeException( + paramName: nameof(value), + actualValue: value, + message: $"Value {value:N0} must be between 0 and {_maxValue:N0} (inclusive)."); + + // + + this.b0 = (byte)(value & 0xFF); + this.b1 = (byte)(value >> 8 & 0xFF); + this.b2 = (byte)(value >> 16 & 0xFF); + } + +#if UNSAFE + public unsafe Byte* Byte0 => &_b0; +#endif + + private int SignedValue => this.b0 | this.b1 << 8 | this.b2 << 16; + public uint Value => (uint)this.SignedValue; + + public byte[] GetBytes() => [this.b0, this.b1, this.b2]; + + // + + #region Struct Tedium + IEquatable + IComparable + + public override string ToString() => this.Value.ToString(); + public override int GetHashCode() => this.SignedValue; + public override bool Equals([NotNullWhen(true)] object? obj) => obj is UInt24 other && this.Equals(other: other); + + public bool Equals(UInt24 other) => this.Value.Equals(other.Value); + public int CompareTo(UInt24 other) => this.Value.CompareTo(other.Value); + + public static int Compare(UInt24 left, UInt24 right) => left.Value.CompareTo(right.Value); + + // + + public static bool operator ==(UInt24 left, UInt24 right) => left.Equals(right); + public static bool operator !=(UInt24 left, UInt24 right) => !left.Equals(right); + + public static bool operator >(UInt24 left, UInt24 right) => Compare(left, right) > 0; + public static bool operator >=(UInt24 left, UInt24 right) => Compare(left, right) >= 0; + + public static bool operator <(UInt24 left, UInt24 right) => Compare(left, right) < 0; + public static bool operator <=(UInt24 left, UInt24 right) => Compare(left, right) <= 0; + + #endregion } \ No newline at end of file diff --git a/FTMS.NET/Utils/ValueCalculation.cs b/FTMS.NET/Utils/ValueCalculation.cs index bf54ee4..550df57 100644 --- a/FTMS.NET/Utils/ValueCalculation.cs +++ b/FTMS.NET/Utils/ValueCalculation.cs @@ -1,9 +1,10 @@ -namespace FTMS.NET.Utils; -using System; - -internal sealed record ValueCalculation(int Multiplier = 1, int DecimalExponent = 0, int BinaryExponent = 0) -{ - private readonly double constantMultiplier = Multiplier * Math.Pow(10, DecimalExponent) * Math.Pow(2, BinaryExponent); - - public double Calculate(long rawValue) => rawValue * this.constantMultiplier; -} +namespace FTMS.NET.Utils; + +using System; + +internal sealed record ValueCalculation(int Multiplier = 1, int DecimalExponent = 0, int BinaryExponent = 0) +{ + private readonly double constantMultiplier = Multiplier * Math.Pow(10, DecimalExponent) * Math.Pow(2, BinaryExponent); + + public double Calculate(long rawValue) => rawValue * this.constantMultiplier; +} \ No newline at end of file From 2b0a5d2d9f17fb3fda18e5d00d43f4ef92c3a27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 23:23:39 +0200 Subject: [PATCH 09/10] typo and cleaning --- FTMS.NET/FitnessMachineServiceFactory.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/FTMS.NET/FitnessMachineServiceFactory.cs b/FTMS.NET/FitnessMachineServiceFactory.cs index 2f94259..2a5a1f5 100644 --- a/FTMS.NET/FitnessMachineServiceFactory.cs +++ b/FTMS.NET/FitnessMachineServiceFactory.cs @@ -40,9 +40,8 @@ public static async Task CreateFitnessMachineDataAsync( var fitnessMachineDataReader = fitnessMachineType.GetDataReader(); var dataCharacteristicId = fitnessMachineType.GetDataCharacteristicId(); - var dataCharacteristic = await connection.GetCharacteristicAsync(dataCharacteristicId); + var dataCharacteristic = await connection.GetRequiredCharacteristic(dataCharacteristicId); - dataCharacteristic.EnsureAvailabieCharacteristic(dataCharacteristicId); return new FitnessMachineData(dataCharacteristic.ObserveValue(), fitnessMachineDataReader); } @@ -84,8 +83,7 @@ public static async Task CreateFitnessMachineState public static async Task ReadFitnessMachineFeaturesAsync( this IFitnessMachineServiceConnection connection) { - var featureCharacteristic = await connection.GetCharacteristicAsync(FtmsUuids.Feature); - featureCharacteristic.EnsureAvailabieCharacteristic(FtmsUuids.Feature); + var featureCharacteristic = await connection.GetRequiredCharacteristic(FtmsUuids.Feature); var featureData = await featureCharacteristic.ReadValueAsync(); var featureDataSpan = featureData.AsSpan(); @@ -113,7 +111,16 @@ public static async Task ReadFitnessMachineFeaturesAsyn } } - public static void EnsureAvailabieCharacteristic( + private static async Task GetRequiredCharacteristic( + this IFitnessMachineServiceConnection connection, + Guid characteristicUuid) + { + var characteristic = await connection.GetCharacteristicAsync(characteristicUuid); + characteristic.EnsureAvailableCharacteristic(characteristicUuid); + return characteristic; + } + + private static void EnsureAvailableCharacteristic( [NotNull] this IFitnessMachineCharacteristic? characteristic, Guid characteristicUuid) { From df3c9179ea4fca7797d5d1b6989bb7317856dbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Aufschl=C3=A4ger?= Date: Thu, 6 Aug 2026 23:25:37 +0200 Subject: [PATCH 10/10] init agents.md --- AGENTS.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec14f53 --- /dev/null +++ b/AGENTS.md @@ -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` (`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.