From 4593d6a29ce7e8f84d73a07b06fb6d41a367a26a Mon Sep 17 00:00:00 2001 From: Alexey Legoshin Date: Thu, 23 Jul 2026 21:24:39 +0300 Subject: [PATCH] Add RawContainerReader: read the SPBXDS container without assuming OWON's JSON schema BinfileReader throws on capture files whose JSON metadata doesn't match the OWON/Hanmatek schema it expects. An AKIP-4122/7 capture, for example, uses the same SPBXDS container but a different JSON layout (lowercase `channel` array, Display_Switch/Reference_Zero/Voltage_Rate instead of CHANNEL/DISPLAY/Current_Ratio/Current_Rate) and includes a trailing comma before the array's closing bracket, which makes System.Text.Json 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. RawContainerReader parses only the vendor-agnostic parts of the container -- magic header, JSON length, JSON text (untouched, no assumptions about its schema), one Int16[] per data segment, and the optional trailing INFO block -- so callers can interpret non-OWON metadata themselves instead of hitting this exception. BinfileReader is untouched; this is purely additive. Includes a real AKIP-4122/7 capture as a test fixture and tests covering both the AKIP and OWON containers, plus the existing error paths. --- .../OwonBinfileReader.Tests.csproj | 3 + .../RawContainerReaderTests.cs | 91 ++++++++++++++ OwonBinfileReader.Tests/testfiles/akip.bin | Bin 0 -> 20702 bytes OwonBinfileReader/Raw/RawBinfile.cs | 10 ++ OwonBinfileReader/Raw/RawContainerReader.cs | 111 ++++++++++++++++++ OwonBinfileReader/Raw/RawSegment.cs | 10 ++ README.md | 31 +++++ 7 files changed, 256 insertions(+) create mode 100644 OwonBinfileReader.Tests/RawContainerReaderTests.cs create mode 100644 OwonBinfileReader.Tests/testfiles/akip.bin create mode 100644 OwonBinfileReader/Raw/RawBinfile.cs create mode 100644 OwonBinfileReader/Raw/RawContainerReader.cs create mode 100644 OwonBinfileReader/Raw/RawSegment.cs 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 0000000000000000000000000000000000000000..130ee79437939bb0658a5275bd8c2da5afedea16 GIT binary patch literal 20702 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 GV*Lm1;e7%C literal 0 HcmV?d00001 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.