diff --git a/OwonBinfileReader.Tests/OwonBinfileReader.Tests.csproj b/OwonBinfileReader.Tests/OwonBinfileReader.Tests.csproj index 5bd2d80..2429af6 100644 --- a/OwonBinfileReader.Tests/OwonBinfileReader.Tests.csproj +++ b/OwonBinfileReader.Tests/OwonBinfileReader.Tests.csproj @@ -39,6 +39,9 @@ PreserveNewest + + PreserveNewest + diff --git a/OwonBinfileReader.Tests/RawContainerReaderTests.cs b/OwonBinfileReader.Tests/RawContainerReaderTests.cs new file mode 100644 index 0000000..c1dc371 --- /dev/null +++ b/OwonBinfileReader.Tests/RawContainerReaderTests.cs @@ -0,0 +1,91 @@ +using OwonBinfileReader.Raw; +using System.Text; + +namespace OwonBinfileReader.Tests; + +[TestClass] +public sealed class RawContainerReaderTests +{ + [TestMethod] + public async Task RawContainerReader_Reads_Akip_SingleSegment_Vendor_Unaware() + { + var raw = await new RawContainerReader().ReadAsync("testfiles/akip.bin"); + + Assert.IsTrue(raw.RawJson.Contains("AKIP")); + Assert.HasCount(1, raw.Segments); + Assert.AreEqual(10000, raw.Segments[0].SampleCount); + Assert.IsNull(raw.Tail); + } + + [TestMethod] + public async Task RawContainerReader_Reads_Owon_SingleDisplayedChannel() + { + var raw = await new RawContainerReader().ReadAsync("testfiles/2025-03-18 14_06_46_879.bin"); + + Assert.IsTrue(raw.RawJson.Contains("\"CHANNEL\"")); + Assert.HasCount(1, raw.Segments); + Assert.AreEqual(10000, raw.Segments[0].SampleCount); + } + + [TestMethod] + public async Task RawContainerReader_Reads_Multiple_Segments_And_Info_Tail() + { + // Synthetic container: magic + tiny JSON header + two data segments + INFO tail, + // to exercise the segment-boundary and tail-detection logic that no single real + // sample file currently exercises (every available fixture only has one channel ON). + using var stream = new MemoryStream(); + var json = Encoding.UTF8.GetBytes("{\"x\":1}"); + + void WriteInt32(int value) => stream.Write(BitConverter.GetBytes(value), 0, 4); + + stream.Write(Encoding.ASCII.GetBytes("SPBXDS"), 0, 6); + WriteInt32(json.Length); + stream.Write(json, 0, json.Length); + + var seg0 = new short[] { 1, 2, 3, 4 }; + var seg0Bytes = seg0.SelectMany(BitConverter.GetBytes).ToArray(); + WriteInt32(seg0Bytes.Length); + stream.Write(seg0Bytes, 0, seg0Bytes.Length); + + var seg1 = new short[] { -5, -6 }; + var seg1Bytes = seg1.SelectMany(BitConverter.GetBytes).ToArray(); + WriteInt32(seg1Bytes.Length); + stream.Write(seg1Bytes, 0, seg1Bytes.Length); + + stream.Write(Encoding.ASCII.GetBytes("INFO"), 0, 4); + var tailPayload = Encoding.ASCII.GetBytes("2025-01-01 00:00:00"); + stream.Write(tailPayload, 0, tailPayload.Length); + + stream.Position = 0; + var raw = await new RawContainerReader().ReadAsync(stream); + + Assert.AreEqual("{\"x\":1}", raw.RawJson); + Assert.HasCount(2, raw.Segments); + Assert.AreEqual(4, raw.Segments[0].SampleCount); + Assert.AreEqual(2, raw.Segments[1].SampleCount); + Assert.IsNotNull(raw.Tail); + Assert.AreEqual("INFO2025-01-01 00:00:00", Encoding.ASCII.GetString(raw.Tail!)); + } + + [TestMethod] + public async Task RawContainerReader_Throws_On_InvalidHeader() + { + using var fs = File.OpenRead("testfiles/invalidheader.bin"); + await Assert.ThrowsExactlyAsync(async () => await new RawContainerReader().ReadAsync(fs)); + } + + [TestMethod] + public async Task RawContainerReader_Throws_On_InvalidJsonSize() + { + using var fs = File.OpenRead("testfiles/invalidjsonsize.bin"); + await Assert.ThrowsExactlyAsync(async () => await new RawContainerReader().ReadAsync(fs)); + } + + [TestMethod] + public async Task RawContainerReader_Throws_When_Not_At_Beginning() + { + using var fs = File.OpenRead("testfiles/empty.bin"); + fs.Seek(1, SeekOrigin.Begin); + await Assert.ThrowsExactlyAsync(async () => await new RawContainerReader().ReadAsync(fs)); + } +} diff --git a/OwonBinfileReader.Tests/testfiles/akip.bin b/OwonBinfileReader.Tests/testfiles/akip.bin new file mode 100644 index 0000000..130ee79 Binary files /dev/null and b/OwonBinfileReader.Tests/testfiles/akip.bin differ diff --git a/OwonBinfileReader/Raw/RawBinfile.cs b/OwonBinfileReader/Raw/RawBinfile.cs new file mode 100644 index 0000000..09ccd9d --- /dev/null +++ b/OwonBinfileReader/Raw/RawBinfile.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace OwonBinfileReader.Raw; + +/// +/// Result of parsing the container structure of a `SPBXDS` binfile without interpreting the +/// vendor-specific JSON schema inside it. Useful for vendors/models whose JSON layout differs +/// from the OWON/Hanmatek schema that expects (e.g. AKIP). +/// +public record RawBinfile(string RawJson, IReadOnlyList Segments, byte[]? Tail); diff --git a/OwonBinfileReader/Raw/RawContainerReader.cs b/OwonBinfileReader/Raw/RawContainerReader.cs new file mode 100644 index 0000000..01092a6 --- /dev/null +++ b/OwonBinfileReader/Raw/RawContainerReader.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OwonBinfileReader.Raw; + +/// +/// Parses the outer `SPBXDS` container shared by OWON/Hanmatek and other (e.g. AKIP) oscilloscope +/// binfiles without assuming any particular JSON schema for the metadata. This makes it possible +/// to support vendors whose JSON layout does not understand, by handing +/// the raw JSON text and raw sample segments to a vendor-specific profile for interpretation. +/// +public class RawContainerReader(Encoding? jsonEncoding = null) +{ + private static readonly byte[] _expectedMagic = Encoding.ASCII.GetBytes("SPBXDS"); + private static readonly byte[] _infoMarker = Encoding.ASCII.GetBytes("INFO"); + + private readonly Encoding _jsonEncoding = jsonEncoding ?? Encoding.UTF8; + + public async Task ReadAsync(string path, CancellationToken cancellationToken = default) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return await ReadAsync(stream, cancellationToken); + } + + public async Task ReadAsync(Stream stream, CancellationToken cancellationToken = default) + { + if (!stream.CanSeek) + { + throw new BinfileStreamReaderException("Stream must be seekable."); + } + if (stream.Position != 0) + { + throw new BinfileStreamReaderException("Not at start of the stream."); + } + + var magic = await ReadExactAsync(stream, 6, cancellationToken); + if (!magic.AsSpan().SequenceEqual(_expectedMagic)) + { + throw new BinfileStreamReaderException("Invalid magic header bytes."); + } + + var lengthBytes = await ReadExactAsync(stream, 4, cancellationToken); + var headerSize = BitConverter.ToInt32(lengthBytes, 0); + if (headerSize <= 0 || headerSize > stream.Length - stream.Position) + { + throw new BinfileStreamReaderException("Invalid JSON header length."); + } + + var jsonBytes = await ReadExactAsync(stream, headerSize, cancellationToken); + var rawJson = _jsonEncoding.GetString(jsonBytes); + + var segments = new List(); + byte[]? tail = null; + var index = 0; + + while (true) + { + var remaining = stream.Length - stream.Position; + if (remaining < 4) + { + tail = remaining > 0 ? await ReadExactAsync(stream, (int)remaining, cancellationToken) : null; + break; + } + + var segmentLengthBytes = await ReadExactAsync(stream, 4, cancellationToken); + if (segmentLengthBytes.AsSpan().SequenceEqual(_infoMarker)) + { + var restLength = (int)(stream.Length - stream.Position); + var rest = restLength > 0 ? await ReadExactAsync(stream, restLength, cancellationToken) : []; + tail = [.. segmentLengthBytes, .. rest]; + break; + } + + var segmentLength = BitConverter.ToInt32(segmentLengthBytes, 0); + var remainingAfterLength = stream.Length - stream.Position; + if (segmentLength <= 0 || segmentLength > remainingAfterLength || segmentLength % 2 != 0) + { + var restLength = (int)remainingAfterLength; + var rest = restLength > 0 ? await ReadExactAsync(stream, restLength, cancellationToken) : []; + tail = [.. segmentLengthBytes, .. rest]; + break; + } + + var data = await ReadExactAsync(stream, segmentLength, cancellationToken); + segments.Add(new RawSegment(index++, data)); + } + + return new RawBinfile(rawJson, segments, tail); + } + + private static async Task ReadExactAsync(Stream stream, int count, CancellationToken cancellationToken) + { + var buffer = new byte[count]; + var pos = 0; + while (pos < count) + { + var read = await stream.ReadAsync(buffer, pos, count - pos, cancellationToken); + if (read == 0) + { + throw new MalformedRecordException(typeof(byte[]), stream.Position, count, pos); + } + pos += read; + } + return buffer; + } +} diff --git a/OwonBinfileReader/Raw/RawSegment.cs b/OwonBinfileReader/Raw/RawSegment.cs new file mode 100644 index 0000000..4a71d8e --- /dev/null +++ b/OwonBinfileReader/Raw/RawSegment.cs @@ -0,0 +1,10 @@ +namespace OwonBinfileReader.Raw; + +/// +/// A single, still vendor-agnostic, data segment as found in a binfile: a 4-byte length prefix +/// followed by that many bytes of little-endian 16-bit signed samples. +/// +public record RawSegment(int Index, byte[] Data) +{ + public int SampleCount => Data.Length / 2; +} diff --git a/README.md b/README.md index acd4165..d0f2448 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,37 @@ The actual measurement is each 16 bit value × (CurrentRatio / CurrentRate). This library will normalize values to the base unit. So values in `mV` will be converted to `V`, values in `µs` will be converted to `s`, etc. Where possible values are parsed to enums (e.g. `Coupling` or `Triggermode`), `bools` for `ON` / `OFF` values, timespans etc. Values are represented in the `double` datatype. +## Reading files from other scopes on the same platform (`RawContainerReader`) + +The outer `SPBXDS` container (magic header, JSON length, data segments, optional `INFO` +tail) turns out to be shared by scopes beyond OWON/Hanmatek — e.g. an **AKIP-4122/7** +capture uses the exact same container, but a different JSON schema inside it (lowercase +`channel` array with `Display_Switch`/`Reference_Zero`/`Voltage_Rate` instead of +`CHANNEL`/`DISPLAY`/`Current_Ratio`/`Current_Rate`), and its JSON has a trailing comma +before the array's closing bracket. Both of these make `BinfileReader` throw: + +``` +System.Text.Json.JsonException: The JSON array contains a trailing comma at the end +which is not supported in this mode. Change the reader options. + at OwonBinfileReader.BinfileReader.ReadAsync(Stream stream, ...) +``` + +`RawContainerReader` parses just the container — magic, JSON header (as a raw string, +untouched), one `Int16[]` per data segment, and the optional trailing `INFO` block — without +assuming any particular JSON schema. That lets you handle files whose metadata `BinfileReader` +doesn't recognize, by parsing the (vendor-specific) JSON yourself: + +```c# +using OwonBinfileReader.Raw; + +var raw = await new RawContainerReader().ReadAsync(@"/path/to/file.bin"); +// raw.RawJson -> the metadata JSON, exactly as stored (may need vendor-specific parsing) +// raw.Segments -> one raw Int16[] per data segment, in file order +// raw.Tail -> the optional trailing INFO block, if present +``` + +`BinfileReader` is unchanged and remains the recommended way to read OWON/Hanmatek files. + ## TestApp This repository contains a [TestApp](TestApp) which is a simple console application to demonstrate the library. It scans given directory / directories and reads the binfiles contained and outputs (some of) the metadata to the console and also converts the file into a CSV file.