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; + } } }