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.