Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions src/Daqifi.Core.Tests/Device/DeviceCapabilitiesTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using Daqifi.Core.Device;
using Xunit;

Expand Down Expand Up @@ -82,14 +83,16 @@ public void Properties_CanBeSetAndRetrieved()
}

[Fact]
public void Clone_CopiesAllFieldValues()
public void Clone_RoundTripsEveryPublicProperty()
{
// Arrange
// Arrange: set every public property to a non-default value so a dropped copy is detectable.
// Reflection-driven so a newly added property can't be silently omitted from Clone() (#331).
var capabilities = new DeviceCapabilities
{
SupportsStreaming = false,
HasSdCard = true,
HasWiFi = true,
HasWincWifiModule = true,
HasUsb = true,
AnalogInputChannels = 8,
AnalogOutputChannels = 2,
Expand All @@ -100,15 +103,20 @@ public void Clone_CopiesAllFieldValues()
// Act
var clone = capabilities.Clone();

// Assert
Assert.Equal(capabilities.SupportsStreaming, clone.SupportsStreaming);
Assert.Equal(capabilities.HasSdCard, clone.HasSdCard);
Assert.Equal(capabilities.HasWiFi, clone.HasWiFi);
Assert.Equal(capabilities.HasUsb, clone.HasUsb);
Assert.Equal(capabilities.AnalogInputChannels, clone.AnalogInputChannels);
Assert.Equal(capabilities.AnalogOutputChannels, clone.AnalogOutputChannels);
Assert.Equal(capabilities.DigitalChannels, clone.DigitalChannels);
Assert.Equal(capabilities.MaxSamplingRate, clone.MaxSamplingRate);
// Assert: every readable public instance property must round-trip.
var properties = typeof(DeviceCapabilities)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead);

foreach (var property in properties)
{
var original = property.GetValue(capabilities);
var copied = property.GetValue(clone);

// Guard against the test itself leaving a property at its default (which would mask a drop).
Assert.NotEqual(property.GetValue(new DeviceCapabilities()), original);
Assert.Equal(original, copied);
}
}

[Fact]
Expand Down
136 changes: 136 additions & 0 deletions src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,115 @@ public void UpdateFromProtobuf_UpdatesChannelCounts()
Assert.Equal(16, metadata.Capabilities.DigitalChannels);
}

[Fact]
public void UpdateFromProtobuf_UpdatesHealthTelemetry()
{
// Arrange
var metadata = new DeviceMetadata();
var message = new DaqifiOutMessage
{
BattStatus = 87,
TempStatus = 42,
PwrStatus = 2,
DeviceStatus = 5
};

// Act
metadata.UpdateFromProtobuf(message);

// Assert
Assert.Equal(87, metadata.Health.BatteryPercent);
Assert.Equal(42, metadata.Health.BoardTemperatureCelsius);
Assert.Equal(2u, metadata.Health.PowerStatus);
Assert.Equal(5u, metadata.Health.DeviceStatus);
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

[Fact]
public void UpdateFromProtobuf_FromSerializedStatusPayload_DecodesHealthTelemetry()
{
// Arrange: build a status message, serialize it to the wire bytes a device would send,
// then decode it back through the protobuf parser — exercising the real frame decode path
// (issue #335 asks for a captured/serialized payload, not just an in-memory message).
var original = new DaqifiOutMessage
{
DevicePn = "Nq1",
BattStatus = 87,
TempStatus = -5,
PwrStatus = 2,
DeviceStatus = 5
};
byte[] payload = original.ToByteArray();
var decoded = DaqifiOutMessage.Parser.ParseFrom(payload);

var metadata = new DeviceMetadata();

// Act
metadata.UpdateFromProtobuf(decoded);

// Assert
Assert.Equal(87, metadata.Health.BatteryPercent);
Assert.Equal(-5, metadata.Health.BoardTemperatureCelsius);
Assert.Equal(2u, metadata.Health.PowerStatus);
Assert.Equal(5u, metadata.Health.DeviceStatus);
}

[Theory]
[InlineData(101u)] // above the documented 0-100 range
[InlineData(500u)] // clearly nonsensical
[InlineData(uint.MaxValue)] // would wrap to -1 if cast straight to int
public void UpdateFromProtobuf_OutOfRangeBattery_IsIgnored(uint battStatus)
{
// Arrange: a prior valid reading, then a bad one.
var metadata = new DeviceMetadata();
metadata.UpdateFromProtobuf(new DaqifiOutMessage { BattStatus = 60 });

// Act
metadata.UpdateFromProtobuf(new DaqifiOutMessage { BattStatus = battStatus });

// Assert: out-of-range battery never surfaces (and never wraps negative); last-known kept.
Assert.Equal(60, metadata.Health.BatteryPercent);
}

[Fact]
public void Health_SetToNull_IsCoercedToInstance_AndStatusUpdateDoesNotThrow()
{
// Health has a public setter; a consumer assigning null must not break the status path.
var metadata = new DeviceMetadata { Health = null! };

Assert.NotNull(metadata.Health);

var exception = Record.Exception(() =>
metadata.UpdateFromProtobuf(new DaqifiOutMessage { BattStatus = 42 }));

Assert.Null(exception);
Assert.Equal(42, metadata.Health.BatteryPercent);
}

[Fact]
public void UpdateFromProtobuf_NegativeBoardTemperature_IsPreserved()
{
// TempStatus is a signed field; sub-zero board temperatures must round-trip.
var metadata = new DeviceMetadata();
var message = new DaqifiOutMessage { TempStatus = -10 };

metadata.UpdateFromProtobuf(message);

Assert.Equal(-10, metadata.Health.BoardTemperatureCelsius);
}

[Fact]
public void UpdateFromProtobuf_ZeroHealthFields_LeaveLastKnownValues()
{
// A partial status message (all health fields 0) must not clobber a prior reading.
var metadata = new DeviceMetadata();
metadata.UpdateFromProtobuf(new DaqifiOutMessage { BattStatus = 90, TempStatus = 30 });

metadata.UpdateFromProtobuf(new DaqifiOutMessage { AnalogInPortNum = 8 });

Assert.Equal(90, metadata.Health.BatteryPercent);
Assert.Equal(30, metadata.Health.BoardTemperatureCelsius);
}

[Fact]
public void UpdateFromProtobuf_IgnoresEmptyOrZeroValues()
{
Expand Down Expand Up @@ -249,6 +358,13 @@ public void CopyFrom_CopiesAllFieldValues()
DigitalChannels = 16,
MaxSamplingRate = 5000
},
Health = new DeviceHealth
{
BatteryPercent = 75,
BoardTemperatureCelsius = 33,
PowerStatus = 1,
DeviceStatus = 4
},
IpAddress = "192.168.1.100",
MacAddress = "AA-BB-CC-DD-EE-FF",
Ssid = "TestNetwork",
Expand Down Expand Up @@ -285,6 +401,26 @@ public void CopyFrom_CopiesAllFieldValues()
Assert.Equal(source.Capabilities.HasWiFi, target.Capabilities.HasWiFi);
Assert.Equal(source.Capabilities.HasUsb, target.Capabilities.HasUsb);
Assert.Equal(source.Capabilities.SupportsStreaming, target.Capabilities.SupportsStreaming);
Assert.Equal(source.Health.BatteryPercent, target.Health.BatteryPercent);
Assert.Equal(source.Health.BoardTemperatureCelsius, target.Health.BoardTemperatureCelsius);
Assert.Equal(source.Health.PowerStatus, target.Health.PowerStatus);
Assert.Equal(source.Health.DeviceStatus, target.Health.DeviceStatus);
}

[Fact]
public void CopyFrom_HealthIsDeepCopiedNotShared()
{
// Arrange
var source = new DeviceMetadata { Health = new DeviceHealth { BatteryPercent = 50 } };
var target = new DeviceMetadata();

// Act
target.CopyFrom(source);
source.Health.BatteryPercent = 10;

// Assert
Assert.NotSame(source.Health, target.Health);
Assert.Equal(50, target.Health.BatteryPercent);
}

[Fact]
Expand Down
1 change: 1 addition & 0 deletions src/Daqifi.Core/Device/DeviceCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public DeviceCapabilities Clone()
SupportsStreaming = SupportsStreaming,
HasSdCard = HasSdCard,
HasWiFi = HasWiFi,
HasWincWifiModule = HasWincWifiModule,
HasUsb = HasUsb,
AnalogInputChannels = AnalogInputChannels,
AnalogOutputChannels = AnalogOutputChannels,
Expand Down
63 changes: 63 additions & 0 deletions src/Daqifi.Core/Device/DeviceHealth.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace Daqifi.Core.Device;

/// <summary>
/// Health/telemetry values decoded from a device status message: battery charge,
/// board temperature, and the raw power/device status codes. These update as new
/// status messages arrive (including the periodic ones emitted during streaming),
/// so a snapshot reflects the most recent reading Core has seen.
/// </summary>
/// <remarks>
/// The underlying protobuf fields are proto3 scalars with no explicit presence, so a
/// value of <c>0</c> is indistinguishable from "not reported". To avoid dropping a known
/// reading when a partial status frame omits a field, each value is <b>sticky</b>: it holds
/// the last value the device actually reported until a new in-contract reading replaces it.
/// <see cref="BatteryPercent"/> and <see cref="BoardTemperatureCelsius"/> are therefore
/// nullable — <c>null</c> means "never reported since this instance was created" — and the raw
/// <see cref="PowerStatus"/> and <see cref="DeviceStatus"/> codes default to <c>0</c>.
/// </remarks>
public class DeviceHealth
{
/// <summary>
/// Gets or sets the battery charge as a percentage (1-100). This is the last in-contract
/// reading the device reported (a value may therefore be older than the most recent status
/// message, which can omit the field), or <c>null</c> if the device has not reported a valid
/// battery level since this instance was created. Out-of-range readings are ignored rather
/// than surfaced.
/// </summary>
public int? BatteryPercent { get; set; }

/// <summary>
/// Gets or sets the board temperature in degrees Celsius. This is the last value the device
/// reported (which may be older than the most recent status message, since a frame can omit
/// the field), or <c>null</c> if the device has not reported a temperature since this instance
/// was created.
/// </summary>
public int? BoardTemperatureCelsius { get; set; }

/// <summary>
/// Gets or sets the raw power/charging status code as reported by the device
/// (<c>PwrStatus</c>). Semantics are firmware-defined; <c>0</c> is the default/unreported value.
/// </summary>
public uint PowerStatus { get; set; }

/// <summary>
/// Gets or sets the raw device status code as reported by the device
/// (<c>DeviceStatus</c>). Semantics are firmware-defined; <c>0</c> is the default/unreported value.
/// </summary>
public uint DeviceStatus { get; set; }

/// <summary>
/// Creates a deep copy of this <see cref="DeviceHealth"/> instance.
/// </summary>
/// <returns>A new <see cref="DeviceHealth"/> instance with the same values.</returns>
public DeviceHealth Clone()
{
return new DeviceHealth
{
BatteryPercent = BatteryPercent,
BoardTemperatureCelsius = BoardTemperatureCelsius,
PowerStatus = PowerStatus,
DeviceStatus = DeviceStatus
};
}
}
53 changes: 51 additions & 2 deletions src/Daqifi.Core/Device/DeviceMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,30 @@ public class DeviceMetadata
/// </summary>
public DeviceType DeviceType { get; set; } = DeviceType.Unknown;

private DeviceCapabilities _capabilities = new();
private DeviceHealth _health = new();

/// <summary>
/// Gets or sets the device capabilities.
/// Gets or sets the device capabilities. Assigning <c>null</c> is coerced to a fresh instance so
/// the status-processing path (which populates channel counts here) can never dereference null.
/// </summary>
public DeviceCapabilities Capabilities { get; set; } = new DeviceCapabilities();
public DeviceCapabilities Capabilities
{
get => _capabilities;
set => _capabilities = value ?? new DeviceCapabilities();
}

/// <summary>
/// Gets or sets the most recent device health telemetry (battery, board temperature,
/// power/device status) decoded from a status message. Updated on each status message,
/// including the periodic ones emitted during streaming. Assigning <c>null</c> is coerced to a
/// fresh instance so <see cref="UpdateFromProtobuf"/> can never dereference null on the status path.
/// </summary>
public DeviceHealth Health
{
get => _health;
set => _health = value ?? new DeviceHealth();
}

/// <summary>
/// Gets or sets the IP address of the device.
Expand Down Expand Up @@ -92,6 +112,7 @@ public void CopyFrom(DeviceMetadata source)
HardwareRevision = source.HardwareRevision;
DeviceType = source.DeviceType;
Capabilities = source.Capabilities?.Clone() ?? new DeviceCapabilities();
Health = source.Health?.Clone() ?? new DeviceHealth();
IpAddress = source.IpAddress;
MacAddress = source.MacAddress;
Ssid = source.Ssid;
Expand Down Expand Up @@ -187,5 +208,33 @@ public void UpdateFromProtobuf(DaqifiOutMessage message)
{
Capabilities.DigitalChannels = (int)message.DigitalPortNum;
}

// Update health telemetry. proto3 scalars have no explicit presence, so a value of 0
// is indistinguishable from "not reported"; guard on non-zero (consistent with the
// other fields above) so a partial status message never clobbers a known reading.

// BattStatus is a uint documented as a battery percentage. Only accept an in-contract
// 1..100 reading: this both filters nonsensical values (>100) and avoids the uint->int
// wrap-to-negative a very large value would produce. Out-of-range readings are ignored
// (treated as not reported), leaving the last-known value in place.
if (message.BattStatus is >= 1 and <= 100)
{
Health.BatteryPercent = (int)message.BattStatus;
}

if (message.TempStatus != 0)
{
Health.BoardTemperatureCelsius = message.TempStatus;
}

if (message.PwrStatus != 0)
{
Health.PowerStatus = message.PwrStatus;
}

if (message.DeviceStatus != 0)
{
Health.DeviceStatus = message.DeviceStatus;
}
}
}