Skip to content
Open
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
3 changes: 3 additions & 0 deletions OwonBinfileReader.Tests/OwonBinfileReader.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
<None Update="testfiles\invalidjsonsize.bin">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="testfiles\akip.bin">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
91 changes: 91 additions & 0 deletions OwonBinfileReader.Tests/RawContainerReaderTests.cs
Original file line number Diff line number Diff line change
@@ -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<BinfileStreamReaderException>(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<BinfileStreamReaderException>(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<BinfileStreamReaderException>(async () => await new RawContainerReader().ReadAsync(fs));
}
}
Binary file added OwonBinfileReader.Tests/testfiles/akip.bin
Binary file not shown.
10 changes: 10 additions & 0 deletions OwonBinfileReader/Raw/RawBinfile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;

namespace OwonBinfileReader.Raw;

/// <summary>
/// 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 <see cref="BinfileReader"/> expects (e.g. AKIP).
/// </summary>
public record RawBinfile(string RawJson, IReadOnlyList<RawSegment> Segments, byte[]? Tail);
111 changes: 111 additions & 0 deletions OwonBinfileReader/Raw/RawContainerReader.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <see cref="BinfileReader"/> does not understand, by handing
/// the raw JSON text and raw sample segments to a vendor-specific profile for interpretation.
/// </summary>
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<RawBinfile> 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<RawBinfile> 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<RawSegment>();
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<byte[]> 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;
}
}
10 changes: 10 additions & 0 deletions OwonBinfileReader/Raw/RawSegment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace OwonBinfileReader.Raw;

/// <summary>
/// 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.
/// </summary>
public record RawSegment(int Index, byte[] Data)
{
public int SampleCount => Data.Length / 2;
}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down