diff --git a/src/Daqifi.Core.Tests/Device/DeviceCapabilitiesTests.cs b/src/Daqifi.Core.Tests/Device/DeviceCapabilitiesTests.cs
index 9fb21790..87bacb79 100644
--- a/src/Daqifi.Core.Tests/Device/DeviceCapabilitiesTests.cs
+++ b/src/Daqifi.Core.Tests/Device/DeviceCapabilitiesTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using Daqifi.Core.Device;
using Xunit;
@@ -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,
@@ -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]
diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
index eeea416a..058d13f9 100644
--- a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
+++ b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
@@ -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);
+ }
+
+ [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()
{
@@ -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",
@@ -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]
diff --git a/src/Daqifi.Core/Device/DeviceCapabilities.cs b/src/Daqifi.Core/Device/DeviceCapabilities.cs
index 7eb94c66..84e54914 100644
--- a/src/Daqifi.Core/Device/DeviceCapabilities.cs
+++ b/src/Daqifi.Core/Device/DeviceCapabilities.cs
@@ -122,6 +122,7 @@ public DeviceCapabilities Clone()
SupportsStreaming = SupportsStreaming,
HasSdCard = HasSdCard,
HasWiFi = HasWiFi,
+ HasWincWifiModule = HasWincWifiModule,
HasUsb = HasUsb,
AnalogInputChannels = AnalogInputChannels,
AnalogOutputChannels = AnalogOutputChannels,
diff --git a/src/Daqifi.Core/Device/DeviceHealth.cs b/src/Daqifi.Core/Device/DeviceHealth.cs
new file mode 100644
index 00000000..15f873ed
--- /dev/null
+++ b/src/Daqifi.Core/Device/DeviceHealth.cs
@@ -0,0 +1,63 @@
+namespace Daqifi.Core.Device;
+
+///
+/// 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.
+///
+///
+/// The underlying protobuf fields are proto3 scalars with no explicit presence, so a
+/// value of 0 is indistinguishable from "not reported". To avoid dropping a known
+/// reading when a partial status frame omits a field, each value is sticky: it holds
+/// the last value the device actually reported until a new in-contract reading replaces it.
+/// and are therefore
+/// nullable — null means "never reported since this instance was created" — and the raw
+/// and codes default to 0.
+///
+public class DeviceHealth
+{
+ ///
+ /// 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 null if the device has not reported a valid
+ /// battery level since this instance was created. Out-of-range readings are ignored rather
+ /// than surfaced.
+ ///
+ public int? BatteryPercent { get; set; }
+
+ ///
+ /// 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 null if the device has not reported a temperature since this instance
+ /// was created.
+ ///
+ public int? BoardTemperatureCelsius { get; set; }
+
+ ///
+ /// Gets or sets the raw power/charging status code as reported by the device
+ /// (PwrStatus). Semantics are firmware-defined; 0 is the default/unreported value.
+ ///
+ public uint PowerStatus { get; set; }
+
+ ///
+ /// Gets or sets the raw device status code as reported by the device
+ /// (DeviceStatus). Semantics are firmware-defined; 0 is the default/unreported value.
+ ///
+ public uint DeviceStatus { get; set; }
+
+ ///
+ /// Creates a deep copy of this instance.
+ ///
+ /// A new instance with the same values.
+ public DeviceHealth Clone()
+ {
+ return new DeviceHealth
+ {
+ BatteryPercent = BatteryPercent,
+ BoardTemperatureCelsius = BoardTemperatureCelsius,
+ PowerStatus = PowerStatus,
+ DeviceStatus = DeviceStatus
+ };
+ }
+}
diff --git a/src/Daqifi.Core/Device/DeviceMetadata.cs b/src/Daqifi.Core/Device/DeviceMetadata.cs
index 790588cd..d01f797a 100644
--- a/src/Daqifi.Core/Device/DeviceMetadata.cs
+++ b/src/Daqifi.Core/Device/DeviceMetadata.cs
@@ -32,10 +32,30 @@ public class DeviceMetadata
///
public DeviceType DeviceType { get; set; } = DeviceType.Unknown;
+ private DeviceCapabilities _capabilities = new();
+ private DeviceHealth _health = new();
+
///
- /// Gets or sets the device capabilities.
+ /// Gets or sets the device capabilities. Assigning null is coerced to a fresh instance so
+ /// the status-processing path (which populates channel counts here) can never dereference null.
///
- public DeviceCapabilities Capabilities { get; set; } = new DeviceCapabilities();
+ public DeviceCapabilities Capabilities
+ {
+ get => _capabilities;
+ set => _capabilities = value ?? new DeviceCapabilities();
+ }
+
+ ///
+ /// 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 null is coerced to a
+ /// fresh instance so can never dereference null on the status path.
+ ///
+ public DeviceHealth Health
+ {
+ get => _health;
+ set => _health = value ?? new DeviceHealth();
+ }
///
/// Gets or sets the IP address of the device.
@@ -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;
@@ -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;
+ }
}
}