Repository: LiberTEM/LiberTEM-rs (language: python)

Issue:
Port our QD MerlinEM support to rust
To make `LiberTEM-live` more uniform, and to improve performance at the same time, our MerlinEM support should be ported to rust to work like our DECTRIS support.

## Tasks

- [x] Build the state machines (background receive thread and the foreground Python interface)
- [x] Make sure we can handle the QD quirks
  - [x] ~~Take care that we only open a single connection, as number of connections is limited~~ it's now less likely we hit issues with this, as the new version keeps the data connection open in the background while the `QdConnection` instance is still alive; only reconnects on errors
  - [x] Improved error handling - in case of an error, just continue to receive data from the detector system, if the error was not encountered in the main receive loop, and possibly just ignore said data (internal state: draining, which doesn't write to shm but only to a fixed buffer?). In active mode, stop the acquisition in case of exceptions?
- [x] Build parsers for the acquisition and frame headers
- [x] Extract some functionality (hopefully most of the "boring" state management that is very similar between the detectors) into a common crate, to make our detector support more uniform.
  - [x] Design towards having traits that are implemented for each detector, and having the high-level code generic over said traits

## Stretch goals

- [ ] Detector control library
  - [ ] low-level `get`/`set`/`cmd`. 
  - [ ] Higher-level: allow to set a desired state, have a `commit` function that sends over the `set` commands and afterwards `get`s them back and checks that the state has been reached (emit warnings for most things, but possibly errors for others - some attributes are supposed to change after setting, others not). Check for ordering constraints etc.
- [x] Port the decoders from our `numba` implementation to rust (can be done incrementally, as the data is put in an encoded state into the queue/shm, so the decoding can continue as it is done currently) - good reason to port is to get rid of jit compilation jitter on the first acquisition.
  - [ ] Implement support for layout options other than `2x2`
  - [x] Proper gap support when using raw format

@matbryan52 @uellue anything I'm missing here? Do you remember other quirks which we need to take care of?


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index ae3eeadc..0bb60433 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -10,6 +10,9 @@ on:
       - 'v[0-9]*'
   pull_request:
 
+env:
+  RUST_BACKTRACE: 1
+
 concurrency: 
   group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
   cancel-in-progress: true
@@ -19,7 +22,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_qd_mpx]
     steps:
     - uses: actions/checkout@v4
       with:
@@ -42,7 +45,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3, ipc_test]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3, libertem_qd_mpx, ipc_test, common]
     steps:
     - uses: actions/checkout@v4
       with:
@@ -62,7 +65,7 @@ jobs:
     runs-on: windows-latest
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_qd_mpx]
     steps:
     - name: Install LLVM and Clang # required for bindgen to work, see https://github.com/rust-lang/rust-bindgen/issues/1797
       uses: KyleMayes/install-llvm-action@v2.0.2
@@ -93,7 +96,7 @@ jobs:
     runs-on: windows-latest
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3, ipc_test]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3, libertem_qd_mpx, ipc_test, common]
     steps:
     - name: Install LLVM and Clang # required for bindgen to work, see https://github.com/rust-lang/rust-bindgen/issues/1797
       uses: KyleMayes/install-llvm-action@v2.0.2
@@ -124,7 +127,7 @@ jobs:
       MACOSX_DEPLOYMENT_TARGET: "11.0"
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_asi_mpx3, libertem_qd_mpx]
         os: [macos-12, macos-14]
     runs-on: ${{ matrix.os }}
     steps:
@@ -150,7 +153,7 @@ jobs:
       MACOSX_DEPLOYMENT_TARGET: "11.0"
     strategy:
       matrix:
-        crate: [libertem_dectris, libertem_asi_tpx3]
+        crate: [libertem_dectris, libertem_asi_tpx3, libertem_qd_mpx]
         target: [x86_64, aarch64]
     steps:
     - name: Set LIBCLANG_PATH
diff --git a/.gitignore b/.gitignore
index 6bab6036..f4051c4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,3 +77,4 @@ perf.data.old
 flamegraph.svg
 
 *.perf
+tarpaulin-report.html
diff --git a/Cargo.lock b/Cargo.lock
index 08a95edd..8edb0f12 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -116,7 +116,7 @@ dependencies = [
  "cfg-if 1.0.0",
  "once_cell",
  "version_check",
- "zerocopy 0.7.32",
+ "zerocopy",
 ]
 
 [[package]]
@@ -152,6 +152,12 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
 
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
 [[package]]
 name = "anstream"
 version = "0.6.11"
@@ -664,6 +670,12 @@ dependencies = [
  "vec_map",
 ]
 
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
 [[package]]
 name = "cc"
 version = "1.0.83"
@@ -726,6 +738,33 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
 [[package]]
 name = "clang-sys"
 version = "1.7.0"
@@ -849,6 +888,26 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "common"
+version = "0.2.12"
+dependencies = [
+ "bincode",
+ "criterion",
+ "ipc-test",
+ "log",
+ "multiversion",
+ "ndarray",
+ "num",
+ "numpy",
+ "pyo3",
+ "serde",
+ "stats",
+ "tempfile",
+ "thiserror",
+ "zerocopy",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "2.4.0"
@@ -916,6 +975,42 @@ dependencies = [
  "cfg-if 1.0.0",
 ]
 
+[[package]]
+name = "criterion"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap 4.4.18",
+ "criterion-plot",
+ "is-terminal",
+ "itertools 0.10.5",
+ "num-traits",
+ "once_cell",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools 0.10.5",
+]
+
 [[package]]
 name = "crossbeam"
 version = "0.8.4"
@@ -1835,6 +1930,7 @@ dependencies = [
  "serde",
  "shared_memory",
  "tempfile",
+ "thiserror",
 ]
 
 [[package]]
@@ -1843,6 +1939,17 @@ version = "2.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
 
+[[package]]
+name = "is-terminal"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b"
+dependencies = [
+ "hermit-abi 0.3.4",
+ "libc",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "itertools"
 version = "0.10.5"
@@ -1852,6 +1959,15 @@ dependencies = [
  "either",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.10"
@@ -1949,27 +2065,30 @@ checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
 
 [[package]]
 name = "libertem-asi-mpx3"
-version = "0.2.11"
+version = "0.2.12"
 dependencies = [
  "bincode",
- "clap 3.2.25",
- "crossbeam",
- "crossbeam-channel",
+ "common",
  "env_logger",
  "ipc-test",
  "log",
+ "num",
+ "numpy",
  "pyo3",
  "serde",
  "serval-client",
  "stats",
  "tempfile",
+ "thiserror",
+ "zerocopy",
 ]
 
 [[package]]
 name = "libertem-asi-tpx3"
-version = "0.2.11"
+version = "0.2.12"
 dependencies = [
  "bincode",
+ "common",
  "crossbeam",
  "crossbeam-channel",
  "env_logger",
@@ -1982,18 +2101,17 @@ dependencies = [
  "stats",
  "tempfile",
  "uuid",
- "zerocopy 0.6.6",
+ "zerocopy",
 ]
 
 [[package]]
 name = "libertem-dectris"
-version = "0.2.11"
+version = "0.2.12"
 dependencies = [
  "bincode",
  "bs_sys",
  "clap 3.2.25",
- "crossbeam",
- "crossbeam-channel",
+ "common",
  "env_logger",
  "ipc-test",
  "log",
@@ -2001,6 +2119,7 @@ dependencies = [
  "md5",
  "memmap2",
  "nix 0.26.4",
+ "num",
  "numpy",
  "pyo3",
  "serde",
@@ -2008,11 +2127,36 @@ dependencies = [
  "spin_sleep",
  "stats",
  "tempfile",
+ "thiserror",
  "uuid",
- "zerocopy 0.6.6",
+ "zerocopy",
  "zmq",
 ]
 
+[[package]]
+name = "libertem_qd_mpx"
+version = "0.2.12"
+dependencies = [
+ "bincode",
+ "common",
+ "criterion",
+ "encoding_rs",
+ "env_logger",
+ "ipc-test",
+ "itertools 0.13.0",
+ "log",
+ "num",
+ "numpy",
+ "pyo3",
+ "rand",
+ "serde",
+ "stats",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "zerocopy",
+]
+
 [[package]]
 name = "libloading"
 version = "0.7.4"
@@ -2068,9 +2212,9 @@ dependencies = [
 
 [[package]]
 name = "log"
-version = "0.4.20"
+version = "0.4.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
 
 [[package]]
 name = "lz4"
@@ -2202,6 +2346,28 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "multiversion"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4851161a11d3ad0bf9402d90ffc3967bf231768bfd7aeb61755ad06dbf1a142"
+dependencies = [
+ "multiversion-macros",
+ "target-features",
+]
+
+[[package]]
+name = "multiversion-macros"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79a74ddee9e0c27d2578323c13905793e91622148f138ba29738f9dddb835e90"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "target-features",
+]
+
 [[package]]
 name = "native-tls"
 version = "0.2.11"
@@ -2241,7 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400"
 dependencies = [
  "indexmap 1.9.3",
- "itertools",
+ "itertools 0.10.5",
  "ndarray",
  "noisy_float",
  "num-integer",
@@ -2375,30 +2541,75 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
 [[package]]
 name = "num-complex"
-version = "0.4.4"
+version = "0.4.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
 dependencies = [
  "num-traits",
 ]
 
 [[package]]
 name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
 version = "0.1.45"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
 dependencies = [
  "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint",
+ "num-integer",
  "num-traits",
 ]
 
 [[package]]
 name = "num-traits"
-version = "0.2.17"
+version = "0.2.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
 dependencies = [
  "autocfg",
 ]
@@ -2540,6 +2751,12 @@ version = "1.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
 
+[[package]]
+name = "oorandom"
+version = "11.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575"
+
 [[package]]
 name = "openssl"
 version = "0.10.64"
@@ -2734,6 +2951,34 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "plotters"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15b6eccb8484002195a3e44fe65a4ce8e93a625797a063735536fd59cb01cf3"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "414cec62c6634ae900ea1c56128dfe87cf63e7caece0852ec76aba307cebadb7"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81b30686a7d9c3e010b84284bdd26a29f2138574f52f5eb6f794fc0ad924e705"
+dependencies = [
+ "plotters-backend",
+]
+
 [[package]]
 name = "png"
 version = "0.17.11"
@@ -2834,9 +3079,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7a8b1990bd018761768d5e608a13df8bd1ac5f678456e0f301bb93e5f3ea16b"
+checksum = "a5e00b96a521718e08e03b1a622f01c8a8deb50719335de3f60b3b3950f069d8"
 dependencies = [
  "cfg-if 1.0.0",
  "indoc",
@@ -2852,9 +3097,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-build-config"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "650dca34d463b6cdbdb02b1d71bfd6eb6b6816afc708faebb3bac1380ff4aef7"
+checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50"
 dependencies = [
  "once_cell",
  "target-lexicon",
@@ -2862,9 +3107,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-ffi"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09a7da8fc04a8a2084909b59f29e1b8474decac98b951d77b80b26dc45f046ad"
+checksum = "01be5843dc60b916ab4dad1dca6d20b9b4e6ddc8e15f50c47fe6d85f1fb97403"
 dependencies = [
  "libc",
  "pyo3-build-config",
@@ -2872,9 +3117,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-macros"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b8a199fce11ebb28e3569387228836ea98110e43a804a530a9fd83ade36d513"
+checksum = "77b34069fc0682e11b31dbd10321cbf94808394c56fd996796ce45217dfac53c"
 dependencies = [
  "proc-macro2",
  "pyo3-macros-backend",
@@ -2884,9 +3129,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-macros-backend"
-version = "0.21.1"
+version = "0.21.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93fbbfd7eb553d10036513cb122b888dcd362a945a00b06c165f2ab480d4cc3b"
+checksum = "08260721f32db5e1a5beae69a55553f56b99bd0e1c3e6e0a5e8851a9d0f5a85c"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -3206,9 +3451,9 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.196"
+version = "1.0.199"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
+checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a"
 dependencies = [
  "serde_derive",
 ]
@@ -3227,9 +3472,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.196"
+version = "1.0.199"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
+checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -3527,6 +3772,12 @@ dependencies = [
  "version-compare",
 ]
 
+[[package]]
+name = "target-features"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
+
 [[package]]
 name = "target-lexicon"
 version = "0.12.13"
@@ -3535,13 +3786,12 @@ checksum = "69758bda2e78f098e4ccb393021a0963bb3442eac05f135c30f61b7370bbafae"
 
 [[package]]
 name = "tempfile"
-version = "3.9.0"
+version = "3.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa"
+checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1"
 dependencies = [
  "cfg-if 1.0.0",
  "fastrand 2.0.1",
- "redox_syscall 0.4.1",
  "rustix 0.38.31",
  "windows-sys 0.52.0",
 ]
@@ -3563,18 +3813,18 @@ checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
 
 [[package]]
 name = "thiserror"
-version = "1.0.56"
+version = "1.0.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad"
+checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.56"
+version = "1.0.61"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
+checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -3627,6 +3877,16 @@ dependencies = [
  "strict-num",
 ]
 
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "tinyvec"
 version = "1.6.0"
@@ -3644,9 +3904,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.36.0"
+version = "1.38.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
+checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df"
 dependencies = [
  "backtrace",
  "bytes",
@@ -4662,39 +4922,19 @@ dependencies = [
 
 [[package]]
 name = "zerocopy"
-version = "0.6.6"
+version = "0.7.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854e949ac82d619ee9a14c66a1b674ac730422372ccb759ce0c39cabcf2bf8e6"
+checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
 dependencies = [
  "byteorder",
- "zerocopy-derive 0.6.6",
-]
-
-[[package]]
-name = "zerocopy"
-version = "0.7.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
-dependencies = [
- "zerocopy-derive 0.7.32",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.6.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.48",
+ "zerocopy-derive",
 ]
 
 [[package]]
 name = "zerocopy-derive"
-version = "0.7.32"
+version = "0.7.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
+checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/Cargo.toml b/Cargo.toml
index 00e890a5..e4020198 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,12 +3,14 @@ members = [
     "libertem_dectris",
     "libertem_asi_tpx3",
     "libertem_asi_mpx3",
+    "libertem_qd_mpx",
     "ipc_test",
     "playegui",
     "bs-sys",
     "stats",
-    "serval-client",
+    "serval-client", "common",
 ]
+resolver = "2"
 
 [profile.release]
 debug = true
diff --git a/README.md b/README.md
index 27dd30eb..f46b73ef 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,10 @@ code in submodules. After cloning, remember to enable pre-commit hooks using
 
 - `bs-sys`: rust bindings to `bitshuffle`.
 - `ipc_test`: internal crate for efficient shared memory communication using a shared slab data structure.
+- `common`: generic traits, types and macros for supporting detectors
 - `libertem_asi_tpx3`: A Rust+Python library for receiving sparse array streams from Amsterdam Scientific Instruments CheeTah TPX3 detectors.
 - `libertem_dectris`: This is a Python package for efficiently receiving data from DECTRIS detectors with the zeromq interface.
+- `libertem_qd_mpx`: A Rust+Python library for receiving data from Quantum Detectors MerlinEM detectors.
 - `playegui`: `egui`-based prototype for efficient on-line visualization of 4D STEM reconstructions
 
 
diff --git a/bs-sys/src/lib.rs b/bs-sys/src/lib.rs
index fe1c0253..2e2843c6 100644
--- a/bs-sys/src/lib.rs
+++ b/bs-sys/src/lib.rs
@@ -67,7 +67,7 @@ pub fn compress_lz4<T>(in_: &[T], block_size: Option<usize>) -> Result<Vec<u8>,
 
     let max_out_size_bytes =
         unsafe { bs_bindings::bshuf_compress_lz4_bound(size_in_elems, elem_size, block_size) };
-    let mut out: Vec<u8> = Vec::with_capacity(usize::try_from(max_out_size_bytes).unwrap());
+    let mut out: Vec<u8> = Vec::with_capacity(max_out_size_bytes);
     let bytes_used = unsafe {
         let c_out = out.as_mut_ptr().cast();
 
@@ -108,8 +108,10 @@ pub fn decompress_lz4<T>(
     let block_size = block_size.unwrap_or(0);
     let mut out: Vec<T> = Vec::with_capacity(out_size);
     let out_ptr: *mut T = out.as_mut_ptr();
-    decompress_lz4_into(in_, out_ptr, out_size, Some(block_size))?;
-    unsafe { out.set_len(out_size) };
+    unsafe {
+        decompress_lz4_into(in_, out_ptr, out_size, Some(block_size))?;
+        out.set_len(out_size)
+    };
     Ok(out)
 }
 
@@ -128,7 +130,7 @@ pub fn decompress_lz4<T>(
 ///
 /// The memory pointed to by `out` must be large enough to fit the output, i.e.
 /// at least `std::mem::size_of::<T> * out_size`.
-pub fn decompress_lz4_into<T>(
+pub unsafe fn decompress_lz4_into<T>(
     in_: &[u8],
     out: *mut T, // FIXME: replace with slice of MaybeUninit from Vec::spare_capacity_mut?
     out_size: usize, // number of elements
diff --git a/common/Cargo.toml b/common/Cargo.toml
new file mode 100644
index 00000000..17f4ebba
--- /dev/null
+++ b/common/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "common"
+authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
+license = "MIT"
+version = "0.2.12"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+bincode = "1.3.3"
+ipc-test = { path = "../ipc_test" }
+stats = { path = "../stats" }
+pyo3 = { version = "0.21.2", features = ["abi3-py37"] }
+numpy = "0.21"
+serde = "1.0.199"
+tempfile = "3.10.1"
+thiserror = "1.0.59"
+log = "0.4.21"
+ndarray = { version = "0.15.6" }
+zerocopy = "0.7.35"
+num = "0.4.3"
+multiversion = "0.7.4"
+
+[dev-dependencies]
+criterion = "0.5.1"
+
+[[bench]]
+name = "casting"
+harness = false
diff --git a/common/README.md b/common/README.md
new file mode 100644
index 00000000..1aa79738
--- /dev/null
+++ b/common/README.md
@@ -0,0 +1,42 @@
+# Contents of this crate, and how to implement support for a new detector system
+
+This is a rough "map" on what is needed, from bottom to top:
+
+- The most detector-specific piece is the background thread, which communicates
+  in some way with the detector system. It's implemented as a background thread
+  so it doesn't interfere with the main Python interpreter, which is running in
+  the same process. If needed, the background thread can itself spawn more threads,
+  for example if receiving the data can be parallelized.
+- The interface to the background thread consists of a `Sender`/`Receiver` pair that
+  communicates using standard messages. These are encapsulated into the
+  `BackgroundThread` type.
+- The messages are handled by the
+  `GenericConnection<BackgroundThread, AcquisitionConfig>`. `GenericConnection`
+  ultimately provides an API to control acquisitions and receive data.
+- `GenericConnection` is rust-only (as `pyo3` bindings can't have type parameters),
+  so there's the macro `impl_py_connection!(...)` which builds the Python-specific
+  wrapper code for one concrete detector system. It also generates a specialized
+  `FrameStackHandle` Python wrapper, which is used to return data back to the user.
+- To access the data, also across process boundaries, there's a `GenericCamClient`,
+  takes a `FrameStackHandle` and allows to access the data referenced by it, for
+  example by decoding it into a user-provided buffer. The Python wrapper for that is
+  generated by the `impl_py_cam_client!(...)` macro.
+- For backwards-compatibility or experimentation, you may want to wrap the
+  generated Python bindings into manually implemented wrappers, where you can
+  provide compatibility- or detector-specific methods. The generated `Connection`
+  type will also need a wrapper type, as that's where the background thread is spawned
+  and detector-specific configuration details are passed down.
+- There are a few adjacent traits that need to be implemented
+  - `FrameMeta` is a central trait that contains per-frame metadata. Each stack of frames
+    also contains a `Vec` of `FrameMeta`, which can be used, for example, in the decoder
+    (see below).
+  - `AcquisitionConfig` - the type returned in the `AcquisitionStart` message,
+    often this contains information from the acquisition header.
+  - `Decoder` - data is often stored in shared memory in encoded or compressed form,
+    which allows to parallelize the decoding process, by running the decode on the
+    process where data is consumed (usually a worker process from a pool). The decoder
+    is used by the `GenericCamClient`. The module `common::decoder` contains a few
+    useful helpers for decoding integers into various pixel formats.
+  - `DetectorConnectionConfig` - this trait encapsulates the configuration needed
+    to start the `GenericConnection` - at minimum, it knows the correct parameters
+    to create the `SharedSlabAllocator`.
diff --git a/common/benches/casting.rs b/common/benches/casting.rs
new file mode 100644
index 00000000..dd620d31
--- /dev/null
+++ b/common/benches/casting.rs
@@ -0,0 +1,150 @@
+//! Explorative benchmark to check the overhead of different casting methods
+//! and different dtype combinations
+
+use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
+use num::{cast::AsPrimitive, NumCast, ToPrimitive};
+
+fn bench_ref_u8(input: &[u8], output: &mut [f32]) {
+    output.iter_mut().zip(input.iter()).for_each(|(dest, src)| {
+        *dest = *src as f32;
+    })
+}
+
+fn bench_ref_u16(input: &[u16], output: &mut [f32]) {
+    output.iter_mut().zip(input.iter()).for_each(|(dest, src)| {
+        *dest = *src as f32;
+    })
+}
+
+fn bench_ref_u32(input: &[u32], output: &mut [f32]) {
+    output.iter_mut().zip(input.iter()).for_each(|(dest, src)| {
+        *dest = *src as f32;
+    })
+}
+
+fn bench_as_primitive<T>(input: &[T], output: &mut [f32])
+where
+    T: AsPrimitive<f32>,
+{
+    output.iter_mut().zip(input.iter()).for_each(|(dest, src)| {
+        *dest = (*src).as_();
+    })
+}
+
+fn bench_checked_cast<T>(input: &[T], output: &mut [f32]) -> Option<()>
+where
+    T: ToPrimitive,
+{
+    for (dest, src) in output.iter_mut().zip(input.iter()) {
+        let converted = (*src).to_f32();
+        if let Some(value) = converted {
+            *dest = value;
+        } else {
+            return None;
+        }
+    }
+
+    Some(())
+}
+
+fn bench_checked_cast_to_int<T>(input: &[T], output: &mut [u32]) -> Option<()>
+where
+    T: ToPrimitive,
+{
+    for (dest, src) in output.iter_mut().zip(input.iter()) {
+        let converted = (*src).to_u32();
+        if let Some(value) = converted {
+            *dest = value;
+        } else {
+            return None;
+        }
+    }
+
+    Some(())
+}
+
+fn bench_checked_cast_to_generic<I, O>(input: &[I], output: &mut [O]) -> Option<()>
+where
+    O: Copy + NumCast,
+    I: Copy + ToPrimitive,
+{
+    for (dest, src) in output.iter_mut().zip(input.iter()) {
+        let converted = NumCast::from(*src);
+        if let Some(value) = converted {
+            *dest = value;
+        } else {
+            return None;
+        }
+    }
+
+    Some(())
+}
+
+pub fn bench_num_casting(c: &mut Criterion) {
+    let input_u8 = (0..(256 * 256))
+        .map(|i| (i % 255) as u8)
+        .collect::<Vec<u8>>();
+    let input_u16 = (0..(256 * 256))
+        .map(|e| (e % 65535) as u16)
+        .collect::<Vec<u16>>();
+    let input_u32 = (0..(256 * 256)).collect::<Vec<u32>>();
+    let mut output = Vec::<f32>::new();
+    output.resize(input_u16.len(), 0.0);
+
+    let mut output_u32 = vec![0; input_u16.len()];
+    //output_u32.resize(input_u16.len(), 0);
+
+    let mut group = c.benchmark_group("cast_to_f32_throughput");
+
+    group.throughput(Throughput::Bytes(
+        (input_u16.len() * std::mem::size_of::<f32>()) as u64,
+    ));
+    // group.throughput(Throughput::Elements((input_u16.len()) as u64));
+
+    group.bench_function("bench_ref_u8", |b| {
+        b.iter(|| bench_ref_u8(black_box(&input_u8[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_ref_u16", |b| {
+        b.iter(|| bench_ref_u16(black_box(&input_u16[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_ref_u32", |b| {
+        b.iter(|| bench_ref_u32(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_as_primitive-u16", |b| {
+        b.iter(|| bench_as_primitive(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_as_primitive-u32", |b| {
+        b.iter(|| bench_as_primitive(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_checked_cast-u16", |b| {
+        b.iter(|| bench_checked_cast(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_checked_cast-u32", |b| {
+        b.iter(|| bench_checked_cast(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.bench_function("bench_checked_cast_to_int-u16", |b| {
+        b.iter(|| bench_checked_cast_to_int(black_box(&input_u32[..]), black_box(&mut output_u32)))
+    });
+
+    group.bench_function("bench_checked_cast_to_generic-u16", |b| {
+        b.iter(|| {
+            bench_checked_cast_to_generic(black_box(&input_u32[..]), black_box(&mut output_u32))
+        })
+    });
+
+    group.bench_function("bench_checked_cast_to_generic-u16-to-f32", |b| {
+        b.iter(|| bench_checked_cast_to_generic(black_box(&input_u32[..]), black_box(&mut output)))
+    });
+
+    group.finish();
+}
+
+criterion_group!(benches, bench_num_casting);
+criterion_main!(benches);
diff --git a/common/src/background_thread.rs b/common/src/background_thread.rs
new file mode 100644
index 00000000..462612e0
--- /dev/null
+++ b/common/src/background_thread.rs
@@ -0,0 +1,81 @@
+use std::{
+    fmt::Debug,
+    io,
+    sync::mpsc::{Receiver, Sender},
+};
+
+use ipc_test::slab::SlabInitError;
+
+use crate::{
+    frame_stack::{FrameMeta, FrameStackHandle},
+    generic_connection::AcquisitionConfig,
+};
+
+/// Messages from the background thread to the foreground code
+#[derive(Debug)]
+pub enum ReceiverMsg<M: FrameMeta, P: AcquisitionConfig> {
+    /// A frame stack has been received and is ready for processing
+    FrameStack { frame_stack: FrameStackHandle<M> },
+
+    /// The acquisition is finished, `frame_stack` contains the remaining frames
+    /// that were received.
+    Finished { frame_stack: FrameStackHandle<M> },
+
+    /// The acquisition was cancelled, as requested
+    /// by `ControlMsg::CancelAcquisition`
+    Cancelled,
+
+    /// A non-recoverable error occurred, the underlying connection
+    /// to the detector system should re-connect.
+    FatalError {
+        error: Box<dyn std::error::Error + 'static + Send + Sync>,
+    },
+
+    /// The receiver is armed for starting an acquisition
+    ReceiverArmed,
+
+    /// The acquisition has started, meaning that we are starting to receive
+    /// data.
+    // FIXME: do we need a separate message for arm vs. really starting to receive data?
+    // these two things are often two distinct states of the receiver
+    AcquisitionStart { pending_acquisition: P },
+}
+
+/// Control messages from the foreground code to the background thread
+#[derive(Debug)]
+pub enum ControlMsg<CM: Debug> {
+    /// Stop processing ASAP
+    StopThread,
+
+    /// Start listening for any acquisitions starting
+    StartAcquisitionPassive,
+
+    /// Cancel the currently running acquisition, if any
+    CancelAcquisition,
+
+    /// Detector-specific control message
+    SpecializedControlMsg { msg: CM },
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum BackgroundThreadSpawnError {
+    #[error("could not spawn background thread: {0}")]
+    SpawnFailed(#[from] io::Error),
+
+    #[error("shm clone/connect failed: {0}")]
+    ShmConnectError(#[from] SlabInitError),
+}
+
+pub trait BackgroundThread {
+    type FrameMetaImpl: FrameMeta;
+    type AcquisitionConfigImpl: AcquisitionConfig;
+    type ExtraControl: Debug;
+
+    fn channel_to_thread(&mut self) -> &mut Sender<ControlMsg<Self::ExtraControl>>;
+
+    fn channel_from_thread(
+        &mut self,
+    ) -> &mut Receiver<ReceiverMsg<Self::FrameMetaImpl, Self::AcquisitionConfigImpl>>;
+
+    fn join(self);
+}
diff --git a/common/src/decoder.rs b/common/src/decoder.rs
new file mode 100644
index 00000000..6d30b550
--- /dev/null
+++ b/common/src/decoder.rs
@@ -0,0 +1,221 @@
+use std::{any::type_name, fmt::Debug, mem::size_of};
+
+use ipc_test::SharedSlabAllocator;
+use ndarray::ArrayViewMut3;
+use num::{cast::AsPrimitive, Bounded, Num, NumCast, ToPrimitive};
+use zerocopy::{AsBytes, FromBytes};
+
+use crate::frame_stack::{FrameMeta, FrameStackHandle};
+
+pub trait DecoderTargetPixelType:
+    AsBytes + FromBytes + NumCast + Copy + Debug + Bounded + Num + 'static
+{
+}
+
+impl DecoderTargetPixelType for u8 {}
+impl DecoderTargetPixelType for u16 {}
+impl DecoderTargetPixelType for u32 {}
+impl DecoderTargetPixelType for u64 {}
+
+impl DecoderTargetPixelType for i8 {}
+impl DecoderTargetPixelType for i16 {}
+impl DecoderTargetPixelType for i32 {}
+impl DecoderTargetPixelType for i64 {}
+
+impl DecoderTargetPixelType for f32 {}
+impl DecoderTargetPixelType for f64 {}
+
+pub trait Decoder: Default {
+    type FrameMeta: FrameMeta;
+
+    /// Decode a range designated by `start_idx` and `end_idx` of `input` in
+    /// `shm` into `output`, converting the data to `T` if safely possible,
+    /// returning an error otherwise.
+    ///
+    /// Note that the end index is exclusive, like the range
+    /// `start_idx..end_idx`, and these indices are independent of the indices
+    /// into `output`. For example, `decode(shm, input, output, 1, 2)` will decode
+    /// the second frame from the frame stack into `output[0, :, :]` (using numpy
+    /// slicing notation here)
+    fn decode<T>(
+        &self,
+        shm: &SharedSlabAllocator,
+        input: &FrameStackHandle<Self::FrameMeta>,
+        output: &mut ArrayViewMut3<'_, T>,
+        start_idx: usize,
+        end_idx: usize,
+    ) -> Result<(), DecoderError>
+    where
+        T: DecoderTargetPixelType,
+        u8: AsPrimitive<T>,
+        u16: AsPrimitive<T>;
+
+    fn zero_copy_available(
+        &self,
+        handle: &FrameStackHandle<Self::FrameMeta>,
+    ) -> Result<bool, DecoderError>;
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum DecoderError {
+    #[error("decoding of frame failed: {msg}")]
+    FrameDecodeFailed { msg: String },
+}
+
+pub fn try_cast_if_safe<I, O>(input: &[I], output: &mut [O]) -> Result<(), DecoderError>
+where
+    O: Copy + NumCast + Debug,
+    I: Copy + ToPrimitive + Debug,
+{
+    for (dest, src) in output.iter_mut().zip(input.iter()) {
+        *dest = try_cast_primitive(*src)?;
+    }
+
+    Ok(())
+}
+
+/// Helper function to cast a primitive value or emit a `DecoderError` if it's
+/// not safely possible.
+#[inline(always)]
+pub fn try_cast_primitive<O, I>(input_value: I) -> Result<O, DecoderError>
+where
+    O: Copy + ToPrimitive + NumCast + Debug,
+    I: ToPrimitive + Debug + Copy,
+{
+    if let Some(value) = NumCast::from(input_value) {
+        Ok(value)
+    } else {
+        Err(DecoderError::FrameDecodeFailed {
+            msg: format!(
+                "dtype conversion error: {input_value:?} does not fit {0}",
+                type_name::<O>()
+            ),
+        })
+    }
+}
+
+pub fn decode_ints_be<'a, O, ItemType>(
+    input: &'a [u8],
+    output: &mut [O],
+) -> Result<(), DecoderError>
+where
+    O: DecoderTargetPixelType,
+    ItemType: ToPrimitive + Debug + Copy + num::traits::FromBytes,
+    &'a <ItemType as num::traits::FromBytes>::Bytes: std::convert::TryFrom<&'a [u8]>,
+    <&'a <ItemType as num::traits::FromBytes>::Bytes as std::convert::TryFrom<&'a [u8]>>::Error:
+        Debug,
+    <ItemType as num::traits::FromBytes>::Bytes: 'a,
+{
+    if input.len() % std::mem::size_of::<ItemType>() != 0 {
+        return Err(DecoderError::FrameDecodeFailed {
+            msg: format!(
+                "input length {} is not divisible by item size {}",
+                input.len(),
+                std::mem::size_of::<ItemType>(),
+            ),
+        });
+    }
+
+    if input.len() / std::mem::size_of::<ItemType>() != output.len() {
+        return Err(DecoderError::FrameDecodeFailed {
+            msg: format!(
+                "input length {} does not match output size {} for type {} item size {}",
+                input.len(),
+                output.len(),
+                type_name::<ItemType>(),
+                size_of::<ItemType>(),
+            ),
+        });
+    }
+
+    let chunks = input.chunks_exact(std::mem::size_of::<ItemType>());
+    for (in_chunk, out_dest) in chunks.zip(output.iter_mut()) {
+        let swapped = ItemType::from_be_bytes(in_chunk.try_into().expect("chunked"));
+        *out_dest = try_cast_primitive(swapped)?;
+    }
+
+    Ok(())
+}
+
+#[cfg(test)]
+mod test {
+    use std::io::Write;
+
+    use super::{decode_ints_be, try_cast_if_safe};
+
+    #[test]
+    fn test_decode_ints_be() {
+        let orig = (0..65535).collect::<Vec<u16>>();
+        let mut encoded = Vec::<u8>::new();
+
+        for i in orig.iter() {
+            encoded.write_all(&i.to_be_bytes()).unwrap();
+        }
+
+        // decode u16be from `orig` into different integer and float formats:
+        let mut result = vec![0u16; 65535];
+        decode_ints_be::<_, u16>(&encoded, &mut result).unwrap();
+        for (a, b) in orig.iter().zip(result.iter()) {
+            assert_eq!(a, b);
+        }
+
+        let mut result = vec![0u32; 65535];
+        decode_ints_be::<_, u16>(&encoded, &mut result).unwrap();
+        for (a, b) in orig.iter().zip(result.iter()) {
+            assert_eq!(*a as u32, *b);
+        }
+
+        let mut result = vec![0u64; 65535];
+        decode_ints_be::<_, u16>(&encoded, &mut result).unwrap();
+        for (a, b) in orig.iter().zip(result.iter()) {
+            assert_eq!(*a as u64, *b);
+        }
+
+        let mut result = vec![0.0f32; 65535];
+        decode_ints_be::<_, u16>(&encoded, &mut result).unwrap();
+        for (a, b) in orig.iter().zip(result.iter()) {
+            assert_eq!(*a as f32, *b);
+        }
+    }
+
+    #[test]
+    fn test_try_cast_u8() {
+        let orig = (0..255).collect::<Vec<u8>>();
+
+        let mut output = vec![0u8; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a, *b);
+        }
+
+        let mut output = vec![0u16; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a as u16, *b);
+        }
+
+        let mut output = vec![0u32; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a as u32, *b);
+        }
+
+        let mut output = vec![0u64; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a as u64, *b);
+        }
+
+        let mut output = vec![0f32; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a as f32, *b);
+        }
+
+        let mut output = vec![0f64; 255];
+        try_cast_if_safe(&orig, &mut output).unwrap();
+        for (a, b) in orig.iter().zip(output.iter()) {
+            assert_eq!(*a as f64, *b);
+        }
+    }
+}
diff --git a/common/src/frame_stack.rs b/common/src/frame_stack.rs
new file mode 100644
index 00000000..fc92bb7f
--- /dev/null
+++ b/common/src/frame_stack.rs
@@ -0,0 +1,627 @@
+use std::fmt::Debug;
+
+use ipc_test::{SharedSlabAllocator, SlotForWriting};
+use log::{error, warn};
+use pyo3::{
+    exceptions::{PyRuntimeError, PyValueError},
+    PyErr,
+};
+use serde::Serialize;
+
+pub trait FrameMeta: Clone + Serialize + Debug {
+    /// Length of the data that belongs to the frame corresponding to this meta object
+    fn get_data_length_bytes(&self) -> usize;
+
+    /// numpy-like dtype of the data as string, including endianess
+    /// (this is supposed to be the dtype closest to the raw data; the actual
+    /// data in the frame stack may be encoded and/or compressed)
+    fn get_dtype_string(&self) -> String;
+
+    /// 2D shape of a single frame (assumes same shape for all frames)
+    fn get_shape(&self) -> (u64, u64);
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum FrameStackError {
+    #[error("could not serialize / deserialize: {0}")]
+    SerdeError(#[from] bincode::Error),
+}
+
+impl From<FrameStackError> for PyErr {
+    fn from(value: FrameStackError) -> Self {
+        match value {
+            FrameStackError::SerdeError(e) => PyRuntimeError::new_err(e.to_string()),
+        }
+    }
+}
+
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum FrameStackWriteError {
+    #[error("will not construct empty FrameStackHandle")]
+    Empty,
+
+    #[error("expected empty FrameStackForWriting")]
+    NonEmpty,
+
+    #[error("too small")]
+    TooSmall,
+}
+
+impl From<FrameStackWriteError> for PyErr {
+    fn from(value: FrameStackWriteError) -> Self {
+        match value {
+            FrameStackWriteError::Empty | FrameStackWriteError::NonEmpty => {
+                PyValueError::new_err(value.to_string())
+            }
+            FrameStackWriteError::TooSmall => {
+                PyValueError::new_err("frame stack too small to handle single frame")
+            }
+        }
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum SplitError<M: FrameMeta> {
+    #[error("shm full")]
+    ShmFull(FrameStackHandle<M>),
+}
+
+pub struct FrameStackForWriting<M>
+where
+    M: FrameMeta,
+{
+    slot: SlotForWriting,
+    meta: Vec<M>,
+
+    /// where in the slot do the frames begin? this can be unevenly spaced
+    offsets: Vec<usize>,
+
+    /// offset where the next frame will be written
+    cursor: usize,
+
+    /// number of bytes reserved for each frame
+    /// as some frames compress better or worse, this is just the "planning" number,
+    /// and not a guarantee!
+    bytes_per_frame: usize,
+}
+
+impl<M> FrameStackForWriting<M>
+where
+    M: FrameMeta,
+{
+    pub fn new(slot: SlotForWriting, capacity: usize, bytes_per_frame: usize) -> Self {
+        FrameStackForWriting {
+            slot,
+            cursor: 0,
+            bytes_per_frame,
+            // reserve a bit more, as we don't know the upper bound of frames
+            // per stack and using a bit more memory is better than having to
+            // resize the vectors
+            meta: Vec::with_capacity(2 * capacity),
+            offsets: Vec::with_capacity(2 * capacity),
+        }
+    }
+
+    /// number of frames already written
+    pub fn len(&self) -> usize {
+        self.meta.len()
+    }
+
+    pub fn get_cursor(&self) -> usize {
+        self.cursor
+    }
+
+    pub fn can_fit(&self, num_bytes: usize) -> bool {
+        self.slot.size - self.cursor >= num_bytes
+    }
+
+    pub fn should_fit(&self, num_bytes: usize) -> Result<(), FrameStackWriteError> {
+        if self.can_fit(num_bytes) {
+            Ok(())
+        } else {
+            Err(FrameStackWriteError::TooSmall)
+        }
+    }
+
+    pub fn slot_size(&self) -> usize {
+        self.slot.size
+    }
+
+    pub fn bytes_free(&self) -> usize {
+        self.slot.size - self.cursor
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// Temporarily take mutable ownership of a buffer
+    /// for a single frame, and receive the data into it.
+    pub fn write_frame<E>(
+        &mut self,
+        meta: &M,
+        mut fill_buffer: impl FnMut(&mut [u8]) -> Result<(), E>,
+    ) -> Result<(), E> {
+        let start = self.cursor;
+        let stop = start + meta.get_data_length_bytes();
+        let dest = &mut self.slot.as_slice_mut()[start..stop];
+        fill_buffer(dest)?;
+
+        self.meta.push(meta.clone());
+        self.offsets.push(self.cursor);
+        self.cursor += meta.get_data_length_bytes();
+
+        Ok(())
+    }
+
+    pub fn writing_done(
+        self,
+        shm: &mut SharedSlabAllocator,
+    ) -> Result<FrameStackHandle<M>, FrameStackWriteError> {
+        if self.is_empty() {
+            let slot_info = shm.writing_done(self.slot);
+            shm.free_idx(slot_info.slot_idx);
+            return Err(FrameStackWriteError::Empty);
+        }
+
+        let slot_info = shm.writing_done(self.slot);
+
+        Ok(FrameStackHandle::new(
+            slot_info,
+            self.meta,
+            self.offsets,
+            self.bytes_per_frame,
+        ))
+    }
+
+    pub fn free_empty_frame_stack(
+        self,
+        shm: &mut SharedSlabAllocator,
+    ) -> Result<(), FrameStackWriteError> {
+        if self.is_empty() {
+            let slot_info = shm.writing_done(self.slot);
+            shm.free_idx(slot_info.slot_idx);
+            Ok(())
+        } else {
+            Err(FrameStackWriteError::NonEmpty)
+        }
+    }
+}
+
+// inner mod to enforce invariants via constructor
+mod inner {
+    use ipc_test::{slab::ShmError, SharedSlabAllocator, Slot, SlotInfo};
+    use serde::{Deserialize, Serialize};
+    use stats::GetStats;
+
+    use super::{FrameMeta, FrameStackError, SplitError};
+
+    /// serializable handle for a stack of frames that live in shm
+    #[derive(PartialEq, Eq, Serialize, Deserialize, Debug)]
+    pub struct FrameStackHandle<M>
+    where
+        M: FrameMeta,
+    {
+        pub(crate) slot: SlotInfo,
+        meta: Vec<M>,
+        pub(crate) offsets: Vec<usize>,
+        pub(crate) bytes_per_frame: usize,
+    }
+
+    impl<M: FrameMeta> FrameStackHandle<M> {
+        pub fn new(
+            slot: SlotInfo,
+            meta: Vec<M>,
+            offsets: Vec<usize>,
+            bytes_per_frame: usize,
+        ) -> Self {
+            assert!(meta.len() == offsets.len());
+            assert!(!meta.is_empty());
+            Self {
+                slot,
+                meta,
+                offsets,
+                bytes_per_frame,
+            }
+        }
+
+        pub fn len(&self) -> usize {
+            self.meta.len()
+        }
+
+        #[must_use]
+        pub fn is_empty(&self) -> bool {
+            self.len() == 0
+        }
+
+        /// total number of _useful_ bytes in this frame stack
+        pub fn payload_size(&self) -> usize {
+            self.meta.iter().map(|fm| fm.get_data_length_bytes()).sum()
+        }
+
+        /// total number of bytes allocated for the slot
+        pub fn slot_size(&self) -> usize {
+            self.slot.size
+        }
+
+        /// Get the underlying shared memory slot information
+        ///
+        /// # Safety
+        /// You must not reference the memory of the slot after it has been
+        /// returned to the shm pool using `free_slot`! If possible, use
+        /// `with_slot` instead.
+        pub unsafe fn get_slot(&self) -> &SlotInfo {
+            &self.slot
+        }
+
+        pub fn get_meta(&self) -> &Vec<M> {
+            &self.meta
+        }
+
+        pub fn deserialize_impl(serialized: &[u8]) -> Result<Self, FrameStackError>
+        where
+            M: for<'a> Deserialize<'a>,
+        {
+            Ok(bincode::deserialize(serialized)?)
+        }
+
+        pub fn serialize(&self) -> Result<Vec<u8>, FrameStackError> {
+            Ok(bincode::serialize(self)?)
+        }
+
+        pub fn get_slice_for_frame<'a>(
+            &'a self,
+            frame_idx: usize,
+            slot: &'a ipc_test::Slot,
+        ) -> &[u8] {
+            let slice = slot.as_slice();
+            let in_offset = self.offsets[frame_idx];
+            let size = self.meta[frame_idx].get_data_length_bytes();
+            &slice[in_offset..in_offset + size]
+        }
+
+        /// Split self at `mid` and create two new `FrameStackHandle`s.
+        /// The first will contain frames with indices [0..mid), the second [mid..len)`
+        pub fn split_at(
+            self,
+            mid: usize,
+            shm: &mut SharedSlabAllocator,
+        ) -> Result<(Self, Self), SplitError<M>> {
+            // FIXME: write this in a safer way
+            let bytes_mid = self.offsets[mid];
+            let (left, right) = {
+                let slot: ipc_test::Slot = shm.get(self.slot.slot_idx);
+                let slice = slot.as_slice();
+
+                let mut slot_left = match shm.try_get_mut() {
+                    Ok(s) => s,
+                    Err(ShmError::NoSlotAvailable) => return Err(SplitError::ShmFull(self)),
+                };
+                let mut slot_right = match shm.try_get_mut() {
+                    Ok(s) => s,
+                    Err(ShmError::NoSlotAvailable) => {
+                        // don't leak the left slot!
+                        let l = shm.writing_done(slot_left);
+                        shm.free_idx(l.slot_idx);
+                        return Err(SplitError::ShmFull(self));
+                    }
+                };
+
+                let slice_left = slot_left.as_slice_mut();
+                let slice_right = slot_right.as_slice_mut();
+
+                slice_left[..bytes_mid].copy_from_slice(&slice[..bytes_mid]);
+                slice_right[..(slice.len() - bytes_mid)].copy_from_slice(&slice[bytes_mid..]);
+
+                let left = shm.writing_done(slot_left);
+                let right = shm.writing_done(slot_right);
+
+                shm.free_idx(self.slot.slot_idx);
+
+                (left, right)
+            };
+
+            let (left_meta, right_meta) = self.meta.split_at(mid);
+            let (left_offsets, right_offsets) = self.offsets.split_at(mid);
+
+            Ok((
+                FrameStackHandle::new(
+                    left,
+                    left_meta.to_vec(),
+                    left_offsets.to_vec(),
+                    self.bytes_per_frame,
+                ),
+                FrameStackHandle::new(
+                    right,
+                    right_meta.to_vec(),
+                    right_offsets.iter().map(|o| o - bytes_mid).collect(),
+                    self.bytes_per_frame,
+                ),
+            ))
+        }
+
+        pub fn first_meta(&self) -> &M {
+            self.meta
+                .first()
+                .expect("FrameStackHandle is non-empty by design")
+        }
+
+        pub fn with_slot<T>(&self, shm: &SharedSlabAllocator, mut f: impl FnMut(&Slot) -> T) -> T {
+            let slot_r = shm.get(self.slot.slot_idx);
+            f(&slot_r)
+        }
+
+        pub fn free_slot(self, shm: &mut SharedSlabAllocator) {
+            shm.free_idx(self.slot.slot_idx);
+        }
+    }
+
+    impl<M: FrameMeta> GetStats for FrameStackHandle<M> {
+        fn payload_size(&self) -> usize {
+            self.payload_size()
+        }
+
+        fn slot_size(&self) -> usize {
+            self.slot_size()
+        }
+
+        fn max_frame_size(&self, old_max: usize) -> usize {
+            self.get_meta()
+                .iter()
+                .max_by_key(|fm| fm.get_data_length_bytes())
+                .map_or(old_max, |fm| fm.get_data_length_bytes())
+        }
+
+        fn min_frame_size(&self, old_min: usize) -> usize {
+            self.get_meta()
+                .iter()
+                .min_by_key(|fm| fm.get_data_length_bytes())
+                .map_or(old_min, |fm| fm.get_data_length_bytes())
+        }
+
+        fn num_frames(&self) -> usize {
+            self.len()
+        }
+    }
+}
+
+pub use inner::FrameStackHandle;
+
+pub struct WriteGuard<'b, M: FrameMeta> {
+    for_writing: Option<FrameStackForWriting<M>>,
+    shm: &'b mut SharedSlabAllocator,
+}
+
+impl<'b, M: FrameMeta> WriteGuard<'b, M> {
+    pub fn new(frame_stack: FrameStackForWriting<M>, shm: &'b mut SharedSlabAllocator) -> Self {
+        Self {
+            for_writing: Some(frame_stack),
+            shm,
+        }
+    }
+
+    pub fn free_empty_frame_stack(mut self) -> Result<(), FrameStackWriteError> {
+        let inner = self.for_writing.take().expect(
+            "only `drop`, `free_empty_frame_stack` and `writing_done` take `for_writing`, why is it `None`?"
+        );
+        inner.free_empty_frame_stack(self.shm)
+    }
+
+    pub fn writing_done(mut self) -> Result<FrameStackHandle<M>, FrameStackWriteError> {
+        let inner = self.for_writing.take().expect(
+            "only `drop`, `free_empty_frame_stack` and `writing_done` take `for_writing`, why is it `None`?"
+        );
+        inner.writing_done(self.shm)
+    }
+
+    pub fn write_frame<E>(
+        &mut self,
+        meta: &M,
+        fill_buffer: impl FnMut(&mut [u8]) -> Result<(), E>,
+    ) -> Result<(), E> {
+        if let Some(inner) = &mut self.for_writing {
+            inner.write_frame(meta, fill_buffer)
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn can_fit(&self, num_bytes: usize) -> bool {
+        if let Some(inner) = &self.for_writing {
+            inner.can_fit(num_bytes)
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn should_fit(&self, num_bytes: usize) -> Result<(), FrameStackWriteError> {
+        if let Some(inner) = &self.for_writing {
+            inner.should_fit(num_bytes)
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        if let Some(inner) = &self.for_writing {
+            inner.len()
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn bytes_free(&self) -> usize {
+        if let Some(inner) = &self.for_writing {
+            inner.bytes_free()
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn slot_size(&self) -> usize {
+        if let Some(inner) = &self.for_writing {
+            inner.slot_size()
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        if let Some(inner) = &self.for_writing {
+            inner.is_empty()
+        } else {
+            panic!("must not take without consuming self or dropping");
+        }
+    }
+
+    pub fn take(mut self) -> Option<FrameStackForWriting<M>> {
+        self.for_writing.take()
+    }
+}
+
+impl<'b, M: FrameMeta> Drop for WriteGuard<'b, M> {
+    fn drop(&mut self) {
+        // if there still is a frame stack in here on drop, we free it using `shm`:
+        if let Some(frame_stack) = self.for_writing.take() {
+            if frame_stack.is_empty() {
+                // we can't handle errors in drop, so best we can do is log and continue:
+                if let Err(e) = frame_stack.free_empty_frame_stack(self.shm) {
+                    warn!("WriteGuard::drop for empty frame stack failed: {e:?}");
+                }
+            } else {
+                // we can't handle errors in drop, so best we can do is log and continue:
+                match frame_stack.writing_done(self.shm) {
+                    Ok(frame_stack) => {
+                        warn!("discarding non-empty frame stack as result of previous errors");
+                        frame_stack.free_slot(self.shm);
+                    }
+                    Err(e) => error!("WriteGuard::drop failed: {e:?}"),
+                }
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{io::Write, path::PathBuf};
+
+    use ipc_test::{SharedSlabAllocator, Slot};
+    use serde::{Deserialize, Serialize};
+    use tempfile::{tempdir, TempDir};
+
+    use crate::frame_stack::FrameStackForWriting;
+
+    use super::FrameMeta;
+
+    #[derive(Serialize, Deserialize, Clone, Debug)]
+    struct MyMeta {
+        data_length: usize,
+    }
+
+    impl FrameMeta for MyMeta {
+        fn get_data_length_bytes(&self) -> usize {
+            self.data_length
+        }
+
+        fn get_dtype_string(&self) -> String {
+            "uint8".to_string()
+        }
+
+        fn get_shape(&self) -> (u64, u64) {
+            // this is a weird detector, yeah
+            (16, 32)
+        }
+    }
+
+    fn get_socket_path() -> (TempDir, PathBuf) {
+        let socket_dir = tempdir().unwrap();
+        let socket_as_path = socket_dir.path().join("stuff.socket");
+
+        (socket_dir, socket_as_path)
+    }
+
+    #[test]
+    fn test_frame_stack() {
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 1, 256);
+        let meta = MyMeta { data_length: 1 };
+        assert_eq!(fs.cursor, 0);
+        fs.write_frame(&meta, |mut buf| buf.write_all(&[42]))
+            .unwrap();
+        assert_eq!(fs.cursor, 1);
+
+        let _fs_handle = fs.writing_done(&mut shm);
+    }
+
+    #[test]
+    fn test_split_frame_stack_handle() {
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        // need at least three slots: one is the source, two for the results.
+        let mut shm = SharedSlabAllocator::new(3, 4096, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 2, 16);
+        assert_eq!(fs.cursor, 0);
+        let meta = MyMeta { data_length: 16 };
+        fs.write_frame(&meta, |mut buf| {
+            buf.write_all(&[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
+        })
+        .unwrap();
+        assert_eq!(fs.cursor, 16);
+        fs.write_frame(&meta, |mut buf| {
+            buf.write_all(&[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
+        })
+        .unwrap();
+        assert_eq!(fs.cursor, 32);
+        assert_eq!(fs.offsets, vec![0, 16]);
+
+        println!("{:?}", fs.slot.as_slice());
+
+        let fs_handle = fs.writing_done(&mut shm).unwrap();
+
+        let slot_r = shm.get(fs_handle.slot.slot_idx);
+        let slice_0 = fs_handle.get_slice_for_frame(0, &slot_r);
+        assert_eq!(slice_0.len(), 16);
+        for &elem in slice_0 {
+            assert_eq!(elem, 1);
+        }
+        let slice_1 = fs_handle.get_slice_for_frame(1, &slot_r);
+        assert_eq!(slice_1.len(), 16);
+        for &elem in slice_1 {
+            assert_eq!(elem, 2);
+        }
+
+        let old_meta_len = fs_handle.get_meta().len();
+
+        let (a, b) = fs_handle.split_at(1, &mut shm).unwrap();
+
+        let slot_a: Slot = shm.get(a.slot.slot_idx);
+        let slot_b: Slot = shm.get(b.slot.slot_idx);
+        let slice_a = &slot_a.as_slice()[..16];
+        let slice_b = &slot_b.as_slice()[..16];
+        println!("{:?}", slice_a);
+        println!("{:?}", slice_b);
+        for &elem in slice_a {
+            assert_eq!(elem, 1);
+        }
+        for &elem in slice_b {
+            assert_eq!(elem, 2);
+        }
+        assert_eq!(slice_a, a.get_slice_for_frame(0, &slot_a));
+        assert_eq!(slice_b, b.get_slice_for_frame(0, &slot_b));
+
+        assert_eq!(a.get_meta().len() + b.get_meta().len(), old_meta_len);
+        assert_eq!(a.offsets.len() + b.offsets.len(), 2);
+
+        // when the split is done, there should be one free shm slot:
+        assert_eq!(shm.num_slots_free(), 1);
+
+        // and we can free them again:
+        shm.free_idx(a.slot.slot_idx);
+        shm.free_idx(b.slot.slot_idx);
+
+        assert_eq!(shm.num_slots_free(), 3);
+    }
+}
diff --git a/common/src/generic_cam_client.rs b/common/src/generic_cam_client.rs
new file mode 100644
index 00000000..ee750694
--- /dev/null
+++ b/common/src/generic_cam_client.rs
@@ -0,0 +1,177 @@
+use std::fmt::Debug;
+
+use ipc_test::{slab::SlabInitError, SharedSlabAllocator};
+use multiversion::multiversion;
+use ndarray::ArrayViewMut3;
+use num::cast::AsPrimitive;
+
+use crate::{
+    decoder::{Decoder, DecoderError, DecoderTargetPixelType},
+    frame_stack::{FrameMeta, FrameStackHandle},
+};
+
+#[derive(thiserror::Error, Debug)]
+pub enum CamClientError {
+    #[error("failed to connect to {handle_path}: {error}")]
+    ConnectError {
+        handle_path: String,
+        error: SlabInitError,
+    },
+
+    #[error("operation on closed client")]
+    Closed,
+
+    #[error("handle is already free'd")]
+    HandleAlreadyFree,
+
+    #[error("decode failed: {0}")]
+    DecodeError(#[from] DecoderError),
+}
+
+pub struct GenericCamClient<D>
+where
+    D: Decoder,
+{
+    shm: Option<SharedSlabAllocator>,
+    decoder: D,
+}
+
+#[multiversion(targets(
+    "x86_64+adx+aes+avx+avx2+bmi1+bmi2+cmpxchg16b+f16c+fma+fxsr+lzcnt+movbe+pclmulqdq+popcnt+rdrand+rdseed+sha+sse+sse2+sse3+sse4.1+sse4.2+ssse3+xsave+xsavec+xsaveopt+xsaves",
+    "x86_64+avx+avx2+bmi1+bmi2+fma+sse+sse2+sse3+sse4.1+sse4.2+ssse3+popcnt",
+    "x86_64+avx+avx2",
+    "x86_64+avx",
+))]
+fn decode_multi_version<D, T>(
+    decoder: &D,
+    shm: &SharedSlabAllocator,
+    input: &FrameStackHandle<D::FrameMeta>,
+    dest: &mut ArrayViewMut3<'_, T>,
+    start_idx: usize,
+    end_idx: usize,
+) -> Result<(), CamClientError>
+where
+    D: Decoder,
+    T: DecoderTargetPixelType,
+    u8: AsPrimitive<T>,
+    u16: AsPrimitive<T>,
+{
+    Ok(decoder.decode(shm, input, dest, start_idx, end_idx)?)
+}
+
+/// Client for reading dense data from SHM. That means we get the data as stacks
+/// of 2D frames, which either already are strided arrays, or can be decoded
+/// into strided arrays.
+impl<D> GenericCamClient<D>
+where
+    D: Decoder,
+{
+    pub fn new(handle_path: &str) -> Result<Self, CamClientError> {
+        match SharedSlabAllocator::connect(handle_path) {
+            Ok(shm) => Ok(Self {
+                shm: Some(shm),
+                decoder: Default::default(),
+            }),
+            Err(e) => Err(CamClientError::ConnectError {
+                handle_path: handle_path.to_owned(),
+                error: e,
+            }),
+        }
+    }
+
+    pub fn get_shm(&self) -> Result<&SharedSlabAllocator, CamClientError> {
+        match &self.shm {
+            Some(shm) => Ok(shm),
+            None => Err(CamClientError::Closed),
+        }
+    }
+
+    fn get_shm_mut(&mut self) -> Result<&mut SharedSlabAllocator, CamClientError> {
+        match &mut self.shm {
+            Some(shm) => Ok(shm),
+            None => Err(CamClientError::Closed),
+        }
+    }
+
+    /// Is the data already in a native integer/float format which we can
+    /// directly use from numpy? Also requires the data to be in a C-contiguous layout.
+    ///
+    /// In case it is available, use `get_array_zero_copy` to get access to the
+    /// array.
+    pub fn zero_copy_available(
+        &self,
+        handle: &FrameStackHandle<D::FrameMeta>,
+    ) -> Result<bool, CamClientError> {
+        Ok(self.decoder.zero_copy_available(handle)?)
+    }
+
+    /// Get an array of the whole frame stack as a C-contiguous array.
+    ///
+    /// This requires that the data is already layed out as a C-contiguous array
+    /// in the `FrameStackHandle`.
+    pub fn get_array_zero_copy<M>(
+        &self,
+        _handle: &FrameStackHandle<M>,
+    ) -> Result<(), CamClientError>
+    where
+        M: FrameMeta,
+    {
+        // FIXME: we need to make sure we only loan out the buffer underlying `handle` as long as
+        // the `FrameStackHandle` is valid
+        todo!("implement get_array_zero_copy; delegate to decoder!");
+    }
+
+    /// Decode into a pre-allocated array.
+    ///
+    /// This supports user-allocated memory, which enables things like copying
+    /// directly into CUDA locked host memory and thus getting rid of a memcpy
+    /// in the case of CUDA.
+    pub fn decode_into_buffer<T>(
+        &self,
+        input: &FrameStackHandle<D::FrameMeta>,
+        dest: &mut ArrayViewMut3<'_, T>,
+    ) -> Result<(), CamClientError>
+    where
+        T: DecoderTargetPixelType,
+        u8: AsPrimitive<T>,
+        u16: AsPrimitive<T>,
+    {
+        self.decode_range_into_buffer(input, dest, 0, input.len())
+    }
+
+    /// Decode a range of frames into a pre-allocated array.
+    ///
+    /// This allows for decoding only the data that will be processed
+    /// immediately afterwards, allowing for more cache-efficient operations.
+    pub fn decode_range_into_buffer<T>(
+        &self,
+        input: &FrameStackHandle<D::FrameMeta>,
+        dest: &mut ArrayViewMut3<'_, T>,
+        start_idx: usize,
+        end_idx: usize,
+    ) -> Result<(), CamClientError>
+    where
+        T: DecoderTargetPixelType,
+        u8: AsPrimitive<T>,
+        u16: AsPrimitive<T>,
+    {
+        let shm = self.get_shm()?;
+        decode_multi_version(&self.decoder, shm, input, dest, start_idx, end_idx)
+    }
+
+    /// Free the given `FrameStackHandle`. When calling this, no Python objects
+    /// may have references to the memory of the `handle`.
+    pub fn frame_stack_done<M>(&mut self, handle: FrameStackHandle<M>) -> Result<(), CamClientError>
+    where
+        M: FrameMeta,
+    {
+        let shm = self.get_shm_mut()?;
+        handle.free_slot(shm);
+        Ok(())
+    }
+
+    pub fn close(&mut self) -> Result<(), CamClientError> {
+        self.shm.take();
+        Ok(())
+    }
+}
diff --git a/common/src/generic_connection.rs b/common/src/generic_connection.rs
new file mode 100644
index 00000000..5c812823
--- /dev/null
+++ b/common/src/generic_connection.rs
@@ -0,0 +1,603 @@
+use std::{
+    fmt::Debug,
+    marker::PhantomData,
+    path::PathBuf,
+    sync::mpsc::RecvTimeoutError,
+    time::{Duration, Instant},
+};
+
+use ipc_test::{slab::SlabInitError, SharedSlabAllocator};
+use log::{debug, info, trace, warn};
+use stats::Stats;
+
+use crate::{
+    background_thread::{BackgroundThread, BackgroundThreadSpawnError, ControlMsg, ReceiverMsg},
+    frame_stack::{FrameMeta, FrameStackHandle, SplitError},
+};
+
+pub trait DetectorConnectionConfig: Clone {
+    /// calculate number of SHM slots
+    fn get_shm_num_slots(&self) -> usize;
+
+    /// calculate SHM slot size
+    fn get_shm_slot_size(&self) -> usize;
+
+    /// should huge pages be enabled, if available?
+    fn get_shm_enable_huge_pages(&self) -> bool;
+
+    /// SHM handle path
+    fn get_shm_handle_path(&self) -> String;
+}
+
+pub trait AcquisitionConfig: Debug {
+    /// total number of frames in the acquisition
+    fn num_frames(&self) -> usize;
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ConnectionError {
+    #[error("periodic callback returned an error: {0}")]
+    PeriodicCallbackError(Box<dyn std::error::Error + 'static + Sync + Send>),
+
+    #[error("a general fatal error occurred: {0}")]
+    FatalError(Box<dyn std::error::Error + 'static + Sync + Send>),
+
+    #[error("could not create SHM area (num_slots={num_slots}, slot_size={slot_size}, total_size={total_size}): {err:?}")]
+    ShmCreateError {
+        num_slots: usize,
+        slot_size: usize,
+        total_size: usize,
+        err: Box<dyn std::error::Error + 'static + Sync + Send>,
+    },
+
+    #[error("could not connect to SHM area: {0}")]
+    ShmConnectError(#[from] SlabInitError),
+
+    #[error("background thread is dead")]
+    Disconnected,
+
+    #[error("operation timed out")]
+    Timeout,
+
+    #[error("operation cancelled")]
+    Cancelled,
+
+    #[error("background thread failed to start: {0}")]
+    SpawnFailed(#[from] BackgroundThreadSpawnError),
+
+    #[error("unexpected message: {0}")]
+    UnexpectedMessage(String),
+}
+
+/// "mirror" of `RecvTimeoutError`
+#[derive(Debug, thiserror::Error)]
+pub enum NextTimeoutError {
+    #[error("timeout expired")]
+    Timeout,
+
+    #[error("the background thread is dead and the channel is disconnected")]
+    Disconnected,
+}
+
+impl From<RecvTimeoutError> for NextTimeoutError {
+    fn from(value: RecvTimeoutError) -> Self {
+        match value {
+            RecvTimeoutError::Timeout => Self::Timeout,
+            RecvTimeoutError::Disconnected => Self::Disconnected,
+        }
+    }
+}
+
+impl From<NextTimeoutError> for ConnectionError {
+    fn from(value: NextTimeoutError) -> Self {
+        Self::FatalError(Box::new(value))
+    }
+}
+
+fn split_with_wait<M: FrameMeta, E>(
+    shm: &mut SharedSlabAllocator,
+    frame_stack: FrameStackHandle<M>,
+    max_size: usize,
+    periodic_callback: impl Fn() -> Result<(), E>,
+) -> Result<(FrameStackHandle<M>, FrameStackHandle<M>), ConnectionError>
+where
+    E: std::error::Error + 'static + Send + Sync,
+{
+    let mut frame_stack = frame_stack;
+    loop {
+        if let Err(e) = periodic_callback() {
+            return Err(ConnectionError::PeriodicCallbackError(Box::new(e)));
+        }
+
+        match frame_stack.split_at(max_size, shm) {
+            Ok((a, b)) => return Ok((a, b)),
+            Err(SplitError::ShmFull(old_frame_stack)) => {
+                trace!("shm is full; waiting...");
+                std::thread::sleep(Duration::from_millis(1));
+                frame_stack = old_frame_stack;
+                continue;
+            }
+        };
+    }
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+pub enum ConnectionStatus {
+    Idle,
+    Armed,
+    Running,
+}
+
+pub struct GenericConnection<B, AC>
+where
+    B: BackgroundThread,
+    AC: AcquisitionConfig,
+{
+    remainder: Vec<FrameStackHandle<B::FrameMetaImpl>>,
+    shm: SharedSlabAllocator,
+    stats: Stats,
+    status: ConnectionStatus,
+    bg_thread: B,
+
+    // TODO: get rid of this?
+    _marker: PhantomData<AC>,
+}
+
+impl<B, AC> GenericConnection<B, AC>
+where
+    B: BackgroundThread,
+    AC: AcquisitionConfig,
+{
+    /// Create a new `GenericConnection`, taking ownership of the `bg_thread` passed in.
+    pub fn new(bg_thread: B, shm: &SharedSlabAllocator) -> Result<Self, ConnectionError> {
+        let shm = shm.clone_and_connect()?;
+        Ok(Self {
+            remainder: Vec::new(),
+            shm,
+            stats: Stats::new(),
+            status: ConnectionStatus::Idle,
+            bg_thread,
+            _marker: PhantomData,
+        })
+    }
+
+    /// Instantiate a `SharedSlabAllocator` from the configuration in `config`.
+    pub fn shm_from_config<D>(config: &D) -> Result<SharedSlabAllocator, ConnectionError>
+    where
+        D: DetectorConnectionConfig,
+    {
+        let num_slots = config.get_shm_num_slots();
+        let slot_size = config.get_shm_slot_size();
+        let enable_huge_pages = config.get_shm_enable_huge_pages();
+        SharedSlabAllocator::new(
+            num_slots,
+            slot_size,
+            enable_huge_pages,
+            &PathBuf::from(config.get_shm_handle_path()),
+        )
+        .map_err(|e| ConnectionError::ShmCreateError {
+            num_slots,
+            slot_size,
+            total_size: num_slots * slot_size,
+            err: Box::new(e),
+        })
+    }
+
+    pub fn get_status(&self) -> ConnectionStatus {
+        self.status
+    }
+
+    fn adjust_status(&mut self, msg: &ReceiverMsg<B::FrameMetaImpl, B::AcquisitionConfigImpl>) {
+        match msg {
+            ReceiverMsg::AcquisitionStart { .. } => {
+                debug!("adjust_status: now Running");
+                self.status = ConnectionStatus::Running;
+            }
+            ReceiverMsg::Finished { .. } => {
+                debug!("adjust_status: now Idle");
+                self.status = ConnectionStatus::Idle;
+            }
+            ReceiverMsg::ReceiverArmed => {
+                debug!("adjust_status: now Armed");
+                self.status = ConnectionStatus::Armed;
+            }
+            ReceiverMsg::FrameStack { .. } => {
+                trace!("adjust_status: FrameStack {{ .. }}");
+            }
+            ReceiverMsg::FatalError { error } => {
+                log::warn!("adjust_status: fatal error: {error:?}; going back to idle state");
+                self.status = ConnectionStatus::Idle;
+            }
+            ReceiverMsg::Cancelled => {
+                log::warn!("adjust_status: acquisition cancelled");
+                self.status = ConnectionStatus::Idle;
+            } // other => {
+              //     trace!("adjust_status: other message: {other:?}");
+              // }
+        }
+    }
+
+    /// Get the next message from the background thread, waiting at most
+    /// `timeout`.
+    ///
+    /// If a `NextTimeoutError::Disconnected` error is encountered, the background
+    /// thread is no longer running.
+    ///
+    /// When handling the results, care must be taken not to leak memory,
+    /// meaning the variants of `ReceiverMsg` that contain a `FrameStackHandle`
+    /// must be either returned to user code or free'd. When ignored, they will
+    /// fill up the shared memory.
+    pub fn next_timeout(
+        &mut self,
+        timeout: Duration,
+    ) -> Result<ReceiverMsg<B::FrameMetaImpl, B::AcquisitionConfigImpl>, NextTimeoutError> {
+        let msg = self.bg_thread.channel_from_thread().recv_timeout(timeout)?;
+        self.adjust_status(&msg);
+        Ok(msg)
+    }
+
+    /// Wait until the detector is armed, or until `timeout` expires. Returns an
+    /// `AcquisitionConfig` matching the `BackgroundThread` impl, or `None` in
+    /// case of timeout.
+    ///
+    /// `periodic_callback` will be called about every 100ms - if that
+    /// returns an `Err`, a `ConnectionError::PeriodicCallbackError`
+    /// will be returned.
+    ///
+    /// If `timeout` is none, wait indefinitely, or until the
+    /// `periodic_callback` returns an error.
+    pub fn wait_for_arm<E>(
+        &mut self,
+        timeout: Option<Duration>,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<Option<B::AcquisitionConfigImpl>, ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        // if an acquisition is already running, cancel and wait for idle status:
+        if self.is_running() {
+            self.cancel(&timeout, &periodic_callback)?;
+        }
+
+        if let Some(timeout) = timeout {
+            self.wait_for_arm_inner(timeout, periodic_callback)
+        } else {
+            // wait indefinitely:
+            loop {
+                let res = self.wait_for_arm_inner(Duration::from_millis(100), &periodic_callback);
+                match res {
+                    Err(ConnectionError::Timeout) => continue,
+                    Ok(None) => continue,
+                    other => break other, // error, or Some(result)
+                }
+            }
+        }
+    }
+
+    fn wait_for_arm_inner<E>(
+        &mut self,
+        timeout: Duration,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<Option<B::AcquisitionConfigImpl>, ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        let deadline = Instant::now() + timeout;
+        let step = Duration::from_millis(100);
+
+        loop {
+            if let Err(e) = periodic_callback() {
+                return Err(ConnectionError::PeriodicCallbackError(Box::new(e)));
+            }
+
+            let timeout_rem = deadline - Instant::now();
+            let this_timeout = timeout_rem.min(step);
+            let res = self.next_timeout(this_timeout);
+            if let Err(NextTimeoutError::Timeout) = &res {
+                if Instant::now() > deadline {
+                    return Ok(None);
+                } else {
+                    continue;
+                }
+            };
+            let res = res?;
+
+            match res {
+                // the receiver should have been armed before, but no harm; just
+                // wait for the next message:
+                ReceiverMsg::ReceiverArmed => continue,
+
+                ReceiverMsg::AcquisitionStart {
+                    pending_acquisition,
+                } => return Ok(Some(pending_acquisition)),
+                ReceiverMsg::FatalError { error } => {
+                    return Err(ConnectionError::FatalError(error))
+                }
+                ReceiverMsg::FrameStack { frame_stack } => {
+                    frame_stack.free_slot(&mut self.shm);
+                    return Err(ConnectionError::UnexpectedMessage(
+                        "ReceiverMsg::FrameStack in wait_for_arm".to_owned(),
+                    ));
+                }
+                ReceiverMsg::Finished { frame_stack } => {
+                    frame_stack.free_slot(&mut self.shm);
+                    return Err(ConnectionError::UnexpectedMessage(
+                        "ReceiverMsg::Finished in wait_for_arm".to_owned(),
+                    ));
+                }
+                ReceiverMsg::Cancelled => {
+                    return Err(ConnectionError::UnexpectedMessage(
+                        "ReceiverMsg::Cancelled in wait_for_arm".to_owned(),
+                    ));
+                }
+            }
+        }
+    }
+
+    /// Wait for a status change, or until `timeout` expires. If `timeout` is
+    /// `None`, wait indefinitely. If the status is already equal to
+    /// `desired_status`, return immediately.
+    ///
+    /// `periodic_callback` will be called about every 100ms - if that
+    /// returns an `Err`, a `ConnectionError::PeriodicCallbackError`
+    /// will be returned.
+    pub fn wait_for_status<E>(
+        &mut self,
+        desired_status: ConnectionStatus,
+        timeout: Option<Duration>,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<(), ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        if let Some(timeout) = timeout {
+            self.wait_for_status_inner(desired_status, timeout, periodic_callback)
+        } else {
+            // wait indefinitely:
+            loop {
+                let res = self.wait_for_status_inner(
+                    desired_status,
+                    Duration::from_millis(100),
+                    &periodic_callback,
+                );
+                if let Err(ConnectionError::Timeout) = &res {
+                    continue;
+                } else {
+                    break res;
+                }
+            }
+        }
+    }
+
+    fn wait_for_status_inner<E>(
+        &mut self,
+        desired_status: ConnectionStatus,
+        timeout: Duration,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<(), ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        debug!("wait_for_status: waiting for {desired_status:?}...");
+        let deadline = Instant::now() + timeout;
+        let step = Duration::from_millis(100);
+
+        if self.status == desired_status {
+            debug!("wait_for_status: already in desired status: {desired_status:?}");
+            return Ok(());
+        }
+
+        loop {
+            if let Err(e) = periodic_callback() {
+                return Err(ConnectionError::PeriodicCallbackError(Box::new(e)));
+            }
+
+            let timeout_rem = deadline - Instant::now();
+            let this_timeout = timeout_rem.min(step);
+            let res = self.next_timeout(this_timeout);
+            if let Err(NextTimeoutError::Timeout) = &res {
+                if Instant::now() > deadline {
+                    return Err(ConnectionError::Timeout);
+                } else {
+                    continue;
+                }
+            };
+            let res = res?;
+            match res {
+                ReceiverMsg::FrameStack { frame_stack } => {
+                    trace!("wait_for_status: ignoring received FrameStackHandle");
+                    frame_stack.free_slot(&mut self.shm);
+                }
+                ReceiverMsg::Finished { frame_stack } => {
+                    warn!("wait_for_status: ignoring FrameStackHandle received in ReceiverMsg::Finished message");
+                    frame_stack.free_slot(&mut self.shm);
+                }
+                ReceiverMsg::FatalError { error } => {
+                    return Err(ConnectionError::FatalError(error));
+                }
+                ReceiverMsg::ReceiverArmed => {
+                    trace!("wait_for_status: received ReceiverMsg::ReceiverArmed");
+                }
+                ReceiverMsg::AcquisitionStart {
+                    pending_acquisition: _,
+                } => {
+                    trace!("wait_for_status: received ReceiverMsg::AcquisitionStart");
+                }
+                ReceiverMsg::Cancelled => {
+                    trace!("wait_for_status: received ReceiverMsg::Cancelled");
+                }
+            }
+            if self.status == desired_status {
+                debug!("wait_for_status: successfully got status {desired_status:?}");
+                return Ok(());
+            }
+        }
+    }
+
+    /// Start a new passive acquisition.
+    pub fn start_passive<E>(
+        &mut self,
+        periodic_callback: impl Fn() -> Result<(), E>,
+        timeout: &Option<Duration>,
+    ) -> Result<(), ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        if self.status == ConnectionStatus::Armed {
+            // already armed, don't have to do anything
+            debug!("start_passive: already armed, nothing to do");
+            return Ok(());
+        }
+
+        self.bg_thread
+            .channel_to_thread()
+            .send(ControlMsg::StartAcquisitionPassive)
+            .map_err(|_e| ConnectionError::Disconnected)?;
+
+        self.wait_for_status(ConnectionStatus::Armed, *timeout, periodic_callback)
+    }
+
+    /// Receive the next frame stack from the background thread and handle any
+    /// other control messages.
+    fn recv_next_stack<E: std::error::Error + 'static + Send + Sync>(
+        &mut self,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<Option<FrameStackHandle<B::FrameMetaImpl>>, ConnectionError> {
+        // first, check if there is anything on the remainder list:
+        if let Some(frame_stack) = self.remainder.pop() {
+            return Ok(Some(frame_stack));
+        }
+
+        match self.status {
+            ConnectionStatus::Running | ConnectionStatus::Armed => {}
+            ConnectionStatus::Idle => return Ok(None),
+        };
+
+        loop {
+            if let Err(e) = periodic_callback() {
+                return Err(ConnectionError::PeriodicCallbackError(Box::new(e)));
+            }
+
+            let recv_result = self.next_timeout(Duration::from_millis(100));
+            if let Err(NextTimeoutError::Timeout) = &recv_result {
+                continue;
+            }
+            let msg = recv_result?;
+
+            match msg {
+                m @ ReceiverMsg::ReceiverArmed => {
+                    info!("recv_next_stack: unexpected message: {m:?}");
+                    continue;
+                }
+                ReceiverMsg::AcquisitionStart { .. } => {
+                    // FIXME: in case of "passive" mode, we should actually not hit this,
+                    // as `wait_for_arm` consumes it?
+                    continue;
+                }
+                ReceiverMsg::FatalError { error } => {
+                    return Err(ConnectionError::FatalError(error));
+                }
+                ReceiverMsg::Finished { frame_stack } => {
+                    // Finished here means we have seen all frame stacks of the acquisition,
+                    // it does _not_ mean that the data consumer has processed them all.
+
+                    // do stats update here to make sure we count the last frame stack!
+                    self.stats.count_stats_item(&frame_stack);
+                    self.stats.log_stats();
+                    self.stats.reset();
+
+                    return Ok(Some(frame_stack));
+                }
+                ReceiverMsg::FrameStack { frame_stack } => {
+                    self.stats.count_stats_item(&frame_stack);
+                    return Ok(Some(frame_stack));
+                }
+                ReceiverMsg::Cancelled => {
+                    return Err(ConnectionError::Cancelled);
+                }
+            }
+        }
+    }
+
+    pub fn get_next_stack<E: std::error::Error + 'static + Send + Sync>(
+        &mut self,
+        max_size: usize,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<Option<FrameStackHandle<B::FrameMetaImpl>>, ConnectionError> {
+        let res = self.recv_next_stack(&periodic_callback);
+        match res {
+            Ok(Some(frame_stack)) => {
+                if frame_stack.len() > max_size {
+                    // split `FrameStackHandle` into two:
+                    trace!(
+                        "FrameStackHandle::split_at({max_size}); len={}",
+                        frame_stack.len()
+                    );
+                    self.stats.count_split();
+                    let (left, right) =
+                        split_with_wait(&mut self.shm, frame_stack, max_size, &periodic_callback)?;
+                    self.remainder.push(right);
+                    assert!(left.len() <= max_size);
+                    return Ok(Some(left));
+                }
+                assert!(frame_stack.len() <= max_size);
+                Ok(Some(frame_stack))
+            }
+            Ok(None) => Ok(None),
+            e @ Err(_) => e,
+        }
+    }
+
+    pub fn is_running(&self) -> bool {
+        self.get_status() == ConnectionStatus::Running
+    }
+
+    pub fn cancel<E>(
+        &mut self,
+        timeout: &Option<Duration>,
+        periodic_callback: impl Fn() -> Result<(), E>,
+    ) -> Result<(), ConnectionError>
+    where
+        E: std::error::Error + 'static + Send + Sync,
+    {
+        self.bg_thread
+            .channel_to_thread()
+            .send(ControlMsg::CancelAcquisition)
+            .map_err(|_| ConnectionError::Disconnected)?;
+
+        self.wait_for_status(ConnectionStatus::Idle, *timeout, periodic_callback)
+    }
+
+    pub fn log_shm_stats(&self) {
+        let shm = &self.shm;
+        let free = shm.num_slots_free();
+        let total = shm.num_slots_total();
+        self.stats.log_stats();
+        info!("shm stats free/total: {}/{}", free, total);
+    }
+
+    pub fn reset_stats(&mut self) {
+        self.stats.reset();
+    }
+
+    pub fn send_specialized(&mut self, msg: B::ExtraControl) -> Result<(), ConnectionError> {
+        self.bg_thread
+            .channel_to_thread()
+            .send(ControlMsg::SpecializedControlMsg { msg })
+            .map_err(|_| ConnectionError::Disconnected)?;
+        Ok(())
+    }
+
+    pub fn close(mut self) {
+        debug!("GenericConnection::close");
+        if self
+            .bg_thread
+            .channel_to_thread()
+            .send(ControlMsg::StopThread)
+            .is_err()
+        {
+            warn!("channel to background thread disconnected, probably already dead");
+        }
+        self.bg_thread.join();
+    }
+}
diff --git a/common/src/lib.rs b/common/src/lib.rs
new file mode 100644
index 00000000..f9414e08
--- /dev/null
+++ b/common/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod background_thread;
+pub mod decoder;
+pub mod frame_stack;
+pub mod generic_cam_client;
+pub mod generic_connection;
+pub mod py_cam_client;
+pub mod py_connection;
+pub mod tcp;
+pub mod utils;
diff --git a/common/src/py_cam_client.rs b/common/src/py_cam_client.rs
new file mode 100644
index 00000000..01cea574
--- /dev/null
+++ b/common/src/py_cam_client.rs
@@ -0,0 +1,190 @@
+// we can't have generics in pyclasses, so we have to generate impls via macros
+// see also: https://pyo3.rs/v0.21.2/class.html#no-generic-parameters
+
+#[macro_export]
+macro_rules! decode_for_dtype {
+    (
+        $dtype: ident,
+        $self: ident,
+        $input: ident,
+        $out: ident,
+        $start_idx: ident,
+        $end_idx: ident,
+        $py: ident
+    ) => {
+        if $out.dtype().is_equiv_to(&dtype_bound::<$dtype>($py)) {
+            let out_downcast = $out.downcast::<PyArray3<$dtype>>()?;
+            $self.decode_impl($input, out_downcast, $start_idx, $end_idx, $py)?;
+            return Ok(());
+        }
+    };
+}
+
+/// This macro generates Python-specific business logic, like dropping the GIL
+/// or type conversions. All other logic is implemented in `GenericCamClient`.
+///
+#[macro_export]
+macro_rules! impl_py_cam_client {
+    (
+        $name: ident,
+        $decoder_type: ident,
+        $py_frame_stack: ident,
+        $frame_meta_type: ident,
+        $mod: ident
+    ) => {
+        mod impl_cam_client {
+            use common::{
+                decoder::{Decoder, DecoderTargetPixelType},
+                frame_stack::FrameStackHandle,
+                generic_cam_client::GenericCamClient,
+            };
+            use ipc_test::SharedSlabAllocator;
+            use num::{
+                cast::AsPrimitive,
+                complex::{Complex32, Complex64},
+                NumCast,
+            };
+            use numpy::{
+                dtype_bound, Element, PyArray3, PyArrayDescrMethods, PyArrayMethods,
+                PyUntypedArray, PyUntypedArrayMethods,
+            };
+            use pyo3::{create_exception, exceptions::PyException, prelude::*};
+            use zerocopy::{AsBytes, FromBytes};
+
+            create_exception!($mod, PyCamClientError, PyException);
+
+            #[pyclass]
+            pub struct $name {
+                client_impl: GenericCamClient<super::$decoder_type>,
+            }
+
+            impl $name {
+                fn decode_impl<'py, T>(
+                    &self,
+                    input: &super::$py_frame_stack,
+                    out: &Bound<'py, PyArray3<T>>,
+                    start_idx: usize,
+                    end_idx: usize,
+                    py: Python<'_>,
+                ) -> PyResult<()>
+                where
+                    T: Element + DecoderTargetPixelType,
+                    u8: AsPrimitive<T>,
+                    u16: AsPrimitive<T>,
+                {
+                    let mut out_rw = out.try_readwrite()?;
+                    let mut out_arr = out_rw.as_array_mut();
+                    return self
+                        .client_impl
+                        .decode_range_into_buffer(
+                            input.try_get_inner()?,
+                            &mut out_arr,
+                            start_idx,
+                            end_idx,
+                        )
+                        .map_err(|e| PyCamClientError::new_err(format!("decode failed: {e}")));
+                }
+
+                pub fn get_shm(&self) -> PyResult<&SharedSlabAllocator> {
+                    self.client_impl
+                        .get_shm()
+                        .map_err(|e| PyCamClientError::new_err(e.to_string()))
+                }
+            }
+
+            #[pymethods]
+            impl $name {
+                #[new]
+                pub fn new(handle_path: &str) -> PyResult<Self> {
+                    Ok(Self {
+                        client_impl: GenericCamClient::new(handle_path)
+                            .map_err(|e| PyCamClientError::new_err(e.to_string()))?,
+                    })
+                }
+
+                /// Decode into a pre-allocated array.
+                ///
+                /// Fow now, this has to be a numpy array, but using the buffer
+                /// protocol, this can, for example, be a reference to pinned
+                /// memory for efficient use with CUDA.
+                pub fn decode_into_buffer<'py>(
+                    &self,
+                    input: &super::$py_frame_stack,
+                    out: &Bound<'py, PyUntypedArray>,
+                    py: Python<'_>,
+                ) -> PyResult<()> {
+                    self.decode_range_into_buffer(input, out, 0, input.__len__()?, py)
+                }
+
+                /// Decode a range of frames into a pre-allocated array.
+                ///
+                /// This allows for decoding only the data that will be processed
+                /// immediately afterwards, allowing for more cache-efficient operations.
+                pub fn decode_range_into_buffer<'py>(
+                    &self,
+                    input: &super::$py_frame_stack,
+                    out: &Bound<'py, PyUntypedArray>,
+                    start_idx: usize,
+                    end_idx: usize,
+                    py: Python<'_>,
+                ) -> PyResult<()> {
+                    if start_idx >= end_idx || end_idx > input.__len__()? {
+                        return Err(PyCamClientError::new_err(format!(
+                            "invalid start or end index: [{start_idx},{end_idx})"
+                        )));
+                    }
+
+                    $crate::decode_for_dtype!(u8, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(u16, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(u32, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(u64, self, input, out, start_idx, end_idx, py);
+
+                    $crate::decode_for_dtype!(i8, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(i16, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(i32, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(i64, self, input, out, start_idx, end_idx, py);
+
+                    $crate::decode_for_dtype!(f32, self, input, out, start_idx, end_idx, py);
+                    $crate::decode_for_dtype!(f64, self, input, out, start_idx, end_idx, py);
+
+                    // FIXME: figure out zerocopy::AsBytes for Complex types
+                    // $crate::decode_for_dtype!(Complex32, self, input, out, start_idx, end_idx, py);
+                    // $crate::decode_for_dtype!(Complex64, self, input, out, start_idx, end_idx, py);
+
+                    Err(PyCamClientError::new_err(format!(
+                        "unknown output dtype: {:?}",
+                        out.dtype()
+                    )))
+                }
+
+                /// Free the given `FrameStackHandle`. When calling this, no Python objects
+                /// may have references to the memory of the `handle`.
+                pub fn frame_stack_done(
+                    &mut self,
+                    handle: &mut super::$py_frame_stack,
+                ) -> PyResult<()> {
+                    let inner_handle = handle.take().ok_or_else(|| {
+                        PyCamClientError::new_err(
+                            "trying to take already free'd frame stack handle",
+                        )
+                    })?;
+                    self.client_impl
+                        .frame_stack_done(inner_handle)
+                        .map_err(|e| {
+                            PyCamClientError::new_err(format!(
+                                "GenericCamClient::frame_stack_done: {e}"
+                            ))
+                        })
+                }
+
+                pub fn close(&mut self) -> PyResult<()> {
+                    self.client_impl
+                        .close()
+                        .map_err(|e| PyCamClientError::new_err(format!("close failed: {e}")))
+                }
+            }
+        }
+
+        use impl_cam_client::{$name, PyCamClientError};
+    };
+}
diff --git a/common/src/py_connection.rs b/common/src/py_connection.rs
new file mode 100644
index 00000000..8121b44f
--- /dev/null
+++ b/common/src/py_connection.rs
@@ -0,0 +1,335 @@
+// we can't have generics in pyclasses, so we have to generate impls via macros
+// see also: https://pyo3.rs/v0.21.2/class.html#no-generic-parameters
+
+/// This macro generates Python-specific business logic, like dropping the GIL
+/// or type conversions. All other logic is implemented in `GenericConnection`.
+#[macro_export]
+macro_rules! impl_py_connection {
+    (
+        $name: ident,
+        $name_frame_stack: ident,
+        $frame_meta_type: ident,
+        $background_thread_type: ident,
+        $pending_acquisition_type: ident,
+        $mod: ident
+    ) => {
+        mod impl_connection {
+            use bincode::serialize;
+            use common::{
+                background_thread::BackgroundThread,
+                decoder::Decoder,
+                frame_stack::{FrameMeta, FrameStackHandle},
+                generic_cam_client::GenericCamClient,
+                generic_connection::{ConnectionStatus, GenericConnection},
+            };
+            use ipc_test::SharedSlabAllocator;
+            use num::NumCast;
+            use numpy::{
+                dtype_bound, Element, PyArray3, PyArrayDescrMethods, PyArrayMethods,
+                PyUntypedArray, PyUntypedArrayMethods,
+            };
+            use pyo3::{
+                create_exception,
+                exceptions::PyException,
+                prelude::*,
+                types::{PyBytes, PyType},
+            };
+            use stats::Stats;
+            use std::time::Duration;
+            use zerocopy::{AsBytes, FromBytes};
+
+            create_exception!($mod, PyConnectionError, PyException);
+
+            #[pyclass]
+            pub struct $name {
+                conn_impl: Option<
+                    GenericConnection<
+                        super::$background_thread_type,
+                        super::$pending_acquisition_type,
+                    >,
+                >,
+                shm: Option<SharedSlabAllocator>,
+                remainder: Vec<FrameStackHandle<super::$frame_meta_type>>,
+                stats: Stats,
+            }
+
+            impl $name {
+                pub fn new(
+                    shm: SharedSlabAllocator,
+                    conn_impl: GenericConnection<
+                        super::$background_thread_type,
+                        super::$pending_acquisition_type,
+                    >,
+                ) -> Self {
+                    Self {
+                        conn_impl: Some(conn_impl),
+                        shm: Some(shm),
+                        remainder: Vec::new(),
+                        stats: Stats::new(),
+                    }
+                }
+
+                fn get_conn_mut(
+                    &mut self,
+                ) -> PyResult<
+                    &mut GenericConnection<
+                        super::$background_thread_type,
+                        super::$pending_acquisition_type,
+                    >,
+                > {
+                    match &mut self.conn_impl {
+                        None => Err(PyConnectionError::new_err("connection is closed")),
+                        Some(c) => Ok(c),
+                    }
+                }
+
+                fn get_conn(
+                    &self,
+                ) -> PyResult<
+                    &GenericConnection<
+                        super::$background_thread_type,
+                        super::$pending_acquisition_type,
+                    >,
+                > {
+                    match &self.conn_impl {
+                        None => Err(PyConnectionError::new_err("connection is closed")),
+                        Some(c) => Ok(c),
+                    }
+                }
+
+                fn get_shm_mut(&mut self) -> PyResult<&mut SharedSlabAllocator> {
+                    match &mut self.shm {
+                        None => Err(PyConnectionError::new_err("shm is closed")),
+                        Some(shm) => Ok(shm),
+                    }
+                }
+
+                fn get_shm(&self) -> PyResult<&SharedSlabAllocator> {
+                    match &self.shm {
+                        None => Err(PyConnectionError::new_err("shm is closed")),
+                        Some(shm) => Ok(shm),
+                    }
+                }
+
+                pub fn send_specialized(
+                    &mut self,
+                    msg: <super::$background_thread_type as BackgroundThread>::ExtraControl,
+                ) -> PyResult<()> {
+                    let mut conn = self.get_conn_mut()?;
+                    conn.send_specialized(msg)
+                        .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+                    Ok(())
+                }
+
+                pub fn wait_for_status(
+                    &mut self,
+                    desired_status: ConnectionStatus,
+                    timeout: Option<Duration>,
+                ) -> PyResult<()> {
+                    let mut conn = self.get_conn_mut()?;
+                    conn.wait_for_status(desired_status, timeout, || {
+                        // re-acquire GIL to check if we need to break
+                        Python::with_gil(|py| py.check_signals())?;
+                        Ok::<_, PyErr>(())
+                    })
+                    .map_err(|e| PyConnectionError::new_err(e.to_string()))
+                }
+            }
+
+            #[pymethods]
+            impl $name {
+                pub fn get_next_stack(
+                    &mut self,
+                    max_size: usize,
+                    py: Python<'_>,
+                ) -> PyResult<Option<$name_frame_stack>> {
+                    let conn_impl = self.get_conn_mut()?;
+                    match py.allow_threads(|| {
+                        conn_impl.get_next_stack(max_size, || {
+                            // re-acquire GIL to check if we need to break
+                            Python::with_gil(|py| py.check_signals())?;
+                            Ok::<_, PyErr>(())
+                        })
+                    }) {
+                        Ok(None) => Ok(None),
+                        Ok(Some(stack)) => Ok(Some($name_frame_stack::new(stack))),
+                        Err(e) => Err(PyConnectionError::new_err(e.to_string())),
+                    }
+                }
+
+                pub fn wait_for_arm(
+                    &mut self,
+                    timeout: Option<f32>,
+                    py: Python<'_>,
+                ) -> PyResult<Option<super::$pending_acquisition_type>> {
+                    let timeout = timeout.map(Duration::from_secs_f32);
+                    py.allow_threads(|| {
+                        let conn_impl = self.get_conn_mut()?;
+                        conn_impl
+                            .wait_for_arm(timeout, || {
+                                // re-acquire GIL to check if we need to break
+                                Python::with_gil(|py| py.check_signals())?;
+                                Ok::<_, PyErr>(())
+                            })
+                            .map_err(|e| PyConnectionError::new_err(e.to_string()))
+                    })
+                }
+
+                pub fn get_socket_path(&self) -> PyResult<String> {
+                    let shm = self.get_shm()?;
+                    Ok(shm.get_handle().os_handle)
+                }
+
+                pub fn close(&mut self) -> PyResult<()> {
+                    if let Some(mut conn_impl) = self.conn_impl.take() {
+                        conn_impl.log_shm_stats();
+                        conn_impl.reset_stats();
+                        conn_impl.close();
+                        Ok(())
+                    } else {
+                        Err(PyConnectionError::new_err("already closed".to_owned()))
+                    }
+                }
+
+                pub fn is_running(&self) -> PyResult<bool> {
+                    let conn_impl = self.get_conn()?;
+                    Ok(conn_impl.is_running())
+                }
+
+                pub fn cancel(&mut self, timeout: Option<f32>, py: Python<'_>) -> PyResult<()> {
+                    let conn_impl = self.get_conn_mut()?;
+
+                    let timeout = timeout.map(Duration::from_secs_f32);
+                    py.allow_threads(|| {
+                        let conn_impl = self.get_conn_mut()?;
+                        conn_impl
+                            .cancel(&timeout, || {
+                                // re-acquire GIL to check if we need to break
+                                Python::with_gil(|py| py.check_signals())?;
+                                Ok::<_, PyErr>(())
+                            })
+                            .map_err(|e| {
+                                PyConnectionError::new_err(format!("cancellation failed: {e}"))
+                            })
+                    })
+                }
+
+                pub fn start_passive(
+                    &mut self,
+                    timeout: Option<f32>,
+                    py: Python<'_>,
+                ) -> PyResult<()> {
+                    let timeout = timeout.map(Duration::from_secs_f32);
+
+                    py.allow_threads(|| {
+                        let conn_impl = self.get_conn_mut()?;
+                        conn_impl
+                            .start_passive(
+                                || {
+                                    Python::with_gil(|py| py.check_signals())?;
+                                    Ok::<_, PyErr>(())
+                                },
+                                &timeout,
+                            )
+                            .map_err(|e| {
+                                PyConnectionError::new_err(format!("start_passive failed: {e}"))
+                            })?;
+
+                        conn_impl
+                            .wait_for_status(ConnectionStatus::Armed, timeout, || {
+                                // re-acquire GIL to check if we need to break
+                                Python::with_gil(|py| py.check_signals())?;
+                                Ok::<_, PyErr>(())
+                            })
+                            .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+
+                        Ok(())
+                    })
+                }
+
+                pub fn log_shm_stats(&self) -> PyResult<()> {
+                    let conn_impl = self.get_conn()?;
+                    conn_impl.log_shm_stats();
+                    Ok(())
+                }
+            }
+
+            #[pyclass]
+            pub struct $name_frame_stack {
+                inner: Option<FrameStackHandle<super::$frame_meta_type>>,
+            }
+
+            impl $name_frame_stack {
+                pub fn new(inner: FrameStackHandle<super::$frame_meta_type>) -> Self {
+                    Self { inner: Some(inner) }
+                }
+
+                pub fn try_get_inner(
+                    &self,
+                ) -> PyResult<&FrameStackHandle<super::$frame_meta_type>> {
+                    if let Some(inner) = &self.inner {
+                        Ok(inner)
+                    } else {
+                        Err(PyConnectionError::new_err(
+                            "operation on free'd FrameStackHandle".to_owned(),
+                        ))
+                    }
+                }
+
+                pub fn take(&mut self) -> Option<FrameStackHandle<super::$frame_meta_type>> {
+                    self.inner.take()
+                }
+
+                pub fn deserialize_impl<'py>(serialized: Bound<'py, PyBytes>) -> PyResult<Self> {
+                    let data = serialized.as_bytes();
+                    let inner: FrameStackHandle<super::$frame_meta_type> =
+                        bincode::deserialize(data).map_err(|e| {
+                            let msg = format!("could not deserialize FrameStackHandle: {e:?}");
+                            PyConnectionError::new_err(msg)
+                        })?;
+
+                    Ok(Self { inner: Some(inner) })
+                }
+            }
+
+            #[pymethods]
+            impl $name_frame_stack {
+                pub fn __len__(&self) -> PyResult<usize> {
+                    Ok(self.try_get_inner()?.len())
+                }
+
+                pub fn get_dtype_string(&self) -> PyResult<String> {
+                    Ok(self.try_get_inner()?.first_meta().get_dtype_string())
+                }
+
+                pub fn get_shape(&self) -> PyResult<(u64, u64)> {
+                    Ok(self.try_get_inner()?.first_meta().get_shape())
+                }
+
+                pub fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
+                    let bytes: Bound<'py, PyBytes> = PyBytes::new_bound(
+                        py,
+                        serialize(self.try_get_inner()?).unwrap().as_slice(),
+                    );
+                    Ok(bytes.into())
+                }
+
+                #[classmethod]
+                pub fn deserialize<'py>(
+                    _cls: Bound<'py, PyType>,
+                    serialized: Bound<'py, PyBytes>,
+                ) -> PyResult<Self> {
+                    Self::deserialize_impl(serialized)
+                }
+            }
+
+            impl $name_frame_stack {
+                pub fn get_meta(&self) -> PyResult<&[super::$frame_meta_type]> {
+                    Ok(&self.try_get_inner()?.get_meta())
+                }
+            }
+        }
+
+        use impl_connection::{$name, $name_frame_stack, PyConnectionError};
+    };
+}
diff --git a/common/src/tcp.rs b/common/src/tcp.rs
new file mode 100644
index 00000000..87095b2d
--- /dev/null
+++ b/common/src/tcp.rs
@@ -0,0 +1,136 @@
+use std::{
+    io::{ErrorKind, Read},
+    net::TcpStream,
+    time::Duration,
+};
+
+use log::trace;
+
+#[derive(thiserror::Error, Debug)]
+pub enum ReadExactError<E> {
+    #[error("read interrupted after {size} bytes; error: {err}")]
+    Interrupted { size: usize, err: E },
+
+    #[error("i/o error")]
+    IOError {
+        #[from]
+        err: std::io::Error,
+    },
+
+    #[error("eof")]
+    Eof,
+
+    #[error("could not peek {size} bytes")]
+    PeekError { size: usize },
+}
+
+/// Fill `buf` from `stream` (like TcpStream::read_exact); if we hit a read timeout
+/// on the stream, invoke `f`. If `f` returns an `Err`, stop reading from the stream.
+///
+/// In case of interruption, `buf` may contain a partial result, and
+/// `ReadExactError::Interrupted` will contain the number of bytes read in the
+/// `size` field.
+pub fn read_exact_interruptible<E, F>(
+    stream: &mut impl Read,
+    buf: &mut [u8],
+    f: F,
+) -> Result<(), ReadExactError<E>>
+where
+    F: Fn() -> Result<(), E>,
+{
+    let total_to_read = buf.len();
+    let mut buf_sliced = buf;
+    let mut bytes_read: usize = 0;
+    loop {
+        if let Err(e) = f() {
+            return Err(ReadExactError::Interrupted {
+                size: bytes_read,
+                err: e,
+            });
+        }
+        match stream.read(buf_sliced) {
+            Ok(size) => {
+                bytes_read += size;
+                buf_sliced = &mut buf_sliced[size..];
+                // it's full! we are done...
+                if bytes_read == total_to_read {
+                    return Ok(());
+                }
+                if size == 0 {
+                    // we aren't done, but got an EOF... propagate that as an error
+                    return Err(ReadExactError::Eof);
+                }
+            }
+            Err(e) => match e.kind() {
+                ErrorKind::WouldBlock | ErrorKind::TimedOut => {
+                    continue;
+                }
+                _ => return Err(ReadExactError::from(e)),
+            },
+        }
+    }
+}
+
+/// Fill `buf` from `stream`; if we hit a read timeout on the stream, invoke
+/// `f`. If `f` returns an `Err`, stop peeking.
+///
+/// In case of interruption, `buf` may contain a partial result, which is also
+/// still in the internal queue of the socket.
+///
+/// Retry `max_retries` times with a sleep of `retry_interval` in between
+/// retries; this allows to break free if the socket buffer is too small to peek
+/// the requested amount of data, and sleeping in between means we don't
+/// completely hog a CPU core.
+pub fn peek_exact_interruptible<E, F>(
+    stream: &mut TcpStream,
+    buf: &mut [u8],
+    retry_interval: Duration,
+    max_retries: usize,
+    f: F,
+) -> Result<(), ReadExactError<E>>
+where
+    F: Fn() -> Result<(), E>,
+{
+    loop {
+        if let Err(e) = f() {
+            return Err(ReadExactError::Interrupted {
+                // we may write bytes to `buf`, but they are not consumed from the socket,
+                // so we report zero here:
+                size: 0,
+                err: e,
+            });
+        }
+
+        let mut retries = 0;
+        match stream.peek(buf) {
+            Ok(size) => {
+                if size == buf.len() {
+                    // it's full! we are done...
+                    return Ok(());
+                } else {
+                    trace!(
+                        "peek_exact_interruptible: not full; {size} != {}",
+                        buf.len()
+                    );
+                    retries += 1;
+                    if retries > max_retries {
+                        return Err(ReadExactError::PeekError { size: buf.len() });
+                    }
+                    // we are only using this for peeking at the first frame, or its header,
+                    // so we can be a bit sleepy here:
+                    std::thread::sleep(retry_interval);
+                }
+            }
+            Err(e) => {
+                trace!("peek_exact_interruptible: err: {e}");
+                match e.kind() {
+                    // in this case, we couldn't peek the full size, so we try again
+                    ErrorKind::WouldBlock | ErrorKind::TimedOut => {
+                        continue;
+                    }
+                    _ => return Err(ReadExactError::from(e)),
+                }
+            }
+        }
+    }
+}
diff --git a/common/src/utils.rs b/common/src/utils.rs
new file mode 100644
index 00000000..2e4a4daf
--- /dev/null
+++ b/common/src/utils.rs
@@ -0,0 +1,41 @@
+use std::{fmt::Debug, mem::replace, str::FromStr};
+
+#[derive(Debug, thiserror::Error)]
+pub enum NumParseError {
+    #[error("expected number, got: {got:X?} ({err})")]
+    ExpectedNumber {
+        got: Vec<u8>,
+        err: Box<dyn std::error::Error + 'static>,
+    },
+
+    #[error("expected UTF-8, got: {got:X?} ({err})")]
+    Utf8Expected {
+        got: Vec<u8>,
+        err: Box<dyn std::error::Error + 'static>,
+    },
+}
+
+/// Parse a decimal number from an ASCII/UTF8 string
+pub fn num_from_byte_slice<T: FromStr>(bytes: &[u8]) -> Result<T, NumParseError>
+where
+    <T as std::str::FromStr>::Err: Debug + std::error::Error + 'static,
+{
+    // This should not become a bottleneck, but in case it does,
+    // there is the `atoi` crate, which provides this functionality
+    // without going via UTF8 first.
+    let s = std::str::from_utf8(bytes).map_err(|e| NumParseError::Utf8Expected {
+        got: bytes.to_vec(),
+        err: Box::new(e),
+    })?;
+    let result = s.parse().map_err(|e| NumParseError::ExpectedNumber {
+        got: bytes.to_vec(),
+        err: Box::new(e),
+    })?;
+    Ok(result)
+}
+
+/// Puts `new` into `right`, `right` into `left` and returns the old `left`
+pub fn three_way_shift<T>(left: &mut T, right: &mut T, new: T) -> T {
+    let old_right = replace(right, new);
+    replace(left, old_right)
+}
diff --git a/coverage.sh b/coverage.sh
new file mode 100755
index 00000000..5d27e914
--- /dev/null
+++ b/coverage.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -eu
+
+# oof...
+export LD_LIBRARY_PATH=$CONDA_PREFIX/lib
+
+# pre-flight check: need to have a virtual environment active with numpy installed:
+python -c 'import numpy'
+
+cargo tarpaulin --engine llvm -o html
diff --git a/libertem_asi_mpx3/Cargo.toml b/libertem_asi_mpx3/Cargo.toml
index 618262b7..b1e5b360 100644
--- a/libertem_asi_mpx3/Cargo.toml
+++ b/libertem_asi_mpx3/Cargo.toml
@@ -2,7 +2,7 @@
 name = "libertem-asi-mpx3"
 authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
 license = "MIT"
-version = "0.2.11"
+version = "0.2.12"
 edition = "2021"
 readme = "README.md"
 rust-version = "1.66"
@@ -14,9 +14,6 @@ crate-type = ["cdylib"]
 
 [dependencies]
 bincode = "1.3.3"
-clap = { version = "3.2.16", features = ["derive"] }
-crossbeam = "0.8.2"
-crossbeam-channel = "0.5.6"
 env_logger = "0.9.3"
 log = "0.4.17"
 pyo3 = { version = "0.21.0", features = ["abi3-py37"] }
@@ -24,6 +21,11 @@ serde = { version = "1.0.143", features = ["derive"] }
 ipc-test = { path = "../ipc_test" }
 serval-client = { path = "../serval-client" }
 stats = { path = "../stats" }
+common = { path = "../common" }
+zerocopy = "0.7.35"
+numpy = "0.21.0"
+num = "0.4.3"
+thiserror = "1.0.61"
 
 [features]
 extension-module = ["pyo3/extension-module"]
diff --git a/libertem_asi_mpx3/README.md b/libertem_asi_mpx3/README.md
index e69de29b..d9beb211 100644
--- a/libertem_asi_mpx3/README.md
+++ b/libertem_asi_mpx3/README.md
@@ -0,0 +1,18 @@
+# LiberTEM-qd-mpx
+
+[![LiberTEM-asi-mpx3 on GitHub](https://img.shields.io/badge/GitHub-MIT-informational)](https://github.com/LiberTEM/LiberTEM-rs)
+
+This is a Python package for efficiently receiving data from different
+detectors made by Amsterdam Scientific, in frame-based mode, 
+
+> [!WARNING]
+> This package is still work in progress.
+
+It is built for [LiberTEM-live](https://github.com/libertem/libertem-live), but can
+also be used stand-alone.
+
+## Changelog
+
+### v0.2.XXX
+
+- Initial release of LiberTEM-qd-mpx
diff --git a/libertem_asi_mpx3/src/receiver.rs b/libertem_asi_mpx3/src/background_thread.rs
similarity index 61%
rename from libertem_asi_mpx3/src/receiver.rs
rename to libertem_asi_mpx3/src/background_thread.rs
index ea66408a..a76014fa 100644
--- a/libertem_asi_mpx3/src/receiver.rs
+++ b/libertem_asi_mpx3/src/background_thread.rs
@@ -1,64 +1,42 @@
 use std::{
-    fmt::{Debug, Display},
-    io::{ErrorKind, Read},
-    mem::replace,
+    fmt::Display,
+    io::ErrorKind,
     net::TcpStream,
-    str::FromStr,
+    sync::mpsc::{channel, Receiver, RecvTimeoutError, SendError, Sender, TryRecvError},
     thread::JoinHandle,
     time::{Duration, Instant},
 };
 
-use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, SendError, Sender, TryRecvError};
-use ipc_test::{SHMHandle, SharedSlabAllocator};
+use common::{
+    background_thread::{BackgroundThread, BackgroundThreadSpawnError, ControlMsg, ReceiverMsg},
+    frame_stack::{FrameStackForWriting, FrameStackWriteError},
+    tcp::{self, ReadExactError},
+    utils::{num_from_byte_slice, three_way_shift, NumParseError},
+};
+use ipc_test::SharedSlabAllocator;
 use log::{debug, error, info, trace, warn};
 use serval_client::{DetectorConfig, ServalClient, ServalError};
 
-use crate::{
-    common::{DType, FrameMeta},
-    frame_stack::{FrameStackForWriting, FrameStackHandle},
-};
-
-#[derive(PartialEq, Debug)]
-pub enum ResultMsg {
-    Error {
-        msg: String,
-    }, // generic error response, might need to specialize later
-
-    /// The frame header failed to parse
-    ParseError {
-        msg: String,
-    },
-
-    AcquisitionStart {
-        detector_config: DetectorConfig,
-        first_frame_meta: FrameMeta,
-    },
-
-    /// A stack of frames, part of an acquisition
-    FrameStack {
-        frame_stack: FrameStackHandle,
-    },
+use crate::base_types::{ASIMpxDetectorConnConfig, ASIMpxFrameMeta, DType, PendingAcquisition};
 
-    /// The last stack of frames of an acquisition
-    /// (can possibly be empty!)
-    End {
-        frame_stack: FrameStackHandle,
-    },
-}
+type ASIMpxControlMsg = ControlMsg<()>;
 
-#[derive(Debug)]
+#[derive(Debug, thiserror::Error)]
 enum ParseError {
+    #[error("wrong magic, should be 'P5', got: {got:X?}")]
     WrongMagic { got: [u8; 2] },
-    Eof,
+
+    #[error("invalid max val, should be in 0..65536, is {got}")]
     InvalidMaxVal { got: u32 },
-    WhiteSpaceExpected { pos: usize, got: u8 },
-}
 
-pub enum ControlMsg {
-    StopThread,
+    #[error("expected whitespace at {pos}, got byte {got:x} instead")]
+    WhiteSpaceExpected { pos: usize, got: u8 },
 
-    /// Wait for any acquisition to start on a given host/port
-    StartAcquisitionPassive,
+    #[error("failed to parse number: {err}")]
+    Num {
+        #[from]
+        err: NumParseError,
+    },
 }
 
 #[derive(PartialEq, Eq)]
@@ -68,46 +46,43 @@ pub enum ReceiverStatus {
     Closed,
 }
 
-// FIXME: error return type
-fn num_from_byte_slice<T: FromStr>(bytes: &[u8]) -> T
-where
-    <T as std::str::FromStr>::Err: Debug,
-{
-    // This should not become a bottleneck, but in case it does,
-    // there is the `atoi` crate, which provides this functionality
-    // without going via UTF8 first.
-    let s = std::str::from_utf8(bytes).unwrap();
-    s.parse().unwrap()
-}
-
-/// Peek and parse the first frame header
-fn peek_header(stream: &mut TcpStream) -> Result<FrameMeta, AcquisitionError> {
+/// Peek and parse the first frame header. Retries until either a header was
+/// received or a control message arrives in the control channel.
+fn peek_header(
+    stream: &mut TcpStream,
+    control_channel: &Receiver<ASIMpxControlMsg>,
+) -> Result<ASIMpxFrameMeta, AcquisitionError> {
     let mut buf: [u8; HEADER_BUF_SIZE] = [0; HEADER_BUF_SIZE];
-    // FIXME: error handling, timeout, ...
-
-    let mut nbytes = 0;
 
-    // Ugh.. wait until enough data is in the buffer
-    // possibly, the sender sends the header and payload separately,
+    // Wait until enough data is in the buffer.
+    // Possibly, the sender sends the header and payload separately,
     // in which case we get only a short header, and we need to retry.
     // All because we don't really know how large the header is supposed to be.
     // This is broken for very small frames (where header+data < 512),
     // so if an acquisition only contains <512 bytes in total, we will wait
     // here indefinitely.
-    while nbytes < HEADER_BUF_SIZE {
-        nbytes = match stream.peek(&mut buf) {
-            Ok(n) => n,
-            Err(e) => return Err(AcquisitionError::ConnectionError { msg: e.to_string() }),
+
+    loop {
+        match tcp::peek_exact_interruptible(stream, &mut buf, Duration::from_millis(10), 10, || {
+            check_for_control(control_channel)
+        }) {
+            Ok(_) => {
+                return Ok(parse_header(&buf, 0)?);
+            }
+            Err(ReadExactError::PeekError { size }) => {
+                trace!("peek  of {size} bytes failed; retrying...");
+                continue;
+            }
+            Err(e) => {
+                return Err(AcquisitionError::from(e));
+            }
         }
-        // FIXME: timeout!!
     }
-
-    Ok(parse_header(&buf, 0)?)
 }
 
 const HEADER_BUF_SIZE: usize = 512;
 
-fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta, ParseError> {
+fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<ASIMpxFrameMeta, ParseError> {
     let mut pos: usize = 0;
 
     // Each PGM image consists of the following:
@@ -131,7 +106,7 @@ fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta,
     while !buf[pos].is_ascii_whitespace() && pos < HEADER_BUF_SIZE {
         pos += 1;
     }
-    let width: u16 = num_from_byte_slice(&buf[width_start..pos]);
+    let width: u16 = num_from_byte_slice(&buf[width_start..pos])?;
 
     // •      Whitespace.
     while buf[pos].is_ascii_whitespace() && pos < HEADER_BUF_SIZE {
@@ -143,7 +118,7 @@ fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta,
     while !buf[pos].is_ascii_whitespace() && pos < HEADER_BUF_SIZE {
         pos += 1;
     }
-    let height: u16 = num_from_byte_slice(&buf[height_start..pos]);
+    let height: u16 = num_from_byte_slice(&buf[height_start..pos])?;
 
     // •      Whitespace.
     while buf[pos].is_ascii_whitespace() && pos < HEADER_BUF_SIZE {
@@ -156,7 +131,7 @@ fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta,
     while !buf[pos].is_ascii_whitespace() && pos < HEADER_BUF_SIZE {
         pos += 1;
     }
-    let maxval: u32 = num_from_byte_slice(&buf[maxval_start..pos]);
+    let maxval: u32 = num_from_byte_slice(&buf[maxval_start..pos])?;
 
     if !(0..65536).contains(&maxval) {
         return Err(ParseError::InvalidMaxVal { got: maxval });
@@ -168,14 +143,14 @@ fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta,
     }
 
     // •      A raster of Height rows, [...]
-    let raster_start_pos = pos;
+    let _raster_start_pos = pos;
 
     let dtype: DType = DType::from_maxval(maxval);
     let data_length_bytes = width as usize * height as usize * dtype.num_bytes();
 
     let header_length_bytes: usize = pos + 1;
 
-    let meta = FrameMeta {
+    let meta = ASIMpxFrameMeta {
         sequence,
         dtype,
         width,
@@ -189,19 +164,13 @@ fn parse_header(buf: &[u8; HEADER_BUF_SIZE], sequence: u64) -> Result<FrameMeta,
     Ok(meta)
 }
 
-/// Puts `new` into `right`, `right` into `left` and returns the old `left`
-fn three_way_shift<T>(left: &mut T, right: &mut T, new: T) -> T {
-    let old_right = replace(right, new);
-    replace(left, old_right)
-}
-
 fn recv_frame(
     sequence: u64,
     stream: &mut TcpStream,
-    control_channel: &Receiver<ControlMsg>,
-    frame_stack: &mut FrameStackForWriting,
-    extra_frame_stack: &mut FrameStackForWriting,
-) -> Result<FrameMeta, AcquisitionError> {
+    control_channel: &Receiver<ASIMpxControlMsg>,
+    frame_stack: &mut FrameStackForWriting<ASIMpxFrameMeta>,
+    extra_frame_stack: &mut FrameStackForWriting<ASIMpxFrameMeta>,
+) -> Result<ASIMpxFrameMeta, AcquisitionError> {
     // TODO:
     // - timeout handling
     // - error handling (parsing, receiving)
@@ -210,19 +179,7 @@ fn recv_frame(
 
     let mut buf: [u8; HEADER_BUF_SIZE] = [0; HEADER_BUF_SIZE];
 
-    // FIXME: need to have a timeout here!
-    // In the happy case, this succeeds, or we get a
-    // ConnectionReset/ConnectionAborted, but in case the network inbetween is
-    // going bad, we might block here indefinitely. But we must regularly check
-    // for control messages from the `control_channel`, which we can't do here
-    // like this.
-    match stream.read_exact(&mut buf) {
-        Ok(_) => {}
-        Err(e) => {
-            // any kind of connection error means something is gone bad
-            return Err(AcquisitionError::ConnectionError { msg: e.to_string() });
-        }
-    }
+    tcp::read_exact_interruptible(stream, &mut buf, || check_for_control(control_channel))?;
 
     // 2) Parse the header, importantly reading width, height, bytes-per-pixel (maxval)
 
@@ -264,57 +221,45 @@ fn recv_frame(
 
         let dest_rest = &mut dest_buf[head_src.len()..];
 
-        // FIXME: this blocks - we need to check for control messages every now and then
-        match stream.read_exact(dest_rest) {
-            Ok(_) => Ok(()),
-            Err(e) => Err(AcquisitionError::ConnectionError { msg: e.to_string() }),
-        }
+        tcp::read_exact_interruptible(stream, dest_rest, || check_for_control(control_channel))?;
+
+        Ok::<_, AcquisitionError>(())
     })?;
 
     Ok(meta)
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, thiserror::Error)]
 enum AcquisitionError {
+    #[error("other end has disconnected")]
     Disconnected,
+
+    #[error("background thread stopped")]
+    StopThread,
+
+    #[error("acquisition cancelled by user")]
     Cancelled,
+
+    #[error("shm buffer is full")]
     BufferFull,
+
+    #[error("state error: {msg}")]
     StateError { msg: String },
+
+    #[error("configuration error: {msg}")]
     ConfigurationError { msg: String },
+
+    #[error("parse error: {msg}")]
     ParseError { msg: String },
+
+    #[error("connection error: {msg}")]
     ConnectionError { msg: String },
+
+    #[error("serval HTTP API error: {msg}")]
     APIError { msg: String },
-}
 
-impl Display for AcquisitionError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            AcquisitionError::Cancelled => {
-                write!(f, "acquisition cancelled")
-            }
-            AcquisitionError::Disconnected => {
-                write!(f, "other end has disconnected")
-            }
-            AcquisitionError::BufferFull => {
-                write!(f, "shm buffer is full")
-            }
-            AcquisitionError::StateError { msg } => {
-                write!(f, "state error: {msg}")
-            }
-            AcquisitionError::ConfigurationError { msg } => {
-                write!(f, "configuration error: {msg}")
-            }
-            AcquisitionError::ParseError { msg } => {
-                write!(f, "parse error: {msg}")
-            }
-            AcquisitionError::ConnectionError { msg } => {
-                write!(f, "connection error: {msg}")
-            }
-            AcquisitionError::APIError { msg } => {
-                write!(f, "serval HTTP API error: {msg}")
-            }
-        }
-    }
+    #[error("error writing to shm: {0}")]
+    WriteError(#[from] FrameStackWriteError),
 }
 
 impl From<ParseError> for AcquisitionError {
@@ -339,16 +284,46 @@ impl From<ServalError> for AcquisitionError {
     }
 }
 
+impl From<ReadExactError<AcquisitionError>> for AcquisitionError {
+    fn from(value: ReadExactError<AcquisitionError>) -> Self {
+        match value {
+            ReadExactError::Interrupted { size, err } => {
+                warn!("interrupted read after {size} bytes; discarding buffer");
+                err
+            }
+            ReadExactError::IOError { err } => Self::from(err),
+            ReadExactError::PeekError { size } => Self::ConnectionError {
+                msg: format!("could not peek {size} bytes"),
+            },
+            ReadExactError::Eof => Self::ConnectionError {
+                msg: "EOF".to_owned(),
+            },
+        }
+    }
+}
+
+impl From<std::io::Error> for AcquisitionError {
+    fn from(value: std::io::Error) -> Self {
+        Self::ConnectionError {
+            msg: format!("i/o error: {value}"),
+        }
+    }
+}
+
 /// With a running acquisition, check for control messages;
 /// especially convert `ControlMsg::StopThread` to `AcquisitionError::Cancelled`.
-fn check_for_control(control_channel: &Receiver<ControlMsg>) -> Result<(), AcquisitionError> {
+fn check_for_control(control_channel: &Receiver<ASIMpxControlMsg>) -> Result<(), AcquisitionError> {
     match control_channel.try_recv() {
         Ok(ControlMsg::StartAcquisitionPassive) => Err(AcquisitionError::StateError {
             msg: "received StartAcquisitionPassive while an acquisition was already running"
                 .to_string(),
         }),
-        Ok(ControlMsg::StopThread) => Err(AcquisitionError::Cancelled),
-        Err(TryRecvError::Disconnected) => Err(AcquisitionError::Cancelled),
+        Ok(ControlMsg::StopThread) => Err(AcquisitionError::StopThread),
+        Ok(ControlMsg::SpecializedControlMsg { msg: _ }) => {
+            panic!("unsupported SpecializedControlMsg")
+        }
+        Ok(ControlMsg::CancelAcquisition) => Err(AcquisitionError::Cancelled),
+        Err(TryRecvError::Disconnected) => Err(AcquisitionError::Disconnected),
         Err(TryRecvError::Empty) => Ok(()),
     }
 }
@@ -356,8 +331,8 @@ fn check_for_control(control_channel: &Receiver<ControlMsg>) -> Result<(), Acqui
 /// Passively listen for the start of an acquisition
 /// and automatically latch on to it.
 fn passive_acquisition(
-    control_channel: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    control_channel: &Receiver<ASIMpxControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<ASIMpxFrameMeta, PendingAcquisition>>,
     frame_stack_size: usize,
     data_uri: &str,
     api_uri: &str,
@@ -386,8 +361,10 @@ fn passive_acquisition(
             },
         };
 
+        stream.set_read_timeout(Some(Duration::from_millis(100)))?;
+
         // block until we get the first frame:
-        let first_frame_meta = match peek_header(&mut stream) {
+        let first_frame_meta = match peek_header(&mut stream, control_channel) {
             Ok(m) => m,
             Err(AcquisitionError::ConnectionError { msg }) => {
                 warn!("connection error while peeking first frame: {msg}; reconnecting");
@@ -419,10 +396,10 @@ fn passive_acquisition(
 }
 
 fn acquisition(
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<ASIMpxControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<ASIMpxFrameMeta, PendingAcquisition>>,
     detector_config: &DetectorConfig,
-    first_frame_meta: &FrameMeta,
+    first_frame_meta: &ASIMpxFrameMeta,
     stream: &mut TcpStream,
     frame_stack_size: usize,
     shm: &mut SharedSlabAllocator,
@@ -430,15 +407,14 @@ fn acquisition(
     let t0 = Instant::now();
     let mut last_control_check = Instant::now();
 
-    from_thread_s.send(ResultMsg::AcquisitionStart {
-        detector_config: detector_config.clone(),
-        first_frame_meta: first_frame_meta.clone(),
+    from_thread_s.send(ReceiverMsg::AcquisitionStart {
+        pending_acquisition: PendingAcquisition::new(detector_config, first_frame_meta),
     })?;
 
     debug!("acquisition starting");
 
     // approx uppper bound of image size in bytes
-    let peek_meta = peek_header(stream)?;
+    let peek_meta = peek_header(stream, to_thread_r)?;
     let approx_size_bytes = 2 * peek_meta.get_size();
 
     let slot = match shm.get_mut() {
@@ -496,10 +472,10 @@ fn acquisition(
 
                 let old_frame_stack =
                     three_way_shift(&mut frame_stack, &mut extra_frame_stack, new_frame_stack);
-                old_frame_stack.writing_done(shm)
+                old_frame_stack.writing_done(shm)?
             };
             // send to our queue:
-            from_thread_s.send(ResultMsg::FrameStack {
+            from_thread_s.send(ReceiverMsg::FrameStack {
                 frame_stack: to_send,
             })?;
         }
@@ -511,14 +487,14 @@ fn acquisition(
             let elapsed = t0.elapsed();
             info!("done in {elapsed:?}");
 
-            let handle = frame_stack.writing_done(shm);
-            from_thread_s.send(ResultMsg::End {
+            let handle = frame_stack.writing_done(shm)?;
+            from_thread_s.send(ReceiverMsg::Finished {
                 frame_stack: handle,
             })?;
 
             if !extra_frame_stack.is_empty() {
-                let handle = extra_frame_stack.writing_done(shm);
-                from_thread_s.send(ResultMsg::End {
+                let handle = extra_frame_stack.writing_done(shm)?;
+                from_thread_s.send(ReceiverMsg::Finished {
                     frame_stack: handle,
                 })?;
             } else {
@@ -527,8 +503,7 @@ fn acquisition(
                 // if `writing_done` was not called manually, which might happen
                 // in case of error handling.
                 // ah, but it can't, because it doesn't have a reference to `shm`! hmm
-                let handle = extra_frame_stack.writing_done(shm);
-                shm.free_idx(handle.slot.slot_idx);
+                extra_frame_stack.free_empty_frame_stack(shm)?;
             }
 
             return Ok(());
@@ -538,8 +513,8 @@ fn acquisition(
 
 /// convert `AcquisitionError`s to messages on `from_threads_s`
 fn background_thread_wrap(
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<ASIMpxControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<ASIMpxFrameMeta, PendingAcquisition>>,
     data_uri: &str,
     api_uri: &str,
     frame_stack_size: usize,
@@ -557,16 +532,16 @@ fn background_thread_wrap(
         // NOTE: `shm` is dropped in case of an error, so anyone who tries to connect afterwards
         // will get an error
         from_thread_s
-            .send(ResultMsg::Error {
-                msg: err.to_string(),
+            .send(ReceiverMsg::FatalError {
+                error: Box::new(err),
             })
             .unwrap();
     }
 }
 
 fn background_thread(
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<ASIMpxControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<ASIMpxFrameMeta, PendingAcquisition>>,
     data_uri: &str,
     api_uri: &str,
     frame_stack_size: usize,
@@ -577,6 +552,9 @@ fn background_thread(
             // control: main threads tells us to quit
             let control = to_thread_r.recv_timeout(Duration::from_millis(100));
             match control {
+                Ok(ControlMsg::CancelAcquisition) => {
+                    warn!("background_thread: ControlMsg::CancelAcquisition without running acquisition");
+                }
                 Ok(ControlMsg::StartAcquisitionPassive) => {
                     match passive_acquisition(
                         to_thread_r,
@@ -587,13 +565,19 @@ fn background_thread(
                         &mut shm,
                     ) {
                         Ok(_) => {}
-                        Err(AcquisitionError::Disconnected | AcquisitionError::Cancelled) => {
+                        Err(AcquisitionError::Cancelled) => {
+                            from_thread_s.send(ReceiverMsg::Cancelled).unwrap();
+                            continue 'outer;
+                        }
+                        e @ Err(AcquisitionError::Disconnected | AcquisitionError::StopThread) => {
+                            info!("background_thread: terminating: {e:?}");
                             return Ok(());
                         }
                         Err(e) => {
-                            let msg = format!("passive_acquisition error: {}", e);
-                            from_thread_s.send(ResultMsg::Error { msg }).unwrap();
                             error!("background_thread: error: {}; re-connecting", e);
+                            from_thread_s
+                                .send(ReceiverMsg::FatalError { error: Box::new(e) })
+                                .unwrap();
                             continue 'outer;
                         }
                     }
@@ -607,6 +591,9 @@ fn background_thread(
                     break 'outer;
                 }
                 Err(RecvTimeoutError::Timeout) => (), // no message, nothing to do
+                Ok(ControlMsg::SpecializedControlMsg { msg: _ }) => {
+                    panic!("ControlMsg::SpecializesControlMsg is unused for ASI MPX3");
+                }
             }
         }
     }
@@ -625,117 +612,66 @@ impl Display for ReceiverError {
     }
 }
 
-/// Start a background thread that receives data from the socket and
-/// puts it into shared memory.
-pub struct ServalReceiver {
-    bg_thread: Option<JoinHandle<()>>,
-    to_thread: Sender<ControlMsg>,
-    from_thread: Receiver<ResultMsg>,
-    pub status: ReceiverStatus,
-    pub shm_handle: SHMHandle,
+pub struct ASIMpxBackgroundThread {
+    bg_thread: JoinHandle<()>,
+    to_thread: Sender<ControlMsg<()>>,
+    from_thread: Receiver<ReceiverMsg<ASIMpxFrameMeta, PendingAcquisition>>,
 }
 
-impl ServalReceiver {
-    pub fn new(
-        data_uri: &str,
-        api_uri: &str,
-        frame_stack_size: usize,
-        shm: SharedSlabAllocator,
-    ) -> Self {
-        let (to_thread_s, to_thread_r) = unbounded();
-        let (from_thread_s, from_thread_r) = unbounded();
+impl BackgroundThread for ASIMpxBackgroundThread {
+    type FrameMetaImpl = ASIMpxFrameMeta;
+    type AcquisitionConfigImpl = PendingAcquisition;
+    type ExtraControl = ();
 
-        let builder = std::thread::Builder::new();
-        let data_uri = data_uri.to_string();
-        let api_uri = api_uri.to_string();
-
-        let shm_handle = shm.get_handle();
-
-        ServalReceiver {
-            bg_thread: Some(
-                builder
-                    .name("bg_thread".to_string())
-                    .spawn(move || {
-                        background_thread_wrap(
-                            &to_thread_r,
-                            &from_thread_s,
-                            &data_uri,
-                            &api_uri,
-                            frame_stack_size,
-                            shm,
-                        )
-                    })
-                    .expect("failed to start background thread"),
-            ),
-            from_thread: from_thread_r,
-            to_thread: to_thread_s,
-            status: ReceiverStatus::Idle,
-            shm_handle,
-        }
-    }
-
-    fn adjust_status(&mut self, msg: &ResultMsg) {
-        match msg {
-            ResultMsg::AcquisitionStart { .. } => {
-                self.status = ReceiverStatus::Running;
-            }
-            ResultMsg::End { .. } => {
-                self.status = ReceiverStatus::Idle;
-            }
-            _ => {}
-        }
+    fn channel_to_thread(&mut self) -> &mut Sender<ControlMsg<Self::ExtraControl>> {
+        &mut self.to_thread
     }
 
-    pub fn recv(&mut self) -> ResultMsg {
-        let result_msg = self
-            .from_thread
-            .recv()
-            .expect("background thread should be running");
-        self.adjust_status(&result_msg);
-        result_msg
+    fn channel_from_thread(
+        &mut self,
+    ) -> &mut std::sync::mpsc::Receiver<ReceiverMsg<Self::FrameMetaImpl, Self::AcquisitionConfigImpl>>
+    {
+        &mut self.from_thread
     }
 
-    pub fn next_timeout(&mut self, timeout: Duration) -> Option<ResultMsg> {
-        let result_msg = self.from_thread.recv_timeout(timeout);
-
-        match result_msg {
-            Ok(result) => {
-                self.adjust_status(&result);
-                Some(result)
-            }
-            Err(e) => match e {
-                RecvTimeoutError::Disconnected => {
-                    panic!("background thread should be running")
-                }
-                RecvTimeoutError::Timeout => None,
-            },
+    fn join(self) {
+        if let Err(e) = self.bg_thread.join() {
+            // FIXME: should we have an error boundary here instead and stop the panic?
+            std::panic::resume_unwind(e)
         }
     }
+}
 
-    pub fn start_passive(&mut self) -> Result<(), ReceiverError> {
-        if self.status == ReceiverStatus::Closed {
-            return Err(ReceiverError {
-                msg: "receiver is closed".to_string(),
-            });
-        }
-        self.to_thread
-            .send(ControlMsg::StartAcquisitionPassive)
-            .expect("background thread should be running");
-        self.status = ReceiverStatus::Running;
-        Ok(())
-    }
+impl ASIMpxBackgroundThread {
+    pub fn spawn(
+        config: &ASIMpxDetectorConnConfig,
+        shm: &SharedSlabAllocator,
+    ) -> Result<Self, BackgroundThreadSpawnError> {
+        let (to_thread_s, to_thread_r) = channel();
+        let (from_thread_s, from_thread_r) = channel();
 
-    pub fn close(&mut self) {
-        if self.to_thread.send(ControlMsg::StopThread).is_err() {
-            warn!("could not stop background thread, probably already dead");
-        }
-        if let Some(join_handle) = self.bg_thread.take() {
-            join_handle
-                .join()
-                .expect("could not join background thread!");
-        } else {
-            warn!("did not have a bg thread join handle, cannot join!");
-        }
-        self.status = ReceiverStatus::Closed;
+        let builder = std::thread::Builder::new();
+        let data_uri = config.data_uri.to_owned();
+        let api_uri = config.api_uri.to_owned();
+        let shm = shm.clone_and_connect()?;
+        let frame_stack_size = config.frame_stack_size;
+
+        Ok(Self {
+            bg_thread: builder
+                .name("bg_thread".to_string())
+                .spawn(move || {
+                    background_thread_wrap(
+                        &to_thread_r,
+                        &from_thread_s,
+                        &data_uri,
+                        &api_uri,
+                        frame_stack_size,
+                        shm,
+                    )
+                })
+                .map_err(BackgroundThreadSpawnError::SpawnFailed)?,
+            from_thread: from_thread_r,
+            to_thread: to_thread_s,
+        })
     }
 }
diff --git a/libertem_asi_mpx3/src/base_types.rs b/libertem_asi_mpx3/src/base_types.rs
new file mode 100644
index 00000000..f047e396
--- /dev/null
+++ b/libertem_asi_mpx3/src/base_types.rs
@@ -0,0 +1,197 @@
+use common::{
+    frame_stack::FrameMeta,
+    generic_connection::{AcquisitionConfig, DetectorConnectionConfig},
+};
+use pyo3::{pyclass, pymethods};
+use serde::{Deserialize, Serialize};
+use serval_client::DetectorConfig;
+
+#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
+#[pyclass]
+pub enum DType {
+    U8,
+    U16,
+}
+
+impl DType {
+    pub fn from_maxval(maxval: u32) -> Self {
+        if maxval < 256 {
+            DType::U8
+        } else {
+            DType::U16
+        }
+    }
+
+    pub fn num_bytes(&self) -> usize {
+        match self {
+            DType::U8 => 1,
+            DType::U16 => 2,
+        }
+    }
+}
+
+#[pymethods]
+impl DType {
+    fn as_string(&self) -> String {
+        match self {
+            Self::U8 => "uint8".to_string(),
+            Self::U16 => "uint16".to_string(),
+        }
+    }
+}
+
+#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
+pub struct ASIMpxFrameMeta {
+    pub sequence: u64,
+    pub dtype: DType,
+    pub width: u16,
+    pub height: u16,
+
+    /// The exact length of the data for this frame
+    pub data_length_bytes: usize,
+
+    /// The length of the header in bytes
+    pub header_length_bytes: usize,
+}
+
+impl ASIMpxFrameMeta {
+    /// Get the number of elements in this frame (`prod(shape)`)
+    pub fn get_size(&self) -> usize {
+        self.width as usize * self.height as usize
+    }
+}
+
+impl FrameMeta for ASIMpxFrameMeta {
+    fn get_data_length_bytes(&self) -> usize {
+        self.data_length_bytes
+    }
+
+    fn get_dtype_string(&self) -> String {
+        self.dtype.as_string()
+    }
+
+    fn get_shape(&self) -> (u64, u64) {
+        (self.width as u64, self.height as u64)
+    }
+}
+
+// FIXME: can we divide this more cleanly - PendingAcquisition probably
+// shouldn't be a `pyclass`? It depends on PyDetectorConfig...
+
+#[derive(Debug)]
+#[pyclass(name = "DetectorConfig")]
+pub struct PyDetectorConfig {
+    config: DetectorConfig,
+}
+
+#[pymethods]
+impl PyDetectorConfig {
+    fn __repr__(&self) -> String {
+        format!("{:?}", self)
+    }
+
+    fn get_n_triggers(&self) -> u64 {
+        self.config.n_triggers
+    }
+}
+
+impl PyDetectorConfig {
+    pub fn new(config: DetectorConfig) -> Self {
+        Self { config }
+    }
+}
+
+#[derive(Debug)]
+#[pyclass]
+pub struct PendingAcquisition {
+    config: DetectorConfig,
+    first_frame_meta: ASIMpxFrameMeta,
+}
+
+impl PendingAcquisition {
+    pub fn new(config: &DetectorConfig, first_frame_meta: &ASIMpxFrameMeta) -> Self {
+        Self {
+            config: config.clone(),
+            first_frame_meta: first_frame_meta.clone(),
+        }
+    }
+}
+
+#[pymethods]
+impl PendingAcquisition {
+    fn get_detector_config(&self) -> PyDetectorConfig {
+        PyDetectorConfig {
+            config: self.config.clone(),
+        }
+    }
+
+    fn get_frame_width(&self) -> u16 {
+        self.first_frame_meta.width
+    }
+
+    fn get_frame_height(&self) -> u16 {
+        self.first_frame_meta.height
+    }
+}
+
+impl AcquisitionConfig for PendingAcquisition {
+    fn num_frames(&self) -> usize {
+        self.config.n_triggers as usize
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ASIMpxDetectorConnConfig {
+    pub data_uri: String,
+    pub api_uri: String,
+
+    /// number of frames per frame stack (approx?)
+    pub frame_stack_size: usize,
+
+    /// approx. number of bytes per frame
+    pub bytes_per_frame: usize,
+
+    num_slots: usize,
+    enable_huge_pages: bool,
+    shm_handle_path: String,
+}
+
+impl ASIMpxDetectorConnConfig {
+    pub fn new(
+        data_uri: &str,
+        api_uri: &str,
+        frame_stack_size: usize,
+        bytes_per_frame: usize,
+        num_slots: usize,
+        enable_huge_pages: bool,
+        shm_handle_path: &str,
+    ) -> Self {
+        Self {
+            data_uri: data_uri.to_owned(),
+            api_uri: api_uri.to_owned(),
+            frame_stack_size,
+            bytes_per_frame,
+            num_slots,
+            enable_huge_pages,
+            shm_handle_path: shm_handle_path.to_owned(),
+        }
+    }
+}
+
+impl DetectorConnectionConfig for ASIMpxDetectorConnConfig {
+    fn get_shm_num_slots(&self) -> usize {
+        self.num_slots
+    }
+
+    fn get_shm_slot_size(&self) -> usize {
+        self.frame_stack_size * self.bytes_per_frame
+    }
+
+    fn get_shm_enable_huge_pages(&self) -> bool {
+        self.enable_huge_pages
+    }
+
+    fn get_shm_handle_path(&self) -> String {
+        self.shm_handle_path.clone()
+    }
+}
diff --git a/libertem_asi_mpx3/src/cam_client.rs b/libertem_asi_mpx3/src/cam_client.rs
deleted file mode 100644
index faf79d54..00000000
--- a/libertem_asi_mpx3/src/cam_client.rs
+++ /dev/null
@@ -1,87 +0,0 @@
-use ipc_test::SharedSlabAllocator;
-use log::trace;
-use std::ffi::c_int;
-
-use crate::{common::DType, exceptions::ConnectionError, frame_stack::FrameStackHandle};
-use pyo3::{exceptions::PyRuntimeError, ffi::PyMemoryView_FromMemory, prelude::*, FromPyPointer};
-
-#[pyclass]
-pub struct CamClient {
-    shm: Option<SharedSlabAllocator>,
-}
-
-#[allow(non_upper_case_globals)]
-const PyBUF_READ: c_int = 0x100;
-
-impl CamClient {
-    fn get_memoryview(&self, py: Python, raw_data: &[u8]) -> PyObject {
-        let ptr = raw_data.as_ptr();
-        let length = raw_data.len();
-
-        let mv = unsafe {
-            PyMemoryView_FromMemory(ptr as *mut i8, length.try_into().unwrap(), PyBUF_READ)
-        };
-        let from_ptr: &PyAny = unsafe { FromPyPointer::from_owned_ptr(py, mv) };
-        from_ptr.into_py(py)
-    }
-}
-
-#[pymethods]
-impl CamClient {
-    #[new]
-    fn new(handle_path: &str) -> PyResult<Self> {
-        match SharedSlabAllocator::connect(handle_path) {
-            Ok(shm) => Ok(CamClient { shm: Some(shm) }),
-            Err(e) => {
-                let msg = format!("failed to connect to SHM: {:?}", e);
-                Err(ConnectionError::new_err(msg))
-            }
-        }
-    }
-
-    fn get_frames(
-        &self,
-        handle: &FrameStackHandle,
-        py: Python,
-    ) -> PyResult<Vec<(PyObject, DType)>> {
-        let slot: ipc_test::Slot = if let Some(shm) = &self.shm {
-            shm.get(handle.slot.slot_idx)
-        } else {
-            return Err(PyRuntimeError::new_err("can't decompress with closed SHM"));
-        };
-
-        Ok(handle
-            .get_meta()
-            .iter()
-            .zip(0..)
-            .map(|(meta, idx)| {
-                let image_data = handle.get_slice_for_frame(idx, &slot);
-                let memory_view = self.get_memoryview(py, image_data);
-
-                (memory_view, meta.dtype.clone())
-            })
-            .collect())
-    }
-
-    fn done(mut slf: PyRefMut<Self>, handle: &FrameStackHandle) -> PyResult<()> {
-        let slot_idx = handle.slot.slot_idx;
-        if let Some(shm) = &mut slf.shm {
-            shm.free_idx(slot_idx);
-            Ok(())
-        } else {
-            Err(PyRuntimeError::new_err(
-                "CamClient.done called with SHM closed",
-            ))
-        }
-    }
-
-    fn close(&mut self) {
-        self.shm.take();
-    }
-}
-
-impl Drop for CamClient {
-    fn drop(&mut self) {
-        trace!("CamClient::drop");
-    }
-}
diff --git a/libertem_asi_mpx3/src/common.rs b/libertem_asi_mpx3/src/common.rs
deleted file mode 100644
index f02593f0..00000000
--- a/libertem_asi_mpx3/src/common.rs
+++ /dev/null
@@ -1,57 +0,0 @@
-use pyo3::{pyclass, pymethods};
-use serde::{Deserialize, Serialize};
-
-#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
-#[pyclass]
-pub enum DType {
-    U8,
-    U16,
-}
-
-impl DType {
-    pub fn from_maxval(maxval: u32) -> Self {
-        if maxval < 256 {
-            DType::U8
-        } else {
-            DType::U16
-        }
-    }
-
-    pub fn num_bytes(&self) -> usize {
-        match self {
-            DType::U8 => 1,
-            DType::U16 => 2,
-        }
-    }
-}
-
-#[pymethods]
-impl DType {
-    fn as_string(&self) -> String {
-        match self {
-            Self::U8 => "uint8".to_string(),
-            Self::U16 => "uint16".to_string(),
-        }
-    }
-}
-
-#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
-pub struct FrameMeta {
-    pub sequence: u64,
-    pub dtype: DType,
-    pub width: u16,
-    pub height: u16,
-
-    /// The exact length of the data for this frame
-    pub data_length_bytes: usize,
-
-    /// The length of the header in bytes
-    pub header_length_bytes: usize,
-}
-
-impl FrameMeta {
-    /// Get the number of elements in this frame (`prod(shape)`)
-    pub fn get_size(&self) -> u64 {
-        self.width as u64 * self.height as u64
-    }
-}
diff --git a/libertem_asi_mpx3/src/decoder.rs b/libertem_asi_mpx3/src/decoder.rs
new file mode 100644
index 00000000..bd7f3859
--- /dev/null
+++ b/libertem_asi_mpx3/src/decoder.rs
@@ -0,0 +1,63 @@
+use common::decoder::{try_cast_if_safe, Decoder, DecoderError, DecoderTargetPixelType};
+use numpy::ndarray::{s, ArrayViewMut3};
+use zerocopy::FromBytes;
+
+use crate::base_types::{ASIMpxFrameMeta, DType};
+
+#[derive(Debug, Default)]
+pub struct ASIMpxDecoder {}
+
+impl Decoder for ASIMpxDecoder {
+    type FrameMeta = ASIMpxFrameMeta;
+
+    fn decode<T>(
+        &self,
+        shm: &ipc_test::SharedSlabAllocator,
+        input: &common::frame_stack::FrameStackHandle<Self::FrameMeta>,
+        output: &mut ArrayViewMut3<'_, T>,
+        start_idx: usize,
+        end_idx: usize,
+    ) -> Result<(), DecoderError>
+    where
+        T: DecoderTargetPixelType,
+    {
+        input.with_slot(shm, |slot| {
+            for ((frame_meta, out_idx), in_idx) in
+                input.get_meta().iter().zip(0..).zip(start_idx..end_idx)
+            {
+                let mut out_view = output.slice_mut(s![out_idx, .., ..]);
+                let out_slice =
+                    out_view
+                        .as_slice_mut()
+                        .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                            msg: "out slice not C-order contiguous".to_owned(),
+                        })?;
+                let dtype = &frame_meta.dtype;
+                let raw_input_data = input.get_slice_for_frame(in_idx, slot);
+
+                match dtype {
+                    DType::U8 => {
+                        try_cast_if_safe(raw_input_data, out_slice)?;
+                    }
+                    DType::U16 => {
+                        let data_as_u16: &[u16] = FromBytes::slice_from(raw_input_data)
+                            .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                                msg: "could not interprete input data as u16".to_owned(),
+                            })?;
+                        try_cast_if_safe(data_as_u16, out_slice)?;
+                    }
+                }
+            }
+
+            Ok(())
+        })
+    }
+
+    fn zero_copy_available(
+        &self,
+        _handle: &common::frame_stack::FrameStackHandle<Self::FrameMeta>,
+    ) -> Result<bool, common::decoder::DecoderError> {
+        // FIXME: impl zero copy!
+        Ok(false)
+    }
+}
diff --git a/libertem_asi_mpx3/src/frame_stack.rs b/libertem_asi_mpx3/src/frame_stack.rs
deleted file mode 100644
index ba03bb7e..00000000
--- a/libertem_asi_mpx3/src/frame_stack.rs
+++ /dev/null
@@ -1,235 +0,0 @@
-use bincode::serialize;
-use ipc_test::{SharedSlabAllocator, SlotForWriting, SlotInfo};
-use pyo3::{
-    exceptions::{PyRuntimeError, PyValueError},
-    prelude::*,
-    types::{PyBytes, PyType},
-};
-use serde::{Deserialize, Serialize};
-
-use crate::common::FrameMeta;
-
-pub struct FrameStackForWriting {
-    slot: SlotForWriting,
-    meta: Vec<FrameMeta>,
-
-    /// where in the slot do the frames begin? this can be unevenly spaced
-    offsets: Vec<usize>,
-
-    /// offset where the next frame will be written
-    pub(crate) cursor: usize,
-
-    /// number of bytes reserved for each frame
-    /// as some frames compress better or worse, this is just the "planning" number
-    bytes_per_frame: usize,
-}
-
-impl FrameStackForWriting {
-    pub fn new(slot: SlotForWriting, capacity: usize, bytes_per_frame: usize) -> Self {
-        FrameStackForWriting {
-            slot,
-            cursor: 0,
-            bytes_per_frame,
-            // reserve a bit more, as we don't know the upper bound of frames
-            // per stack and using a bit more memory is better than having to
-            // resize the vectors
-            meta: Vec::with_capacity(2 * capacity),
-            offsets: Vec::with_capacity(2 * capacity),
-        }
-    }
-
-    pub fn len(&self) -> usize {
-        self.meta.len()
-    }
-
-    pub fn can_fit(&self, num_bytes: usize) -> bool {
-        self.slot.size - self.cursor >= num_bytes
-    }
-
-    pub fn slot_size(&self) -> usize {
-        self.slot.size
-    }
-
-    pub fn bytes_free(&self) -> usize {
-        self.slot.size - self.cursor
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-
-    /// Temporarily take mutable ownership of a buffer
-    /// for a single frame, and receive the data into it.
-    pub fn write_frame<E>(
-        &mut self,
-        meta: &FrameMeta,
-        mut fill_buffer: impl FnMut(&mut [u8]) -> Result<(), E>,
-    ) -> Result<(), E> {
-        // FIXME: `fill_buffer` should return a `Result`
-        let start = self.cursor;
-        let stop = start + meta.data_length_bytes;
-        let dest = &mut self.slot.as_slice_mut()[start..stop];
-        fill_buffer(dest)?;
-
-        self.meta.push(meta.clone());
-        self.offsets.push(self.cursor);
-        self.cursor += meta.data_length_bytes;
-
-        Ok(())
-    }
-
-    pub fn writing_done(self, shm: &mut SharedSlabAllocator) -> FrameStackHandle {
-        let slot_info = shm.writing_done(self.slot);
-
-        FrameStackHandle {
-            slot: slot_info,
-            meta: self.meta,
-            offsets: self.offsets,
-            bytes_per_frame: self.bytes_per_frame,
-        }
-    }
-}
-
-/// serializable handle for a stack of frames that live in shm
-#[pyclass]
-#[derive(PartialEq, Eq, Serialize, Deserialize, Debug)]
-pub struct FrameStackHandle {
-    pub(crate) slot: SlotInfo,
-    meta: Vec<FrameMeta>,
-    pub(crate) offsets: Vec<usize>,
-    pub(crate) bytes_per_frame: usize,
-}
-
-impl FrameStackHandle {
-    pub fn len(&self) -> usize {
-        self.meta.len()
-    }
-
-    #[must_use]
-    pub fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-
-    /// total number of _useful_ bytes in this frame stack
-    pub fn payload_size(&self) -> usize {
-        self.meta.iter().map(|fm| fm.data_length_bytes).sum()
-    }
-
-    /// total number of bytes allocated for the slot
-    pub fn slot_size(&self) -> usize {
-        self.slot.size
-    }
-
-    pub fn get_meta(&self) -> &Vec<FrameMeta> {
-        &self.meta
-    }
-
-    pub(crate) fn deserialize_impl(serialized: &PyBytes) -> PyResult<Self> {
-        let data = serialized.as_bytes();
-        bincode::deserialize(data).map_err(|e| {
-            let msg = format!("could not deserialize FrameStackHandle: {e:?}");
-            PyRuntimeError::new_err(msg)
-        })
-    }
-
-    pub fn get_slice_for_frame<'a>(&'a self, frame_idx: usize, slot: &'a ipc_test::Slot) -> &[u8] {
-        let slice = slot.as_slice();
-        let in_offset = self.offsets[frame_idx];
-        let size = self.meta[frame_idx].data_length_bytes;
-        &slice[in_offset..in_offset + size]
-    }
-
-    /// Split self at `mid` and create two new `FrameStackHandle`s.
-    /// The first will contain frames with indices [0..mid), the second [mid..len)`
-    pub fn split_at(self, mid: usize, shm: &mut SharedSlabAllocator) -> (Self, Self) {
-        // FIXME: this whole thing is falliable, so modify return type to Result<> (or PyResult<>?)
-        let bytes_mid = self.offsets[mid];
-        let (left, right) = {
-            let slot: ipc_test::Slot = shm.get(self.slot.slot_idx);
-            let slice = slot.as_slice();
-
-            let mut slot_left = shm.get_mut().expect("shm slot for writing");
-            let slice_left = slot_left.as_slice_mut();
-
-            let mut slot_right = shm.get_mut().expect("shm slot for writing");
-            let slice_right = slot_right.as_slice_mut();
-
-            slice_left[..bytes_mid].copy_from_slice(&slice[..bytes_mid]);
-            slice_right[..(slice.len() - bytes_mid)].copy_from_slice(&slice[bytes_mid..]);
-
-            let left = shm.writing_done(slot_left);
-            let right = shm.writing_done(slot_right);
-
-            shm.free_idx(self.slot.slot_idx);
-
-            (left, right)
-        };
-
-        let (left_meta, right_meta) = self.meta.split_at(mid);
-        let (left_offsets, right_offsets) = self.offsets.split_at(mid);
-
-        (
-            FrameStackHandle {
-                slot: left,
-                meta: left_meta.to_vec(),
-                offsets: left_offsets.to_vec(),
-                bytes_per_frame: self.bytes_per_frame,
-            },
-            FrameStackHandle {
-                slot: right,
-                meta: right_meta.to_vec(),
-                offsets: right_offsets.iter().map(|o| o - bytes_mid).collect(),
-                bytes_per_frame: self.bytes_per_frame,
-            },
-        )
-    }
-
-    fn first_meta(&self) -> PyResult<&FrameMeta> {
-        self.meta.first().map_or_else(
-            || Err(PyValueError::new_err("empty frame stack".to_string())),
-            Ok,
-        )
-    }
-}
-
-#[pymethods]
-impl FrameStackHandle {
-    pub fn serialize(&self, py: Python) -> PyResult<Py<PyBytes>> {
-        let bytes: &PyBytes = PyBytes::new(py, serialize(self).unwrap().as_slice());
-        Ok(bytes.into())
-    }
-
-    #[classmethod]
-    fn deserialize(_cls: &PyType, serialized: &PyBytes) -> PyResult<Self> {
-        Self::deserialize_impl(serialized)
-    }
-
-    fn get_frame_id(slf: PyRef<Self>) -> PyResult<u64> {
-        Ok(slf.first_meta()?.sequence)
-    }
-
-    fn get_shape(slf: PyRef<Self>) -> PyResult<(u16, u16)> {
-        let meta = slf.first_meta()?;
-        Ok((meta.height, meta.width))
-    }
-
-    fn __len__(slf: PyRef<Self>) -> usize {
-        slf.len()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::path::PathBuf;
-
-    use super::FrameStackForWriting;
-    use ipc_test::{SharedSlabAllocator, Slot};
-    use tempfile::{tempdir, TempDir};
-
-    fn get_socket_path() -> (TempDir, PathBuf) {
-        let socket_dir = tempdir().unwrap();
-        let socket_as_path = socket_dir.path().join("stuff.socket");
-
-        (socket_dir, socket_as_path)
-    }
-}
diff --git a/libertem_asi_mpx3/src/lib.rs b/libertem_asi_mpx3/src/lib.rs
index df7da9b8..5d367ee3 100644
--- a/libertem_asi_mpx3/src/lib.rs
+++ b/libertem_asi_mpx3/src/lib.rs
@@ -1,7 +1,5 @@
-pub mod cam_client;
-pub mod common;
+pub mod background_thread;
+pub mod base_types;
+pub mod decoder;
 pub mod exceptions;
-pub mod frame_stack;
 pub mod main_py;
-pub mod receiver;
-pub mod stats;
diff --git a/libertem_asi_mpx3/src/main_py.rs b/libertem_asi_mpx3/src/main_py.rs
index 3f50d5f3..271fa9ac 100644
--- a/libertem_asi_mpx3/src/main_py.rs
+++ b/libertem_asi_mpx3/src/main_py.rs
@@ -1,42 +1,44 @@
 #![allow(clippy::borrow_deref_ref)]
 
-use std::{
-    convert::Infallible,
-    path::PathBuf,
-    time::{Duration, Instant},
-};
-
 use crate::{
-    cam_client::CamClient,
-    common::{DType, FrameMeta},
+    base_types::{
+        ASIMpxDetectorConnConfig, ASIMpxFrameMeta, DType, PendingAcquisition, PyDetectorConfig,
+    },
     exceptions::{ConnectionError, TimeoutError},
-    frame_stack::FrameStackHandle,
-    receiver::{ReceiverStatus, ResultMsg, ServalReceiver},
 };
 
-use ipc_test::SharedSlabAllocator;
-use log::{info, trace};
+use common::{generic_connection::GenericConnection, impl_py_cam_client, impl_py_connection};
+
+use log::trace;
+use numpy::PyUntypedArray;
 use pyo3::{
-    exceptions::{self, PyRuntimeError},
+    exceptions::PyRuntimeError,
     prelude::*,
+    types::{PyBytes, PyType},
 };
-use serval_client::{DetectorConfig, DetectorInfo, DetectorLayout, ServalClient};
-use stats::Stats;
+use serval_client::{DetectorInfo, DetectorLayout, ServalClient};
+
+use crate::background_thread::ASIMpxBackgroundThread;
+use crate::decoder::ASIMpxDecoder;
+
+use std::ffi::c_int;
+
+use pyo3::ffi::PyMemoryView_FromMemory;
 
 #[pymodule]
-fn libertem_asi_mpx3(py: Python, m: &PyModule) -> PyResult<()> {
+fn libertem_asi_mpx3(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
     // FIXME: logging integration deadlocks on close(), when trying to acquire
     // the GIL
     // pyo3_log::init();
 
-    m.add_class::<FrameStackHandle>()?;
+    m.add_class::<PyFrameStackHandle>()?;
     m.add_class::<ServalConnection>()?;
     m.add_class::<DType>()?;
     m.add_class::<PyDetectorConfig>()?;
     m.add_class::<PyDetectorInfo>()?;
     m.add_class::<PyServalClient>()?;
     m.add_class::<CamClient>()?;
-    m.add("TimeoutError", py.get_type::<TimeoutError>())?;
+    m.add("TimeoutError", py.get_type_bound::<TimeoutError>())?;
 
     register_header_module(py, m)?;
 
@@ -50,29 +52,12 @@ fn libertem_asi_mpx3(py: Python, m: &PyModule) -> PyResult<()> {
     Ok(())
 }
 
-fn register_header_module(py: Python<'_>, parent_module: &PyModule) -> PyResult<()> {
-    let headers_module = PyModule::new(py, "headers")?;
-    parent_module.add_submodule(headers_module)?;
+fn register_header_module(py: Python<'_>, parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
+    let headers_module = PyModule::new_bound(py, "headers")?;
+    parent_module.add_submodule(&headers_module)?;
     Ok(())
 }
 
-#[derive(Debug)]
-#[pyclass(name = "DetectorConfig")]
-struct PyDetectorConfig {
-    config: DetectorConfig,
-}
-
-#[pymethods]
-impl PyDetectorConfig {
-    fn __repr__(&self) -> String {
-        format!("{:?}", self)
-    }
-
-    fn get_n_triggers(&self) -> u64 {
-        self.config.n_triggers
-    }
-}
-
 #[derive(Debug)]
 #[pyclass(name = "DetectorInfo")]
 struct PyDetectorInfo {
@@ -99,7 +84,7 @@ struct PyDetectorLayout {
 #[pymethods]
 impl PyDetectorLayout {
     fn __repr__(&self) -> String {
-        format!("{:?}", self)
+        format!("{:?}", self.info)
     }
 }
 
@@ -127,7 +112,7 @@ impl PyServalClient {
         self.client
             .get_detector_config()
             .map_err(|e| PyRuntimeError::new_err(e.to_string()))
-            .map(|value| PyDetectorConfig { config: value })
+            .map(PyDetectorConfig::new)
     }
 
     fn get_detector_info(&self) -> PyResult<PyDetectorInfo> {
@@ -138,286 +123,238 @@ impl PyServalClient {
     }
 }
 
-struct FrameChunkedIterator<'a, 'b, 'c, 'd> {
-    receiver: &'a mut ServalReceiver,
-    shm: &'b mut SharedSlabAllocator,
-    remainder: &'c mut Vec<FrameStackHandle>,
-    stats: &'d mut Stats,
+#[pyclass]
+struct ServalConnection {
+    conn: _PyASIMpxConnection,
 }
 
-impl<'a, 'b, 'c, 'd> FrameChunkedIterator<'a, 'b, 'c, 'd> {
-    /// Get the next frame stack. Mainly handles splitting logic for boundary
-    /// conditions and delegates communication with the background thread to `recv_next_stack_impl`
-    pub fn get_next_stack_impl(
+#[pymethods]
+impl ServalConnection {
+    #[new]
+    fn new(
+        data_uri: &str,
+        api_uri: &str,
+        frame_stack_size: usize,
+        handle_path: String,
+        num_slots: Option<usize>,
+        bytes_per_frame: Option<usize>,
+        huge: Option<bool>,
+    ) -> PyResult<Self> {
+        let num_slots = num_slots.map_or_else(|| 2000, |x| x);
+        let bytes_per_frame = bytes_per_frame.map_or_else(|| 512 * 512 * 2, |x| x);
+
+        let config = ASIMpxDetectorConnConfig::new(
+            data_uri,
+            api_uri,
+            frame_stack_size,
+            bytes_per_frame,
+            num_slots,
+            huge.unwrap_or(false),
+            &handle_path,
+        );
+
+        let shm = GenericConnection::<ASIMpxBackgroundThread, PendingAcquisition>::shm_from_config(
+            &config,
+        )
+        .map_err(|e| PyConnectionError::new_err(format!("could not init shm: {e}")))?;
+
+        let bg_thread = ASIMpxBackgroundThread::spawn(&config, &shm)
+            .map_err(|e| ConnectionError::new_err(e.to_string()))?;
+
+        let generic_conn =
+            GenericConnection::<ASIMpxBackgroundThread, PendingAcquisition>::new(bg_thread, &shm)
+                .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+
+        let conn = _PyASIMpxConnection::new(shm, generic_conn);
+
+        Ok(Self { conn })
+    }
+
+    fn wait_for_arm(
         &mut self,
-        py: Python,
-        max_size: usize,
-    ) -> PyResult<Option<FrameStackHandle>> {
-        let res = self.recv_next_stack_impl(py);
-        match res {
-            Ok(Some(frame_stack)) => {
-                if frame_stack.len() > max_size {
-                    // split `FrameStackHandle` into two:
-                    trace!(
-                        "FrameStackHandle::split_at({max_size}); len={}",
-                        frame_stack.len()
-                    );
-                    self.stats.count_split();
-                    let (left, right) = frame_stack.split_at(max_size, self.shm);
-                    self.remainder.push(right);
-                    assert!(left.len() <= max_size);
-                    return Ok(Some(left));
-                }
-                assert!(frame_stack.len() <= max_size);
-                Ok(Some(frame_stack))
-            }
-            Ok(None) => Ok(None),
-            e @ Err(_) => e,
-        }
+        timeout: Option<f32>,
+        py: Python<'_>,
+    ) -> PyResult<Option<PendingAcquisition>> {
+        self.conn.wait_for_arm(timeout, py)
     }
 
-    /// Receive the next frame stack from the background thread and handle any
-    /// other control messages.
-    fn recv_next_stack_impl(&mut self, py: Python) -> PyResult<Option<FrameStackHandle>> {
-        // first, check if there is anything on the remainder list:
-        if let Some(frame_stack) = self.remainder.pop() {
-            return Ok(Some(frame_stack));
-        }
+    fn get_socket_path(&self) -> PyResult<String> {
+        self.conn.get_socket_path()
+    }
 
-        match self.receiver.status {
-            ReceiverStatus::Closed => {
-                return Err(exceptions::PyRuntimeError::new_err("receiver is closed"))
-            }
-            ReceiverStatus::Idle => return Ok(None),
-            ReceiverStatus::Running => {}
-        }
+    fn is_running(&self) -> PyResult<bool> {
+        self.conn.is_running()
+    }
 
-        let recv = &mut self.receiver;
-
-        loop {
-            py.check_signals()?;
-
-            let recv_result = py.allow_threads(|| {
-                let next: Result<Option<ResultMsg>, Infallible> =
-                    Ok(recv.next_timeout(Duration::from_millis(100)));
-                next
-            })?;
-
-            match recv_result {
-                None => {
-                    continue;
-                }
-                Some(ResultMsg::ParseError { msg }) => {
-                    return Err(exceptions::PyRuntimeError::new_err(msg))
-                }
-                Some(ResultMsg::AcquisitionStart {
-                    detector_config: _,
-                    first_frame_meta: _,
-                }) => {
-                    // FIXME: in case of "passive" mode, we should actually not hit this,
-                    // as the "outer" structure (`ServalConnection`) handles it?
-                    continue;
-                }
-                Some(ResultMsg::Error { msg }) => {
-                    return Err(exceptions::PyRuntimeError::new_err(msg))
-                }
-                Some(ResultMsg::End { frame_stack }) => {
-                    self.stats.log_stats();
-                    self.stats.reset();
-                    return Ok(Some(frame_stack));
-                }
-                Some(ResultMsg::FrameStack { frame_stack }) => {
-                    return Ok(Some(frame_stack));
-                }
-            }
-        }
+    fn start_passive(&mut self, timeout: Option<f32>, py: Python<'_>) -> PyResult<()> {
+        self.conn.start_passive(timeout, py)
     }
 
-    fn new(
-        receiver: &'a mut ServalReceiver,
-        shm: &'b mut SharedSlabAllocator,
-        remainder: &'c mut Vec<FrameStackHandle>,
-        stats: &'d mut Stats,
-    ) -> PyResult<Self> {
-        Ok(FrameChunkedIterator {
-            receiver,
-            shm,
-            remainder,
-            stats,
-        })
+    fn close(&mut self) -> PyResult<()> {
+        self.conn.close()
+    }
+
+    fn get_next_stack(
+        &mut self,
+        max_size: usize,
+        py: Python,
+    ) -> PyResult<Option<PyFrameStackHandle>> {
+        let stack_inner = self.conn.get_next_stack(max_size, py)?;
+        Ok(stack_inner.map(PyFrameStackHandle::new))
     }
 }
 
-#[pyclass]
-struct ServalConnection {
-    receiver: ServalReceiver,
-    remainder: Vec<FrameStackHandle>,
-    local_shm: SharedSlabAllocator,
-    stats: Stats,
+impl_py_connection!(
+    _PyASIMpxConnection,
+    _PyASIMpxFrameStack,
+    ASIMpxFrameMeta,
+    ASIMpxBackgroundThread,
+    PendingAcquisition,
+    libertem_asi_mpx3
+);
+
+impl_py_cam_client!(
+    _PyASIMpxCamClient,
+    ASIMpxDecoder,
+    _PyASIMpxFrameStack,
+    ASIMpxFrameMeta,
+    libertem_asi_mpx3
+);
+
+#[pyclass(name = "FrameStackHandle")]
+pub struct PyFrameStackHandle {
+    inner: _PyASIMpxFrameStack,
 }
 
-impl ServalConnection {
-    fn start_passive_impl(&mut self) -> PyResult<()> {
-        self.receiver
-            .start_passive()
-            .map_err(|err| exceptions::PyRuntimeError::new_err(err.msg))
+#[pymethods]
+impl PyFrameStackHandle {
+    fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
+        self.inner.serialize(py)
     }
 
-    fn close_impl(&mut self) {
-        self.receiver.close();
+    #[classmethod]
+    fn deserialize<'py>(
+        _cls: Bound<'py, PyType>,
+        serialized: Bound<'py, PyBytes>,
+    ) -> PyResult<Self> {
+        Ok(Self {
+            inner: _PyASIMpxFrameStack::deserialize_impl(serialized)?,
+        })
     }
-}
 
-#[pyclass]
-struct PendingAcquisition {
-    config: DetectorConfig,
-    first_frame_meta: FrameMeta,
+    fn get_frame_id(&self) -> PyResult<u64> {
+        Ok(self.inner.try_get_inner()?.first_meta().sequence)
+    }
+
+    fn get_shape(&self) -> PyResult<(u64, u64)> {
+        self.inner.get_shape()
+    }
+
+    fn __len__(&self) -> PyResult<usize> {
+        self.inner.__len__()
+    }
 }
 
-#[pymethods]
-impl PendingAcquisition {
-    fn get_detector_config(&self) -> PyDetectorConfig {
-        PyDetectorConfig {
-            config: self.config.clone(),
-        }
+impl PyFrameStackHandle {
+    fn new(inner: _PyASIMpxFrameStack) -> Self {
+        Self { inner }
     }
 
-    fn get_frame_width(&self) -> u16 {
-        self.first_frame_meta.width
+    fn get_inner(&self) -> &_PyASIMpxFrameStack {
+        &self.inner
     }
 
-    fn get_frame_height(&self) -> u16 {
-        self.first_frame_meta.height
+    fn get_inner_mut(&mut self) -> &mut _PyASIMpxFrameStack {
+        &mut self.inner
     }
 }
 
-#[pymethods]
-impl ServalConnection {
-    #[new]
-    fn new(
-        data_uri: &str,
-        api_uri: &str,
-        frame_stack_size: usize,
-        handle_path: String,
-        num_slots: Option<usize>,
-        bytes_per_frame: Option<usize>,
-        huge: Option<bool>,
-    ) -> PyResult<Self> {
-        let num_slots = num_slots.map_or_else(|| 2000, |x| x);
-        let bytes_per_frame = bytes_per_frame.map_or_else(|| 512 * 512 * 2, |x| x);
-        let slot_size = frame_stack_size * bytes_per_frame;
-        let shm = match SharedSlabAllocator::new(
-            num_slots,
-            slot_size,
-            huge.map_or_else(|| false, |x| x),
-            &PathBuf::from(handle_path),
-        ) {
-            Ok(shm) => shm,
-            Err(e) => {
-                let total_size = num_slots * slot_size;
-                let msg = format!("could not create SHM area (num_slots={num_slots}, slot_size={slot_size} total_size={total_size} huge={huge:?}): {e:?}");
-                return Err(ConnectionError::new_err(msg));
-            }
+#[allow(non_upper_case_globals)]
+const PyBUF_READ: c_int = 0x100;
+
+#[pyclass]
+pub struct CamClient {
+    inner: _PyASIMpxCamClient,
+}
+
+impl CamClient {
+    fn get_memoryview(&self, py: Python, raw_data: &[u8]) -> PyObject {
+        let ptr = raw_data.as_ptr();
+        let length = raw_data.len();
+
+        let mv = unsafe {
+            PyMemoryView_FromMemory(ptr as *mut i8, length.try_into().unwrap(), PyBUF_READ)
         };
 
-        let local_shm = shm.clone_and_connect().expect("clone SHM");
+        let from_ptr: Bound<PyAny> = unsafe {
+            //FromPyPointer::from_owned_ptr(py, mv)
 
+            Bound::from_owned_ptr(py, mv)
+        };
+        from_ptr.into_py(py)
+    }
+}
+
+#[pymethods]
+impl CamClient {
+    #[new]
+    fn new(handle_path: &str) -> PyResult<Self> {
         Ok(Self {
-            receiver: ServalReceiver::new(data_uri, api_uri, frame_stack_size, shm),
-            remainder: Vec::new(),
-            local_shm,
-            stats: Stats::new(),
+            inner: _PyASIMpxCamClient::new(handle_path)?,
         })
     }
 
-    /// Wait until the detector is armed, or until the timeout expires (in seconds)
-    /// Returns `None` in case of timeout, the detector config otherwise.
-    /// This method drops the GIL to allow concurrent Python threads.
-    fn wait_for_arm(&mut self, timeout: f32, py: Python) -> PyResult<Option<PendingAcquisition>> {
-        let timeout = Duration::from_secs_f32(timeout);
-        let deadline = Instant::now() + timeout;
-        let step = Duration::from_millis(100);
-
-        loop {
-            py.check_signals()?;
-
-            let res = py.allow_threads(|| {
-                let timeout_rem = deadline - Instant::now();
-                let this_timeout = timeout_rem.min(step);
-                self.receiver.next_timeout(this_timeout)
-            });
-
-            match res {
-                Some(ResultMsg::AcquisitionStart {
-                    detector_config,
-                    first_frame_meta,
-                }) => {
-                    return Ok(Some(PendingAcquisition {
-                        config: detector_config,
-                        first_frame_meta,
-                    }))
-                }
-                msg @ Some(ResultMsg::End { .. }) | msg @ Some(ResultMsg::FrameStack { .. }) => {
-                    let err = format!("unexpected message: {:?}", msg);
-                    return Err(PyRuntimeError::new_err(err));
-                }
-                Some(ResultMsg::ParseError { msg }) => return Err(PyRuntimeError::new_err(msg)),
-                Some(ResultMsg::Error { msg }) => return Err(PyRuntimeError::new_err(msg)),
-                None => {
-                    // timeout
-                    if Instant::now() > deadline {
-                        return Ok(None);
-                    } else {
-                        continue;
-                    }
-                }
-            }
-        }
-    }
+    // FIXME: add safe replacement for this function
+    // (probably needs Python 3.11+)
+    #[deprecated]
+    fn get_frames(
+        &self,
+        handle: &PyFrameStackHandle,
+        py: Python,
+    ) -> PyResult<Vec<(PyObject, DType)>> {
+        let shm = self.inner.get_shm()?;
 
-    fn get_socket_path(&self) -> PyResult<String> {
-        Ok(self.local_shm.get_handle().os_handle)
-    }
+        // safety: Python code must not use memoryviews after calling `done`.
+        let slot = unsafe { shm.get(handle.get_inner().try_get_inner()?.get_slot().slot_idx) };
+
+        let meta = handle.get_inner().get_meta()?;
+        let inner = handle.get_inner().try_get_inner()?;
 
-    fn is_running(slf: PyRef<Self>) -> bool {
-        slf.receiver.status == ReceiverStatus::Running
+        Ok(meta
+            .iter()
+            .zip(0..)
+            .map(|(meta, frame_idx)| {
+                let image_data = inner.get_slice_for_frame(frame_idx, &slot);
+                let memory_view = self.get_memoryview(py, image_data);
+
+                (memory_view, meta.dtype.clone())
+            })
+            .collect())
     }
 
-    /// Start listening for global acquisition headers on the zeromq socket.
-    /// Call `wait_for_arm` to wait
-    fn start_passive(mut slf: PyRefMut<Self>) -> PyResult<()> {
-        slf.start_passive_impl()
+    fn decode_range_into_buffer<'py>(
+        &self,
+        input: &PyFrameStackHandle,
+        out: &Bound<'py, PyUntypedArray>,
+        start_idx: usize,
+        end_idx: usize,
+        py: Python<'py>,
+    ) -> PyResult<()> {
+        self.inner
+            .decode_range_into_buffer(input.get_inner(), out, start_idx, end_idx, py)
     }
 
-    fn close(mut slf: PyRefMut<Self>) {
-        slf.stats.log_stats();
-        slf.stats.reset();
-        slf.close_impl();
+    fn done(&mut self, handle: &mut PyFrameStackHandle) -> PyResult<()> {
+        self.inner.frame_stack_done(handle.get_inner_mut())
     }
 
-    fn get_next_stack(
-        &mut self,
-        py: Python,
-        max_size: usize,
-    ) -> PyResult<Option<FrameStackHandle>> {
-        let mut iter = FrameChunkedIterator::new(
-            &mut self.receiver,
-            &mut self.local_shm,
-            &mut self.remainder,
-            &mut self.stats,
-        )?;
-        iter.get_next_stack_impl(py, max_size).map(|maybe_stack| {
-            if let Some(frame_stack) = &maybe_stack {
-                self.stats.count_stats_item(frame_stack);
-            }
-            maybe_stack
-        })
+    fn close(&mut self) -> PyResult<()> {
+        self.inner.close()
     }
+}
 
-    fn log_shm_stats(&self) {
-        let free = self.local_shm.num_slots_free();
-        let total = self.local_shm.num_slots_total();
-        self.stats.log_stats();
-        info!("shm stats free/total: {}/{}", free, total);
+impl Drop for CamClient {
+    fn drop(&mut self) {
+        trace!("CamClient::drop");
     }
 }
diff --git a/libertem_asi_mpx3/src/stats.rs b/libertem_asi_mpx3/src/stats.rs
deleted file mode 100644
index 04b97b39..00000000
--- a/libertem_asi_mpx3/src/stats.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use stats::GetStats;
-
-use crate::frame_stack::FrameStackHandle;
-
-impl GetStats for FrameStackHandle {
-    fn payload_size(&self) -> usize {
-        self.payload_size()
-    }
-
-    fn slot_size(&self) -> usize {
-        self.slot_size()
-    }
-
-    fn max_frame_size(&self, old_max: usize) -> usize {
-        self.get_meta()
-            .iter()
-            .max_by_key(|fm| fm.data_length_bytes)
-            .map_or(old_max, |fm| fm.data_length_bytes)
-    }
-
-    fn min_frame_size(&self, old_min: usize) -> usize {
-        self.get_meta()
-            .iter()
-            .min_by_key(|fm| fm.data_length_bytes)
-            .map_or(old_min, |fm| fm.data_length_bytes)
-    }
-
-    fn num_frames(&self) -> usize {
-        self.len()
-    }
-}
diff --git a/libertem_asi_tpx3/Cargo.toml b/libertem_asi_tpx3/Cargo.toml
index f92f818a..99f6019e 100644
--- a/libertem_asi_tpx3/Cargo.toml
+++ b/libertem_asi_tpx3/Cargo.toml
@@ -2,7 +2,7 @@
 name = "libertem-asi-tpx3"
 authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
 license = "MIT"
-version = "0.2.11"
+version = "0.2.12"
 edition = "2021"
 readme = "README.md"
 rust-version = "1.66"
@@ -24,7 +24,8 @@ serde = { version = "1.0.143", features = ["derive"] }
 uuid = { version = "1.1.2", features = ["v4", "fast-rng"] }
 ipc-test = { path = "../ipc_test" }
 stats = { path = "../stats" }
-zerocopy = "0.6.1"
+common = { path = "../common" }
+zerocopy = "0.7.35"
 page_size = "0.5.0"
 
 [dev-dependencies]
diff --git a/libertem_asi_tpx3/src/receiver.rs b/libertem_asi_tpx3/src/background_thread.rs
similarity index 97%
rename from libertem_asi_tpx3/src/receiver.rs
rename to libertem_asi_tpx3/src/background_thread.rs
index ebd576b9..4d133a16 100644
--- a/libertem_asi_tpx3/src/receiver.rs
+++ b/libertem_asi_tpx3/src/background_thread.rs
@@ -17,7 +17,6 @@ use crate::{
     stream::{stream_recv_chunk, stream_recv_header, StreamError},
 };
 
-///
 #[derive(PartialEq, Eq, Debug)]
 pub enum ResultMsg {
     SerdeError {
@@ -169,11 +168,9 @@ fn wait_for_acquisition(
     let mut header_bytes: [u8; 32] = [0; 32];
     loop {
         info!("waiting for acquisition header...");
-        let header = stream_recv_header(
-            stream,
-            &mut header_bytes,
-            Box::new(|_kind| Ok(check_for_control(control_channel)?)),
-        )?;
+        let header = stream_recv_header(stream, &mut header_bytes, || {
+            check_for_control(control_channel)
+        })?;
 
         trace!("got a header: {header:?}");
 
@@ -230,11 +227,9 @@ fn wait_for_scan(
     let mut header_bytes: [u8; 32] = [0; 32];
     info!("waiting for scan header...");
     loop {
-        let header = stream_recv_header(
-            stream,
-            &mut header_bytes,
-            Box::new(|_kind| Ok(check_for_control(control_channel)?)),
-        )?;
+        let header = stream_recv_header(stream, &mut header_bytes, || {
+            check_for_control(control_channel)
+        })?;
         trace!("got a header: {header:?}");
 
         match header {
@@ -330,11 +325,8 @@ fn handle_scan(
         }
 
         let mut header_bytes: [u8; 32] = [0; 32];
-        let header = stream_recv_header(
-            stream,
-            &mut header_bytes,
-            Box::new(|_kind| Ok(check_for_control(to_thread_r)?)),
-        )?;
+        let header =
+            stream_recv_header(stream, &mut header_bytes, || check_for_control(to_thread_r))?;
 
         match header {
             HeaderTypes::ArrayChunk { header } => {
@@ -394,7 +386,7 @@ fn handle_scan(
                     data_length_bytes: nbytes,
                 };
                 let buf = chunk_stack.slice_for_writing(nbytes, layout.clone());
-                stream_recv_chunk(stream, buf)?;
+                stream_recv_chunk(stream, buf, || check_for_control(to_thread_r))?;
                 CSRViewRaw::from_bytes_with_layout(buf, &layout);
             }
             HeaderTypes::ScanEnd { header } => {
diff --git a/libertem_asi_tpx3/src/cam_client.rs b/libertem_asi_tpx3/src/cam_client.rs
index 7fe25e74..1516a40e 100644
--- a/libertem_asi_tpx3/src/cam_client.rs
+++ b/libertem_asi_tpx3/src/cam_client.rs
@@ -198,7 +198,7 @@ mod tests {
         assert_eq!(slices.len(), 1);
 
         assert_eq!(
-            slices.get(0).unwrap(),
+            slices.first().unwrap(),
             &(&indptr[..], &indices[..], &values[..])
         );
     }
diff --git a/libertem_asi_tpx3/src/chunk_stack.rs b/libertem_asi_tpx3/src/chunk_stack.rs
index f4c4c97b..0413fae0 100644
--- a/libertem_asi_tpx3/src/chunk_stack.rs
+++ b/libertem_asi_tpx3/src/chunk_stack.rs
@@ -249,7 +249,7 @@ impl ChunkStackHandle {
         &self.layout
     }
 
-    pub(crate) fn deserialize_impl<'py>(serialized: &Bound<'py, PyBytes>) -> PyResult<Self> {
+    pub(crate) fn deserialize_impl(serialized: &Bound<'_, PyBytes>) -> PyResult<Self> {
         let data = serialized.as_bytes();
         bincode::deserialize(data).map_err(|e| {
             let msg = format!("could not deserialize FrameStackHandle: {e:?}");
@@ -426,7 +426,7 @@ impl ChunkStackHandle {
     pub fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
         let bytes: Bound<'py, PyBytes> =
             PyBytes::new_bound(py, serialize(self).unwrap().as_slice());
-        Ok(bytes.into())
+        Ok(bytes)
     }
 
     #[classmethod]
@@ -550,9 +550,9 @@ mod tests {
 
         let fs_handle = fs.writing_done(&mut shm);
 
-        let slot_r = shm.get(fs_handle.slot.slot_idx);
+        let _slot_r = shm.get(fs_handle.slot.slot_idx);
 
-        let old_layout_len = fs_handle.layout.len();
+        let _old_layout_len = fs_handle.layout.len();
 
         let (a, b) = fs_handle.split_at(2, &mut shm);
 
diff --git a/libertem_asi_tpx3/src/csr_view.rs b/libertem_asi_tpx3/src/csr_view.rs
index 646f61bc..a8198cfd 100644
--- a/libertem_asi_tpx3/src/csr_view.rs
+++ b/libertem_asi_tpx3/src/csr_view.rs
@@ -1,8 +1,7 @@
-use zerocopy::{AsBytes, FromBytes, LayoutVerified};
+use zerocopy::{AsBytes, FromBytes, Ref};
 
 use crate::{chunk_stack::ChunkCSRLayout, sparse_csr::CSRSizes};
 
-///
 pub struct CSRView<'a, I, IP, V> {
     pub indices: &'a [I],
     pub indptr: &'a [IP],
@@ -32,9 +31,9 @@ where
         // their data types - in practice that means there should be
         // padding between these slices and we need to properly adjust the sizing calculation
         // according to these alignment requirements
-        let indptr: &[IP] = LayoutVerified::new_slice(indptr_raw).unwrap().into_slice();
-        let indices: &[I] = LayoutVerified::new_slice(indices_raw).unwrap().into_slice();
-        let values: &[V] = LayoutVerified::new_slice(values_raw).unwrap().into_slice();
+        let indptr: &[IP] = Ref::new_slice(indptr_raw).unwrap().into_slice();
+        let indices: &[I] = Ref::new_slice(indices_raw).unwrap().into_slice();
+        let values: &[V] = Ref::new_slice(values_raw).unwrap().into_slice();
 
         Self {
             indices,
@@ -87,15 +86,9 @@ where
         assert_eq!(sizes.values, values_raw.len());
 
         // FIXME: error handling
-        let indptr: &mut [IP] = LayoutVerified::new_slice(indptr_raw)
-            .unwrap()
-            .into_mut_slice();
-        let indices: &mut [I] = LayoutVerified::new_slice(indices_raw)
-            .unwrap()
-            .into_mut_slice();
-        let values: &mut [V] = LayoutVerified::new_slice(values_raw)
-            .unwrap()
-            .into_mut_slice();
+        let indptr: &mut [IP] = Ref::new_slice(indptr_raw).unwrap().into_mut_slice();
+        let indices: &mut [I] = Ref::new_slice(indices_raw).unwrap().into_mut_slice();
+        let values: &mut [V] = Ref::new_slice(values_raw).unwrap().into_mut_slice();
 
         Self {
             indices,
diff --git a/libertem_asi_tpx3/src/csr_view_raw.rs b/libertem_asi_tpx3/src/csr_view_raw.rs
index 7e3aa5f5..dc62316c 100644
--- a/libertem_asi_tpx3/src/csr_view_raw.rs
+++ b/libertem_asi_tpx3/src/csr_view_raw.rs
@@ -1,4 +1,4 @@
-use zerocopy::{AsBytes, FromBytes, LayoutVerified};
+use zerocopy::{AsBytes, FromBytes, Ref};
 
 use crate::{chunk_stack::ChunkCSRLayout, sparse_csr::CSRSizes};
 
@@ -29,7 +29,7 @@ impl<'a> CSRViewRaw<'a> {
         let sizes = self.get_sizes();
         let offset = self.layout.indptr_offset;
         let indptr_raw = &self.raw_data[offset..offset + sizes.indptr];
-        LayoutVerified::new_slice(indptr_raw).unwrap().into_slice()
+        Ref::new_slice(indptr_raw).unwrap().into_slice()
     }
 
     pub fn get_indptr_raw(&self) -> &'a [u8] {
@@ -75,9 +75,7 @@ impl<'a> CSRViewRawMut<'a> {
         let sizes = self.get_sizes();
         let offset = self.layout.indptr_offset;
         let indptr_raw = &mut self.raw_data[offset..offset + sizes.indptr];
-        LayoutVerified::new_slice(indptr_raw)
-            .unwrap()
-            .into_mut_slice()
+        Ref::new_slice(indptr_raw).unwrap().into_mut_slice()
     }
 
     pub fn get_indptr_raw(&mut self) -> &mut [u8] {
diff --git a/libertem_asi_tpx3/src/headers.rs b/libertem_asi_tpx3/src/headers.rs
index b7741b1d..f2a90a99 100644
--- a/libertem_asi_tpx3/src/headers.rs
+++ b/libertem_asi_tpx3/src/headers.rs
@@ -157,7 +157,6 @@ impl FormatType {
 pub struct AcquisitionStart {
     tag: u8, // const. 0x00
 
-    ///
     pub version: u8,
     pub format_type: FormatType,
 
diff --git a/libertem_asi_tpx3/src/lib.rs b/libertem_asi_tpx3/src/lib.rs
index e0517de9..e62fa375 100644
--- a/libertem_asi_tpx3/src/lib.rs
+++ b/libertem_asi_tpx3/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod background_thread;
 pub mod cam_client;
 pub mod chunk_stack;
 pub mod common;
@@ -8,7 +9,6 @@ pub mod exceptions;
 pub mod headers;
 pub mod headers_py;
 pub mod main_py;
-pub mod receiver;
 pub mod serialize;
 pub mod sparse_csr;
 pub mod stats;
diff --git a/libertem_asi_tpx3/src/main_py.rs b/libertem_asi_tpx3/src/main_py.rs
index 0a90ab37..980cb165 100644
--- a/libertem_asi_tpx3/src/main_py.rs
+++ b/libertem_asi_tpx3/src/main_py.rs
@@ -7,12 +7,12 @@ use std::{
 };
 
 use crate::{
+    background_thread::{ReceiverStatus, ResultMsg, TPXReceiver},
     cam_client::CamClient,
     chunk_stack::ChunkStackHandle,
     exceptions::{ConnectionError, TimeoutError},
     headers::{AcquisitionEnd, AcquisitionStart, DType, FormatType, ScanEnd, ScanStart},
     headers_py::make_sim_data,
-    receiver::{ReceiverStatus, ResultMsg, TPXReceiver},
 };
 
 use ipc_test::SharedSlabAllocator;
@@ -24,7 +24,7 @@ use pyo3::{
 use stats::Stats;
 
 #[pymodule]
-fn libertem_asi_tpx3<'py>(py: Python, m: Bound<'py, PyModule>) -> PyResult<()> {
+fn libertem_asi_tpx3(py: Python, m: Bound<'_, PyModule>) -> PyResult<()> {
     // FIXME: logging integration deadlocks on close(), when trying to acquire
     // the GIL
     // pyo3_log::init();
@@ -299,7 +299,7 @@ impl ASITpx3Connection {
         slf.receiver.status == ReceiverStatus::Running
     }
 
-    /// Start listening for global acquisition headers on the zeromq socket.
+    /// Start listening for global acquisition headers on the tcp socket.
     /// Call `wait_for_arm` to wait
     fn start_passive(mut slf: PyRefMut<Self>) -> PyResult<()> {
         slf.start_passive_impl()
diff --git a/libertem_asi_tpx3/src/serialize.rs b/libertem_asi_tpx3/src/serialize.rs
index 0bfd0dcf..0aa291c2 100644
--- a/libertem_asi_tpx3/src/serialize.rs
+++ b/libertem_asi_tpx3/src/serialize.rs
@@ -7,12 +7,11 @@ impl Serialize for FormatType {
     where
         S: Serializer,
     {
-        match &*self {
-            value => {
-                let mut state = serializer.serialize_struct("FormatType", 1)?;
-                state.serialize_field("type", &(*value as u8))?;
-                state.end()
-            }
+        let value = self;
+        {
+            let mut state = serializer.serialize_struct("FormatType", 1)?;
+            state.serialize_field("type", &(*value as u8))?;
+            state.end()
         }
     }
 }
@@ -22,12 +21,11 @@ impl Serialize for DType {
     where
         S: Serializer,
     {
-        match &*self {
-            value => {
-                let mut state = serializer.serialize_struct("DType", 1)?;
-                state.serialize_field("type", &(*value as u8))?;
-                state.end()
-            }
+        let value = self;
+        {
+            let mut state = serializer.serialize_struct("DType", 1)?;
+            state.serialize_field("type", &(*value as u8))?;
+            state.end()
         }
     }
 }
diff --git a/libertem_asi_tpx3/src/sparse_csr.rs b/libertem_asi_tpx3/src/sparse_csr.rs
index 04e0eec5..045b84d2 100644
--- a/libertem_asi_tpx3/src/sparse_csr.rs
+++ b/libertem_asi_tpx3/src/sparse_csr.rs
@@ -50,7 +50,7 @@ impl<'a> CSRSplitter<'a> {
     {
         let view: CSRViewRaw = CSRViewRaw::from_bytes_with_layout(self.raw_data, &self.layout);
 
-        let left_nnz = view.get_indptr::<IP>()[mid].try_into().unwrap();
+        let left_nnz = view.get_indptr::<IP>()[mid].into();
         let left_nframes = mid;
         let left_indptr_size = size_of::<IP>() * (left_nframes + 1);
         let left_indices_size = left_nnz as usize * self.layout.indices_dtype.size();
diff --git a/libertem_asi_tpx3/src/stream.rs b/libertem_asi_tpx3/src/stream.rs
index 25e94678..33365f99 100644
--- a/libertem_asi_tpx3/src/stream.rs
+++ b/libertem_asi_tpx3/src/stream.rs
@@ -1,16 +1,17 @@
-use std::{io::Read, net::TcpStream};
+use std::net::TcpStream;
 
-use log::warn;
+use common::tcp::{read_exact_interruptible, ReadExactError};
 
 use crate::{
+    background_thread::ControlError,
     headers::{HeaderTypes, WireFormatError},
-    receiver::ControlError,
 };
 
 #[derive(Debug)]
 pub enum StreamError {
     Timeout,
     IoError(std::io::Error),
+    Eof,
     FormatError(WireFormatError),
     ControlError(ControlError),
 }
@@ -36,34 +37,32 @@ impl From<ControlError> for StreamError {
     }
 }
 
-type RecvCallback<'a> = Box<dyn Fn(&std::io::ErrorKind) -> Result<(), StreamError> + 'a>;
+impl From<ReadExactError<ControlError>> for StreamError {
+    fn from(value: ReadExactError<ControlError>) -> Self {
+        match value {
+            ReadExactError::Interrupted { size: _, err } => Self::ControlError(err),
+            ReadExactError::IOError { err } => Self::IoError(err),
+            ReadExactError::PeekError { size: _ } => Self::Timeout,
+            ReadExactError::Eof => Self::Eof,
+        }
+    }
+}
 
 pub fn stream_recv_header(
     stream: &mut TcpStream,
     header_bytes: &mut [u8; 32],
-    timeout_cb: RecvCallback,
+    timeout_cb: impl Fn() -> Result<(), ControlError>,
 ) -> Result<HeaderTypes, StreamError> {
-    // TODO: timeout? can't block indefinitely - can we make this atomic?
-
-    loop {
-        match stream.read_exact(header_bytes) {
-            Ok(_) => break,
-            Err(e) => match e.kind() {
-                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
-                    warn!("stream error: {e}");
-                    timeout_cb(&e.kind())?;
-                    continue;
-                }
-                _ => return Err(e.into()),
-            },
-        }
-    }
-
+    read_exact_interruptible(stream, header_bytes, timeout_cb)?;
     Ok(HeaderTypes::from_bytes(header_bytes)?)
 }
 
 /// Fill `buf` with data
-pub fn stream_recv_chunk(stream: &mut TcpStream, buf: &mut [u8]) -> Result<(), StreamError> {
+pub fn stream_recv_chunk(
+    stream: &mut TcpStream,
+    buf: &mut [u8],
+    timeout_cb: impl Fn() -> Result<(), ControlError>,
+) -> Result<(), StreamError> {
     //
     // Possible situations to handle:
     //
@@ -78,11 +77,6 @@ pub fn stream_recv_chunk(stream: &mut TcpStream, buf: &mut [u8]) -> Result<(), S
     //
     // - A control message arrived from the main thread, which we need to handle.
     //
-
-    // Cases:
-    // - all goes well, `buf` is filled with data from the stream
-    //    - this includes if it was interrupted in the meantime
-    // - in case of read timeout, the error is passed down
-    stream.read_exact(buf)?;
+    read_exact_interruptible(stream, buf, timeout_cb)?;
     Ok(())
 }
diff --git a/libertem_dectris/Cargo.toml b/libertem_dectris/Cargo.toml
index adc69b0c..c1290811 100644
--- a/libertem_dectris/Cargo.toml
+++ b/libertem_dectris/Cargo.toml
@@ -2,7 +2,7 @@
 name = "libertem-dectris"
 authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
 license = "MIT"
-version = "0.2.11"
+version = "0.2.12"
 edition = "2021"
 readme = "README.md"
 rust-version = "1.66"
@@ -17,11 +17,10 @@ name = "libertem-dectris-tool"
 path = "src/main.rs"
 
 [dependencies]
+thiserror = "1.0.59"
 bincode = "1.3.3"
 bs_sys = { path = "../bs-sys" }
 clap = { version = "3.2.16", features = ["derive"] }
-crossbeam = "0.8.2"
-crossbeam-channel = "0.5.6"
 env_logger = "0.9.3"
 log = "0.4.17"
 memmap2 = "0.5.6"
@@ -34,10 +33,12 @@ uuid = { version = "1.1.2", features = ["v4", "fast-rng"] }
 zmq = { version = "0.10.0", features = [] }
 ipc-test = { path = "../ipc_test" }
 stats = { path = "../stats" }
+common = { path = "../common" }
 nix = "0.26.1"
 lz4 = "1.24.0"
-zerocopy = "0.6.1"
+zerocopy = "0.7.35"
 md5 = "0.7.0"
+num = "0.4.3"
 
 [features]
 extension-module = ["pyo3/extension-module"]
diff --git a/libertem_dectris/README.md b/libertem_dectris/README.md
index d6232084..bd2291e9 100644
--- a/libertem_dectris/README.md
+++ b/libertem_dectris/README.md
@@ -8,6 +8,9 @@ The low-level, high-frequency operations are performed in a background thread
 implemented in rust, and multiple frames are batched together for further
 processing in Python.
 
+It is built for [LiberTEM-live](https://github.com/libertem/libertem-live), but can
+also be used stand-alone.
+
 ## Usage
 
 ```python
@@ -81,6 +84,15 @@ finally:
 
 ## Changelog
 
+### v0.2.12
+
+- Refactored and unified interface - allows for more code sharing between crates
+- Some robustness changes around adding timeout parameters for many top-level operations
+
+### v0.2.11
+
+- Re-release for CI changes
+
 ### v0.2.10
 
 - Add functionality to generate mock data for sending via the simulator
diff --git a/libertem_dectris/src/receiver.rs b/libertem_dectris/src/background_thread.rs
similarity index 50%
rename from libertem_dectris/src/receiver.rs
rename to libertem_dectris/src/background_thread.rs
index 7d2e5181..6925ef57 100644
--- a/libertem_dectris/src/receiver.rs
+++ b/libertem_dectris/src/background_thread.rs
@@ -1,69 +1,36 @@
 use std::{
+    convert::Infallible,
     fmt::Display,
     mem::replace,
+    ops::Deref,
+    sync::mpsc::{channel, Receiver, RecvTimeoutError, SendError, Sender, TryRecvError},
     thread::JoinHandle,
     time::{Duration, Instant},
 };
 
-use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, SendError, Sender, TryRecvError};
-use ipc_test::{SHMHandle, SharedSlabAllocator};
+use common::{
+    background_thread::{self, BackgroundThread, ControlMsg, ReceiverMsg},
+    frame_stack::{FrameStackForWriting, FrameStackWriteError},
+    generic_connection::DetectorConnectionConfig,
+};
+use ipc_test::{slab::ShmError, SharedSlabAllocator};
 use log::{debug, error, info, trace, warn};
 use zmq::{Message, Socket};
 
-use crate::{
-    common::{
-        setup_monitor, DConfig, DHeader, DImage, DImageD, DSeriesAndType, DSeriesEnd,
-        DetectorConfig,
-    },
-    frame_stack::{FrameStackForWriting, FrameStackHandle},
+use crate::base_types::{
+    setup_monitor, DConfig, DHeader, DImage, DImageD, DSeriesAndType, DSeriesEnd, DectrisFrameMeta,
+    DectrisPendingAcquisition, DetectorConfig,
 };
 
-///
-#[derive(PartialEq, Eq, Debug)]
-pub enum ResultMsg {
-    Error {
-        msg: String,
-    }, // generic error response, might need to specialize later
-    SerdeError {
-        msg: String,
-        recvd_msg: String,
-    },
-    AcquisitionStart {
-        series: u64,
-        detector_config: DetectorConfig,
-    },
-    FrameStack {
-        frame_stack: FrameStackHandle,
-    },
-    End {
-        frame_stack: FrameStackHandle,
-    },
-}
-
-pub enum ControlMsg {
-    StopThread,
-
-    /// Wait for DHeader messages and latch onto acquisitions,
-    /// until the background thread is stopped.
-    StartAcquisitionPassive,
-
-    /// Wait for a specific series to start
-    StartAcquisition {
-        series: u64,
-    },
-}
-
-#[derive(PartialEq, Eq)]
-pub enum ReceiverStatus {
-    Idle,
-    Running,
-    Closed,
-}
+type DectrisControlMsg = ControlMsg<DectrisExtraControl>;
 
+/// Receive a message into `msg`, and periodically check for control messages on
+/// `control_channel` which are converted into `AcquisitionError`s. Return once
+/// a message has been read into `msg`.
 fn recv_part(
     msg: &mut Message,
     socket: &Socket,
-    control_channel: &Receiver<ControlMsg>,
+    control_channel: &Receiver<DectrisControlMsg>,
 ) -> Result<(), AcquisitionError> {
     loop {
         match socket.recv(msg, 0) {
@@ -82,56 +49,36 @@ fn recv_part(
 /// that are passed in.
 fn recv_frame_into(
     socket: &Socket,
-    control_channel: &Receiver<ControlMsg>,
+    control_channel: &Receiver<DectrisControlMsg>,
     msg: &mut Message,
     msg_image: &mut Message,
 ) -> Result<(DImage, DImageD, DConfig), AcquisitionError> {
     recv_part(msg, socket, control_channel)?;
     let dimage_res: Result<DImage, _> = serde_json::from_str(msg.as_str().unwrap());
-
-    let dimage = match dimage_res {
-        Ok(image) => image,
-        Err(err) => {
-            return Err(AcquisitionError::SerdeError {
-                msg: err.to_string(),
-                recvd_msg: msg
-                    .as_str()
-                    .map_or_else(|| "".to_string(), |m| m.to_string()),
-            });
-        }
-    };
+    let dimage = dimage_res.map_err(|err| AcquisitionError::serde_from_msg(&err, msg))?;
 
     recv_part(msg, socket, control_channel)?;
     let dimaged_res: Result<DImageD, _> = serde_json::from_str(msg.as_str().unwrap());
-
-    let dimaged = match dimaged_res {
-        Ok(image) => image,
-        Err(err) => {
-            return Err(AcquisitionError::SerdeError {
-                msg: err.to_string(),
-                recvd_msg: msg
-                    .as_str()
-                    .map_or_else(|| "".to_string(), |m| m.to_string()),
-            });
-        }
-    };
+    let dimaged = dimaged_res.map_err(|err| AcquisitionError::serde_from_msg(&err, msg))?;
 
     // compressed image data:
     recv_part(msg_image, socket, control_channel)?;
 
     // DConfig:
     recv_part(msg, socket, control_channel)?;
-    let dconfig: DConfig = serde_json::from_str(msg.as_str().unwrap()).unwrap();
+    let dconfig: DConfig = serde_json::from_str(msg.as_str().unwrap())
+        .map_err(|err| AcquisitionError::serde_from_msg(&err, msg))?;
 
     Ok((dimage, dimaged, dconfig))
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, thiserror::Error)]
 enum AcquisitionError {
     Disconnected,
     SeriesMismatch,
     FrameIdMismatch { expected_id: u64, got_id: u64 },
     SerdeError { recvd_msg: String, msg: String },
+    StopThread,
     Cancelled,
     ZmqError { err: zmq::Error },
     BufferFull,
@@ -145,7 +92,7 @@ impl Display for AcquisitionError {
             AcquisitionError::ZmqError { err } => {
                 write!(f, "zmq error {err}")
             }
-            AcquisitionError::Cancelled => {
+            AcquisitionError::StopThread => {
                 write!(f, "acquisition cancelled")
             }
             AcquisitionError::SerdeError { recvd_msg, msg } => {
@@ -172,32 +119,117 @@ impl Display for AcquisitionError {
             AcquisitionError::ConfigurationError { msg } => {
                 write!(f, "configuration error: {msg}")
             }
+            AcquisitionError::Cancelled => {
+                write!(f, "acquisition cancelled")
+            }
+        }
+    }
+}
+
+impl<T> From<SendError<T>> for AcquisitionError {
+    fn from(_value: SendError<T>) -> Self {
+        AcquisitionError::Disconnected
+    }
+}
+
+impl AcquisitionError {
+    fn serde_from_msg(err: &serde_json::Error, msg: &Message) -> Self {
+        Self::SerdeError {
+            msg: err.to_string(),
+            recvd_msg: msg
+                .as_str()
+                .map_or_else(|| "".to_string(), |m| m.to_string()),
         }
     }
 }
 
 /// With a running acquisition, check for control messages;
 /// especially convert `ControlMsg::StopThread` to `AcquisitionError::Cancelled`.
-fn check_for_control(control_channel: &Receiver<ControlMsg>) -> Result<(), AcquisitionError> {
+fn check_for_control(
+    control_channel: &Receiver<DectrisControlMsg>,
+) -> Result<(), AcquisitionError> {
     match control_channel.try_recv() {
-        Ok(ControlMsg::StartAcquisition { series: _ }) => Err(AcquisitionError::StateError {
+        Ok(ControlMsg::SpecializedControlMsg {
+            msg: DectrisExtraControl::StartAcquisitionWithSeries { series: _ },
+        }) => Err(AcquisitionError::StateError {
             msg: "received StartAcquisition while an acquisition was already running".to_string(),
         }),
         Ok(ControlMsg::StartAcquisitionPassive) => Err(AcquisitionError::StateError {
             msg: "received StartAcquisitionPassive while an acquisition was already running"
                 .to_string(),
         }),
-        Ok(ControlMsg::StopThread) => Err(AcquisitionError::Cancelled),
-        Err(TryRecvError::Disconnected) => Err(AcquisitionError::Cancelled),
+        Ok(ControlMsg::StopThread) => {
+            debug!("check_for_control: StopThread received");
+
+            Err(AcquisitionError::StopThread)
+        }
+        Ok(ControlMsg::CancelAcquisition) => {
+            debug!("check_for_control: CancelAcquisition");
+
+            Err(AcquisitionError::Cancelled)
+        }
+        Err(TryRecvError::Disconnected) => {
+            debug!("check_for_control: Disconnected");
+
+            Err(AcquisitionError::StopThread)
+        }
         Err(TryRecvError::Empty) => Ok(()),
     }
 }
 
+fn serialization_error(
+    from_thread_s: &Sender<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
+    msg: &Message,
+    err: &serde_json::Error,
+) {
+    log::error!(
+        "background_thread: serialization error: {}",
+        err.to_string()
+    );
+    from_thread_s
+        .send(ReceiverMsg::FatalError {
+            error: Box::new(AcquisitionError::SerdeError {
+                recvd_msg: msg
+                    .as_str()
+                    .map_or_else(|| "".to_string(), |m| m.to_string()),
+                msg: err.to_string(),
+            }),
+        })
+        .unwrap();
+}
+
+fn make_frame_stack(
+    shm: &mut SharedSlabAllocator,
+    capacity: usize,
+    bytes_per_frame: usize,
+    to_thread_r: &Receiver<DectrisControlMsg>,
+) -> Result<FrameStackForWriting<DectrisFrameMeta>, AcquisitionError> {
+    loop {
+        // keep some slots free for splitting frame stacks
+        if shm.num_slots_free() < 3 && shm.num_slots_total() >= 3 {
+            trace!("shm is almost full; waiting and creating backpressure...");
+            check_for_control(to_thread_r)?;
+            std::thread::sleep(Duration::from_millis(1));
+            continue;
+        }
+
+        match shm.try_get_mut() {
+            Ok(slot) => return Ok(FrameStackForWriting::new(slot, capacity, bytes_per_frame)),
+            Err(ShmError::NoSlotAvailable) => {
+                trace!("shm is full; waiting and creating backpressure...");
+                check_for_control(to_thread_r)?;
+                std::thread::sleep(Duration::from_millis(1));
+                continue;
+            }
+        }
+    }
+}
+
 /// Passively listen for global acquisition headers
 /// and automatically latch on to them.
 fn passive_acquisition(
-    control_channel: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    control_channel: &Receiver<DectrisControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
     socket: &Socket,
     frame_stack_size: usize,
     shm: &mut SharedSlabAllocator,
@@ -219,7 +251,7 @@ fn passive_acquisition(
             };
             debug!("dheader: {dheader:?}");
 
-            if dheader.header_detail == "none" {
+            if dheader.header_detail.deref() == "none" {
                 return Err(AcquisitionError::ConfigurationError {
                     msg: "header_detail must be 'basic' or 'all', is 'none'".to_string(),
                 });
@@ -228,16 +260,19 @@ fn passive_acquisition(
             // second message: the header itself
             recv_part(&mut msg, socket, control_channel)?;
 
-            if let Some(msg_str) = msg.as_str() {
+            let detector_config: DetectorConfig = if let Some(msg_str) = msg.as_str() {
                 debug!("detector config: {}", msg_str);
+                match serde_json::from_str(msg_str) {
+                    Ok(header) => header,
+                    Err(err) => {
+                        serialization_error(from_thread_s, &msg, &err);
+                        continue;
+                    }
+                }
             } else {
-                warn!("non-string received as detector config!")
-            }
-
-            let detector_config: DetectorConfig =
-                serde_json::from_str(msg.as_str().unwrap()).unwrap();
-
-            debug!("detector config: {}", msg.as_str().unwrap());
+                warn!("non-string received as detector config! ignoring message.");
+                continue;
+            };
 
             acquisition(
                 detector_config,
@@ -263,8 +298,8 @@ fn passive_acquisition(
 
 fn acquisition(
     detector_config: DetectorConfig,
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<DectrisControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
     socket: &Socket,
     series: u64,
     frame_stack_size: usize,
@@ -275,9 +310,8 @@ fn acquisition(
 
     let mut expected_frame_id = 0;
 
-    match from_thread_s.send(ResultMsg::AcquisitionStart {
-        series,
-        detector_config: detector_config.clone(),
+    match from_thread_s.send(ReceiverMsg::AcquisitionStart {
+        pending_acquisition: DectrisPendingAcquisition::new(detector_config.clone(), series),
     }) {
         Ok(_) => (),
         Err(SendError(_)) => return Err(AcquisitionError::Disconnected),
@@ -286,15 +320,10 @@ fn acquisition(
     debug!("acquisition starting");
 
     // approx uppper bound of image size in bytes
-    let approx_size_bytes = detector_config.get_num_pixels()
-        * (detector_config.bit_depth_image as f32 / 8.0f32).ceil() as u64;
+    let approx_size_bytes = detector_config.get_num_pixels() as usize
+        * (detector_config.bit_depth_image as f32 / 8.0f32).ceil() as usize;
 
-    let slot = match shm.get_mut() {
-        None => return Err(AcquisitionError::BufferFull),
-        Some(x) => x,
-    };
-    let mut frame_stack =
-        FrameStackForWriting::new(slot, frame_stack_size, approx_size_bytes as usize);
+    let mut frame_stack = make_frame_stack(shm, frame_stack_size, approx_size_bytes, to_thread_r)?;
 
     let mut msg = Message::new();
     let mut msg_image = Message::new();
@@ -325,29 +354,45 @@ fn acquisition(
         if !frame_stack.can_fit(msg_image.len()) {
             // send to our queue:
             let handle = {
-                let slot = match shm.get_mut() {
-                    None => return Err(AcquisitionError::BufferFull),
-                    Some(x) => x,
-                };
                 let new_frame_stack =
-                    FrameStackForWriting::new(slot, frame_stack_size, approx_size_bytes as usize);
+                    make_frame_stack(shm, frame_stack_size, approx_size_bytes, to_thread_r)?;
                 let old_frame_stack = replace(&mut frame_stack, new_frame_stack);
                 old_frame_stack.writing_done(shm)
             };
-            match from_thread_s.send(ResultMsg::FrameStack {
-                frame_stack: handle,
-            }) {
-                Ok(_) => (),
-                Err(SendError(_)) => return Err(AcquisitionError::Disconnected),
+            match handle {
+                Ok(frame_stack) => {
+                    from_thread_s.send(ReceiverMsg::FrameStack { frame_stack })?;
+                }
+                Err(FrameStackWriteError::Empty) => {
+                    warn!("acquisition: unexpected empty frame stack")
+                }
+                Err(FrameStackWriteError::NonEmpty) => {
+                    warn!("acquisition: unexpected non-empty frame stack")
+                }
+                Err(FrameStackWriteError::TooSmall) => {
+                    warn!("acquisition: frame stack too small")
+                }
             }
         }
 
-        let frame = frame_stack.frame_done(dimage, dimaged, dconfig, &msg_image);
+        let meta = DectrisFrameMeta {
+            dimage,
+            dimaged,
+            dconfig,
+            data_length_bytes: msg_image.len(),
+        };
+
+        frame_stack
+            .write_frame(&meta, |buf| {
+                buf.copy_from_slice(&msg_image);
+                Ok::<_, Infallible>(())
+            })
+            .unwrap();
 
         expected_frame_id += 1;
 
         // we will be done after this frame:
-        let done = frame.dimage.frame == detector_config.get_num_images() - 1;
+        let done = meta.dimage.frame == detector_config.get_num_images() - 1;
 
         if done {
             let elapsed = t0.elapsed();
@@ -362,13 +407,19 @@ fn acquisition(
             info!("series {series} done");
 
             let handle = frame_stack.writing_done(shm);
-
-            match from_thread_s.send(ResultMsg::End {
-                frame_stack: handle,
-            }) {
-                Ok(_) => (),
-                Err(SendError(_)) => return Err(AcquisitionError::Disconnected),
+            match handle {
+                Ok(frame_stack) => from_thread_s.send(ReceiverMsg::Finished { frame_stack })?,
+                Err(FrameStackWriteError::Empty) => {
+                    warn!("acquisition: unexpected empty frame stack")
+                }
+                Err(FrameStackWriteError::NonEmpty) => {
+                    warn!("acquisition: unexpected non-empty frame stack")
+                }
+                Err(FrameStackWriteError::TooSmall) => {
+                    warn!("acquisition: frame stack too small")
+                }
             }
+
             return Ok(());
         }
     }
@@ -376,8 +427,8 @@ fn acquisition(
 
 /// convert `AcquisitionError`s to messages on `from_threads_s`
 fn background_thread_wrap(
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<DectrisControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
     uri: String,
     frame_stack_size: usize,
     shm: SharedSlabAllocator,
@@ -387,30 +438,31 @@ fn background_thread_wrap(
         // NOTE: `shm` is dropped in case of an error, so anyone who tries to connect afterwards
         // will get an error
         from_thread_s
-            .send(ResultMsg::Error {
-                msg: err.to_string(),
+            .send(ReceiverMsg::FatalError {
+                error: Box::new(err),
             })
             .unwrap();
     }
+    info!("background_thread_wrap: done");
 }
 
 fn drain_if_mismatch(
     msg: &mut Message,
     socket: &Socket,
     series: u64,
-    control_channel: &Receiver<ControlMsg>,
+    control_channel: &Receiver<DectrisControlMsg>,
 ) -> Result<(), AcquisitionError> {
     loop {
         let series_res: Result<DSeriesAndType, _> = serde_json::from_str(msg.as_str().unwrap());
 
         if let Ok(recvd_series) = series_res {
             // everything is ok, we can go ahead:
-            if recvd_series.series == series && recvd_series.htype == "dheader-1.0" {
+            if recvd_series.series == series && recvd_series.htype.deref() == "dheader-1.0" {
                 return Ok(());
             }
         }
 
-        debug!(
+        trace!(
             "drained message header: {} expected series {}",
             msg.as_str().unwrap(),
             series
@@ -421,9 +473,9 @@ fn drain_if_mismatch(
             recv_part(msg, socket, control_channel)?;
 
             if let Some(msg_str) = msg.as_str() {
-                debug!("drained message part: {}", msg_str);
+                trace!("drained message part: {}", msg_str);
             } else {
-                debug!("drained non-utf message part");
+                trace!("drained non-utf message part");
             }
         }
 
@@ -433,13 +485,15 @@ fn drain_if_mismatch(
 }
 
 fn background_thread(
-    to_thread_r: &Receiver<ControlMsg>,
-    from_thread_s: &Sender<ResultMsg>,
+    to_thread_r: &Receiver<DectrisControlMsg>,
+    from_thread_s: &Sender<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
     uri: String,
     frame_stack_size: usize,
     mut shm: SharedSlabAllocator,
 ) -> Result<(), AcquisitionError> {
     'outer: loop {
+        let thread_id = std::thread::current().id();
+        info!("background_thread: connecting to {uri} ({thread_id:?})");
         let ctx = zmq::Context::new();
         let socket = ctx.socket(zmq::PULL).unwrap();
         socket.set_rcvtimeo(1000).unwrap();
@@ -453,6 +507,7 @@ fn background_thread(
             let control = to_thread_r.recv_timeout(Duration::from_millis(100));
             match control {
                 Ok(ControlMsg::StartAcquisitionPassive) => {
+                    from_thread_s.send(ReceiverMsg::ReceiverArmed).unwrap();
                     match passive_acquisition(
                         to_thread_r,
                         from_thread_s,
@@ -461,18 +516,32 @@ fn background_thread(
                         &mut shm,
                     ) {
                         Ok(_) => {}
-                        Err(AcquisitionError::Disconnected | AcquisitionError::Cancelled) => {
-                            return Ok(());
+                        Err(AcquisitionError::Cancelled) => {
+                            from_thread_s.send(ReceiverMsg::Cancelled).unwrap();
+                            continue 'outer;
+                        }
+                        Err(
+                            msg @ (AcquisitionError::Disconnected | AcquisitionError::StopThread),
+                        ) => {
+                            debug!("background_thread: passive_acquisition returned {msg:?}");
+                            break 'outer;
                         }
                         Err(e) => {
-                            let msg = format!("passive_acquisition error: {}", e);
-                            from_thread_s.send(ResultMsg::Error { msg }).unwrap();
+                            from_thread_s
+                                .send(ReceiverMsg::FatalError {
+                                    error: Box::new(e.clone()),
+                                })
+                                .unwrap();
                             error!("background_thread: error: {}; re-connecting", e);
                             continue 'outer;
                         }
                     }
                 }
-                Ok(ControlMsg::StartAcquisition { series }) => {
+                Ok(ControlMsg::SpecializedControlMsg {
+                    msg: DectrisExtraControl::StartAcquisitionWithSeries { series },
+                }) => {
+                    from_thread_s.send(ReceiverMsg::ReceiverArmed).unwrap();
+
                     let mut msg: Message = Message::new();
                     recv_part(&mut msg, &socket, to_thread_r)?;
 
@@ -483,19 +552,8 @@ fn background_thread(
                     let dheader: DHeader = match dheader_res {
                         Ok(header) => header,
                         Err(err) => {
-                            from_thread_s
-                                .send(ResultMsg::SerdeError {
-                                    msg: err.to_string(),
-                                    recvd_msg: msg
-                                        .as_str()
-                                        .map_or_else(|| "".to_string(), |m| m.to_string()),
-                                })
-                                .unwrap();
-                            log::error!(
-                                "background_thread: serialization error: {}",
-                                err.to_string()
-                            );
-                            break;
+                            serialization_error(from_thread_s, &msg, &err);
+                            continue 'outer;
                         }
                     };
                     debug!("dheader: {dheader:?}");
@@ -503,14 +561,19 @@ fn background_thread(
                     // second message: the header itself
                     recv_part(&mut msg, &socket, to_thread_r)?;
 
-                    if let Some(msg_str) = msg.as_str() {
+                    let detector_config: DetectorConfig = if let Some(msg_str) = msg.as_str() {
                         debug!("detector config: {}", msg_str);
+                        match serde_json::from_str(msg_str) {
+                            Ok(header) => header,
+                            Err(err) => {
+                                serialization_error(from_thread_s, &msg, &err);
+                                continue 'outer;
+                            }
+                        }
                     } else {
-                        warn!("non-string received as detector config!")
-                    }
-
-                    let detector_config: DetectorConfig =
-                        serde_json::from_str(msg.as_str().unwrap()).unwrap();
+                        warn!("non-string received as detector config! re-connecting");
+                        continue 'outer;
+                    };
 
                     match acquisition(
                         detector_config,
@@ -522,12 +585,18 @@ fn background_thread(
                         &mut shm,
                     ) {
                         Ok(_) => {}
-                        Err(AcquisitionError::Disconnected | AcquisitionError::Cancelled) => {
-                            return Ok(());
+                        Err(AcquisitionError::Cancelled) => {
+                            from_thread_s.send(ReceiverMsg::Cancelled).unwrap();
+                        }
+                        Err(AcquisitionError::Disconnected | AcquisitionError::StopThread) => {
+                            break 'outer;
                         }
                         Err(e) => {
-                            let msg = format!("acquisition error: {}", e);
-                            from_thread_s.send(ResultMsg::Error { msg }).unwrap();
+                            from_thread_s
+                                .send(ReceiverMsg::FatalError {
+                                    error: Box::new(e.clone()),
+                                })
+                                .unwrap();
                             error!("background_thread: error: {}; re-connecting", e);
                             continue 'outer;
                         }
@@ -538,9 +607,12 @@ fn background_thread(
                     break 'outer;
                 }
                 Err(RecvTimeoutError::Disconnected) => {
-                    debug!("background_thread: control channel has disconnected");
+                    warn!("background_thread: control channel has disconnected");
                     break 'outer;
                 }
+                Ok(ControlMsg::CancelAcquisition) => {
+                    warn!("background_thread: ignoring ControlMsg::CancelAcquisition outside of running acquisition")
+                }
                 Err(RecvTimeoutError::Timeout) => (), // no message, nothing to do
             }
         }
@@ -560,123 +632,130 @@ impl Display for ReceiverError {
     }
 }
 
-/// Start a background thread that received data from the zeromq socket and
-/// puts it into shared memory.
-pub struct DectrisReceiver {
-    bg_thread: Option<JoinHandle<()>>,
-    to_thread: Sender<ControlMsg>,
-    from_thread: Receiver<ResultMsg>,
-    pub status: ReceiverStatus,
-    pub shm_handle: SHMHandle,
+#[derive(Debug)]
+pub enum DectrisExtraControl {
+    StartAcquisitionWithSeries { series: u64 },
 }
 
-impl DectrisReceiver {
-    pub fn new(uri: &str, frame_stack_size: usize, shm: SharedSlabAllocator) -> Self {
-        let (to_thread_s, to_thread_r) = unbounded();
-        let (from_thread_s, from_thread_r) = unbounded();
+pub struct DectrisBackgroundThread {
+    bg_thread: JoinHandle<()>,
+    to_thread: Sender<ControlMsg<DectrisExtraControl>>,
+    from_thread: Receiver<ReceiverMsg<DectrisFrameMeta, DectrisPendingAcquisition>>,
+}
 
-        let builder = std::thread::Builder::new();
-        let uri = uri.to_string();
-
-        let shm_handle = shm.get_handle();
-
-        DectrisReceiver {
-            bg_thread: Some(
-                builder
-                    .name("bg_thread".to_string())
-                    .spawn(move || {
-                        background_thread_wrap(
-                            &to_thread_r,
-                            &from_thread_s,
-                            uri.to_string(),
-                            frame_stack_size,
-                            shm,
-                        )
-                    })
-                    .expect("failed to start background thread"),
-            ),
-            from_thread: from_thread_r,
-            to_thread: to_thread_s,
-            status: ReceiverStatus::Idle,
-            shm_handle,
-        }
+impl BackgroundThread for DectrisBackgroundThread {
+    type FrameMetaImpl = DectrisFrameMeta;
+    type AcquisitionConfigImpl = DectrisPendingAcquisition;
+    type ExtraControl = DectrisExtraControl;
+
+    fn channel_to_thread(
+        &mut self,
+    ) -> &mut Sender<background_thread::ControlMsg<Self::ExtraControl>> {
+        &mut self.to_thread
     }
 
-    fn adjust_status(&mut self, msg: &ResultMsg) {
-        match msg {
-            ResultMsg::AcquisitionStart { .. } => {
-                self.status = ReceiverStatus::Running;
-            }
-            ResultMsg::End { .. } => {
-                self.status = ReceiverStatus::Idle;
-            }
-            _ => {}
+    fn channel_from_thread(
+        &mut self,
+    ) -> &mut Receiver<
+        background_thread::ReceiverMsg<Self::FrameMetaImpl, Self::AcquisitionConfigImpl>,
+    > {
+        &mut self.from_thread
+    }
+
+    fn join(self) {
+        if let Err(e) = self.bg_thread.join() {
+            // FIXME: should we have an error boundary here instead and stop the panic?
+            std::panic::resume_unwind(e)
         }
     }
+}
+
+impl DectrisBackgroundThread {
+    pub fn spawn(
+        config: &DectrisDetectorConnConfig,
+        shm: &SharedSlabAllocator,
+    ) -> Result<Self, background_thread::BackgroundThreadSpawnError>
+    where
+        Self: std::marker::Sized,
+    {
+        let (to_thread_s, to_thread_r) = channel();
+        let (from_thread_s, from_thread_r) = channel();
+        let builder = std::thread::Builder::new();
 
-    pub fn recv(&mut self) -> ResultMsg {
-        let result_msg = self
-            .from_thread
-            .recv()
-            .expect("background thread should be running");
-        self.adjust_status(&result_msg);
-        result_msg
+        let shm = shm.clone_and_connect()?;
+
+        let config = config.clone();
+
+        Ok(Self {
+            bg_thread: builder
+                .name("bg_thread".to_string())
+                .spawn(move || {
+                    background_thread_wrap(
+                        &to_thread_r,
+                        &from_thread_s,
+                        config.uri.clone(),
+                        config.frame_stack_size,
+                        shm,
+                    )
+                })
+                .expect("failed to start background thread"),
+            to_thread: to_thread_s,
+            from_thread: from_thread_r,
+        })
     }
+}
 
-    pub fn next_timeout(&mut self, timeout: Duration) -> Option<ResultMsg> {
-        let result_msg = self.from_thread.recv_timeout(timeout);
+#[derive(Clone, Debug)]
+pub struct DectrisDetectorConnConfig {
+    /// zmq URI of the DCU
+    pub uri: String,
 
-        match result_msg {
-            Ok(result) => {
-                self.adjust_status(&result);
-                Some(result)
-            }
-            Err(e) => match e {
-                RecvTimeoutError::Disconnected => {
-                    panic!("background thread should be running")
-                }
-                RecvTimeoutError::Timeout => None,
-            },
+    /// number of frames per frame stack; approximated because of compression
+    pub frame_stack_size: usize,
+
+    /// approx. number of bytes per frame, used for sizing frame stacks together
+    /// with `frame_stack_size`
+    pub bytes_per_frame: usize,
+
+    num_slots: usize,
+    enable_huge_pages: bool,
+    shm_handle_path: String,
+}
+
+impl DectrisDetectorConnConfig {
+    pub fn new(
+        uri: &str,
+        frame_stack_size: usize,
+        bytes_per_frame: usize,
+        num_slots: usize,
+        enable_huge_pages: bool,
+        shm_handle_path: &str,
+    ) -> Self {
+        Self {
+            uri: uri.to_owned(),
+            frame_stack_size,
+            bytes_per_frame,
+            num_slots,
+            enable_huge_pages,
+            shm_handle_path: shm_handle_path.to_owned(),
         }
     }
+}
 
-    pub fn start_series(&mut self, series: u64) -> Result<(), ReceiverError> {
-        if self.status == ReceiverStatus::Closed {
-            return Err(ReceiverError {
-                msg: "receiver is closed".to_string(),
-            });
-        }
-        self.to_thread
-            .send(ControlMsg::StartAcquisition { series })
-            .expect("background thread should be running");
-        self.status = ReceiverStatus::Running;
-        Ok(())
+impl DetectorConnectionConfig for DectrisDetectorConnConfig {
+    fn get_shm_num_slots(&self) -> usize {
+        self.num_slots
     }
 
-    pub fn start_passive(&mut self) -> Result<(), ReceiverError> {
-        if self.status == ReceiverStatus::Closed {
-            return Err(ReceiverError {
-                msg: "receiver is closed".to_string(),
-            });
-        }
-        self.to_thread
-            .send(ControlMsg::StartAcquisitionPassive)
-            .expect("background thread should be running");
-        self.status = ReceiverStatus::Running;
-        Ok(())
+    fn get_shm_slot_size(&self) -> usize {
+        self.frame_stack_size * self.bytes_per_frame
     }
 
-    pub fn close(&mut self) {
-        if self.to_thread.send(ControlMsg::StopThread).is_err() {
-            warn!("could not stop background thread, probably already dead");
-        }
-        if let Some(join_handle) = self.bg_thread.take() {
-            join_handle
-                .join()
-                .expect("could not join background thread!");
-        } else {
-            warn!("did not have a bg thread join handle, cannot join!");
-        }
-        self.status = ReceiverStatus::Closed;
+    fn get_shm_enable_huge_pages(&self) -> bool {
+        self.enable_huge_pages
+    }
+
+    fn get_shm_handle_path(&self) -> String {
+        self.shm_handle_path.clone()
     }
 }
diff --git a/libertem_dectris/src/common.rs b/libertem_dectris/src/base_types.rs
similarity index 55%
rename from libertem_dectris/src/common.rs
rename to libertem_dectris/src/base_types.rs
index a1d890bf..db9e1844 100644
--- a/libertem_dectris/src/common.rs
+++ b/libertem_dectris/src/base_types.rs
@@ -1,5 +1,8 @@
 #![allow(clippy::borrow_deref_ref)]
 
+use std::ops::Deref;
+
+use common::{frame_stack::FrameMeta, generic_connection::AcquisitionConfig};
 use log::info;
 use serde::{Deserialize, Serialize};
 
@@ -7,18 +10,48 @@ use pyo3::prelude::*;
 use uuid::Uuid;
 use zmq::{Context, Message, Socket, SocketEvent};
 
+#[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)]
+#[serde(try_from = "String")]
+pub struct NonEmptyString(String);
+
+impl TryFrom<String> for NonEmptyString {
+    type Error = String;
+
+    fn try_from(value: String) -> Result<Self, Self::Error> {
+        if value.is_empty() {
+            Err("empty string provided where non-empty was expected".to_owned())
+        } else {
+            Ok(NonEmptyString(value))
+        }
+    }
+}
+
+impl ToString for NonEmptyString {
+    fn to_string(&self) -> String {
+        self.0.clone()
+    }
+}
+
+impl Deref for NonEmptyString {
+    type Target = String;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
 #[derive(Serialize, Deserialize, Debug, Clone)]
 #[pyclass]
 pub struct DSeriesAndType {
     pub series: u64,
-    pub htype: String,
+    pub htype: NonEmptyString,
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
 #[pyclass]
 pub struct DHeader {
-    pub htype: String,
-    pub header_detail: String,
+    pub htype: NonEmptyString,
+    pub header_detail: NonEmptyString,
     pub series: u64,
 }
 
@@ -27,8 +60,8 @@ impl DHeader {
     #[new]
     fn new(series: u64) -> Self {
         DHeader {
-            htype: "dheader-1.0".to_string(),
-            header_detail: "basic".to_string(),
+            htype: "dheader-1.0".to_owned().try_into().unwrap(),
+            header_detail: "basic".to_owned().try_into().unwrap(),
             series,
         }
     }
@@ -91,10 +124,43 @@ impl DetectorConfig {
     }
 }
 
+#[derive(Debug)]
+#[pyclass]
+pub struct DectrisPendingAcquisition {
+    detector_config: DetectorConfig,
+    series: u64,
+}
+
+#[pymethods]
+impl DectrisPendingAcquisition {
+    pub fn get_series(&self) -> u64 {
+        self.series
+    }
+}
+
+impl DectrisPendingAcquisition {
+    pub fn new(detector_config: DetectorConfig, series: u64) -> Self {
+        Self {
+            detector_config,
+            series,
+        }
+    }
+
+    pub fn get_detector_config(&self) -> DetectorConfig {
+        self.detector_config.clone()
+    }
+}
+
+impl AcquisitionConfig for DectrisPendingAcquisition {
+    fn num_frames(&self) -> usize {
+        self.detector_config.get_num_images() as usize
+    }
+}
+
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
 #[pyclass]
 pub struct DImage {
-    pub htype: String,
+    pub htype: NonEmptyString,
 
     /// the current series id
     pub series: u64,
@@ -103,7 +169,7 @@ pub struct DImage {
     pub frame: u64,
 
     /// md5 hash of the image data
-    pub hash: String,
+    pub hash: NonEmptyString,
 }
 
 #[pymethods]
@@ -111,10 +177,10 @@ impl DImage {
     #[new]
     fn new(frame: u64, series: u64, hash: &str) -> Self {
         DImage {
-            htype: "dimage-1.0".to_string(),
+            htype: "dimage-1.0".to_owned().try_into().unwrap(),
             series,
             frame,
-            hash: hash.to_string(),
+            hash: hash.to_owned().try_into().unwrap(),
         }
     }
 }
@@ -133,22 +199,22 @@ pub enum PixelType {
 #[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)]
 #[pyclass]
 pub struct DImageD {
-    pub htype: String,
-    pub shape: Vec<u64>,
+    pub htype: NonEmptyString,
+    pub shape: (u64, u64),
     #[serde(rename = "type")]
     pub type_: PixelType,
-    pub encoding: String, // [bs<BIT>][[-]lz4][<|>]
+    pub encoding: NonEmptyString, // [bs<BIT>][[-]lz4][<|>]
 }
 
 #[pymethods]
 impl DImageD {
     #[new]
-    fn new(shape: Vec<u64>, type_: PixelType, encoding: &str) -> Self {
+    fn new(shape: (u64, u64), type_: PixelType, encoding: &str) -> Self {
         DImageD {
-            htype: "dimage_d-1.0".to_string(),
+            htype: "dimage_d-1.0".to_string().try_into().unwrap(),
             shape,
             type_,
-            encoding: encoding.to_string(),
+            encoding: encoding.to_string().try_into().unwrap(),
         }
     }
 }
@@ -157,7 +223,7 @@ impl DImageD {
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
 #[pyclass]
 pub struct DConfig {
-    pub htype: String, // constant dconfig-1.0
+    pub htype: NonEmptyString, // constant dconfig-1.0
     pub start_time: u64,
     pub stop_time: u64,
     pub real_time: u64,
@@ -168,7 +234,7 @@ impl DConfig {
     #[new]
     fn new(start_time: u64, stop_time: u64, real_time: u64) -> Self {
         DConfig {
-            htype: "dconfig-1.0".to_string(),
+            htype: "dconfig-1.0".to_string().try_into().unwrap(),
             start_time,
             stop_time,
             real_time,
@@ -177,24 +243,73 @@ impl DConfig {
 }
 
 #[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Debug)]
-pub struct FrameMeta {
+pub struct DectrisFrameMeta {
     pub dimage: DImage,
     pub dimaged: DImageD,
     pub dconfig: DConfig,
     pub data_length_bytes: usize,
 }
 
-impl FrameMeta {
-    /// Get the number of elements in this frame (`prod(shape)`)
-    pub fn get_size(&self) -> u64 {
-        self.dimaged.shape.iter().product()
+#[derive(Debug)]
+pub enum Endianess {
+    Little,
+    Big,
+}
+
+impl Endianess {
+    pub fn as_string(&self) -> String {
+        match self {
+            Endianess::Little => "<".to_owned(),
+            Endianess::Big => ">".to_owned(),
+        }
+    }
+}
+
+impl DectrisFrameMeta {
+    /// number of pixels in the uncompressed frame (from the shape)
+    pub fn get_number_of_pixels(&self) -> usize {
+        self.dimaged.shape.0 as usize * self.dimaged.shape.1 as usize
+    }
+
+    /// endianess after decompression (little/big)
+    pub fn get_endianess(&self) -> Endianess {
+        match self.dimaged.encoding.chars().last().unwrap() {
+            '>' => Endianess::Big,
+            '<' => Endianess::Little,
+            _ => {
+                panic!("malformed encoding field");
+            }
+        }
+    }
+}
+
+impl FrameMeta for DectrisFrameMeta {
+    fn get_data_length_bytes(&self) -> usize {
+        self.data_length_bytes
+    }
+
+    fn get_dtype_string(&self) -> String {
+        let endianess = self.get_endianess();
+        // TODO: &'static str instead?
+        match (endianess, &self.dimaged.type_) {
+            (Endianess::Little, PixelType::Uint8) => "uint8".to_owned(),
+            (Endianess::Little, PixelType::Uint16) => "<u2".to_owned(),
+            (Endianess::Little, PixelType::Uint32) => "<u4".to_owned(),
+            (Endianess::Big, PixelType::Uint8) => "uint8".to_owned(),
+            (Endianess::Big, PixelType::Uint16) => ">u2".to_owned(),
+            (Endianess::Big, PixelType::Uint32) => ">u4".to_owned(),
+        }
+    }
+
+    fn get_shape(&self) -> (u64, u64) {
+        self.dimaged.shape
     }
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone)]
 #[pyclass]
 pub struct DSeriesEnd {
-    pub htype: String,
+    pub htype: NonEmptyString,
     pub series: u64,
 }
 
@@ -203,7 +318,7 @@ impl DSeriesEnd {
     #[new]
     fn new(series: u64) -> Self {
         DSeriesEnd {
-            htype: "dseries_end-1.0".to_string(),
+            htype: "dseries_end-1.0".to_string().try_into().unwrap(),
             series,
         }
     }
diff --git a/libertem_dectris/src/cam_client.rs b/libertem_dectris/src/cam_client.rs
index e002ee3c..8b137891 100644
--- a/libertem_dectris/src/cam_client.rs
+++ b/libertem_dectris/src/cam_client.rs
@@ -1,381 +1,1 @@
-use ipc_test::SharedSlabAllocator;
-use log::trace;
-use numpy::{PyArray3, PyArrayMethods};
-use pyo3::{
-    exceptions::{self, PyRuntimeError},
-    prelude::*,
-};
-use zerocopy::{AsBytes, FromBytes};
 
-use crate::{
-    common::PixelType,
-    exceptions::{ConnectionError, DecompressError},
-    frame_stack::FrameStackHandle,
-};
-
-#[pyclass]
-pub struct CamClient {
-    shm: Option<SharedSlabAllocator>,
-}
-
-impl CamClient {
-    fn decompress_bslz4_impl<'py, T: numpy::Element>(
-        &self,
-        handle: &FrameStackHandle,
-        out: &Bound<'py, PyArray3<T>>,
-    ) -> PyResult<()> {
-        let mut out_rw = out.readwrite();
-        let out_slice = out_rw.as_slice_mut().expect("`out` must be C-contiguous");
-        let slot: ipc_test::Slot = if let Some(shm) = &self.shm {
-            shm.get(handle.slot.slot_idx)
-        } else {
-            return Err(PyRuntimeError::new_err("can't decompress with closed SHM"));
-        };
-
-        for (frame_meta, idx) in handle.get_meta().iter().zip(0..) {
-            let out_size = usize::try_from(frame_meta.get_size()).unwrap();
-
-            // NOTE: frames should all have the same shape
-            // FIXME: frames in a stack can _theoretically_ have different bit depth?
-            let out_offset = idx * out_size;
-            let out_ptr: *mut T = out_slice[out_offset..out_offset + out_size]
-                .as_mut_ptr()
-                .cast();
-
-            let image_data = handle.get_slice_for_frame(idx, &slot);
-
-            match bs_sys::decompress_lz4_into(&image_data[12..], out_ptr, out_size, None) {
-                Ok(()) => {}
-                Err(e) => {
-                    let msg = format!("decompression failed: {e:?}");
-                    return Err(DecompressError::new_err(msg));
-                }
-            }
-        }
-
-        Ok(())
-    }
-
-    fn decompress_plain_lz4_impl<'py, T: numpy::Element + AsBytes + FromBytes>(
-        &self,
-        handle: &FrameStackHandle,
-        out: &Bound<'py, PyArray3<T>>,
-    ) -> PyResult<()> {
-        let mut out_rw = out.readwrite();
-        let out_slice = match out_rw.as_slice_mut() {
-            Ok(s) => s,
-            Err(e) => {
-                let msg = format!("`out` must be C-contiguous: {e:?}");
-                return Err(DecompressError::new_err(msg));
-            }
-        };
-        let slot: ipc_test::Slot = if let Some(shm) = &self.shm {
-            shm.get(handle.slot.slot_idx)
-        } else {
-            return Err(PyRuntimeError::new_err("can't decompress with closed SHM"));
-        };
-
-        for (frame_meta, idx) in handle.get_meta().iter().zip(0..) {
-            // NOTE: frames should all have the same shape
-            // FIXME: frames in a stack can _theoretically_ have different bit depth?
-            let out_size = usize::try_from(frame_meta.get_size()).unwrap();
-            let out_slice_cast = out_slice[0..out_size].as_bytes_mut();
-            let image_data = handle.get_slice_for_frame(idx, &slot);
-
-            println!("{} {}", image_data.len(), out_slice_cast.len());
-            match lz4::block::decompress_to_buffer(
-                image_data,
-                Some(out_slice_cast.len().try_into().unwrap()),
-                out_slice_cast,
-            ) {
-                Ok(_) => {}
-                Err(e) => {
-                    let msg = format!("decompression failed: {e:?}");
-                    return Err(DecompressError::new_err(msg));
-                }
-            }
-        }
-
-        Ok(())
-    }
-}
-
-#[pymethods]
-impl CamClient {
-    #[new]
-    fn new(handle_path: &str) -> PyResult<Self> {
-        match SharedSlabAllocator::connect(handle_path) {
-            Ok(shm) => Ok(CamClient { shm: Some(shm) }),
-            Err(e) => {
-                let msg = format!("failed to connect to SHM: {:?}", e);
-                Err(ConnectionError::new_err(msg))
-            }
-        }
-    }
-
-    fn decompress_frame_stack<'py>(
-        slf: PyRef<Self>,
-        handle: &FrameStackHandle,
-        out: Bound<'py, PyAny>,
-    ) -> PyResult<()> {
-        let arr_u8: Result<&Bound<'py, PyArray3<u8>>, _> = out.downcast();
-        let arr_u16: Result<&Bound<'py, PyArray3<u16>>, _> = out.downcast();
-        let arr_u32: Result<&Bound<'py, PyArray3<u32>>, _> = out.downcast();
-
-        let (encoding, type_) = if handle.is_empty() {
-            return Ok(());
-        } else {
-            let dimaged = &handle.get_meta().first().unwrap().dimaged;
-            (&dimaged.encoding, &dimaged.type_)
-        };
-
-        match encoding.as_str() {
-            // "<encoding>: String of the form ”[bs<BIT>][[-]lz4][<|>]”. bs<BIT> stands for bit shuffling with <BIT> bits, lz4 for
-            // lz4 compression and < (>) for little (big) endian. E.g. ”bs8-lz4<” stands for 8bit bitshuffling, lz4 compression
-            // and little endian. lz4 data is written as defined at https://code.google.com/p/lz4/ without any additional data like
-            // block size etc."
-            "bs32-lz4<" | "bs16-lz4<" | "bs8-lz4<" => match type_ {
-                PixelType::Uint8 => slf.decompress_bslz4_impl(handle, arr_u8.unwrap())?,
-                PixelType::Uint16 => slf.decompress_bslz4_impl(handle, arr_u16.unwrap())?,
-                PixelType::Uint32 => slf.decompress_bslz4_impl(handle, arr_u32.unwrap())?,
-            },
-            "lz4<" => match type_ {
-                PixelType::Uint8 => slf.decompress_plain_lz4_impl(handle, arr_u8.unwrap())?,
-                PixelType::Uint16 => slf.decompress_plain_lz4_impl(handle, arr_u16.unwrap())?,
-                PixelType::Uint32 => slf.decompress_plain_lz4_impl(handle, arr_u32.unwrap())?,
-            },
-            e => {
-                let msg = format!("can't deal with encoding {e}");
-                return Err(exceptions::PyValueError::new_err(msg));
-            }
-        }
-        Ok(())
-    }
-
-    fn done(mut slf: PyRefMut<Self>, handle: &FrameStackHandle) -> PyResult<()> {
-        let slot_idx = handle.slot.slot_idx;
-        if let Some(shm) = &mut slf.shm {
-            shm.free_idx(slot_idx);
-            Ok(())
-        } else {
-            Err(PyRuntimeError::new_err(
-                "CamClient.done called with SHM closed",
-            ))
-        }
-    }
-
-    fn close(&mut self) {
-        self.shm.take();
-    }
-}
-
-impl Drop for CamClient {
-    fn drop(&mut self) {
-        trace!("CamClient::drop");
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::path::PathBuf;
-
-    use lz4::block::CompressionMode;
-    use numpy::{PyArray, PyArrayMethods};
-    use tempfile::tempdir;
-
-    use ipc_test::SharedSlabAllocator;
-    use pyo3::{prepare_freethreaded_python, Python};
-    use zerocopy::AsBytes;
-
-    use crate::{
-        cam_client::CamClient,
-        frame_stack::{FrameStackForWriting, FrameStackHandle},
-    };
-    use tempfile::TempDir;
-
-    fn get_socket_path() -> (TempDir, PathBuf) {
-        let socket_dir = tempdir().unwrap();
-        let socket_as_path = socket_dir.path().join("stuff.socket");
-
-        (socket_dir, socket_as_path)
-    }
-
-    #[test]
-    fn test_cam_client() {
-        let (_socket_dir, socket_as_path) = get_socket_path();
-        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
-        let slot = shm.get_mut().expect("get a free shm slot");
-        let mut fs = FrameStackForWriting::new(slot, 1, 512);
-        let dimage = crate::common::DImage {
-            htype: "".to_string(),
-            series: 1,
-            frame: 1,
-            hash: "".to_string(),
-        };
-        let dimaged = crate::common::DImageD {
-            htype: "".to_string(),
-            shape: vec![16, 16],
-            type_: crate::common::PixelType::Uint16,
-            encoding: "bs16-lz4<".to_string(),
-        };
-        let dconfig = crate::common::DConfig {
-            htype: "".to_string(),
-            start_time: 0,
-            stop_time: 0,
-            real_time: 0,
-        };
-
-        // some predictable test data:
-        let in_: Vec<u16> = (0..256).map(|i| i % 16).collect();
-        let compressed_data = bs_sys::compress_lz4(&in_, None).unwrap();
-
-        // compressed dectris data stream has an (unknown)
-        // header in front of the compressed data, which we just cut off,
-        // so here we just prepend 12 zero-bytes
-        let mut data_with_prefix = vec![0; 12];
-        data_with_prefix.extend_from_slice(&compressed_data);
-        assert!(data_with_prefix.len() < 512);
-        data_with_prefix.iter().take(12).for_each(|&e| {
-            assert_eq!(e, 0);
-        });
-        println!("{:x?}", &compressed_data);
-        println!("{:x?}", &data_with_prefix[12..]);
-        assert_eq!(fs.cursor, 0);
-        fs.frame_done(dimage, dimaged, dconfig, &data_with_prefix);
-        assert_eq!(fs.cursor, data_with_prefix.len());
-
-        // we have one frame in there:
-        assert_eq!(fs.len(), 1);
-
-        let fs_handle = fs.writing_done(&mut shm);
-
-        // we still have one frame in there:
-        assert_eq!(fs_handle.len(), 1);
-
-        // initialize a Python interpreter so we are able to construct a PyBytes instance:
-        prepare_freethreaded_python();
-
-        // roundtrip serialize/deserialize:
-        Python::with_gil(|py| {
-            let bytes = fs_handle.serialize(py).unwrap();
-            let new_handle = FrameStackHandle::deserialize_impl(&bytes).unwrap();
-            assert_eq!(fs_handle, new_handle);
-        });
-
-        let client = CamClient::new(socket_as_path.to_str().unwrap()).unwrap();
-
-        let slot_r: ipc_test::Slot = shm.get(fs_handle.slot.slot_idx);
-        let slice = slot_r.as_slice();
-        println!("{:x?}", slice);
-
-        Python::with_gil(|py| {
-            let flat: Vec<u16> = (0..256).collect();
-            let out = PyArray::from_vec_bound(py, flat)
-                .reshape((1, 16, 16))
-                .unwrap();
-            client.decompress_bslz4_impl(&fs_handle, &out).unwrap();
-
-            out.readonly()
-                .as_slice()
-                .unwrap()
-                .iter()
-                .zip(0..)
-                .for_each(|(&item, idx)| {
-                    assert_eq!(item, in_[idx]);
-                    assert_eq!(item, (idx % 16) as u16);
-                });
-        });
-    }
-
-    #[test]
-    fn test_cam_client_lz4() {
-        let (_socket_dir, socket_as_path) = get_socket_path();
-        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
-        let slot = shm.get_mut().expect("get a free shm slot");
-        let mut fs = FrameStackForWriting::new(slot, 1, 512);
-        let dimage = crate::common::DImage {
-            htype: "".to_string(),
-            series: 1,
-            frame: 1,
-            hash: "".to_string(),
-        };
-        let dimaged = crate::common::DImageD {
-            htype: "".to_string(),
-            shape: vec![16, 16],
-            type_: crate::common::PixelType::Uint16,
-            encoding: "lz4<".to_string(),
-        };
-        let dconfig = crate::common::DConfig {
-            htype: "".to_string(),
-            start_time: 0,
-            stop_time: 0,
-            real_time: 0,
-        };
-
-        // some predictable test data:
-        let in_: Vec<u16> = (0..256).map(|i| i % 16).collect();
-        let in_bytes = in_.as_bytes();
-        let compressed_data =
-            lz4::block::compress(in_bytes, Some(CompressionMode::DEFAULT), false).unwrap();
-
-        println!("{:x?}", &compressed_data);
-        assert_eq!(fs.cursor, 0);
-        fs.frame_done(dimage, dimaged, dconfig, &compressed_data);
-        assert_eq!(fs.cursor, compressed_data.len());
-
-        // we have one frame in there:
-        assert_eq!(fs.len(), 1);
-
-        let fs_handle = fs.writing_done(&mut shm);
-
-        // we still have one frame in there:
-        assert_eq!(fs_handle.len(), 1);
-
-        // initialize a Python interpreter so we are able to construct a PyBytes instance:
-        prepare_freethreaded_python();
-
-        // roundtrip serialize/deserialize:
-        Python::with_gil(|py| {
-            let bytes = fs_handle.serialize(py).unwrap();
-            let new_handle = FrameStackHandle::deserialize_impl(&bytes).unwrap();
-            assert_eq!(fs_handle, new_handle);
-        });
-
-        let client = CamClient::new(socket_as_path.to_str().unwrap()).unwrap();
-
-        let slot_r: ipc_test::Slot = shm.get(fs_handle.slot.slot_idx);
-        let slice = slot_r.as_slice();
-        let slice_for_frame = fs_handle.get_slice_for_frame(0, &slot_r);
-
-        // try decompression directly:
-        let out_size = 256 * TryInto::<i32>::try_into(std::mem::size_of::<u16>()).unwrap();
-        println!(
-            "slice_for_frame.len(): {}, uncompressed_size: {}",
-            slice_for_frame.len(),
-            out_size
-        );
-        lz4::block::decompress(slice_for_frame, Some(out_size)).unwrap();
-
-        println!("{:x?}", slice_for_frame);
-        println!("{:x?}", slice);
-
-        Python::with_gil(|py| {
-            let flat: Vec<u16> = (0..256).collect();
-            let out = PyArray::from_vec_bound(py, flat)
-                .reshape((1, 16, 16))
-                .unwrap();
-
-            client.decompress_plain_lz4_impl(&fs_handle, &out).unwrap();
-
-            out.readonly()
-                .as_slice()
-                .unwrap()
-                .iter()
-                .zip(0..)
-                .for_each(|(&item, idx)| {
-                    assert_eq!(item, in_[idx]);
-                    assert_eq!(item, (idx % 16) as u16);
-                });
-        });
-    }
-}
diff --git a/libertem_dectris/src/decoder.rs b/libertem_dectris/src/decoder.rs
new file mode 100644
index 00000000..5d518aba
--- /dev/null
+++ b/libertem_dectris/src/decoder.rs
@@ -0,0 +1,190 @@
+use std::any::TypeId;
+
+use common::{
+    decoder::{try_cast_if_safe, Decoder, DecoderError, DecoderTargetPixelType},
+    frame_stack::FrameStackHandle,
+};
+use ipc_test::SharedSlabAllocator;
+
+use numpy::ndarray::s;
+use zerocopy::{AsBytes, FromBytes};
+
+use crate::base_types::{DectrisFrameMeta, NonEmptyString, PixelType};
+
+#[derive(Debug, Default)]
+pub struct DectrisDecoder {}
+
+impl Decoder for DectrisDecoder {
+    type FrameMeta = DectrisFrameMeta;
+
+    /// Decode (a part of a) compressed frame stack from the handle `input` to
+    /// the array `output`.
+    ///
+    /// There are ~ three types involved here:
+    ///
+    /// 1) The output dtype `T`, which is what the user wants to work in (this
+    ///    can be an integer type, but also one of the floats, or possibly even
+    ///    complex{128,64})
+    ///
+    /// 2) The "native" output dtype (`DImageD::type_`), which is what we have
+    ///    to decompress into (we can't easily map a function over individual
+    ///    pixels as part of the decompression) let's call this one `N` (even
+    ///    though we don't have it as a concrete type parameter here).  We could
+    ///    also call this the intermediate dtype, as it may be different from the
+    ///    final output type `T`.
+    ///
+    /// 3) The per-frame encoding type, like "bs32-lz4<", where the 32 means
+    ///    that 32 bits of the input are shuffled together - it doesn't have to
+    ///    match `N`! This is mostly an internal encoding type, but we have to be
+    ///    sure to handle it independently from the other types.
+    ///
+    /// The goal is to handle decompression as efficiently as possible, and
+    /// especially to handle the "native" case where `T == N` without an extra
+    /// copy, but fall back to a almost-as-good method of buffering the data in
+    /// as small as possible chunks (sadly in this case: a frame) in the
+    /// intermediate dtype, before converting to `T`.
+    fn decode<T>(
+        &self,
+        shm: &SharedSlabAllocator,
+        input: &FrameStackHandle<Self::FrameMeta>,
+        output: &mut numpy::ndarray::ArrayViewMut3<'_, T>,
+        start_idx: usize,
+        end_idx: usize,
+    ) -> Result<(), DecoderError>
+    where
+        T: DecoderTargetPixelType,
+    {
+        input.with_slot(shm, |slot| {
+            // our three cute special cases:
+            let mut tmp_u8: Vec<u8> = Vec::new();
+            let mut tmp_u16: Vec<u16> = Vec::new();
+            let mut tmp_u32: Vec<u32> = Vec::new();
+
+            for ((frame_meta, out_idx), in_idx) in
+                input.get_meta().iter().zip(0..).zip(start_idx..end_idx)
+            {
+                let mut out_view = output.slice_mut(s![out_idx, .., ..]);
+                let frame_compressed_data = input.get_slice_for_frame(in_idx, slot);
+                let out_slice =
+                    out_view
+                        .as_slice_mut()
+                        .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                            msg: "out slice not C-order contiguous".to_owned(),
+                        })?;
+
+                let pixel_type = &frame_meta.dimaged.type_;
+                let u8_t = TypeId::of::<u8>();
+                let u16_t = TypeId::of::<u16>();
+                let u32_t = TypeId::of::<u32>();
+
+                let t_type = TypeId::of::<T>();
+
+                if t_type == u8_t || t_type == u16_t || t_type == u32_t {
+                    // "zero"-copy shortcut: decompress directly into the destination
+                    self.decode_single_frame(
+                        frame_compressed_data,
+                        out_slice,
+                        &frame_meta.dimaged.encoding,
+                    )?;
+                } else {
+                    // in the general case, we need the temporary Vec:
+                    let dest_size = frame_meta.get_number_of_pixels();
+
+                    match pixel_type {
+                        PixelType::Uint8 => {
+                            if tmp_u8.capacity() < dest_size {
+                                tmp_u8.resize(dest_size, 0);
+                            }
+                            self.decode_single_frame(
+                                frame_compressed_data,
+                                &mut tmp_u8,
+                                &frame_meta.dimaged.encoding,
+                            )?;
+                            try_cast_if_safe(&tmp_u8, out_slice)?;
+                        }
+                        PixelType::Uint16 => {
+                            if tmp_u16.capacity() < dest_size {
+                                tmp_u16.resize(dest_size, 0);
+                            }
+                            self.decode_single_frame(
+                                frame_compressed_data,
+                                &mut tmp_u16,
+                                &frame_meta.dimaged.encoding,
+                            )?;
+                            try_cast_if_safe(&tmp_u16, out_slice)?;
+                        }
+                        PixelType::Uint32 => {
+                            if tmp_u32.capacity() < dest_size {
+                                tmp_u32.resize(dest_size, 0);
+                            }
+                            self.decode_single_frame(
+                                frame_compressed_data,
+                                &mut tmp_u32,
+                                &frame_meta.dimaged.encoding,
+                            )?;
+                            try_cast_if_safe(&tmp_u32, out_slice)?;
+                        }
+                    }
+                }
+            }
+
+            Ok(())
+        })
+    }
+
+    fn zero_copy_available(
+        &self,
+        _handle: &FrameStackHandle<Self::FrameMeta>,
+    ) -> Result<bool, DecoderError> {
+        Ok(false)
+    }
+}
+
+impl DectrisDecoder {
+    fn decode_single_frame<T: AsBytes + FromBytes>(
+        &self,
+        input: &[u8],
+        output: &mut [T],
+        encoding: &NonEmptyString,
+    ) -> Result<(), DecoderError> {
+        match encoding.as_str() {
+            "bs32-lz4<" | "bs16-lz4<" | "bs8-lz4<" => self.decode_single_frame_bslz4(input, output),
+            "lz4<" => self.decode_single_frame_plain_lz4(input, output),
+            enc => Err(DecoderError::FrameDecodeFailed {
+                msg: format!("unknown or unsupported encoding: {enc}"),
+            }),
+        }
+    }
+
+    fn decode_single_frame_bslz4<T>(
+        &self,
+        input: &[u8],
+        output: &mut [T],
+    ) -> Result<(), DecoderError> {
+        let out_ptr = output.as_mut_ptr();
+        unsafe { bs_sys::decompress_lz4_into(&input[12..], out_ptr, output.len(), None) }.map_err(
+            |e| {
+                let msg = format!("decompression failed: {e:?}");
+                DecoderError::FrameDecodeFailed { msg }
+            },
+        )
+    }
+
+    fn decode_single_frame_plain_lz4<T: AsBytes + FromBytes>(
+        &self,
+        input: &[u8],
+        output: &mut [T],
+    ) -> Result<(), DecoderError> {
+        let out_slice_bytes = output.as_bytes_mut();
+        let out_size: i32 = out_slice_bytes.len().try_into().map_err(|e| {
+            let msg = format!("output buffer size error: {e}");
+            DecoderError::FrameDecodeFailed { msg }
+        })?;
+
+        lz4::block::decompress_to_buffer(input, Some(out_size), out_slice_bytes).map_err(|e| {
+            let msg = format!("plain lz4 decompression failed: {e}");
+            DecoderError::FrameDecodeFailed { msg }
+        })?;
+        Ok(())
+    }
+}
diff --git a/libertem_dectris/src/dectris_py.rs b/libertem_dectris/src/dectris_py.rs
index 6505d688..dd3d2fae 100644
--- a/libertem_dectris/src/dectris_py.rs
+++ b/libertem_dectris/src/dectris_py.rs
@@ -1,37 +1,41 @@
-#![allow(clippy::borrow_deref_ref)]
+//! This module should be a wrapper around the generic functionality of the `common`
+//! crate, and only contain logic that is specific to the detector. The interface
+//! exported to Python should be as uniform as possible compared to other detectors,
+//! pending future unification with full compatability between detectors.
+use std::time::Duration;
 
-use std::{
-    convert::Infallible,
-    path::PathBuf,
-    time::{Duration, Instant},
-};
+use common::generic_connection::{ConnectionStatus, GenericConnection};
 
 use crate::{
-    cam_client::CamClient,
-    common::{
-        DConfig, DHeader, DImage, DImageD, DSeriesEnd, DetectorConfig, PixelType, TriggerMode,
+    background_thread::{DectrisBackgroundThread, DectrisDetectorConnConfig, DectrisExtraControl},
+    base_types::{
+        DConfig, DHeader, DImage, DImageD, DSeriesEnd, DectrisFrameMeta, DectrisPendingAcquisition,
+        DetectorConfig, PixelType, TriggerMode,
     },
-    exceptions::{ConnectionError, DecompressError, TimeoutError},
-    frame_stack::FrameStackHandle,
-    receiver::{DectrisReceiver, ReceiverStatus, ResultMsg},
+    exceptions::{DecompressError, TimeoutError},
     sim::DectrisSim,
 };
 
-use ipc_test::SharedSlabAllocator;
-use log::{info, trace};
+use common::{impl_py_cam_client, impl_py_connection};
+
 use pyo3::{
-    exceptions::{self, PyRuntimeError},
+    exceptions::PyDeprecationWarning,
     prelude::*,
+    types::{PyBytes, PyType},
 };
-use stats::Stats;
+
+use log::trace;
+use numpy::PyUntypedArray;
+
+use crate::decoder::DectrisDecoder;
 
 #[pymodule]
-fn libertem_dectris<'py>(py: Python, m: Bound<'py, PyModule>) -> PyResult<()> {
+fn libertem_dectris(py: Python, m: Bound<'_, PyModule>) -> PyResult<()> {
     // FIXME: logging integration deadlocks on close(), when trying to acquire
     // the GIL
     // pyo3_log::init();
 
-    m.add_class::<FrameStackHandle>()?;
+    m.add_class::<DectrisFrameStack>()?;
     m.add_class::<DectrisConnection>()?;
     m.add_class::<PixelType>()?;
     m.add_class::<DectrisSim>()?;
@@ -51,10 +55,7 @@ fn libertem_dectris<'py>(py: Python, m: Bound<'py, PyModule>) -> PyResult<()> {
     Ok(())
 }
 
-fn register_header_module<'py>(
-    py: Python<'_>,
-    parent_module: &Bound<'py, PyModule>,
-) -> PyResult<()> {
+fn register_header_module(py: Python<'_>, parent_module: &Bound<'_, PyModule>) -> PyResult<()> {
     let headers_module = PyModule::new_bound(py, "headers")?;
     headers_module.add_class::<DHeader>()?;
     headers_module.add_class::<DImage>()?;
@@ -65,143 +66,18 @@ fn register_header_module<'py>(
     Ok(())
 }
 
-struct FrameChunkedIterator<'a, 'b, 'c, 'd> {
-    receiver: &'a mut DectrisReceiver,
-    shm: &'b mut SharedSlabAllocator,
-    remainder: &'c mut Vec<FrameStackHandle>,
-    stats: &'d mut Stats,
-}
-
-impl<'a, 'b, 'c, 'd> FrameChunkedIterator<'a, 'b, 'c, 'd> {
-    /// Get the next frame stack. Mainly handles splitting logic for boundary
-    /// conditions and delegates communication with the background thread to `recv_next_stack_impl`
-    pub fn get_next_stack_impl(
-        &mut self,
-        py: Python,
-        max_size: usize,
-    ) -> PyResult<Option<FrameStackHandle>> {
-        let res = self.recv_next_stack_impl(py);
-        match res {
-            Ok(Some(frame_stack)) => {
-                if frame_stack.len() > max_size {
-                    // split `FrameStackHandle` into two:
-                    trace!(
-                        "FrameStackHandle::split_at({max_size}); len={}",
-                        frame_stack.len()
-                    );
-                    self.stats.count_split();
-                    let (left, right) = frame_stack.split_at(max_size, self.shm);
-                    self.remainder.push(right);
-                    assert!(left.len() <= max_size);
-                    return Ok(Some(left));
-                }
-                assert!(frame_stack.len() <= max_size);
-                Ok(Some(frame_stack))
-            }
-            Ok(None) => Ok(None),
-            e @ Err(_) => e,
-        }
-    }
-
-    /// Receive the next frame stack from the background thread and handle any
-    /// other control messages.
-    fn recv_next_stack_impl(&mut self, py: Python) -> PyResult<Option<FrameStackHandle>> {
-        // first, check if there is anything on the remainder list:
-        if let Some(frame_stack) = self.remainder.pop() {
-            return Ok(Some(frame_stack));
-        }
-
-        match self.receiver.status {
-            ReceiverStatus::Closed => {
-                return Err(exceptions::PyRuntimeError::new_err("receiver is closed"))
-            }
-            ReceiverStatus::Idle => return Ok(None),
-            ReceiverStatus::Running => {}
-        }
-
-        let recv = &mut self.receiver;
-
-        loop {
-            py.check_signals()?;
-
-            let recv_result = py.allow_threads(|| {
-                let next: Result<Option<ResultMsg>, Infallible> =
-                    Ok(recv.next_timeout(Duration::from_millis(100)));
-                next
-            })?;
-
-            match recv_result {
-                None => {
-                    continue;
-                }
-                Some(ResultMsg::AcquisitionStart {
-                    series: _,
-                    detector_config: _,
-                }) => {
-                    // FIXME: in case of "passive" mode, we should actually not hit this,
-                    // as the "outer" structure (`DectrisConnection`) handles it?
-                    continue;
-                }
-                Some(ResultMsg::SerdeError { msg, recvd_msg }) => {
-                    return Err(exceptions::PyRuntimeError::new_err(format!(
-                        "serialization error: {}, message: {}",
-                        msg, recvd_msg
-                    )))
-                }
-                Some(ResultMsg::Error { msg }) => {
-                    return Err(exceptions::PyRuntimeError::new_err(msg))
-                }
-                Some(ResultMsg::End { frame_stack }) => {
-                    self.stats.log_stats();
-                    self.stats.reset();
-                    return Ok(Some(frame_stack));
-                }
-                Some(ResultMsg::FrameStack { frame_stack }) => {
-                    return Ok(Some(frame_stack));
-                }
-            }
-        }
-    }
-
-    fn new(
-        receiver: &'a mut DectrisReceiver,
-        shm: &'b mut SharedSlabAllocator,
-        remainder: &'c mut Vec<FrameStackHandle>,
-        stats: &'d mut Stats,
-    ) -> PyResult<Self> {
-        Ok(FrameChunkedIterator {
-            receiver,
-            shm,
-            remainder,
-            stats,
-        })
-    }
-}
+impl_py_connection!(
+    _PyDectrisConnection,
+    _PyDectrisFrameStack,
+    DectrisFrameMeta,
+    DectrisBackgroundThread,
+    DectrisPendingAcquisition,
+    libertem_dectris
+);
 
 #[pyclass]
 struct DectrisConnection {
-    receiver: DectrisReceiver,
-    remainder: Vec<FrameStackHandle>,
-    local_shm: SharedSlabAllocator,
-    stats: Stats,
-}
-
-impl DectrisConnection {
-    fn start_series_impl(&mut self, series: u64) -> PyResult<()> {
-        self.receiver
-            .start_series(series)
-            .map_err(|err| exceptions::PyRuntimeError::new_err(err.msg))
-    }
-
-    fn start_passive_impl(&mut self) -> PyResult<()> {
-        self.receiver
-            .start_passive()
-            .map_err(|err| exceptions::PyRuntimeError::new_err(err.msg))
-    }
-
-    fn close_impl(&mut self) {
-        self.receiver.close();
-    }
+    conn: _PyDectrisConnection,
 }
 
 #[pymethods]
@@ -210,36 +86,36 @@ impl DectrisConnection {
     fn new(
         uri: &str,
         frame_stack_size: usize,
-        handle_path: String,
+        handle_path: &str,
         num_slots: Option<usize>,
         bytes_per_frame: Option<usize>,
         huge: Option<bool>,
     ) -> PyResult<Self> {
         let num_slots = num_slots.map_or_else(|| 2000, |x| x);
         let bytes_per_frame = bytes_per_frame.map_or_else(|| 512 * 512 * 2, |x| x);
-        let slot_size = frame_stack_size * bytes_per_frame;
-        let shm = match SharedSlabAllocator::new(
+        let config = DectrisDetectorConnConfig::new(
+            uri,
+            frame_stack_size,
+            bytes_per_frame,
             num_slots,
-            slot_size,
             huge.map_or_else(|| false, |x| x),
-            &PathBuf::from(handle_path),
-        ) {
-            Ok(shm) => shm,
-            Err(e) => {
-                let total_size = num_slots * slot_size;
-                let msg = format!("could not create SHM area (num_slots={num_slots}, slot_size={slot_size} total_size={total_size} huge={huge:?}): {e:?}");
-                return Err(ConnectionError::new_err(msg));
-            }
-        };
-
-        let local_shm = shm.clone_and_connect().expect("clone SHM");
-
-        Ok(Self {
-            receiver: DectrisReceiver::new(uri, frame_stack_size, shm),
-            remainder: Vec::new(),
-            local_shm,
-            stats: Stats::new(),
-        })
+            handle_path,
+        );
+
+        let shm = GenericConnection::<DectrisBackgroundThread, DectrisPendingAcquisition>::shm_from_config(&config).map_err(|e| {
+            PyConnectionError::new_err(format!("could not init shm: {}", e))
+        })?;
+        let bg_thread = DectrisBackgroundThread::spawn(&config, &shm)
+            .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+        let generic_conn =
+            GenericConnection::<DectrisBackgroundThread, DectrisPendingAcquisition>::new(
+                bg_thread, &shm,
+            )
+            .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+
+        let conn = _PyDectrisConnection::new(shm, generic_conn);
+
+        Ok(Self { conn })
     }
 
     /// Wait until the detector is armed, or until the timeout expires (in seconds)
@@ -247,94 +123,459 @@ impl DectrisConnection {
     /// This method drops the GIL to allow concurrent Python threads.
     fn wait_for_arm(
         &mut self,
-        timeout: f32,
-        py: Python,
+        timeout: Option<f32>,
+        py: Python<'_>,
     ) -> PyResult<Option<(DetectorConfig, u64)>> {
-        let timeout = Duration::from_secs_f32(timeout);
-        let deadline = Instant::now() + timeout;
-        let step = Duration::from_millis(100);
-
-        loop {
-            py.check_signals()?;
-
-            let res = py.allow_threads(|| {
-                let timeout_rem = deadline - Instant::now();
-                let this_timeout = timeout_rem.min(step);
-                self.receiver.next_timeout(this_timeout)
-            });
-
-            match res {
-                Some(ResultMsg::AcquisitionStart {
-                    series,
-                    detector_config,
-                }) => return Ok(Some((detector_config, series))),
-                msg @ Some(ResultMsg::End { .. }) | msg @ Some(ResultMsg::FrameStack { .. }) => {
-                    let err = format!("unexpected message: {:?}", msg);
-                    return Err(PyRuntimeError::new_err(err));
-                }
-                Some(ResultMsg::Error { msg }) => return Err(PyRuntimeError::new_err(msg)),
-                Some(ResultMsg::SerdeError { msg, recvd_msg: _ }) => {
-                    return Err(PyRuntimeError::new_err(msg))
-                }
-                None => {
-                    // timeout
-                    if Instant::now() > deadline {
-                        return Ok(None);
-                    } else {
-                        continue;
-                    }
-                }
-            }
-        }
+        let res = self.conn.wait_for_arm(timeout, py)?;
+        Ok(res.map(|config| (config.get_detector_config(), config.get_series())))
     }
 
     fn get_socket_path(&self) -> PyResult<String> {
-        Ok(self.local_shm.get_handle().os_handle)
+        self.conn.get_socket_path()
     }
 
-    fn is_running(slf: PyRef<Self>) -> bool {
-        slf.receiver.status == ReceiverStatus::Running
+    fn is_running(&self) -> PyResult<bool> {
+        self.conn.is_running()
     }
 
-    fn start(mut slf: PyRefMut<Self>, series: u64) -> PyResult<()> {
-        slf.start_series_impl(series)
+    fn start(&mut self, series: u64, timeout: Option<f32>) -> PyResult<()> {
+        let timeout = timeout.map_or(Duration::from_millis(100), Duration::from_secs_f32);
+
+        self.conn
+            .send_specialized(DectrisExtraControl::StartAcquisitionWithSeries { series })?;
+        self.conn
+            .wait_for_status(ConnectionStatus::Armed, Some(timeout))?;
+        Ok(())
     }
 
     /// Start listening for global acquisition headers on the zeromq socket.
-    /// Call `wait_for_arm` to wait
-    fn start_passive(mut slf: PyRefMut<Self>) -> PyResult<()> {
-        slf.start_passive_impl()
+    fn start_passive(&mut self, timeout: Option<f32>, py: Python<'_>) -> PyResult<()> {
+        self.conn.start_passive(timeout, py)
     }
 
-    fn close(mut slf: PyRefMut<Self>) {
-        slf.stats.log_stats();
-        slf.stats.reset();
-        slf.close_impl();
+    fn close(&mut self) -> PyResult<()> {
+        self.conn.close()
+    }
+
+    fn log_shm_stats(&self) -> PyResult<()> {
+        self.conn.log_shm_stats()
     }
 
     fn get_next_stack(
         &mut self,
-        py: Python,
         max_size: usize,
-    ) -> PyResult<Option<FrameStackHandle>> {
-        let mut iter = FrameChunkedIterator::new(
-            &mut self.receiver,
-            &mut self.local_shm,
-            &mut self.remainder,
-            &mut self.stats,
+        py: Python<'_>,
+    ) -> PyResult<Option<DectrisFrameStack>> {
+        let stack_inner = self.conn.get_next_stack(max_size, py)?;
+        Ok(stack_inner.map(DectrisFrameStack::new))
+    }
+}
+
+#[pyclass(name = "FrameStackHandle")]
+pub struct DectrisFrameStack {
+    inner: _PyDectrisFrameStack,
+}
+
+impl DectrisFrameStack {
+    pub fn new(inner: _PyDectrisFrameStack) -> Self {
+        Self { inner }
+    }
+
+    pub fn get_inner(&self) -> &_PyDectrisFrameStack {
+        &self.inner
+    }
+
+    pub fn get_inner_mut(&mut self) -> &mut _PyDectrisFrameStack {
+        &mut self.inner
+    }
+}
+
+#[pymethods]
+impl DectrisFrameStack {
+    fn __len__(&self) -> PyResult<usize> {
+        self.inner.__len__()
+    }
+
+    fn get_dtype_string(&self) -> PyResult<String> {
+        self.inner.get_dtype_string()
+    }
+
+    /// use `get_dtype_string` instead
+    #[deprecated]
+    fn get_pixel_type(&self, py: Python<'_>) -> PyResult<String> {
+        let meta = self.inner.try_get_inner()?.first_meta();
+        PyErr::warn_bound(
+            py,
+            &py.get_type_bound::<PyDeprecationWarning>(),
+            "FrameStackHandle.get_pixel_type is deprecated, use get_dtype_string instead.",
+            0,
+        )?;
+
+        Ok(match meta.dimaged.type_ {
+            PixelType::Uint8 => "uint8",
+            PixelType::Uint16 => "uint16",
+            PixelType::Uint32 => "uint32",
+        }
+        .to_owned())
+    }
+
+    /// use `get_dtype_string` instead, that includes endianess
+    #[deprecated]
+    fn get_endianess(&self, py: Python<'_>) -> PyResult<String> {
+        PyErr::warn_bound(
+            py,
+            &py.get_type_bound::<PyDeprecationWarning>(),
+            "FrameStackHandle.get_endianess is deprecated, use get_dtype_string instead.",
+            0,
+        )?;
+        Ok(self
+            .inner
+            .try_get_inner()?
+            .first_meta()
+            .get_endianess()
+            .as_string())
+    }
+
+    /// implementation detail that Python shouldn't care about
+    #[deprecated]
+    fn get_encoding(&self, py: Python<'_>) -> PyResult<String> {
+        PyErr::warn_bound(
+            py,
+            &py.get_type_bound::<PyDeprecationWarning>(),
+            "FrameStackHandle.get_encoding is deprecated and will be removed in the future.",
+            0,
         )?;
-        iter.get_next_stack_impl(py, max_size).map(|maybe_stack| {
-            if let Some(frame_stack) = &maybe_stack {
-                self.stats.count_stats_item(frame_stack);
-            }
-            maybe_stack
+        Ok(self
+            .inner
+            .try_get_inner()?
+            .first_meta()
+            .dimaged
+            .encoding
+            .to_string())
+    }
+
+    fn get_shape(&self) -> PyResult<(u64, u64)> {
+        self.inner.get_shape()
+    }
+
+    fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
+        self.inner.serialize(py)
+    }
+
+    #[classmethod]
+    fn deserialize<'py>(
+        _cls: Bound<'py, PyType>,
+        serialized: Bound<'py, PyBytes>,
+    ) -> PyResult<Self> {
+        Ok(Self {
+            inner: _PyDectrisFrameStack::deserialize_impl(serialized)?,
         })
     }
+}
+
+impl_py_cam_client!(
+    _PyDectrisCamClient,
+    DectrisDecoder,
+    _PyDectrisFrameStack,
+    DectrisFrameMeta,
+    libertem_dectris
+);
 
-    fn log_shm_stats(&self) {
-        let free = self.local_shm.num_slots_free();
-        let total = self.local_shm.num_slots_total();
-        self.stats.log_stats();
-        info!("shm stats free/total: {}/{}", free, total);
+#[pyclass]
+pub struct CamClient {
+    inner: _PyDectrisCamClient,
+}
+
+#[pymethods]
+impl CamClient {
+    #[new]
+    fn new(handle_path: &str) -> PyResult<Self> {
+        Ok(Self {
+            inner: _PyDectrisCamClient::new(handle_path)?,
+        })
+    }
+
+    fn decode_range_into_buffer<'py>(
+        &self,
+        input: &DectrisFrameStack,
+        out: &Bound<'py, PyUntypedArray>,
+        start_idx: usize,
+        end_idx: usize,
+        py: Python<'py>,
+    ) -> PyResult<()> {
+        self.inner
+            .decode_range_into_buffer(input.get_inner(), out, start_idx, end_idx, py)
+    }
+
+    #[deprecated]
+    fn decompress_frame_stack<'py>(
+        &self,
+        handle: &DectrisFrameStack,
+        out: &Bound<'py, PyUntypedArray>,
+        py: Python<'py>,
+    ) -> PyResult<()> {
+        PyErr::warn_bound(
+            py,
+            &py.get_type_bound::<PyDeprecationWarning>(),
+            "CamClient.decompress_frame_stack is deprecated, use decode_range_into_buffer instead.",
+            0,
+        )?;
+        self.inner.decode_into_buffer(handle.get_inner(), out, py)
+    }
+
+    fn done(&mut self, handle: &mut DectrisFrameStack) -> PyResult<()> {
+        self.inner.frame_stack_done(handle.get_inner_mut())
+    }
+
+    fn close(&mut self) -> PyResult<()> {
+        self.inner.close()
+    }
+}
+
+impl Drop for CamClient {
+    fn drop(&mut self) {
+        trace!("CamClient::drop");
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{convert::Infallible, io::Write, path::PathBuf};
+
+    use common::frame_stack::{FrameStackForWriting, FrameStackHandle};
+    use lz4::block::CompressionMode;
+    use numpy::{PyArray, PyArrayMethods};
+    use tempfile::tempdir;
+
+    use ipc_test::SharedSlabAllocator;
+    use pyo3::{prepare_freethreaded_python, Python};
+    use zerocopy::AsBytes;
+
+    use crate::{
+        base_types::DectrisFrameMeta,
+        dectris_py::{CamClient, DectrisFrameStack, _PyDectrisFrameStack},
+    };
+    use tempfile::TempDir;
+
+    fn get_socket_path() -> (TempDir, PathBuf) {
+        let socket_dir = tempdir().unwrap();
+        let socket_as_path = socket_dir.path().join("stuff.socket");
+
+        (socket_dir, socket_as_path)
+    }
+
+    #[test]
+    fn test_cam_client() {
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 1, 512);
+        let dimage = crate::base_types::DImage {
+            htype: "dimage-1.0".to_string().try_into().unwrap(),
+            series: 1,
+            frame: 1,
+            hash: "aaaabbbb".to_string().try_into().unwrap(),
+        };
+        let dimaged = crate::base_types::DImageD {
+            htype: "d-image_d-1.0".to_string().try_into().unwrap(),
+            shape: (16, 16),
+            type_: crate::base_types::PixelType::Uint16,
+            encoding: "bs16-lz4<".to_string().try_into().unwrap(),
+        };
+        let dconfig = crate::base_types::DConfig {
+            htype: "dconfig-1.0".to_string().try_into().unwrap(),
+            start_time: 0,
+            stop_time: 0,
+            real_time: 0,
+        };
+
+        // some predictable test data:
+        let in_: Vec<u16> = (0..256).map(|i| i % 16).collect();
+        let compressed_data = bs_sys::compress_lz4(&in_, None).unwrap();
+
+        // compressed dectris data stream has an (unknown)
+        // header in front of the compressed data, which we just cut off,
+        // so here we just prepend 12 zero-bytes
+        let mut data_with_prefix = vec![0; 12];
+        data_with_prefix.extend_from_slice(&compressed_data);
+        assert!(data_with_prefix.len() < 512);
+        data_with_prefix.iter().take(12).for_each(|&e| {
+            assert_eq!(e, 0);
+        });
+        println!("compressed_data:        {:x?}", &compressed_data);
+        println!("data_with_prefix[12..]: {:x?}", &data_with_prefix[12..]);
+        assert_eq!(fs.get_cursor(), 0);
+
+        let meta = DectrisFrameMeta {
+            dimage,
+            dimaged,
+            dconfig,
+            data_length_bytes: data_with_prefix.len(),
+        };
+
+        fs.write_frame(&meta, |mut b| -> Result<(), Infallible> {
+            b.write_all(&data_with_prefix).unwrap();
+            Ok(())
+        })
+        .unwrap();
+
+        assert_eq!(fs.get_cursor(), data_with_prefix.len());
+
+        // we have one frame in there:
+        assert_eq!(fs.len(), 1);
+
+        let fs_handle = fs.writing_done(&mut shm).unwrap();
+
+        // we still have one frame in there:
+        assert_eq!(fs_handle.len(), 1);
+
+        // initialize a Python interpreter so we are able to construct a PyBytes instance:
+        prepare_freethreaded_python();
+
+        // roundtrip serialize/deserialize:
+        Python::with_gil(|_py| {
+            let bytes = fs_handle.serialize().unwrap();
+            let new_handle = FrameStackHandle::deserialize_impl(&bytes).unwrap();
+            assert_eq!(fs_handle, new_handle);
+        });
+
+        let client = CamClient::new(socket_as_path.to_str().unwrap()).unwrap();
+
+        fs_handle.with_slot(&shm, |slot_r| {
+            let slice = slot_r.as_slice();
+            println!("slice: {:x?}", slice);
+        });
+
+        Python::with_gil(|py| {
+            let flat: Vec<u16> = (0..256).collect();
+            let out = PyArray::from_vec_bound(py, flat)
+                .reshape((1, 16, 16))
+                .unwrap();
+
+            let out_untyped = out.as_untyped();
+            let dfsh = DectrisFrameStack::new(_PyDectrisFrameStack::new(fs_handle));
+            client
+                .decode_range_into_buffer(&dfsh, out_untyped, 0, dfsh.__len__().unwrap(), py)
+                .unwrap();
+
+            out.readonly()
+                .as_slice()
+                .unwrap()
+                .iter()
+                .zip(0..)
+                .for_each(|(&item, idx)| {
+                    assert_eq!(item, in_[idx]);
+                    assert_eq!(item, (idx % 16) as u16);
+                });
+        });
+    }
+
+    #[test]
+    fn test_cam_client_lz4() {
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 1, 512);
+        let dimage = crate::base_types::DImage {
+            htype: "dimage-1.0".to_string().try_into().unwrap(),
+            series: 1,
+            frame: 1,
+            hash: "aaaabbbb".to_string().try_into().unwrap(),
+        };
+        let dimaged = crate::base_types::DImageD {
+            htype: "dimage_d-1.0".to_string().try_into().unwrap(),
+            shape: (16, 16),
+            type_: crate::base_types::PixelType::Uint16,
+            encoding: "lz4<".to_string().try_into().unwrap(),
+        };
+        let dconfig = crate::base_types::DConfig {
+            htype: "dconfig-1.0".to_string().try_into().unwrap(),
+            start_time: 0,
+            stop_time: 0,
+            real_time: 0,
+        };
+
+        // some predictable test data:
+        let in_: Vec<u16> = (0..256).map(|i| i % 16).collect();
+        let in_bytes = in_.as_bytes();
+        let compressed_data =
+            lz4::block::compress(in_bytes, Some(CompressionMode::DEFAULT), false).unwrap();
+
+        println!("compressed_data: {:x?}", &compressed_data);
+        assert_eq!(fs.get_cursor(), 0);
+
+        let meta = DectrisFrameMeta {
+            dimage,
+            dimaged,
+            dconfig,
+            data_length_bytes: compressed_data.len(),
+        };
+
+        fs.write_frame(&meta, |buf| {
+            buf.copy_from_slice(&compressed_data);
+            Ok::<_, Infallible>(())
+        })
+        .unwrap();
+
+        assert_eq!(fs.get_cursor(), compressed_data.len());
+
+        // we have one frame in there:
+        assert_eq!(fs.len(), 1);
+
+        let fs_handle = fs.writing_done(&mut shm).unwrap();
+
+        // we still have one frame in there:
+        assert_eq!(fs_handle.len(), 1);
+
+        // initialize a Python interpreter so we are able to construct a PyBytes instance:
+        prepare_freethreaded_python();
+
+        // roundtrip serialize/deserialize:
+        Python::with_gil(|_py| {
+            let bytes = fs_handle.serialize().unwrap();
+            let new_handle = FrameStackHandle::deserialize_impl(&bytes).unwrap();
+            assert_eq!(fs_handle, new_handle);
+        });
+
+        let client = CamClient::new(socket_as_path.to_str().unwrap()).unwrap();
+
+        fs_handle.with_slot(&shm, |slot_r| {
+            let slice = slot_r.as_slice();
+            let slice_for_frame = fs_handle.get_slice_for_frame(0, slot_r);
+
+            // try decompression directly:
+            let out_size = 256 * TryInto::<i32>::try_into(std::mem::size_of::<u16>()).unwrap();
+            println!(
+                "slice_for_frame.len(): {}, uncompressed_size: {}",
+                slice_for_frame.len(),
+                out_size
+            );
+            lz4::block::decompress(slice_for_frame, Some(out_size)).unwrap();
+
+            println!("slice_for_frame: {:x?}", slice_for_frame);
+            println!("slice:           {:x?}", slice);
+        });
+
+        Python::with_gil(|py| {
+            let flat: Vec<u16> = (0..256).collect();
+            let out = PyArray::from_vec_bound(py, flat)
+                .reshape((1, 16, 16))
+                .unwrap();
+            let out_untyped = out.as_untyped();
+            let dfsh = DectrisFrameStack::new(_PyDectrisFrameStack::new(fs_handle));
+            client
+                .decode_range_into_buffer(&dfsh, out_untyped, 0, dfsh.__len__().unwrap(), py)
+                .unwrap();
+
+            out.readonly()
+                .as_slice()
+                .unwrap()
+                .iter()
+                .zip(0..)
+                .for_each(|(&item, idx)| {
+                    assert_eq!(item, in_[idx]);
+                    assert_eq!(item, (idx % 16) as u16);
+                });
+        });
     }
 }
diff --git a/libertem_dectris/src/frame_stack.rs b/libertem_dectris/src/frame_stack.rs
deleted file mode 100644
index 8f2712f9..00000000
--- a/libertem_dectris/src/frame_stack.rs
+++ /dev/null
@@ -1,390 +0,0 @@
-use bincode::serialize;
-use ipc_test::{SharedSlabAllocator, SlotForWriting, SlotInfo};
-use pyo3::{
-    exceptions::{self, PyRuntimeError, PyValueError},
-    prelude::*,
-    types::{PyBytes, PyType},
-};
-use serde::{Deserialize, Serialize};
-
-use crate::common::{DConfig, DImage, DImageD, FrameMeta, PixelType};
-
-pub struct FrameStackForWriting {
-    slot: SlotForWriting,
-    meta: Vec<FrameMeta>,
-
-    /// where in the slot do the frames begin? this can be unevenly spaced
-    offsets: Vec<usize>,
-
-    /// offset where the next frame will be written
-    pub(crate) cursor: usize,
-
-    /// number of bytes reserved for each frame
-    /// as some frames compress better or worse, this is just the "planning" number
-    bytes_per_frame: usize,
-}
-
-impl FrameStackForWriting {
-    pub fn new(slot: SlotForWriting, capacity: usize, bytes_per_frame: usize) -> Self {
-        FrameStackForWriting {
-            slot,
-            cursor: 0,
-            bytes_per_frame,
-            // reserve a bit more, as we don't know the upper bound of frames
-            // per stack and using a bit more memory is better than having to
-            // resize the vectors
-            meta: Vec::with_capacity(2 * capacity),
-            offsets: Vec::with_capacity(2 * capacity),
-        }
-    }
-
-    pub fn len(&self) -> usize {
-        self.meta.len()
-    }
-
-    pub fn can_fit(&self, num_bytes: usize) -> bool {
-        self.slot.size - self.cursor >= num_bytes
-    }
-
-    pub fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-
-    /// take frame metadata and put it into our list
-    /// and copy data into the right shm position
-    /// (we can't have zero-copy directly into shm with 0mq sadly, as far as I
-    /// can tell)
-    /// TODO: in a general library, this should also have a zero-copy api available!
-    pub fn frame_done(
-        &mut self,
-        dimage: DImage,
-        dimaged: DImageD,
-        dconfig: DConfig,
-        data: &[u8],
-    ) -> FrameMeta {
-        let start = self.cursor;
-        let stop = start + data.len();
-        let dest = &mut self.slot.as_slice_mut()[start..stop];
-        // FIXME: return error on slice length mismatch, don't panic
-        dest.copy_from_slice(data);
-        let meta = FrameMeta {
-            dimage,
-            dimaged,
-            dconfig,
-            data_length_bytes: data.len(),
-        };
-        self.meta.push(meta.clone());
-        self.offsets.push(self.cursor);
-        self.cursor += data.len();
-        meta
-    }
-
-    pub fn writing_done(self, shm: &mut SharedSlabAllocator) -> FrameStackHandle {
-        let slot_info = shm.writing_done(self.slot);
-
-        FrameStackHandle {
-            slot: slot_info,
-            meta: self.meta,
-            offsets: self.offsets,
-            bytes_per_frame: self.bytes_per_frame,
-        }
-    }
-}
-
-/// serializable handle for a stack of frames that live in shm
-#[pyclass]
-#[derive(PartialEq, Eq, Serialize, Deserialize, Debug)]
-pub struct FrameStackHandle {
-    pub(crate) slot: SlotInfo,
-    meta: Vec<FrameMeta>,
-    pub(crate) offsets: Vec<usize>,
-    pub(crate) bytes_per_frame: usize,
-}
-
-impl FrameStackHandle {
-    pub fn len(&self) -> usize {
-        self.meta.len()
-    }
-
-    #[must_use]
-    pub fn is_empty(&self) -> bool {
-        self.len() == 0
-    }
-
-    /// total number of _useful_ bytes in this frame stack
-    pub fn payload_size(&self) -> usize {
-        self.meta.iter().map(|fm| fm.data_length_bytes).sum()
-    }
-
-    /// total number of bytes allocated for the slot
-    pub fn slot_size(&self) -> usize {
-        self.slot.size
-    }
-
-    pub fn get_meta(&self) -> &Vec<FrameMeta> {
-        &self.meta
-    }
-
-    pub(crate) fn deserialize_impl<'py>(serialized: &Bound<'py, PyBytes>) -> PyResult<Self> {
-        let data = serialized.as_bytes();
-        bincode::deserialize(data).map_err(|e| {
-            let msg = format!("could not deserialize FrameStackHandle: {e:?}");
-            PyRuntimeError::new_err(msg)
-        })
-    }
-
-    pub fn get_slice_for_frame<'a>(&'a self, frame_idx: usize, slot: &'a ipc_test::Slot) -> &[u8] {
-        let slice = slot.as_slice();
-        let in_offset = self.offsets[frame_idx];
-        let size = self.meta[frame_idx].data_length_bytes;
-        &slice[in_offset..in_offset + size]
-    }
-
-    /// Split self at `mid` and create two new `FrameStackHandle`s.
-    /// The first will contain frames with indices [0..mid), the second [mid..len)`
-    pub fn split_at(self, mid: usize, shm: &mut SharedSlabAllocator) -> (Self, Self) {
-        // FIXME: this whole thing is falliable, so modify return type to Result<> (or PyResult<>?)
-        let bytes_mid = self.offsets[mid];
-        let (left, right) = {
-            let slot: ipc_test::Slot = shm.get(self.slot.slot_idx);
-            let slice = slot.as_slice();
-
-            let mut slot_left = shm.get_mut().expect("shm slot for writing");
-            let slice_left = slot_left.as_slice_mut();
-
-            let mut slot_right = shm.get_mut().expect("shm slot for writing");
-            let slice_right = slot_right.as_slice_mut();
-
-            slice_left[..bytes_mid].copy_from_slice(&slice[..bytes_mid]);
-            slice_right[..(slice.len() - bytes_mid)].copy_from_slice(&slice[bytes_mid..]);
-
-            let left = shm.writing_done(slot_left);
-            let right = shm.writing_done(slot_right);
-
-            shm.free_idx(self.slot.slot_idx);
-
-            (left, right)
-        };
-
-        let (left_meta, right_meta) = self.meta.split_at(mid);
-        let (left_offsets, right_offsets) = self.offsets.split_at(mid);
-
-        (
-            FrameStackHandle {
-                slot: left,
-                meta: left_meta.to_vec(),
-                offsets: left_offsets.to_vec(),
-                bytes_per_frame: self.bytes_per_frame,
-            },
-            FrameStackHandle {
-                slot: right,
-                meta: right_meta.to_vec(),
-                offsets: right_offsets.iter().map(|o| o - bytes_mid).collect(),
-                bytes_per_frame: self.bytes_per_frame,
-            },
-        )
-    }
-
-    fn first_meta(&self) -> PyResult<&FrameMeta> {
-        self.meta.first().map_or_else(
-            || Err(PyValueError::new_err("empty frame stack".to_string())),
-            Ok,
-        )
-    }
-}
-
-#[pymethods]
-impl FrameStackHandle {
-    pub fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>> {
-        let bytes: Bound<'py, PyBytes> =
-            PyBytes::new_bound(py, serialize(self).unwrap().as_slice());
-        Ok(bytes.into())
-    }
-
-    #[classmethod]
-    fn deserialize<'py>(
-        _cls: Bound<'py, PyType>,
-        serialized: Bound<'py, PyBytes>,
-    ) -> PyResult<Self> {
-        Self::deserialize_impl(&serialized)
-    }
-
-    fn get_series_id(&self) -> PyResult<u64> {
-        Ok(self.first_meta()?.dimage.series)
-    }
-
-    fn get_frame_id(&self) -> PyResult<u64> {
-        Ok(self.first_meta()?.dimage.frame)
-    }
-
-    fn get_hash(&self) -> PyResult<String> {
-        Ok(self.first_meta()?.dimage.hash.clone())
-    }
-
-    fn get_pixel_type(&self) -> PyResult<String> {
-        Ok(match &self.first_meta()?.dimaged.type_ {
-            PixelType::Uint8 => "uint8".to_string(),
-            PixelType::Uint16 => "uint16".to_string(),
-            PixelType::Uint32 => "uint32".to_string(),
-        })
-    }
-
-    fn get_encoding(&self) -> PyResult<String> {
-        Ok(self.first_meta()?.dimaged.encoding.clone())
-    }
-
-    /// return endianess in numpy notation
-    fn get_endianess(&self) -> PyResult<String> {
-        match self.first_meta()?.dimaged.encoding.chars().last() {
-            Some(c) => Ok(c.to_string()),
-            None => Err(exceptions::PyValueError::new_err(
-                "encoding should be non-empty".to_string(),
-            )),
-        }
-    }
-
-    fn get_shape(&self) -> PyResult<Vec<u64>> {
-        Ok(self.first_meta()?.dimaged.shape.clone())
-    }
-
-    fn __len__(&self) -> usize {
-        self.len()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::path::PathBuf;
-
-    use super::FrameStackForWriting;
-    use ipc_test::{SharedSlabAllocator, Slot};
-    use tempfile::{tempdir, TempDir};
-
-    fn get_socket_path() -> (TempDir, PathBuf) {
-        let socket_dir = tempdir().unwrap();
-        let socket_as_path = socket_dir.path().join("stuff.socket");
-
-        (socket_dir, socket_as_path)
-    }
-
-    #[test]
-    fn test_frame_stack() {
-        let (_socket_dir, socket_as_path) = get_socket_path();
-        let mut shm = SharedSlabAllocator::new(1, 4096, false, &socket_as_path).unwrap();
-        let slot = shm.get_mut().expect("get a free shm slot");
-        let mut fs = FrameStackForWriting::new(slot, 1, 256);
-        let dimage = crate::common::DImage {
-            htype: "".to_string(),
-            series: 1,
-            frame: 1,
-            hash: "".to_string(),
-        };
-        let dimaged = crate::common::DImageD {
-            htype: "".to_string(),
-            shape: vec![512, 512],
-            type_: crate::common::PixelType::Uint16,
-            encoding: ">bslz4".to_string(),
-        };
-        let dconfig = crate::common::DConfig {
-            htype: "".to_string(),
-            start_time: 0,
-            stop_time: 0,
-            real_time: 0,
-        };
-        assert_eq!(fs.cursor, 0);
-        fs.frame_done(dimage, dimaged, dconfig, &[42]);
-        assert_eq!(fs.cursor, 1);
-
-        let _fs_handle = fs.writing_done(&mut shm);
-    }
-
-    #[test]
-    fn test_split_frame_stack_handle() {
-        let (_socket_dir, socket_as_path) = get_socket_path();
-        // need at least three slots: one is the source, two for the results.
-        let mut shm = SharedSlabAllocator::new(3, 4096, false, &socket_as_path).unwrap();
-        let slot = shm.get_mut().expect("get a free shm slot");
-        let mut fs = FrameStackForWriting::new(slot, 2, 16);
-        let dimage = crate::common::DImage {
-            htype: "".to_string(),
-            series: 1,
-            frame: 1,
-            hash: "".to_string(),
-        };
-        let dimaged = crate::common::DImageD {
-            htype: "".to_string(),
-            shape: vec![512, 512],
-            type_: crate::common::PixelType::Uint16,
-            encoding: ">bslz4".to_string(),
-        };
-        let dconfig = crate::common::DConfig {
-            htype: "".to_string(),
-            start_time: 0,
-            stop_time: 0,
-            real_time: 0,
-        };
-        assert_eq!(fs.cursor, 0);
-        fs.frame_done(
-            dimage.clone(),
-            dimaged.clone(),
-            dconfig.clone(),
-            &[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
-        );
-        assert_eq!(fs.cursor, 16);
-        fs.frame_done(
-            dimage,
-            dimaged,
-            dconfig,
-            &[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2],
-        );
-        assert_eq!(fs.cursor, 32);
-        assert_eq!(fs.offsets, vec![0, 16]);
-
-        println!("{:?}", fs.slot.as_slice());
-
-        let fs_handle = fs.writing_done(&mut shm);
-
-        let slot_r = shm.get(fs_handle.slot.slot_idx);
-        let slice_0 = fs_handle.get_slice_for_frame(0, &slot_r);
-        assert_eq!(slice_0.len(), 16);
-        for &elem in slice_0 {
-            assert_eq!(elem, 1);
-        }
-        let slice_1 = fs_handle.get_slice_for_frame(1, &slot_r);
-        assert_eq!(slice_1.len(), 16);
-        for &elem in slice_1 {
-            assert_eq!(elem, 2);
-        }
-
-        let old_meta_len = fs_handle.meta.len();
-
-        let (a, b) = fs_handle.split_at(1, &mut shm);
-
-        let slot_a: Slot = shm.get(a.slot.slot_idx);
-        let slot_b: Slot = shm.get(b.slot.slot_idx);
-        let slice_a = &slot_a.as_slice()[..16];
-        let slice_b = &slot_b.as_slice()[..16];
-        println!("{:?}", slice_a);
-        println!("{:?}", slice_b);
-        for &elem in slice_a {
-            assert_eq!(elem, 1);
-        }
-        for &elem in slice_b {
-            assert_eq!(elem, 2);
-        }
-        assert_eq!(slice_a, a.get_slice_for_frame(0, &slot_a));
-        assert_eq!(slice_b, b.get_slice_for_frame(0, &slot_b));
-
-        assert_eq!(a.meta.len() + b.meta.len(), old_meta_len);
-        assert_eq!(a.offsets.len() + b.offsets.len(), 2);
-
-        // when the split is done, there should be one free shm slot:
-        assert_eq!(shm.num_slots_free(), 1);
-
-        // and we can free them again:
-        shm.free_idx(a.slot.slot_idx);
-        shm.free_idx(b.slot.slot_idx);
-
-        assert_eq!(shm.num_slots_free(), 3);
-    }
-}
diff --git a/libertem_dectris/src/lib.rs b/libertem_dectris/src/lib.rs
index 8a9597e1..98c50034 100644
--- a/libertem_dectris/src/lib.rs
+++ b/libertem_dectris/src/lib.rs
@@ -1,11 +1,10 @@
+pub mod background_thread;
+pub mod base_types;
 pub mod bin_fmt;
 pub mod cam_client;
-pub mod common;
+pub mod decoder;
 pub mod dectris_py;
 pub mod exceptions;
-pub mod frame_stack;
-pub mod receiver;
 pub mod sim;
 pub mod sim_data_source;
 pub mod sim_gen;
-pub mod stats;
diff --git a/libertem_dectris/src/main.rs b/libertem_dectris/src/main.rs
index 48200061..e6ca5df6 100644
--- a/libertem_dectris/src/main.rs
+++ b/libertem_dectris/src/main.rs
@@ -1,14 +1,14 @@
+mod base_types;
 mod bin_fmt;
-mod common;
 mod exceptions;
 mod sim;
 mod sim_data_source;
 mod sim_gen;
 
+use crate::base_types::DHeader;
+use crate::base_types::DImage;
+use crate::base_types::DetectorConfig;
 use crate::bin_fmt::{write_raw_msg, write_raw_msg_fh, write_serializable};
-use crate::common::DHeader;
-use crate::common::DImage;
-use crate::common::DetectorConfig;
 use crate::sim::FrameSender;
 use crate::sim_data_source::DumpRecordFile;
 use crate::sim_gen::make_sim_data;
diff --git a/libertem_dectris/src/sim.rs b/libertem_dectris/src/sim.rs
index 3f73f392..bd7f538d 100644
--- a/libertem_dectris/src/sim.rs
+++ b/libertem_dectris/src/sim.rs
@@ -9,7 +9,7 @@ use serde_json::json;
 use zmq::{Context, Socket, SocketType::PUSH};
 
 use crate::{
-    common::{setup_monitor, DHeader, DetectorConfig},
+    base_types::{setup_monitor, DHeader, DetectorConfig},
     exceptions::TimeoutError,
     sim_data_source::{DumpRecordFile, RecordCursor},
     sim_gen::make_sim_data,
@@ -77,8 +77,12 @@ impl FrameSender {
 
         cursor.seek_to_first_header_of_type("dheader-1.0");
         let dheader_raw = cursor.read_raw_msg().to_owned();
-        let dheader: DHeader = serde_json::from_slice(&dheader_raw)
-            .expect("json should match our serialization schema");
+        let dheader: DHeader = serde_json::from_slice(&dheader_raw).unwrap_or_else(|e| {
+            panic!(
+                "json should match our serialization schema, got '{}' ({e})",
+                String::from_utf8_lossy(&dheader_raw)
+            )
+        });
 
         debug!("{dheader:?}");
 
@@ -216,12 +220,6 @@ impl FrameSender {
         Ok(())
     }
 
-    pub fn send_frames(&mut self) {
-        for _ in 0..self.nimages {
-            self.send_frame().expect("send_frame should not time out");
-        }
-    }
-
     pub fn send_footer(&mut self) {
         // for simplicity, always "emulate" the footer message
         let footer_json = json!({
@@ -255,8 +253,8 @@ impl DectrisSim {
     }
 
     #[classmethod]
-    fn new_mocked<'py>(
-        _cls: Bound<'py, PyType>,
+    fn new_mocked(
+        _cls: Bound<'_, PyType>,
         uri: &str,
         num_frames: usize,
         dwelltime: Option<u64>,
diff --git a/libertem_dectris/src/stats.rs b/libertem_dectris/src/stats.rs
deleted file mode 100644
index 04b97b39..00000000
--- a/libertem_dectris/src/stats.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use stats::GetStats;
-
-use crate::frame_stack::FrameStackHandle;
-
-impl GetStats for FrameStackHandle {
-    fn payload_size(&self) -> usize {
-        self.payload_size()
-    }
-
-    fn slot_size(&self) -> usize {
-        self.slot_size()
-    }
-
-    fn max_frame_size(&self, old_max: usize) -> usize {
-        self.get_meta()
-            .iter()
-            .max_by_key(|fm| fm.data_length_bytes)
-            .map_or(old_max, |fm| fm.data_length_bytes)
-    }
-
-    fn min_frame_size(&self, old_min: usize) -> usize {
-        self.get_meta()
-            .iter()
-            .min_by_key(|fm| fm.data_length_bytes)
-            .map_or(old_min, |fm| fm.data_length_bytes)
-    }
-
-    fn num_frames(&self) -> usize {
-        self.len()
-    }
-}
diff --git a/libertem_qd_mpx/.github/workflows/CI.yml b/libertem_qd_mpx/.github/workflows/CI.yml
new file mode 100644
index 00000000..f8847112
--- /dev/null
+++ b/libertem_qd_mpx/.github/workflows/CI.yml
@@ -0,0 +1,138 @@
+# This file is autogenerated by maturin v1.5.1
+# To update, run
+#
+#    maturin generate-ci github
+#
+name: CI
+
+on:
+  push:
+    branches:
+      - main
+      - master
+    tags:
+      - '*'
+  pull_request:
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  linux:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: ubuntu-latest
+            target: x86_64
+          - runner: ubuntu-latest
+            target: x86
+          - runner: ubuntu-latest
+            target: aarch64
+          - runner: ubuntu-latest
+            target: armv7
+          - runner: ubuntu-latest
+            target: s390x
+          - runner: ubuntu-latest
+            target: ppc64le
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.10'
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+          manylinux: auto
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-linux-${{ matrix.platform.target }}
+          path: dist
+
+  windows:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: windows-latest
+            target: x64
+          - runner: windows-latest
+            target: x86
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.10'
+          architecture: ${{ matrix.platform.target }}
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-windows-${{ matrix.platform.target }}
+          path: dist
+
+  macos:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: macos-latest
+            target: x86_64
+          - runner: macos-14
+            target: aarch64
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.10'
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter
+          sccache: 'true'
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-macos-${{ matrix.platform.target }}
+          path: dist
+
+  sdist:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Build sdist
+        uses: PyO3/maturin-action@v1
+        with:
+          command: sdist
+          args: --out dist
+      - name: Upload sdist
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-sdist
+          path: dist
+
+  release:
+    name: Release
+    runs-on: ubuntu-latest
+    if: "startsWith(github.ref, 'refs/tags/')"
+    needs: [linux, windows, macos, sdist]
+    steps:
+      - uses: actions/download-artifact@v4
+      - name: Publish to PyPI
+        uses: PyO3/maturin-action@v1
+        env:
+          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
+        with:
+          command: upload
+          args: --non-interactive --skip-existing wheels-*/*
diff --git a/libertem_qd_mpx/.gitignore b/libertem_qd_mpx/.gitignore
new file mode 100644
index 00000000..c8f04429
--- /dev/null
+++ b/libertem_qd_mpx/.gitignore
@@ -0,0 +1,72 @@
+/target
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+.pytest_cache/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+.venv/
+env/
+bin/
+build/
+develop-eggs/
+dist/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+include/
+man/
+venv/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+pip-selfcheck.json
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+
+# Rope
+.ropeproject
+
+# Django stuff:
+*.log
+*.pot
+
+.DS_Store
+
+# Sphinx documentation
+docs/_build/
+
+# PyCharm
+.idea/
+
+# VSCode
+.vscode/
+
+# Pyenv
+.python-version
diff --git a/libertem_qd_mpx/Cargo.toml b/libertem_qd_mpx/Cargo.toml
new file mode 100644
index 00000000..e9b96887
--- /dev/null
+++ b/libertem_qd_mpx/Cargo.toml
@@ -0,0 +1,42 @@
+[package]
+name = "libertem_qd_mpx"
+authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
+license = "MIT"
+version = "0.2.12"
+edition = "2021"
+readme = "README.md"
+rust-version = "1.66"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+# [lib]
+# name = "libertem_qd_mpx"
+# crate-type = ["cdylib"]
+
+[dependencies]
+bincode = "1.3.3"
+env_logger = "0.9.3"
+log = "0.4.17"
+pyo3 = { version = "0.21.0", features = ["abi3-py37"] }
+serde = { version = "1.0.143", features = ["derive"] }
+ipc-test = { path = "../ipc_test" }
+stats = { path = "../stats" }
+common = { path = "../common" }
+zerocopy = "0.7.35"
+numpy = "0.21.0"
+num = "0.4.3"
+thiserror = "1.0.61"
+encoding_rs = { version = "0.8.34", features = [] }
+itertools = "0.13.0"
+rand = "0.8.5"
+criterion = "0.5.1"
+
+[features]
+extension-module = ["pyo3/extension-module"]
+
+[dev-dependencies]
+tempfile = "3.3.0"
+tokio = { version = "1.38.1", features = ["rt", "net", "time", "sync", "io-util", "rt-multi-thread"] } 
+
+[[bench]]
+name = "decoders"
+harness = false
diff --git a/libertem_qd_mpx/README.md b/libertem_qd_mpx/README.md
new file mode 100644
index 00000000..cfde136f
--- /dev/null
+++ b/libertem_qd_mpx/README.md
@@ -0,0 +1,16 @@
+# LiberTEM-qd-mpx
+
+[![LiberTEM-qd-mpx on GitHub](https://img.shields.io/badge/GitHub-MIT-informational)](https://github.com/LiberTEM/LiberTEM-rs)
+
+This is a Python package for efficiently receiving data from
+Quantum Detectors Merlin, in both single- and quad-chip configurations.
+It is implemented in rust, with a Python interface on top.
+
+It is built for [LiberTEM-live](https://github.com/libertem/libertem-live), but can
+also be used stand-alone.
+
+## Changelog
+
+### v0.2.12
+
+- Initial release of LiberTEM-qd-mpx
diff --git a/libertem_qd_mpx/benches/decoders.rs b/libertem_qd_mpx/benches/decoders.rs
new file mode 100644
index 00000000..b4611ee3
--- /dev/null
+++ b/libertem_qd_mpx/benches/decoders.rs
@@ -0,0 +1,160 @@
+use std::fmt::Debug;
+
+use criterion::{
+    black_box, criterion_group, criterion_main, measurement::Measurement, BenchmarkGroup,
+    Criterion, Throughput,
+};
+use libertem_qd_mpx::decoder::{RawType, R1, R12, R6};
+use num::{cast::AsPrimitive, Bounded, Num, NumCast, ToPrimitive};
+
+fn bench_raw_generic<'a, O, M: Measurement>(
+    group: &mut BenchmarkGroup<M>,
+    input_u8: &[u8],
+    output: &mut [O],
+) where
+    O: Copy + ToPrimitive + NumCast + Debug + Bounded + Num + 'a + 'static,
+    u8: AsPrimitive<O>,
+    u16: AsPrimitive<O>,
+{
+    let mut encoded_r1 = vec![0u8; 256 * 256 / 8];
+    R1::encode_all(&mut input_u8.iter(), &mut encoded_r1).unwrap();
+    group.bench_function("bench_r1", |b| {
+        b.iter(|| R1::decode_all::<_, 64>(black_box(&encoded_r1[..]), black_box(output)))
+    });
+
+    let mut encoded_r6 = vec![0u8; 256 * 256];
+    R6::encode_all(&mut input_u8.iter(), &mut encoded_r6).unwrap();
+    group.bench_function("bench_r6", |b| {
+        b.iter(|| R6::decode_all::<_, 8>(black_box(&encoded_r6[..]), black_box(output)))
+    });
+
+    let mut encoded_r12 = vec![0u8; 256 * 256 * 2];
+    R12::encode_all(&mut input_u8.iter(), &mut encoded_r12).unwrap();
+    group.bench_function("bench_r12", |b| {
+        b.iter(|| R12::decode_all::<_, 4>(black_box(&encoded_r12[..]), black_box(output)))
+    });
+}
+
+pub fn bench_raw(c: &mut Criterion) {
+    let input_u8 = (0..(256 * 256u32))
+        .map(|i| (i % 255) as u8)
+        .collect::<Vec<u8>>();
+
+    let mut output = vec![0u8; input_u8.len()];
+    let mut group = c.benchmark_group("bench_raw_to_u8");
+    group.throughput(Throughput::Bytes(input_u8.len() as u64));
+    bench_raw_generic(&mut group, &input_u8, &mut output);
+    group.finish();
+
+    let mut output = vec![0u16; input_u8.len()];
+    let mut group = c.benchmark_group("bench_raw_to_u16");
+    group.throughput(Throughput::Bytes(input_u8.len() as u64));
+    bench_raw_generic(&mut group, &input_u8, &mut output);
+    group.finish();
+
+    let mut output = vec![0u32; input_u8.len()];
+    let mut group = c.benchmark_group("bench_raw_to_u32");
+    group.throughput(Throughput::Bytes(input_u8.len() as u64));
+    bench_raw_generic(&mut group, &input_u8, &mut output);
+    group.finish();
+
+    let mut output = vec![0f32; input_u8.len()];
+    let mut group = c.benchmark_group("bench_raw_to_f32");
+    group.throughput(Throughput::Bytes(input_u8.len() as u64));
+    bench_raw_generic(&mut group, &input_u8, &mut output);
+    group.finish();
+}
+
+pub fn bench_raw_quad(c: &mut Criterion) {
+    let input_u8 = (0..(512 * 512u32))
+        .map(|i| (i % 255) as u8)
+        .collect::<Vec<u8>>();
+
+    let mut output = Vec::<f32>::new();
+    output.resize(input_u8.len(), 0.0);
+
+    let mut group = c.benchmark_group("bench_quad_raw_to_f32");
+    group.throughput(Throughput::Bytes(input_u8.len() as u64));
+
+    let mut encoded_r1 = vec![0u8; 512 * 512 / 8];
+    R1::encode_2x2_raw(&input_u8, &mut encoded_r1).unwrap();
+    group.bench_function("bench_r1", |b| {
+        b.iter(|| R1::decode_all::<_, 64>(black_box(&encoded_r1[..]), black_box(&mut output)))
+    });
+
+    let mut encoded_r6 = vec![0u8; 512 * 512];
+    R6::encode_2x2_raw(&input_u8, &mut encoded_r6).unwrap();
+    group.bench_function("bench_r6", |b| {
+        b.iter(|| R6::decode_all::<_, 8>(black_box(&encoded_r6[..]), black_box(&mut output)))
+    });
+
+    let mut encoded_r12 = vec![0u8; 512 * 512 * 2];
+    R12::encode_2x2_raw(&input_u8, &mut encoded_r12).unwrap();
+    group.bench_function("bench_r12", |b| {
+        b.iter(|| R12::decode_all::<_, 4>(black_box(&encoded_r12[..]), black_box(&mut output)))
+    });
+
+    group.finish();
+}
+
+pub fn bench_decode_chunk(c: &mut Criterion) {
+    let input_u8 = [0u8; 8];
+    let mut group = c.benchmark_group("bench_decode_chunk");
+    group.throughput(Throughput::Bytes(8));
+
+    let mut output = [0u8; 64];
+    group.bench_function("bench_r1", |b| {
+        b.iter(|| R1::decode_chunk(black_box(&input_u8), black_box(&mut output)))
+    });
+
+    let mut output = [0u8; 8];
+    group.bench_function("bench_r6", |b| {
+        b.iter(|| R6::decode_chunk(black_box(&input_u8), black_box(&mut output)))
+    });
+
+    let mut output = [0u8; 4];
+    group.bench_function("bench_r12", |b| {
+        b.iter(|| R12::decode_chunk(black_box(&input_u8), black_box(&mut output)))
+    });
+}
+
+fn decode_chunk_r6_ref<'a, OI>(input: &[u8; 8], output: &mut OI)
+where
+    OI: Iterator<Item = &'a mut u8>,
+{
+    for (value_chunk, out_value) in input.chunks_exact(2).rev().zip(output) {
+        let value = u16::from_be_bytes(value_chunk.try_into().expect("chunked by 2 bytes"));
+        *out_value = (value).as_();
+    }
+}
+
+fn decode_chunk_r6_ref_no_iterator(input: &[u8; 8], output: &mut [u8; 8]) {
+    for (value_chunk, out_value) in input.chunks_exact(2).rev().zip(output.iter_mut()) {
+        let value = u16::from_be_bytes(value_chunk.try_into().expect("chunked by 2 bytes"));
+        *out_value = (value).as_();
+    }
+}
+
+pub fn bench_decode_chunk_ref(c: &mut Criterion) {
+    let input_u8 = [0u8; 8];
+    let mut group = c.benchmark_group("bench_decode_chunk_ref");
+    group.throughput(Throughput::Bytes(8));
+
+    let mut output = [0u8; 8];
+    group.bench_function("bench_r6", |b| {
+        b.iter(|| decode_chunk_r6_ref(black_box(&input_u8), black_box(&mut output.iter_mut())))
+    });
+
+    group.bench_function("bench_r6_no_iterator", |b| {
+        b.iter(|| decode_chunk_r6_ref_no_iterator(black_box(&input_u8), black_box(&mut output)))
+    });
+}
+
+criterion_group!(
+    benches,
+    bench_decode_chunk_ref,
+    bench_decode_chunk,
+    bench_raw,
+    bench_raw_quad
+);
+criterion_main!(benches);
diff --git a/libertem_qd_mpx/examples/simple.py b/libertem_qd_mpx/examples/simple.py
new file mode 100644
index 00000000..0b7af49d
--- /dev/null
+++ b/libertem_qd_mpx/examples/simple.py
@@ -0,0 +1,43 @@
+import time
+
+from libertem_live.detectors.merlin.control import MerlinControl
+from libertem_qd_mpx import CamClient, QdConnection, QdFrameStack
+import numpy as np
+
+
+if __name__ == "__main__":
+    socket_path = "/tmp/qdmpxshm.socket"
+    conn = QdConnection(
+        data_host="localhost",
+        data_port=6342,
+        frame_stack_size=16,
+        shm_handle_path=socket_path,
+    )
+    try:
+        cam_client = CamClient(handle_path=socket_path)
+        conn.start_passive()
+        while True:
+            print("starting acquisition...")
+            with MerlinControl() as c:
+                time.sleep(1)
+                c.cmd('STARTACQUISITION')
+                c.cmd('SOFTTRIGGER')
+            header = conn.wait_for_arm(10)
+            print(header)
+            assert header is not None
+            buf = np.zeros((32, 512, 512), dtype=np.float32)
+            cbed = np.zeros((512, 512), dtype=np.float32)
+            t0 = time.perf_counter()
+            while True:
+                stack = conn.get_next_stack(32)
+                if stack is None:
+                    print("done")
+                    break
+                view = buf[:len(stack)]
+                cam_client.decode_range_into_buffer(stack, view, 0, len(stack))
+                cam_client.done(stack)
+                cbed += view.sum(axis=0)
+            t1 = time.perf_counter()
+            print(t1 - t0)
+    finally:
+        conn.close()
diff --git a/libertem_qd_mpx/pyproject.toml b/libertem_qd_mpx/pyproject.toml
new file mode 100644
index 00000000..b18be28b
--- /dev/null
+++ b/libertem_qd_mpx/pyproject.toml
@@ -0,0 +1,15 @@
+[build-system]
+requires = ["maturin>=1.5,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "libertem_qd_mpx"
+requires-python = ">=3.8"
+classifiers = [
+    "Programming Language :: Rust",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+]
+dynamic = ["version"]
+[tool.maturin]
+features = ["pyo3/extension-module"]
diff --git a/libertem_qd_mpx/src/background_thread.rs b/libertem_qd_mpx/src/background_thread.rs
new file mode 100644
index 00000000..eedecf05
--- /dev/null
+++ b/libertem_qd_mpx/src/background_thread.rs
@@ -0,0 +1,2023 @@
+use std::{
+    io::{self, ErrorKind, Read},
+    net::TcpStream,
+    ops::{Deref, DerefMut},
+    sync::mpsc::{channel, Receiver, RecvTimeoutError, SendError, Sender, TryRecvError},
+    thread::JoinHandle,
+    time::{Duration, Instant},
+};
+
+use common::{
+    background_thread::{BackgroundThread, BackgroundThreadSpawnError, ControlMsg, ReceiverMsg},
+    frame_stack::{FrameMeta, FrameStackForWriting, FrameStackWriteError, WriteGuard},
+    tcp::{self, ReadExactError},
+    utils::{num_from_byte_slice, NumParseError},
+};
+use ipc_test::{slab::ShmError, SharedSlabAllocator};
+use log::{debug, error, info, trace, warn};
+
+use crate::base_types::{
+    AcqHeaderParseError, FrameMetaParseError, QdAcquisitionConfig, QdAcquisitionHeader,
+    QdDetectorConnConfig, QdFrameMeta, RecoveryStrategy, PREFIX_SIZE,
+};
+
+type QdControlMsg = ControlMsg<()>;
+
+type QdReceiverMsg = ReceiverMsg<QdFrameMeta, QdAcquisitionConfig>;
+
+#[derive(Debug, thiserror::Error)]
+pub enum AcquisitionError {
+    #[error("channel disconnected")]
+    Disconnected,
+
+    #[error("background thread stopped")]
+    ThreadStopped,
+
+    #[error("acquisition cancelled by user")]
+    Cancelled,
+
+    #[error("receiver state error: {msg}")]
+    StateError { msg: String },
+
+    #[error("connection error: {msg}")]
+    ConnectionError { msg: String },
+
+    #[error("parse error: {msg}")]
+    HeaderParseError { msg: String },
+
+    #[error("configuration error: {msg}")]
+    ConfigurationError { msg: String },
+
+    #[error("shm buffer full")]
+    NoSlotAvailable,
+
+    #[error("error writing to shm: {0}")]
+    WriteError(#[from] FrameStackWriteError),
+
+    #[error("I/O error: {source}")]
+    IOError {
+        #[from]
+        source: io::Error,
+    },
+
+    #[error("peek error: could not peek {nbytes}")]
+    PeekError { nbytes: usize },
+}
+
+impl From<NumParseError> for AcquisitionError {
+    fn from(value: NumParseError) -> Self {
+        Self::HeaderParseError {
+            msg: value.to_string(),
+        }
+    }
+}
+
+impl From<AcqHeaderParseError> for AcquisitionError {
+    fn from(value: AcqHeaderParseError) -> Self {
+        AcquisitionError::HeaderParseError {
+            msg: value.to_string(),
+        }
+    }
+}
+
+impl From<FrameMetaParseError> for AcquisitionError {
+    fn from(value: FrameMetaParseError) -> Self {
+        AcquisitionError::HeaderParseError {
+            msg: value.to_string(),
+        }
+    }
+}
+
+impl<T> From<SendError<T>> for AcquisitionError {
+    fn from(_value: SendError<T>) -> Self {
+        AcquisitionError::Disconnected
+    }
+}
+
+impl From<ShmError> for AcquisitionError {
+    fn from(value: ShmError) -> Self {
+        match value {
+            ShmError::NoSlotAvailable => AcquisitionError::NoSlotAvailable,
+        }
+    }
+}
+
+impl From<ReadExactError<AcquisitionError>> for AcquisitionError {
+    fn from(value: ReadExactError<AcquisitionError>) -> Self {
+        match value {
+            ReadExactError::Interrupted { size, err } => {
+                if size > 0 {
+                    warn!("interrupted read after {size} bytes; discarding buffer");
+                }
+                err
+            }
+            ReadExactError::IOError { err } => AcquisitionError::from(err),
+            ReadExactError::PeekError { size } => AcquisitionError::PeekError { nbytes: size },
+            ReadExactError::Eof => Self::ConnectionError {
+                msg: "EOF".to_owned(),
+            },
+        }
+    }
+}
+
+pub struct QdBackgroundThread {
+    bg_thread: JoinHandle<()>,
+    to_thread: Sender<QdControlMsg>,
+    from_thread: Receiver<QdReceiverMsg>,
+}
+
+/// With a running acquisition, check for control messages;
+/// especially convert `ControlMsg::StopThread` to `AcquisitionError::Cancelled`.
+fn check_for_control(control_channel: &Receiver<QdControlMsg>) -> Result<(), AcquisitionError> {
+    match control_channel.try_recv() {
+        Ok(ControlMsg::StartAcquisitionPassive) => Err(AcquisitionError::StateError {
+            msg: "received StartAcquisitionPassive while an acquisition was already running"
+                .to_string(),
+        }),
+        Ok(ControlMsg::StopThread) => Err(AcquisitionError::ThreadStopped),
+        Ok(ControlMsg::SpecializedControlMsg { msg: _ }) => {
+            panic!("unsupported SpecializedControlMsg")
+        }
+        Ok(ControlMsg::CancelAcquisition) => Err(AcquisitionError::Cancelled),
+        Err(TryRecvError::Disconnected) => Err(AcquisitionError::Disconnected),
+        Err(TryRecvError::Empty) => Ok(()),
+    }
+}
+
+/// Fill `buf` from `stream` (like TcpStream::read_exact), but periodically check
+/// `to_thread_r` for control messages, which allows to interrupt the acquisition.
+/// Assumes a blocking socket with a timeout. In case of interruption, data will be discarded
+/// (it's still in `buf`, but we don't keep track how much we read...)
+fn read_exact_interruptible(
+    stream: &mut impl Read,
+    buf: &mut [u8],
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<(), AcquisitionError> {
+    common::tcp::read_exact_interruptible(stream, buf, || check_for_control(to_thread_r))?;
+
+    Ok(())
+}
+
+/// Read from `stream` until we hit the timeout. Original socket timeout is
+/// overwritten and should be restored by the caller.
+fn drain_until_timeout(
+    stream: &mut TcpStream,
+    to_thread_r: &Receiver<QdControlMsg>,
+    timeout: &Duration,
+) -> Result<usize, AcquisitionError> {
+    let mut tmp = vec![0; 1024 * 1024];
+    let mut total_drained: usize = 0;
+    stream.set_read_timeout(Some(*timeout))?;
+    loop {
+        check_for_control(to_thread_r)?;
+        total_drained += match stream.read(&mut tmp) {
+            Ok(size) => {
+                trace!("drain_until_timeout: drained {size}");
+                if size == 0 {
+                    // EOF: okay....
+                    warn!("drained until EOF!");
+                    return Ok(total_drained);
+                }
+                size
+            }
+            Err(e) => match e.kind() {
+                ErrorKind::TimedOut | ErrorKind::WouldBlock => {
+                    trace!("drain_until_timeout: timeout: {e:?}");
+                    return Ok(total_drained);
+                }
+                kind => {
+                    trace!("drain_until_timeout: other error kind: {kind:?}");
+                    return Err(e.into());
+                }
+            },
+        };
+    }
+}
+
+/// Fill `buf` from `stream` (like TcpStream::peek), but periodically check
+/// `to_thread_r` for control messages, which allows to interrupt the acquisition.
+/// Assumes a blocking socket with a timeout.
+fn peek_exact_interruptible(
+    stream: &mut TcpStream,
+    buf: &mut [u8],
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<(), AcquisitionError> {
+    tcp::peek_exact_interruptible(stream, buf, Duration::from_millis(10), 10, || {
+        check_for_control(to_thread_r)
+    })?;
+
+    Ok(())
+}
+
+/// Parse the length from a string in the form of 'MPX,<length>', where
+/// `<length>` is a 10 digit number (possibly with leading zeros).
+fn parse_mpx_length(buf: &[u8]) -> Result<usize, AcquisitionError> {
+    let magic = &buf[0..4];
+    if magic != b"MPX," {
+        return Err(AcquisitionError::HeaderParseError {
+            msg: format!(
+                "expected 'MPX,', got: '{:X?}' ('{}') (maybe previous read was short?)",
+                magic,
+                String::from_utf8_lossy(magic)
+            ),
+        });
+    }
+    Ok(num_from_byte_slice(&buf[4..14])?)
+}
+
+/// Read MPX message from `stream`
+///
+/// Message is in the form: "MPX,<length>,<MSGTYPE>,<MSGPAYLOAD>"
+///
+/// Where <length> is the length of ",<MSGTYPE>,<MSGPAYLOAD>" and is encoded
+/// as a 10-character decimal number w/ leading zeros.
+///
+/// The message is read into a newly allocated `Vec`, so this is not the
+/// appropriate function to use for receiving payload data.
+fn read_mpx_message(
+    stream: &mut impl Read,
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<MPXMessage, AcquisitionError> {
+    // read and parse the 'MPX,<length>,' part:
+    let mut prefix = [0u8; PREFIX_SIZE];
+    read_exact_interruptible(stream, &mut prefix, to_thread_r)?;
+    trace!(
+        "read_mpx_message: prefix={prefix:X?} ({})",
+        std::str::from_utf8(&prefix).unwrap()
+    );
+    let length = parse_mpx_length(&prefix)?;
+    let mut payload = vec![0u8; length - 1];
+    read_exact_interruptible(stream, &mut payload, to_thread_r)?;
+    Ok(MPXMessage { length, payload })
+}
+
+/// Read a frame header, based on the information in `first_frame_meta`,
+/// validating that the header+payload length is the same as the first frame.
+///
+/// This includes reading the MPX prefix.
+fn read_frame_header(
+    stream: &mut impl Read,
+    to_thread_r: &Receiver<QdControlMsg>,
+    first_frame_meta: &QdFrameMeta,
+    scratch_buf: &mut Vec<u8>,
+) -> Result<QdFrameMeta, AcquisitionError> {
+    // read the full header in one go:
+    scratch_buf.resize(first_frame_meta.get_total_size_header(), 0);
+    read_exact_interruptible(stream, scratch_buf, to_thread_r)?;
+
+    trace!(
+        "got raw frame header: '{}'",
+        String::from_utf8_lossy(scratch_buf)
+    );
+    // parse and validate "mpx length":
+    let length = parse_mpx_length(&scratch_buf[..PREFIX_SIZE])?;
+    if length != first_frame_meta.get_mpx_length() {
+        return Err(AcquisitionError::HeaderParseError {
+            msg: format!(
+                "unexpected length difference: expected {} got {}",
+                first_frame_meta.get_mpx_length(),
+                length
+            ),
+        });
+    }
+
+    Ok(QdFrameMeta::parse_bytes(
+        &scratch_buf[PREFIX_SIZE..],
+        length,
+    )?)
+}
+
+fn read_frame_payload_into(
+    stream: &mut impl Read,
+    to_thread_r: &Receiver<QdControlMsg>,
+    frame_meta: &QdFrameMeta,
+    out: &mut [u8],
+) -> Result<(), AcquisitionError> {
+    assert_eq!(frame_meta.get_data_length_bytes(), out.len());
+    read_exact_interruptible(stream, out, to_thread_r)
+}
+
+pub struct MPXMessage {
+    length: usize,
+    payload: Vec<u8>,
+}
+
+/// Peek MPX message from `stream`
+///
+/// Message is in the form: "MPX,<length>,<MSGTYPE>,<MSGPAYLOAD>"
+///
+/// Where <length> is the length of ",<MSGTYPE>,<MSGPAYLOAD>" and is encoded
+/// as a 10-character decimal number w/ leading zeros.
+fn peek_mpx_message(
+    stream: &mut TcpStream,
+    to_thread_r: &Receiver<QdControlMsg>,
+    max_length: usize,
+) -> Result<MPXMessage, AcquisitionError> {
+    // read and parse the 'MPX,<length>,' part:
+    let mut prefix = [0u8; PREFIX_SIZE];
+    peek_exact_interruptible(stream, &mut prefix, to_thread_r)?;
+
+    trace!("peek_mpx_message: {}", String::from_utf8_lossy(&prefix));
+
+    let magic = &prefix[0..4];
+    if magic != b"MPX," {
+        return Err(AcquisitionError::HeaderParseError {
+            msg: format!(
+                "expected 'MPX,', got: '{:X?}' ('{}'; maybe previous read was short?)",
+                magic,
+                String::from_utf8_lossy(magic),
+            ),
+        });
+    }
+    let length: usize = num_from_byte_slice(&prefix[4..14])?;
+
+    // subtle difference w/ `read_mpx_message`: we need to have a larger buffer here,
+    // because here we get the prefix again!
+    let mut prefix_and_payload = vec![0u8; max_length.min(length + PREFIX_SIZE - 1)];
+    peek_exact_interruptible(stream, &mut prefix_and_payload, to_thread_r)?;
+    Ok(MPXMessage {
+        payload: prefix_and_payload[PREFIX_SIZE..].to_vec(),
+        length,
+    })
+}
+
+fn read_acquisition_header(
+    stream: &mut impl Read,
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<QdAcquisitionHeader, AcquisitionError> {
+    let acq_header_raw = read_mpx_message(stream, to_thread_r)?;
+    Ok(QdAcquisitionHeader::parse_bytes(&acq_header_raw.payload)?)
+}
+
+fn peek_first_frame_header(
+    stream: &mut TcpStream,
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<QdFrameMeta, AcquisitionError> {
+    let msg = peek_mpx_message(stream, to_thread_r, 2048)?;
+    trace!(
+        "parsing first frame header: '{}'",
+        String::from_utf8_lossy(&msg.payload)
+    );
+    Ok(QdFrameMeta::parse_bytes(&msg.payload, msg.length)?)
+}
+
+fn make_frame_stack<'a>(
+    shm: &'a mut SharedSlabAllocator,
+    config: &QdDetectorConnConfig,
+    meta: &QdFrameMeta,
+    to_thread_r: &Receiver<QdControlMsg>,
+) -> Result<WriteGuard<'a, QdFrameMeta>, AcquisitionError> {
+    loop {
+        // keep some slots free for splitting frame stacks
+        if shm.num_slots_free() < 3 && shm.num_slots_total() >= 3 {
+            trace!("shm is almost full; waiting and creating backpressure...");
+            check_for_control(to_thread_r)?;
+            std::thread::sleep(Duration::from_millis(1));
+            continue;
+        }
+
+        match shm.try_get_mut() {
+            Ok(slot) => {
+                return Ok(WriteGuard::new(
+                    FrameStackForWriting::new(
+                        slot,
+                        config.frame_stack_size,
+                        meta.get_data_length_bytes(),
+                    ),
+                    shm,
+                ))
+            }
+            Err(ShmError::NoSlotAvailable) => {
+                trace!("shm is full; waiting and creating backpressure...");
+                check_for_control(to_thread_r)?;
+                std::thread::sleep(Duration::from_millis(1));
+                continue;
+            }
+        }
+    }
+}
+
+fn acquisition(
+    to_thread_r: &Receiver<QdControlMsg>,
+    from_thread_s: &Sender<QdReceiverMsg>,
+    acquisition_header: &QdAcquisitionHeader,
+    first_frame_meta: &QdFrameMeta,
+    stream: &mut TcpStream,
+    config: &QdDetectorConnConfig,
+    shm: &mut SharedSlabAllocator,
+) -> Result<(), AcquisitionError> {
+    let t0 = Instant::now();
+    let mut last_control_check = Instant::now();
+
+    from_thread_s.send(ReceiverMsg::AcquisitionStart {
+        pending_acquisition: QdAcquisitionConfig::new(
+            acquisition_header.clone(),
+            first_frame_meta.clone(),
+        ),
+    })?;
+
+    let mut frame_stack = make_frame_stack(shm, config, first_frame_meta, to_thread_r)?;
+
+    let mut sequence = 0;
+
+    // should fit in most cases, but will be resized gracefully if it doesn't:
+    let mut header_scratch_buf: Vec<u8> = Vec::with_capacity(1024);
+
+    loop {
+        if last_control_check.elapsed() > Duration::from_millis(300) {
+            last_control_check = Instant::now();
+            check_for_control(to_thread_r)?;
+            trace!("acquisition progress: sequence={sequence}");
+        }
+
+        let meta = read_frame_header(
+            stream,
+            to_thread_r,
+            first_frame_meta,
+            &mut header_scratch_buf,
+        )?;
+
+        let payload_size = meta.get_data_length_bytes();
+
+        if frame_stack.can_fit(payload_size) {
+            frame_stack.write_frame(&meta, |dest_buf| {
+                read_frame_payload_into(stream, to_thread_r, &meta, dest_buf)
+            })?;
+        } else {
+            let handle = frame_stack.writing_done()?;
+            from_thread_s.send(ReceiverMsg::FrameStack {
+                frame_stack: handle,
+            })?;
+            frame_stack = make_frame_stack(shm, config, first_frame_meta, to_thread_r)?;
+            frame_stack.should_fit(payload_size)?;
+            frame_stack.write_frame(&meta, |dest_buf| {
+                read_frame_payload_into(stream, to_thread_r, &meta, dest_buf)
+            })?;
+        }
+
+        // At this point, `frame_stack` contains at least one frame.
+        assert!(!frame_stack.is_empty());
+
+        sequence += 1;
+
+        if sequence != meta.get_sequence() {
+            warn!("sequence number mismatch; did we lose a frame?");
+            // if this happens for some reason, we must adjust the sequence
+            // number to properly terminate the acquisition:
+            sequence = meta.get_sequence();
+        }
+
+        // be explicit: number of frames zero means continuous live view, so we
+        // are "never" done (or rather only done if we are cancelled from the outside)
+        let num_frames = acquisition_header.num_frames();
+        let done = sequence as usize == num_frames && num_frames != 0;
+        if done {
+            let elapsed = t0.elapsed();
+            info!("done in {elapsed:?}");
+            let handle = frame_stack.writing_done()?;
+            from_thread_s.send(ReceiverMsg::Finished {
+                frame_stack: handle,
+            })?;
+
+            return Ok(());
+        }
+    }
+}
+
+/// Implement a recovery strategy on drop
+struct TcpStreamGuard {
+    // FIXME: make `stream` an `Option` and implement a happy path that doesn't
+    // drain (needs more top-level support for a `ControlMsg::Stop` message or
+    // similar)
+    stream: TcpStream,
+    strategy: RecoveryStrategy,
+    drain_timeout: Duration,
+}
+
+impl Drop for TcpStreamGuard {
+    fn drop(&mut self) {
+        match self.strategy {
+            RecoveryStrategy::ImmediateReconnect => {
+                // nothing to do on drop here, as the outer loop will
+                // immediately try to reconnect.
+            }
+            RecoveryStrategy::DrainThenReconnect => {
+                warn!("RecoveryStrategy::DrainThenReconnect");
+                if let Err(e) = self.stream.set_read_timeout(Some(self.drain_timeout)) {
+                    warn!("could not set read timeout when draining; in case there is no timeout set, no draining will happen; error: {e}");
+                    return;
+                }
+                let mut tmp = vec![0; 1024 * 1024]; // keep this buffer large for efficient draining
+                loop {
+                    match self.stream.read(&mut tmp) {
+                        Ok(size) => {
+                            if size == 0 {
+                                break; // EOF, we are done.
+                            }
+                        }
+                        Err(e) => {
+                            warn!("TcpStreamGuard::drop: {e}");
+                            break;
+                        }
+                    }
+                }
+                warn!("RecoveryStrategy::DrainThenReconnect: done");
+            }
+        }
+    }
+}
+
+impl Deref for TcpStreamGuard {
+    type Target = TcpStream;
+
+    fn deref(&self) -> &Self::Target {
+        &self.stream
+    }
+}
+
+impl DerefMut for TcpStreamGuard {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.stream
+    }
+}
+
+fn passive_acquisition(
+    to_thread_r: &Receiver<QdControlMsg>,
+    from_thread_s: &Sender<QdReceiverMsg>,
+    config: &QdDetectorConnConfig,
+    shm: &mut SharedSlabAllocator,
+) -> Result<(), AcquisitionError> {
+    let host = &config.data_host;
+    let port = config.data_port;
+
+    let data_uri = format!("{host}:{port}");
+    info!("connecting to {}...", &data_uri);
+
+    check_for_control(to_thread_r)?;
+    let mut stream: TcpStreamGuard = loop {
+        break match TcpStream::connect(&data_uri) {
+            Ok(s) => TcpStreamGuard {
+                stream: s,
+                strategy: config.recovery_strategy,
+                drain_timeout: Duration::from_millis(100),
+            },
+            Err(e) => match e.kind() {
+                ErrorKind::ConnectionRefused
+                | ErrorKind::TimedOut
+                | ErrorKind::ConnectionAborted
+                | ErrorKind::ConnectionReset => {
+                    std::thread::sleep(Duration::from_millis(10));
+                    continue;
+                }
+                _ => return Err(AcquisitionError::ConnectionError { msg: e.to_string() }),
+            },
+        };
+    };
+
+    info!("connected to {}.", &data_uri);
+
+    if let Some(timeout) = &config.drain_on_connect {
+        info!("draining for {timeout:?}...");
+        let drained = drain_until_timeout(&mut stream, to_thread_r, timeout)?;
+        if drained > 0 {
+            info!("drained {drained} bytes of garbage");
+        }
+    }
+
+    from_thread_s.send(ReceiverMsg::ReceiverArmed)?;
+
+    stream.set_read_timeout(Some(Duration::from_millis(100)))?;
+
+    loop {
+        // wait for the acquisition header, which is sent when the detector
+        // is armed with STARTACQUISITION:
+        let acquisition_header: QdAcquisitionHeader =
+            read_acquisition_header(stream.deref_mut(), to_thread_r)?;
+
+        info!("acquisition header: {:?}", acquisition_header);
+
+        // Wait for the first frame, which is sent when the acquisition actually starts
+        // (for example, if it is triggered with SOFTTRIGGER or by the configured
+        // hardware trigger):
+        let first_frame_meta: QdFrameMeta = peek_first_frame_header(&mut stream, to_thread_r)?;
+
+        info!("first frame header: {:?}", first_frame_meta);
+
+        match &first_frame_meta.mq1a {
+            None => warn!("missing mq1a header, this will probably cause issues in raw mode!"),
+            Some(mq1a) => match mq1a.counter_depth {
+                1 | 6 | 12 | 24 => {}
+                _ => {
+                    warn!("unknown counter depth {}", mq1a.counter_depth);
+                }
+            },
+        }
+
+        acquisition(
+            to_thread_r,
+            from_thread_s,
+            &acquisition_header,
+            &first_frame_meta,
+            &mut stream,
+            config,
+            shm,
+        )?;
+
+        let free = shm.num_slots_free();
+        let total = shm.num_slots_total();
+        info!("passive acquisition done; free slots: {}/{}", free, total);
+
+        check_for_control(to_thread_r)?;
+    }
+}
+
+fn background_thread(
+    config: &QdDetectorConnConfig,
+    to_thread_r: &Receiver<QdControlMsg>,
+    from_thread_s: &Sender<QdReceiverMsg>,
+    mut shm: SharedSlabAllocator,
+) -> Result<(), AcquisitionError> {
+    'outer: loop {
+        loop {
+            // control: main threads tells us to quit
+            let control = to_thread_r.recv_timeout(Duration::from_millis(100));
+            match control {
+                Ok(ControlMsg::StartAcquisitionPassive) => {
+                    match passive_acquisition(to_thread_r, from_thread_s, config, &mut shm) {
+                        Ok(_) => {}
+                        Err(AcquisitionError::Cancelled) => {
+                            info!("acquisition cancelled by user");
+                            from_thread_s.send(ReceiverMsg::Cancelled).unwrap();
+                            continue 'outer;
+                        }
+                        e @ Err(
+                            AcquisitionError::Disconnected | AcquisitionError::ThreadStopped,
+                        ) => {
+                            info!("background_thread: terminating: {e:?}");
+                            return Ok(());
+                        }
+                        Err(e) => {
+                            error!("background_thread: error: {}; re-connecting", e);
+                            from_thread_s
+                                .send(ReceiverMsg::FatalError { error: Box::new(e) })
+                                .unwrap();
+                            continue 'outer;
+                        }
+                    }
+                }
+                Ok(ControlMsg::CancelAcquisition) => {
+                    warn!(
+                        "background_thread: got a CancelAcquisition message in main loop; ignoring"
+                    );
+                }
+                Ok(ControlMsg::StopThread) => {
+                    debug!("background_thread: got a StopThread message");
+                    break 'outer;
+                }
+                Err(RecvTimeoutError::Disconnected) => {
+                    warn!("background_thread: control channel has disconnected");
+                    break 'outer;
+                }
+                Err(RecvTimeoutError::Timeout) => (), // no message, nothing to do
+                Ok(ControlMsg::SpecializedControlMsg { msg: _ }) => {
+                    panic!("ControlMsg::SpecializesControlMsg is unused for QD MPX");
+                }
+            }
+        }
+    }
+    debug!("background_thread: is done");
+    Ok(())
+}
+
+fn background_thread_wrap(
+    config: &QdDetectorConnConfig,
+    to_thread_r: &Receiver<QdControlMsg>,
+    from_thread_s: &Sender<QdReceiverMsg>,
+    shm: SharedSlabAllocator,
+) {
+    if let Err(err) = background_thread(config, to_thread_r, from_thread_s, shm) {
+        log::error!("background_thread err'd: {}", err.to_string());
+        // NOTE: `shm` is dropped in case of an error, so anyone who tries to connect afterwards
+        // will get an error
+        from_thread_s
+            .send(ReceiverMsg::FatalError {
+                error: Box::new(err),
+            })
+            .unwrap();
+    }
+}
+
+impl QdBackgroundThread {
+    pub fn spawn(
+        config: &QdDetectorConnConfig,
+        shm: &SharedSlabAllocator,
+    ) -> Result<Self, BackgroundThreadSpawnError> {
+        let (to_thread_s, to_thread_r) = channel();
+        let (from_thread_s, from_thread_r) = channel();
+
+        let builder = std::thread::Builder::new();
+        let shm = shm.clone_and_connect()?;
+        let config = config.clone();
+
+        debug!("connection config: {config:?}");
+
+        Ok(Self {
+            bg_thread: builder
+                .name("bg_thread".to_string())
+                .spawn(move || background_thread_wrap(&config, &to_thread_r, &from_thread_s, shm))
+                .map_err(BackgroundThreadSpawnError::SpawnFailed)?,
+            from_thread: from_thread_r,
+            to_thread: to_thread_s,
+        })
+    }
+}
+
+impl BackgroundThread for QdBackgroundThread {
+    type FrameMetaImpl = QdFrameMeta;
+    type AcquisitionConfigImpl = QdAcquisitionConfig;
+    type ExtraControl = ();
+
+    fn channel_to_thread(
+        &mut self,
+    ) -> &mut std::sync::mpsc::Sender<common::background_thread::ControlMsg<Self::ExtraControl>>
+    {
+        &mut self.to_thread
+    }
+
+    fn channel_from_thread(
+        &mut self,
+    ) -> &mut std::sync::mpsc::Receiver<
+        common::background_thread::ReceiverMsg<Self::FrameMetaImpl, Self::AcquisitionConfigImpl>,
+    > {
+        &mut self.from_thread
+    }
+
+    fn join(self) {
+        if let Err(e) = self.bg_thread.join() {
+            // FIXME: should we have an error boundary here instead and stop the panic?
+            std::panic::resume_unwind(e)
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::{
+        net::SocketAddr,
+        path::PathBuf,
+        sync::mpsc::channel,
+        time::{Duration, Instant},
+    };
+
+    use common::generic_connection::{ConnectionError, ConnectionStatus, GenericConnection};
+    use ipc_test::SharedSlabAllocator;
+    use log::info;
+    use tempfile::{tempdir, TempDir};
+    use tokio::{
+        io::AsyncWriteExt,
+        net::{TcpListener, TcpStream},
+        runtime::Runtime,
+        time::timeout,
+    };
+
+    use crate::base_types::{QdAcquisitionConfig, QdDetectorConnConfig, RecoveryStrategy};
+
+    use super::QdBackgroundThread;
+
+    fn make_test_server() -> (std::net::TcpListener, SocketAddr) {
+        let fake_server = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+        let local_addr = fake_server.local_addr().unwrap();
+
+        fake_server.set_nonblocking(true).unwrap();
+
+        (fake_server, local_addr)
+    }
+
+    fn get_socket_path() -> (TempDir, PathBuf) {
+        let socket_dir = tempdir().unwrap();
+        let socket_as_path = socket_dir.path().join("stuff.socket");
+
+        (socket_dir, socket_as_path)
+    }
+
+    fn wrap_into_mpx(full_msg: &[u8]) -> Vec<u8> {
+        let mut out = Vec::new();
+        let mpx = format!("MPX,{:010},", full_msg.len() + 1);
+        std::io::Write::write_all(&mut out, mpx.as_bytes()).unwrap();
+        std::io::Write::write_all(&mut out, full_msg).unwrap();
+        out
+    }
+
+    async fn send_frames_simple(
+        stream: &mut TcpStream,
+        frame_indices: impl IntoIterator<Item = usize>,
+    ) {
+        for idx in frame_indices {
+            // copies all over.. but that's ok, it's only test code!
+            let num_pixels = 256 * 256;
+            let data_offset = 384;
+            let header = format!(
+                "MQ1,{:06},{data_offset:05},01,0256,0256,U08,   1x1,01,2020-05-18 16:51:49.971626,0.000555,0,0,0,1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511,MQ1A,2020-05-18T14:51:49.971626178Z,555000ns,6,",
+                idx + 1,
+            );
+            let header_bytes = header.as_bytes();
+            let padding = vec![0x0u8; data_offset - header_bytes.len()];
+
+            let mut frame_msg = Vec::new();
+            std::io::Write::write_all(&mut frame_msg, header_bytes).unwrap();
+            std::io::Write::write_all(&mut frame_msg, &padding).unwrap();
+            assert_eq!(frame_msg.len(), data_offset); // data_offset is the "full header length"
+
+            let data = vec![idx as u8; num_pixels];
+            std::io::Write::write_all(&mut frame_msg, &data).unwrap();
+
+            let full_frame_msg = wrap_into_mpx(&frame_msg);
+            // eprintln!("full_frame_msg: {}", String::from_utf8_lossy(&full_frame_msg));
+
+            stream.write_all(&full_frame_msg).await.unwrap();
+        }
+    }
+
+    async fn send_frames_with_size(
+        stream: &mut TcpStream,
+        frame_indices: impl IntoIterator<Item = usize>,
+        width: usize,
+        height: usize,
+    ) {
+        let num_pixels = width * height;
+        for idx in frame_indices {
+            // copies all over.. but that's ok, it's only test code!
+            let data_offset = 384;
+            let header = format!(
+                "MQ1,{:06},{data_offset:05},01,{width:04},{height:04},U08,   1x1,01,2020-05-18 16:51:49.971626,0.000555,0,0,0,1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511,MQ1A,2020-05-18T14:51:49.971626178Z,555000ns,6,",
+                idx + 1,
+            );
+            let header_bytes = header.as_bytes();
+            let padding = vec![0x0u8; data_offset - header_bytes.len()];
+
+            let mut frame_msg = Vec::new();
+            std::io::Write::write_all(&mut frame_msg, header_bytes).unwrap();
+            std::io::Write::write_all(&mut frame_msg, &padding).unwrap();
+            assert_eq!(frame_msg.len(), data_offset); // data_offset is the "full header length"
+
+            let data = vec![idx as u8; num_pixels];
+            std::io::Write::write_all(&mut frame_msg, &data).unwrap();
+
+            let full_frame_msg = wrap_into_mpx(&frame_msg);
+            // eprintln!("full_frame_msg: {}", String::from_utf8_lossy(&full_frame_msg));
+
+            stream.write_all(&full_frame_msg).await.unwrap();
+        }
+    }
+
+    async fn send_acq_header(stream: &mut TcpStream, number_of_frames: usize) {
+        let hdr = format!(
+            r"HDR,	
+Time and Date Stamp (day, mnth, yr, hr, min, s):	18/05/2020 16:51:48
+Chip ID:	W529_F5,-,-,-
+Chip Type (Medipix 3.0, Medipix 3.1, Medipix 3RX):	Medipix 3RX
+Assembly Size (NX1, 2X2):	   1x1
+Chip Mode  (SPM, CSM, CM, CSCM):	SPM
+Counter Depth (number):	6
+Gain:	SLGM
+Active Counters:	Alternating
+Thresholds (keV):	1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0
+DACs:	175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511
+bpc File:	c:\MERLIN_Quad_Config\W529_F5\W529_F5_SPM.bpc,,,
+DAC File:	c:\MERLIN_Quad_Config\W529_F5\W529_F5_SPM.dacs,,,
+Gap Fill Mode:	Distribute
+Flat Field File:	None
+Dead Time File:	Dummy (C:\<NUL>\)
+Acquisition Type (Normal, Th_scan, Config):	Normal
+Frames in Acquisition (Number):	{number_of_frames}
+Frames per Trigger (Number):	1
+Trigger Start (Positive, Negative, Internal):	Rising Edge LVDS
+Trigger Stop (Positive, Negative, Internal):	Internal
+Sensor Bias (V):	120 V
+Sensor Polarity (Positive, Negative):	Positive
+Temperature (C):	Board Temp 37.384918 Deg C
+Humidity (%):	Board Humidity 1.331848 
+Medipix Clock (MHz):	120MHz
+Readout System:	Merlin Quad
+Software Version:	0.67.0.9
+End	                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
+"
+        );
+        let hdr = wrap_into_mpx(hdr.as_bytes());
+        stream.write_all(&hdr).await.unwrap();
+    }
+
+    #[test]
+    fn test_connect() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 2 * 256 * 256;
+        let slot_size = bytes_per_frame;
+        let shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            1,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and... nothing else happens.
+                    let (_stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+                    shutdown_r.recv_timeout(Duration::from_millis(100)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_wait_for_arm() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 2 * 256 * 256;
+        let slot_size = bytes_per_frame;
+        let shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            1,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 16384).await;
+
+                    shutdown_r.recv_timeout(Duration::from_millis(500)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_bg_thread_crash() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            1,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simulate: we send some garbage, the background thread should
+                    // handle that and reconnect:
+                    let hdr = r"PROTOCOL_VIOLATION";
+                    stream.write_all(hdr.as_bytes()).await.unwrap();
+
+                    // now, let's accept again and process the next connection cleanly:
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(500), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    send_acq_header(&mut stream, 16).await;
+                    send_frames_simple(&mut stream, 0..16).await;
+
+                    shutdown_r.recv_timeout(Duration::from_millis(100)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            let res = conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            });
+            assert!(res.is_err());
+
+            assert!(matches!(res, Err(ConnectionError::FatalError(_))));
+
+            // ok, we should be able to reconnect:
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            let mut idx = 0;
+            loop {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap();
+
+                if let Some(stack) = stack {
+                    eprintln!("got a stack with {} frames", stack.len());
+                    stack.with_slot(&shm, |s| {
+                        assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                    });
+                    stack.free_slot(&mut shm);
+                    idx += 1;
+                } else {
+                    eprintln!("done!");
+                    break;
+                }
+            }
+
+            assert_eq!(idx, 16);
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_full_acquisition() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            1,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 16).await;
+
+                    // someone sent a trigger signal to the detector trigger input, so let's start to "record some data":
+                    send_frames_simple(&mut stream, 0..16).await;
+
+                    shutdown_r.recv_timeout(Duration::from_millis(500)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            let mut idx = 0;
+            loop {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap();
+
+                if let Some(stack) = stack {
+                    eprintln!("got a stack with {} frames", stack.len());
+                    stack.with_slot(&shm, |s| {
+                        assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                    });
+                    stack.free_slot(&mut shm);
+                } else {
+                    eprintln!("done!");
+                    break;
+                }
+                idx += 1;
+            }
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_shared_memory_backpressure() {
+        // This test deliberately creates a shared memory area that is too
+        // small, and makes sure the code behaves well in this situation,
+        // meaning it generates backpressure on the tcp socket.
+
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+
+        // NOTE: only 5 slots, meaning we can have three slots for splitting
+        // plus some for users
+        let mut shm = SharedSlabAllocator::new(5, bytes_per_frame, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            5,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 256).await;
+
+                    // someone sent a trigger signal to the detector trigger input, so let's start to "record some data":
+                    send_frames_simple(&mut stream, 0..256).await;
+
+                    shutdown_r.recv_timeout(Duration::from_secs(10)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            let mut idx = 0;
+            loop {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap();
+
+                // let's be a bit slow in consuming the data:
+                std::thread::sleep(Duration::from_millis(1));
+
+                if let Some(stack) = stack {
+                    eprintln!("got a stack with {} frames", stack.len());
+                    stack.with_slot(&shm, |s| {
+                        assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                    });
+                    stack.free_slot(&mut shm);
+                } else {
+                    eprintln!("done!");
+                    break;
+                }
+                idx += 1;
+            }
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_drain() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            1,
+            false,
+            socket_as_path.to_str().unwrap(),
+            Some(Duration::from_millis(100)),
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+            let (arm_s, arm_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // just write some straight garbage:
+                    stream.write_all(&vec![0xFFu8; 10024 * 1024]).await.unwrap();
+
+                    // wait for the arm signal (timeout so we don't wait forever here):
+                    arm_r.recv_timeout(Duration::from_secs(10)).unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 256).await;
+
+                    // someone sent a trigger signal to the detector trigger input, so let's start to "record some data":
+                    send_frames_simple(&mut stream, 0..256).await;
+
+                    shutdown_r.recv_timeout(Duration::from_secs(10)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(500)),
+            )
+            .unwrap();
+
+            // arm the fake detector and send the acquisition header:
+            arm_s.send(()).unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            let mut idx = 0;
+            loop {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap();
+
+                // let's be a bit slow in consuming the data:
+                std::thread::sleep(Duration::from_millis(1));
+
+                if let Some(stack) = stack {
+                    eprintln!("got a stack with {} frames", stack.len());
+                    stack.with_slot(&shm, |s| {
+                        assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                    });
+                    stack.free_slot(&mut shm);
+                } else {
+                    eprintln!("done!");
+                    break;
+                }
+                idx += 1;
+            }
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_server_goes_away() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            100,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 16).await;
+
+                    // someone sent a trigger signal to the detector trigger input, so let's start to "record some data":
+                    // oops, we only send 10 frames!
+                    send_frames_simple(&mut stream, 0..10).await;
+
+                    // and then explicitly close the connection:
+                    stream.shutdown().await.unwrap();
+                    // proper dead, yeah:
+                    std::mem::drop(stream);
+                    info!("server side stream should be proper dead now");
+
+                    shutdown_r.recv_timeout(Duration::from_millis(500)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            // NOTE: we only get 9 valid frames, because the error is noticed before sending
+            // the last frame stack on its way (which would only happen when noticing that the
+            // newly received frame doesn't fir into the current frame stack)
+            for idx in 0..9 {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap()
+                    .unwrap(); // the first 10 frames must come through!
+
+                eprintln!("got a stack with {} frames", stack.len());
+                stack.with_slot(&shm, |s| {
+                    assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                });
+                stack.free_slot(&mut shm);
+            }
+
+            let next = conn.get_next_stack(1, || Ok::<_, std::io::Error>(()));
+            assert!(next.is_err());
+            assert!(matches!(next, Err(ConnectionError::FatalError(_))));
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            // we don't leak any slots, yay!
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    #[test]
+    fn test_continuous_acquisition() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            100,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::ImmediateReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    // the server accepts our connection, and...
+                    let (mut stream, _addr) =
+                        timeout(Duration::from_millis(100), fake_server.accept())
+                            .await
+                            .unwrap()
+                            .unwrap();
+
+                    // simlate: someone armed the detector ("STARTACQUISITION"), meaning
+                    // the acquisition header is sent out and a real detector would now wait
+                    // for either a SOFTTRIGGER or a hardware triggering signal:
+                    send_acq_header(&mut stream, 0).await;
+
+                    // NOTE: this thread panics, as the `write_all` fails when the
+                    // client goes away. This is fine™.
+
+                    // someone sent a trigger signal to the detector trigger input, so let's start to "record some data":
+                    send_frames_simple(&mut stream, 0..).await;
+
+                    shutdown_r.recv_timeout(Duration::from_millis(100)).unwrap();
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            // let it run for half a second or so:
+            let timeout = Duration::from_millis(100);
+            let deadline = Instant::now() + timeout;
+            let mut idx = 0;
+            loop {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap()
+                    .unwrap(); // note double unwrap: we expect to never get a `None` here!
+
+                stack.with_slot(&shm, |s| {
+                    assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                });
+                stack.free_slot(&mut shm);
+                idx += 1;
+
+                if Instant::now() > deadline {
+                    eprintln!("deadline exceeded, stopping acquisition");
+                    break;
+                }
+            }
+            eprintln!("got {idx} frames in {timeout:?}");
+            assert!(conn.is_running());
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close(); // this also cancels the running acquisition
+
+            // the server thread is a bit unhappy here, but that's fine:
+            assert!(server_thread.join().is_err());
+
+            // assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+            // FIXME: we can't assert this, because the background thread runs
+            // "longer" than our loop above, so it continues to fill the SHM until
+            // we call `conn.close()` - this could be improved by adding an explicit
+            // cancel or `stop_passive` command.
+        });
+    }
+
+    /// We assume frame size is constant within an acquisition. Test what
+    /// happens in case it isn't.
+    ///
+    /// This shouldn't happen in reality, but we handle things like
+    /// this gracefully.
+    #[test]
+    fn test_confused_by_diff_length() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            100,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::DrainThenReconnect, // make sure we don't kill the sending side prematurely
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    {
+                        // the server accepts our connection, and...
+                        let (mut stream, _addr) =
+                            timeout(Duration::from_millis(100), fake_server.accept())
+                                .await
+                                .unwrap()
+                                .unwrap();
+
+                        // we say we send 16 frames, but...
+                        send_acq_header(&mut stream, 16).await;
+
+                        // we only send two frames at correct size, and then...
+                        send_frames_simple(&mut stream, 0..2).await;
+                        // ... oops, suddenly quad (at least the size)!
+                        send_frames_with_size(&mut stream, 2..3, 512, 512).await;
+                    }
+
+                    info!("server side stream should be proper dead now");
+
+                    {
+                        // restart the whole thing, and now successfully send 16 frames
+                        // without size change:
+                        let (mut stream, _addr) =
+                            timeout(Duration::from_millis(100), fake_server.accept())
+                                .await
+                                .unwrap()
+                                .unwrap();
+
+                        info!("and the server lives again and sends some thtuff");
+
+                        send_acq_header(&mut stream, 16).await;
+                        send_frames_simple(&mut stream, 0..16).await;
+
+                        // and we are done:
+                        shutdown_r.recv_timeout(Duration::from_millis(500)).unwrap();
+                    }
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(1000)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            let first = conn
+                .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                .unwrap()
+                .unwrap();
+            info!("not even this");
+            first.with_slot(&shm, |s| {
+                assert_eq!(s.as_slice(), vec![0u8; 256 * 256],);
+            });
+            first.free_slot(&mut shm);
+
+            info!("got the first one");
+
+            let next = conn.get_next_stack(1, || Ok::<_, std::io::Error>(()));
+            assert!(next.is_err());
+            info!("got the second one (before match check)");
+            assert!(matches!(next, Err(ConnectionError::FatalError(_))));
+
+            info!("got the second one (err)");
+
+            // we can recover after the error:
+            assert_eq!(conn.get_status(), ConnectionStatus::Idle);
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(1000)),
+            )
+            .unwrap();
+
+            for idx in 0..16 {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap()
+                    .unwrap(); // all frames come through!
+
+                stack.with_slot(&shm, |s| {
+                    assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                });
+                stack.free_slot(&mut shm);
+            }
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+            server_thread.join().unwrap();
+
+            // we don't leak any slots, yay!
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+
+    /// Test the alternative drain error recovery strategy.
+    ///
+    /// It seems like the QD software doesn't really like if we disconnect in
+    /// the middle of a stream, so an alternative strategy is to drain the socket completely
+    /// before reconnecting.
+    #[test]
+    fn test_recovery_strategy_drain() {
+        let env = env_logger::Env::default()
+            .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+            .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+        let _ = env_logger::Builder::from_env(env)
+            .format_timestamp_micros()
+            .try_init();
+
+        let (fake_server, local_addr) = make_test_server();
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+        let bytes_per_frame = 256 * 256;
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(100, slot_size, false, &socket_as_path).unwrap();
+
+        let config = &QdDetectorConnConfig::new(
+            "127.0.0.1",
+            local_addr.port() as usize,
+            1,
+            bytes_per_frame,
+            100,
+            false,
+            socket_as_path.to_str().unwrap(),
+            None,
+            RecoveryStrategy::DrainThenReconnect,
+        );
+
+        std::thread::scope(move |s| {
+            let (shutdown_s, shutdown_r) = channel();
+
+            let server_thread = s.spawn(move || {
+                let rt = Runtime::new().unwrap();
+                rt.block_on(async {
+                    let fake_server = TcpListener::from_std(fake_server).unwrap();
+                    {
+                        // the server accepts our connection, and...
+                        let (mut stream, _addr) =
+                            timeout(Duration::from_millis(100), fake_server.accept())
+                                .await
+                                .unwrap()
+                                .unwrap();
+
+                        send_acq_header(&mut stream, 128).await;
+                        send_frames_simple(&mut stream, 0..16).await;
+
+                        // provoke an error in the middle of the stream:
+                        stream.write_all("OOPS".as_bytes()).await.unwrap();
+
+                        // and continue sending stuff (in the reconnect
+                        // immediately strategy, this should crash the thread):
+                        send_frames_simple(&mut stream, 16..128).await;
+                    }
+
+                    info!("server side stream should be proper dead now");
+
+                    {
+                        // restart the whole thing, and now successfully send 16 frames
+                        // without size change:
+                        let (mut stream, _addr) =
+                            timeout(Duration::from_millis(100), fake_server.accept())
+                                .await
+                                .unwrap()
+                                .unwrap();
+
+                        info!("and the server lives again and sends some thtuff");
+
+                        send_acq_header(&mut stream, 16).await;
+                        send_frames_simple(&mut stream, 0..16).await;
+
+                        // and we are done:
+                        shutdown_r.recv_timeout(Duration::from_millis(500)).unwrap();
+                    }
+                });
+            });
+
+            // we let the `GenericConnection` drive the background thread:
+            let bg = QdBackgroundThread::spawn(config, &shm).unwrap();
+            let mut conn: GenericConnection<QdBackgroundThread, QdAcquisitionConfig> =
+                GenericConnection::new(bg, &shm).unwrap();
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            // we only get 15 frames, as the error happens before pushing the
+            // frame stack into the channel:
+            for idx in 0..15 {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap()
+                    .unwrap(); // all frames come through!
+
+                stack.with_slot(&shm, |s| {
+                    assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                });
+                stack.free_slot(&mut shm);
+            }
+
+            // next stack is an error:
+            let err = conn.get_next_stack(1, || Ok::<_, std::io::Error>(()));
+            assert!(err.is_err());
+
+            // we can recover after the error:
+            assert_eq!(conn.get_status(), ConnectionStatus::Idle);
+            // restart connection:
+            conn.start_passive(
+                || Ok::<(), ConnectionError>(()),
+                &Some(Duration::from_millis(100)),
+            )
+            .unwrap();
+
+            conn.wait_for_arm(Some(Duration::from_millis(100)), || {
+                Ok::<(), ConnectionError>(())
+            })
+            .unwrap();
+
+            // everything should read properly here:
+            for idx in 0..16 {
+                let stack = conn
+                    .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                    .unwrap()
+                    .unwrap(); // all frames come through!
+
+                stack.with_slot(&shm, |s| {
+                    assert_eq!(s.as_slice(), vec![idx as u8; 256 * 256],);
+                });
+                stack.free_slot(&mut shm);
+            }
+
+            // that was the last stack:
+            assert!(conn
+                .get_next_stack(1, || Ok::<_, std::io::Error>(()))
+                .unwrap()
+                .is_none());
+
+            // allow the server thread to end:
+            shutdown_s.send(()).unwrap();
+
+            // tear down the connection/bg thread and join the server thread:
+            conn.close();
+
+            // especially, the background thread did not panic:
+            server_thread
+                .join()
+                .expect("server should be able to send everything");
+
+            // we don't leak any slots, yay!
+            assert_eq!(shm.num_slots_total(), shm.num_slots_free());
+        });
+    }
+}
diff --git a/libertem_qd_mpx/src/base_types.rs b/libertem_qd_mpx/src/base_types.rs
new file mode 100644
index 00000000..62cdf174
--- /dev/null
+++ b/libertem_qd_mpx/src/base_types.rs
@@ -0,0 +1,1049 @@
+use std::{
+    any::type_name,
+    collections::HashMap,
+    fmt::Debug,
+    str::{FromStr, Split, Utf8Error},
+    string::FromUtf8Error,
+    time::Duration,
+};
+
+use common::{
+    frame_stack::FrameMeta,
+    generic_connection::{AcquisitionConfig, DetectorConnectionConfig},
+};
+use log::{trace, warn};
+use num::Num;
+use pyo3::{
+    exceptions::PyValueError,
+    pyclass, pymethods,
+    types::{PyBytes, PyBytesMethods, PyType},
+    Bound, PyResult,
+};
+use serde::{Deserialize, Serialize};
+
+/// Size of the full prefix, in bytes, including the comma separator to the payload: 'MPX,<length>,'
+pub const PREFIX_SIZE: usize = 15;
+
+#[derive(Debug, thiserror::Error)]
+pub enum FrameMetaParseError {
+    #[error("frame header is not valid utf8: {err}")]
+    Utf8Expected {
+        #[from]
+        err: FromUtf8Error,
+    },
+
+    #[error("frame header is not valid utf8: {err}")]
+    Utf8Error {
+        #[from]
+        err: Utf8Error,
+    },
+
+    #[error("unknown version: {prefix}")]
+    UnknownVersion { prefix: String },
+
+    #[error("unknown variant for enum: {variant}")]
+    UnknownVariant { variant: String },
+
+    #[error("unexpected end of header")]
+    Eof,
+
+    #[error("value conversion error: {msg}")]
+    ValueError { msg: String },
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub enum DType {
+    U01,
+    U08,
+    U16,
+    U32,
+    U64,
+    R64,
+}
+
+impl FromStr for DType {
+    type Err = FrameMetaParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "U01" => Self::U01,
+            "U08" => Self::U08,
+            "U16" => Self::U16,
+            "U32" => Self::U32,
+            "U64" => Self::U64,
+            "R64" => Self::R64,
+            _ => {
+                return Err(FrameMetaParseError::UnknownVariant {
+                    variant: s.to_owned(),
+                })
+            }
+        })
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub enum Layout {
+    L1x1,
+    L2x2,
+    LNx1,
+    L2x2G,
+    LNx1G,
+}
+
+impl FromStr for Layout {
+    type Err = FrameMetaParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // input is padded with leading spaces:
+        Ok(match s.trim_start() {
+            "1x1" => Self::L1x1,
+            "2x2" => Self::L2x2,
+            "Nx1" => Self::LNx1,
+            "2x2G" => Self::L2x2G,
+            "Nx1G" => Self::LNx1G,
+            _ => {
+                return Err(FrameMetaParseError::UnknownVariant {
+                    variant: s.to_owned(),
+                })
+            }
+        })
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum NextPartError {
+    #[error("unexpected end of header")]
+    Eof,
+
+    #[error("value conversion error: {msg}")]
+    ValueError { msg: String },
+}
+
+impl From<NextPartError> for FrameMetaParseError {
+    fn from(value: NextPartError) -> Self {
+        match value {
+            NextPartError::Eof => Self::Eof,
+            NextPartError::ValueError { msg } => Self::ValueError { msg },
+        }
+    }
+}
+
+fn next_part_from_str<T: FromStr>(parts: &mut Split<char>) -> Result<T, NextPartError>
+where
+    <T as std::str::FromStr>::Err: std::fmt::Debug,
+{
+    let part_str = parts.next().ok_or(NextPartError::Eof)?;
+    let value: T = part_str.parse().map_err(|e| NextPartError::ValueError {
+        msg: format!(
+            "unexpected value: '{part_str}', expected {} ({e:?})",
+            type_name::<T>()
+        ),
+    })?;
+    Ok(value)
+}
+
+fn next_part_from_str_with_map<T: FromStr>(
+    parts: &mut Split<char>,
+    f: impl Fn(&str) -> Result<&str, NextPartError>,
+) -> Result<T, NextPartError>
+where
+    <T as std::str::FromStr>::Err: std::fmt::Debug,
+{
+    let part_str = parts.next().ok_or(NextPartError::Eof)?;
+    let part_str = f(part_str)?;
+    let value: T = part_str.parse().map_err(|e| NextPartError::ValueError {
+        msg: format!(
+            "unexpected value: '{part_str}', expected {} ({e:?})",
+            type_name::<T>()
+        ),
+    })?;
+    Ok(value)
+}
+
+fn next_part_from_str_radix<T: Num>(parts: &mut Split<char>, radix: u32) -> Result<T, NextPartError>
+where
+    <T as num::Num>::FromStrRadixErr: std::fmt::Debug,
+{
+    let part_str = parts.next().ok_or(NextPartError::Eof)?;
+    let value = T::from_str_radix(part_str, radix).map_err(|e| NextPartError::ValueError {
+        msg: format!(
+            "unexpected value: '{part_str}', expected {} ({e:?}); radix={radix}",
+            type_name::<T>()
+        ),
+    })?;
+    Ok(value)
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MQ1A {
+    pub timestamp_ext: String,
+
+    pub acquisition_time_shutter_ns: u64,
+
+    pub counter_depth: u8,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum ColourMode {
+    Single,
+    Multi,
+}
+
+impl FromStr for ColourMode {
+    type Err = FrameMetaParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let int_val: u8 = s.parse().map_err(|_| FrameMetaParseError::ValueError {
+            msg: format!("invalid colour mode: {s}"),
+        })?;
+        Ok(match int_val {
+            0 => Self::Single,
+            1 => Self::Multi,
+            _ => {
+                return Err(FrameMetaParseError::UnknownVariant {
+                    variant: s.to_owned(),
+                })
+            }
+        })
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Gain {
+    SLGM,
+    LGM,
+    HGM,
+    SHGM,
+}
+
+impl FromStr for Gain {
+    type Err = FrameMetaParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let int_val: u8 = s.parse().map_err(|_| FrameMetaParseError::ValueError {
+            msg: format!("invalid gain: {s}"),
+        })?;
+        Ok(match int_val {
+            0 => Self::SLGM,
+            1 => Self::LGM,
+            2 => Self::HGM,
+            3 => Self::SHGM,
+            _ => {
+                return Err(FrameMetaParseError::UnknownVariant {
+                    variant: s.to_owned(),
+                })
+            }
+        })
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct QdFrameMeta {
+    mpx_length: usize,
+
+    sequence: u32,
+
+    data_offset: u16,
+
+    num_chips: u8,
+
+    width_in_pixels: u32,
+
+    height_in_pixels: u32,
+
+    width_in_pixels_raw: u32,
+
+    height_in_pixels_raw: u32,
+
+    pub dtype: DType,
+
+    pub layout: Layout,
+
+    chip_select: u8,
+
+    timestamp: String,
+
+    acquisition_shutter_time: f64,
+
+    counter: u8,
+
+    colour_mode: ColourMode,
+
+    gain_mode: Gain,
+
+    pub mq1a: Option<MQ1A>,
+}
+
+impl QdFrameMeta {
+    // this is only used internally for testing... could also make everything
+    // pub or pub(crate) instead...
+    #[allow(clippy::too_many_arguments)]
+    #[cfg(test)]
+    pub(crate) fn new(
+        mpx_length: usize,
+        sequence: u32,
+        data_offset: u16,
+        num_chips: u8,
+        width_in_pixels_raw: u32,
+        height_in_pixels_raw: u32,
+        width_in_pixels: u32,
+        height_in_pixels: u32,
+        dtype: DType,
+        layout: Layout,
+        chip_select: u8,
+        timestamp: String,
+        acquisition_shutter_time: f64,
+        counter: u8,
+        colour_mode: ColourMode,
+        gain_mode: Gain,
+        mq1a: Option<MQ1A>,
+    ) -> Self {
+        Self {
+            mpx_length,
+            sequence,
+            data_offset,
+            num_chips,
+            width_in_pixels_raw,
+            height_in_pixels_raw,
+            width_in_pixels,
+            height_in_pixels,
+            dtype,
+            layout,
+            chip_select,
+            timestamp,
+            acquisition_shutter_time,
+            counter,
+            colour_mode,
+            gain_mode,
+            mq1a,
+        }
+    }
+
+    /// Parse frame header, including the MQ1 prefix
+    pub fn parse_bytes(input: &[u8], mpx_length: usize) -> Result<Self, FrameMetaParseError> {
+        if input.len() < 16 {
+            return Err(FrameMetaParseError::Eof);
+        }
+
+        // first, split off the header if there is more stuff after it:
+        let data_offset: u32 = std::str::from_utf8(&input[11..16])?.parse().map_err(|e| {
+            FrameMetaParseError::ValueError {
+                msg: format!("could not parse data offset: {e:?} ({:X?})", &input[11..16]),
+            }
+        })?;
+
+        let input = if input.len() > data_offset as usize {
+            &input[..data_offset as usize]
+        } else {
+            input
+        };
+
+        let input_string = std::str::from_utf8(input)?;
+
+        let mut parts = input_string.split(',');
+
+        let magic = parts.next().ok_or(FrameMetaParseError::Eof)?;
+        if magic != "MQ1" {
+            return Err(FrameMetaParseError::UnknownVersion {
+                prefix: magic.to_owned(),
+            });
+        }
+
+        let sequence: u32 = next_part_from_str(&mut parts)?;
+        let data_offset: u16 = next_part_from_str(&mut parts)?;
+        let num_chips: u8 = next_part_from_str(&mut parts)?;
+        let width_in_pixels_raw: u32 = next_part_from_str(&mut parts)?;
+        let height_in_pixels_raw: u32 = next_part_from_str(&mut parts)?;
+        let dtype: DType = next_part_from_str(&mut parts)?;
+        let layout: Layout = next_part_from_str(&mut parts)?;
+        let chip_select: u8 = next_part_from_str_radix(&mut parts, 16)?;
+        let timestamp: String = next_part_from_str(&mut parts)?;
+        let acquisition_shutter_time: f64 = next_part_from_str(&mut parts)?;
+        let counter = next_part_from_str(&mut parts)?;
+        let colour_mode = next_part_from_str(&mut parts)?;
+        let gain_mode = next_part_from_str(&mut parts)?;
+
+        // thresholds 0..7?
+
+        let mq1a = if parts.any(|item| item == "MQ1A") {
+            let timestamp_ext = next_part_from_str(&mut parts)?;
+            let acquisition_time_shutter_ns =
+                next_part_from_str_with_map(&mut parts, |s| Ok(&s[0..s.len() - 2]))?;
+            let counter_depth = next_part_from_str_with_map(&mut parts, |s| Ok(s.trim()))?;
+
+            Some(MQ1A {
+                timestamp_ext,
+                acquisition_time_shutter_ns,
+                counter_depth,
+            })
+        } else {
+            None
+        };
+
+        // fixups: layout -> width/height mapping
+        let width_in_pixels = if dtype == DType::R64 {
+            match layout {
+                Layout::L1x1 => width_in_pixels_raw,
+                Layout::LNx1 => 1024,
+                Layout::LNx1G => 1024, // FIXME: gap support?
+
+                Layout::L2x2 => 512,
+                Layout::L2x2G => 514,
+            }
+        } else {
+            width_in_pixels_raw
+        };
+
+        let height_in_pixels = if dtype == DType::R64 {
+            match layout {
+                Layout::L1x1 => height_in_pixels_raw,
+                Layout::LNx1 => 256,
+                Layout::LNx1G => 256, // FIXME: gap support?
+
+                Layout::L2x2 => 512,
+                Layout::L2x2G => 514,
+            }
+        } else {
+            height_in_pixels_raw
+        };
+
+        Ok(Self {
+            mpx_length,
+            sequence,
+            data_offset,
+            num_chips,
+            width_in_pixels_raw,
+            height_in_pixels_raw,
+            width_in_pixels,
+            height_in_pixels,
+            dtype,
+            layout,
+            chip_select,
+            timestamp,
+            acquisition_shutter_time,
+            counter,
+            colour_mode,
+            gain_mode,
+            mq1a,
+        })
+    }
+
+    /// Get the length of the message for the frame, starting with and including
+    /// the 'MQ1' prefix. This is the same value that is sent in the MPX prefix.
+    pub fn get_mpx_length(&self) -> usize {
+        self.mpx_length
+    }
+
+    /// size of the frame header on the wire, including the MPX prefix:
+    pub fn get_total_size_header(&self) -> usize {
+        self.data_offset as usize + PREFIX_SIZE
+    }
+
+    /// Get the sequence number of the frame, starting from 1, as in the frame
+    /// header.
+    pub fn get_sequence(&self) -> u32 {
+        self.sequence
+    }
+}
+
+impl FrameMeta for QdFrameMeta {
+    fn get_data_length_bytes(&self) -> usize {
+        // minus one here because... mpx_length includes the comma, data_offset doesn't?
+        self.mpx_length - self.data_offset as usize - 1
+    }
+
+    fn get_dtype_string(&self) -> String {
+        let raw_dtype = if let Some(mq1a) = &self.mq1a {
+            match mq1a.counter_depth {
+                1 | 6 => "uint8",
+                12 => "uint16",
+                24 => "uint32",
+                _ => {
+                    // FIXME: unknown counter depth; can't determine raw dtype!
+                    "uint8"
+                }
+            }
+        } else {
+            // FIXME: not really correct, but we can't know the data type if the
+            // detector doesn't include the mq1a header!
+            "uint8"
+        };
+
+        match self.dtype {
+            DType::U01 | DType::U08 => "uint8",
+            DType::U16 => "uint16",
+            DType::U32 => "uint32",
+            DType::U64 => "uint64",
+            DType::R64 => raw_dtype,
+        }
+        .to_owned()
+    }
+
+    fn get_shape(&self) -> (u64, u64) {
+        (self.width_in_pixels as u64, self.height_in_pixels as u64)
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum AcqHeaderParseError {
+    #[error("acquisition header is not valid utf8: {err}")]
+    Utf8Expected {
+        #[from]
+        err: FromUtf8Error,
+    },
+
+    #[error("acquisition header is not valid utf8: {err}")]
+    Utf8Error {
+        #[from]
+        err: Utf8Error,
+    },
+
+    #[error("syntax error: line='{line}'")]
+    SyntaxError { line: String },
+
+    #[error("syntax error: {msg} (token: '{token}')")]
+    ValueError { token: String, msg: String },
+
+    #[error("missing key: {key}")]
+    MissingKey { key: String },
+}
+
+#[derive(Debug, Clone)]
+#[pyclass]
+pub struct QdAcquisitionHeader {
+    frames_in_acquisition: usize,
+    frames_per_trigger: usize,
+    scan_x: Option<usize>,
+    scan_y: Option<usize>,
+    raw_kv: HashMap<String, String>,
+}
+
+#[pymethods]
+impl QdAcquisitionHeader {
+    #[classmethod]
+    fn new_from_bytes(_cls: &Bound<'_, PyType>, input: Bound<'_, PyBytes>) -> PyResult<Self> {
+        Self::parse_bytes(input.as_bytes())
+            .map_err(|e| PyValueError::new_err(format!("invalid acquisition header: {e}")))
+    }
+
+    fn frames_in_acquisition(&self) -> usize {
+        self.frames_in_acquisition
+    }
+
+    fn frames_per_trigger(&self) -> usize {
+        self.frames_per_trigger
+    }
+}
+
+impl QdAcquisitionHeader {
+    pub fn num_frames(&self) -> usize {
+        self.frames_in_acquisition
+    }
+
+    pub fn nav_shape(&self) -> Option<(usize, usize)> {
+        if let (Some(scan_x), Some(scan_y)) = (self.scan_x, self.scan_y) {
+            return Some((scan_y, scan_x));
+        }
+        None
+    }
+}
+
+fn get_key_and_parse<T: FromStr>(
+    raw_kv: &HashMap<String, String>,
+    key: &str,
+) -> Result<T, AcqHeaderParseError>
+where
+    <T as FromStr>::Err: ToString,
+{
+    let owned_key = key.to_owned();
+    let val = raw_kv
+        .get(&owned_key)
+        .ok_or(AcqHeaderParseError::MissingKey { key: owned_key })?;
+    val.trim()
+        .parse()
+        .map_err(|e: <T as FromStr>::Err| AcqHeaderParseError::ValueError {
+            token: val.clone(),
+            msg: e.to_string(),
+        })
+}
+
+fn get_key_and_parse_optional<T: FromStr>(
+    raw_kv: &HashMap<String, String>,
+    key: &str,
+) -> Result<Option<T>, AcqHeaderParseError>
+where
+    <T as FromStr>::Err: ToString,
+{
+    let owned_key = key.to_owned();
+    let maybe_val = raw_kv.get(&owned_key);
+    Ok(if let Some(val) = maybe_val {
+        Some(val.trim().parse().map_err(|e: <T as FromStr>::Err| {
+            AcqHeaderParseError::ValueError {
+                token: val.clone(),
+                msg: e.to_string(),
+            }
+        })?)
+    } else {
+        None
+    })
+}
+
+impl QdAcquisitionHeader {
+    pub fn parse_bytes(input: &[u8]) -> Result<Self, AcqHeaderParseError> {
+        // the acquisition header can actually be latin-1 (µA...) so let's try that:
+        let input_string = encoding_rs::mem::decode_latin1(input);
+        trace!(
+            "parsing acquisition header: {}",
+            String::from_utf8_lossy(input)
+        );
+        // trace!("parsing acquisition header: {}", String::from_utf8_lossy(&input[1003..]));
+
+        // let input= input.strip_suffix(&[0]).unwrap_or(input);
+        // let input_string = std::str::from_utf8(input)?;
+        let mut raw_kv: HashMap<String, String> = HashMap::new();
+        let mut parts = input_string.split('\n');
+
+        let _ = parts.next(); // first line: 'HDR,'...
+
+        for line in parts {
+            if line.trim() == "End" {
+                break;
+            }
+            if let Some((k, v)) = line.split_once(':') {
+                raw_kv
+                    .entry(k.trim().to_string())
+                    .or_insert(v.trim().to_owned());
+            } else {
+                warn!("ignoring broken acquisition header line: {line}")
+            }
+        }
+        let frames_in_acquisition: u32 =
+            get_key_and_parse(&raw_kv, "Frames in Acquisition (Number)")?;
+
+        let frames_per_trigger: u32 = get_key_and_parse(&raw_kv, "Frames per Trigger (Number)")?;
+
+        let scan_x = get_key_and_parse_optional(&raw_kv, "ScanX")?;
+        let scan_y = get_key_and_parse_optional(&raw_kv, "ScanY")?;
+
+        Ok(Self {
+            frames_in_acquisition: frames_in_acquisition as usize,
+            frames_per_trigger: frames_per_trigger as usize,
+            scan_x,
+            scan_y,
+            raw_kv,
+        })
+    }
+
+    pub fn get_raw_kv(&self) -> &HashMap<String, String> {
+        &self.raw_kv
+    }
+
+    pub fn get_scan_x(&self) -> Option<usize> {
+        self.scan_x
+    }
+
+    pub fn get_scan_y(&self) -> Option<usize> {
+        self.scan_y
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum RecoveryStrategyError {
+    #[error("invalid variant: {variant}")]
+    InvalidVariant { variant: String },
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+pub enum RecoveryStrategy {
+    #[default]
+    ImmediateReconnect,
+    DrainThenReconnect,
+}
+
+impl FromStr for RecoveryStrategy {
+    type Err = RecoveryStrategyError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(match s {
+            "immediate_reconnect" => Self::ImmediateReconnect,
+            "drain_then_reconnect" => Self::DrainThenReconnect,
+            _ => {
+                return Err(RecoveryStrategyError::InvalidVariant {
+                    variant: s.to_owned(),
+                })
+            }
+        })
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct QdDetectorConnConfig {
+    pub data_host: String,
+    pub data_port: usize,
+
+    /// number of frames per frame stack; approximated because of compression
+    pub frame_stack_size: usize,
+
+    /// approx. number of bytes per frame, used for sizing frame stacks together
+    /// with `frame_stack_size`
+    pub bytes_per_frame: usize,
+
+    pub drain_on_connect: Option<Duration>,
+
+    pub recovery_strategy: RecoveryStrategy,
+
+    num_slots: usize,
+    enable_huge_pages: bool,
+    shm_handle_path: String,
+}
+
+impl QdDetectorConnConfig {
+    #[allow(clippy::too_many_arguments)]
+    pub fn new(
+        data_host: &str,
+        data_port: usize,
+        frame_stack_size: usize,
+        bytes_per_frame: usize,
+        num_slots: usize,
+        enable_huge_pages: bool,
+        shm_handle_path: &str,
+        drain_on_connect: Option<Duration>,
+        recovery_strategy: RecoveryStrategy,
+    ) -> Self {
+        Self {
+            data_host: data_host.to_owned(),
+            data_port,
+            frame_stack_size,
+            bytes_per_frame,
+            num_slots,
+            enable_huge_pages,
+            shm_handle_path: shm_handle_path.to_owned(),
+            drain_on_connect,
+            recovery_strategy,
+        }
+    }
+}
+
+impl DetectorConnectionConfig for QdDetectorConnConfig {
+    fn get_shm_num_slots(&self) -> usize {
+        self.num_slots
+    }
+
+    fn get_shm_slot_size(&self) -> usize {
+        self.frame_stack_size * self.bytes_per_frame
+    }
+
+    fn get_shm_enable_huge_pages(&self) -> bool {
+        self.enable_huge_pages
+    }
+
+    fn get_shm_handle_path(&self) -> String {
+        self.shm_handle_path.clone()
+    }
+}
+
+#[pyclass]
+#[derive(Debug, Clone)]
+pub struct QdAcquisitionConfig {
+    acq_header: QdAcquisitionHeader,
+    frame_header: QdFrameMeta,
+}
+
+impl QdAcquisitionConfig {
+    pub fn new(acq_header: QdAcquisitionHeader, frame_header: QdFrameMeta) -> Self {
+        Self {
+            acq_header,
+            frame_header,
+        }
+    }
+}
+
+impl AcquisitionConfig for QdAcquisitionConfig {
+    fn num_frames(&self) -> usize {
+        self.acq_header.frames_in_acquisition
+    }
+}
+
+#[pymethods]
+impl QdAcquisitionConfig {
+    fn frames_in_acquisition(&self) -> usize {
+        self.acq_header.frames_in_acquisition
+    }
+
+    fn frames_per_trigger(&self) -> usize {
+        self.acq_header.frames_per_trigger
+    }
+
+    fn nav_shape(&self) -> Option<(usize, usize)> {
+        self.acq_header.nav_shape()
+    }
+
+    fn detector_shape(&self) -> (u32, u32) {
+        (
+            self.frame_header.height_in_pixels,
+            self.frame_header.width_in_pixels,
+        )
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use common::frame_stack::FrameMeta;
+
+    use crate::base_types::{DType, Layout};
+
+    use super::{QdAcquisitionHeader, QdFrameMeta};
+
+    #[test]
+    fn test_parse_frame_header_example_1_single() {
+        let inp = "MQ1,000001,00384,01,0256,0256,U08,   1x1,01,2020-05-18 16:51:49.971626,0.000555,0,0,0,1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511,MQ1A,2020-05-18T14:51:49.971626178Z,555000ns,6,";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 384 + 256 * 256 + 1).unwrap();
+
+        assert_eq!(fm.get_sequence(), 1);
+        assert_eq!(fm.get_mpx_length(), 384 + 256 * 256 + 1);
+        assert_eq!(fm.data_offset, 384);
+        assert_eq!(fm.num_chips, 1);
+        assert_eq!(fm.width_in_pixels, 256);
+        assert_eq!(fm.height_in_pixels, 256);
+        assert_eq!(fm.dtype, DType::U08);
+        assert_eq!(fm.layout, Layout::L1x1);
+        assert_eq!(fm.get_total_size_header(), 384 + 15);
+
+        let mq1a = fm.mq1a.unwrap();
+        assert_eq!(mq1a.acquisition_time_shutter_ns, 555_000);
+        assert_eq!(mq1a.counter_depth, 6);
+    }
+
+    #[test]
+    fn test_parse_frame_header_example_2_quad() {
+        let inp = "MQ1,000001,00768,04,0514,0514,U16,  2x2G,0F,2024-06-14 11:31:30.060732,0.001000,0,0,0,3.500000E+1,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,052,511,000,000,000,000,000,000,100,255,100,125,100,100,081,100,074,030,128,004,255,136,128,188,176,000,000,3RX,055,511,000,000,000,000,000,000,100,255,100,125,100,100,079,100,077,030,128,004,255,138,128,191,188,000,000,3RX,052,511,000,000,000,000,000,000,100,255,100,125,100,100,077,100,075,030,128,004,255,140,128,197,177,000,000,3RX,049,511,000,000,000,000,000,000,100,255,100,125,100,100,070,100,073,030,128,004,255,140,128,183,179,000,000,MQ1A,2024-06-14T09:31:30.060732344Z,1000000ns,12,";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 768 + (2 * 514 * 514) + 1).unwrap();
+
+        assert_eq!(fm.get_sequence(), 1);
+        assert_eq!(fm.get_mpx_length(), 768 + (2 * 514 * 514) + 1);
+        assert_eq!(fm.data_offset, 768);
+        assert_eq!(fm.num_chips, 4);
+        assert_eq!(fm.width_in_pixels, 514);
+        assert_eq!(fm.height_in_pixels, 514);
+        assert_eq!(fm.dtype, DType::U16);
+        assert_eq!(fm.layout, Layout::L2x2G);
+        assert_eq!(fm.get_total_size_header(), 768 + 15);
+
+        let mq1a = fm.mq1a.unwrap();
+        assert_eq!(mq1a.acquisition_time_shutter_ns, 1_000_000);
+        assert_eq!(mq1a.counter_depth, 12);
+    }
+
+    #[test]
+    fn test_quad_raw_correct_shape() {
+        let inp = r"MQ1,000001,00768,04,1024,0256,R64,  2x2G,0F,2024-04-25 15:50:41.049778,0.000309,0,0,0,3.400000E+1,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,051,511,000,000,000,000,000,000,100,255,100,125,100,100,081,100,074,030,128,004,255,136,128,188,176,000,000,3RX,054,511,000,000,000,000,000,000,100,255,100,125,100,100,079,100,077,030,128,004,255,138,128,191,188,000,000,3RX,051,511,000,000,000,000,000,000,100,255,100,125,100,100,077,100,075,030,128,004,255,140,128,197,177,000,000,3RX,048,511,000,000,000,000,000,000,100,255,100,125,100,100,070,100,073,030,128,004,255,140,128,183,179,000,000,MQ1A,2024-04-25T13:50:41.049778723Z,309200ns,6,";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 768 + (514 * 514) + 1).unwrap();
+
+        assert_eq!(fm.get_sequence(), 1);
+        assert_eq!(fm.get_mpx_length(), 768 + (514 * 514) + 1);
+        assert_eq!(fm.data_offset, 768);
+        assert_eq!(fm.num_chips, 4);
+        assert_eq!(fm.width_in_pixels, 514);
+        assert_eq!(fm.height_in_pixels, 514);
+        assert_eq!(fm.dtype, DType::R64);
+        assert_eq!(fm.layout, Layout::L2x2G);
+        assert_eq!(fm.get_total_size_header(), 768 + 15);
+
+        let mq1a = fm.mq1a.unwrap();
+        assert_eq!(mq1a.acquisition_time_shutter_ns, 309_200);
+        assert_eq!(mq1a.counter_depth, 6);
+    }
+
+    #[test]
+    fn test_parse_frame_header_example_3_raw_6bit() {
+        let inp = "MQ1,000001,00384,01,0256,0256,R64,   1x1,01,2020-05-25 17:48:56.475280,0.001000,0,0,0,,MQ1A,2020-05-25T15:48:56.475280991Z,1000000ns,6,";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 384 + 256 * 256 + 1).unwrap();
+
+        assert_eq!(fm.get_sequence(), 1);
+        assert_eq!(fm.get_mpx_length(), 384 + 256 * 256 + 1);
+        assert_eq!(fm.data_offset, 384);
+        assert_eq!(fm.num_chips, 1);
+        assert_eq!(fm.width_in_pixels, 256);
+        assert_eq!(fm.height_in_pixels, 256);
+        assert_eq!(fm.dtype, DType::R64);
+        assert_eq!(fm.layout, Layout::L1x1);
+        assert_eq!(fm.get_total_size_header(), 384 + 15);
+
+        let mq1a = fm.mq1a.clone().unwrap();
+        assert_eq!(mq1a.acquisition_time_shutter_ns, 1_000_000);
+        assert_eq!(mq1a.counter_depth, 6);
+
+        assert_eq!(fm.get_data_length_bytes(), 256 * 256);
+        assert_eq!(fm.get_shape(), (256, 256));
+    }
+
+    #[test]
+    fn test_parse_frame_header_from_mock() {
+        let inp = "MQ1,000001,00340,01,0016,0016,U08,   1x1,01,2020-05-18 16:51:49.971626,0.000555,0,0,0,1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511,MQ1A,2020-05-18T14:51:49.971626178Z,555000ns,6\n";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 384 + 256 * 256 + 1).unwrap();
+
+        eprintln!("{fm:?}");
+    }
+
+    #[test]
+    fn test_parse_frame_header_from_mock_with_additional_stuff() {
+        let inp = "MQ1,000001,00340,01,0016,0016,U08,   1x1,01,2020-05-18 16:51:49.971626,0.000555,0,0,0,1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511,MQ1A,2020-05-18T14:51:49.971626178Z,555000ns,6\n";
+        let inp_bytes = inp.as_bytes();
+        let inp_plus_data: Vec<u8> = inp_bytes
+            .iter()
+            .chain(vec![0; 16 * 16].iter())
+            .copied()
+            .collect();
+        let fm = QdFrameMeta::parse_bytes(&inp_plus_data, 384 + 256 * 256 + 1).unwrap();
+
+        eprintln!("{fm:?}");
+    }
+
+    #[test]
+    fn test_parse_frame_header_example_4_u16_quad() {
+        let inp = "MQ1,000001,00768,04,0512,0512,U16,   2x2,0F,2022-03-28 15:06:08.361851,0.000832,0,0,0,1.000000E+1,5.000000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,3RX,033,511,000,000,000,000,000,000,100,255,100,125,100,100,065,100,069,030,128,004,255,145,128,199,191,511,511,3RX,030,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,064,030,128,004,255,143,128,201,193,511,511,3RX,032,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,071,030,128,004,255,147,128,191,184,511,511,3RX,030,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,074,030,128,004,255,147,128,191,184,511,511,MQ1A,2022-03-28T14:06:08.361851667Z,831990ns,12,";
+        let inp_bytes = inp.as_bytes();
+        let fm = QdFrameMeta::parse_bytes(inp_bytes, 768 + 512 * 512 * 2 + 1).unwrap();
+
+        assert_eq!(fm.get_sequence(), 1);
+        assert_eq!(fm.get_mpx_length(), 768 + 512 * 512 * 2 + 1);
+        assert_eq!(fm.data_offset, 768);
+        assert_eq!(fm.num_chips, 4);
+        assert_eq!(fm.width_in_pixels, 512);
+        assert_eq!(fm.height_in_pixels, 512);
+        assert_eq!(fm.dtype, DType::U16);
+        assert_eq!(fm.layout, Layout::L2x2);
+        assert_eq!(fm.get_total_size_header(), 768 + 15);
+
+        let mq1a = fm.mq1a.clone().unwrap();
+        assert_eq!(mq1a.acquisition_time_shutter_ns, 831_990);
+        assert_eq!(mq1a.counter_depth, 12);
+
+        assert_eq!(fm.get_data_length_bytes(), 2 * 512 * 512);
+        assert_eq!(fm.get_shape(), (512, 512));
+    }
+
+    #[test]
+    fn test_parse_acquisition_header_example_1() {
+        let inp = r"HDR,	
+Time and Date Stamp (day, mnth, yr, hr, min, s):	18/05/2020 16:51:48
+Chip ID:	W529_F5,-,-,-
+Chip Type (Medipix 3.0, Medipix 3.1, Medipix 3RX):	Medipix 3RX
+Assembly Size (NX1, 2X2):	   1x1
+Chip Mode  (SPM, CSM, CM, CSCM):	SPM
+Counter Depth (number):	6
+Gain:	SLGM
+Active Counters:	Alternating
+Thresholds (keV):	1.200000E+2,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0
+DACs:	175,511,000,000,000,000,000,000,125,255,125,125,100,100,082,100,087,030,128,004,255,129,128,176,168,511,511
+bpc File:	c:\MERLIN_Quad_Config\W529_F5\W529_F5_SPM.bpc,,,
+DAC File:	c:\MERLIN_Quad_Config\W529_F5\W529_F5_SPM.dacs,,,
+Gap Fill Mode:	Distribute
+Flat Field File:	None
+Dead Time File:	Dummy (C:\<NUL>\)
+Acquisition Type (Normal, Th_scan, Config):	Normal
+Frames in Acquisition (Number):	16384
+Frames per Trigger (Number):	128
+Trigger Start (Positive, Negative, Internal):	Rising Edge LVDS
+Trigger Stop (Positive, Negative, Internal):	Internal
+Sensor Bias (V):	120 V
+Sensor Polarity (Positive, Negative):	Positive
+Temperature (C):	Board Temp 37.384918 Deg C
+Humidity (%):	Board Humidity 1.331848 
+Medipix Clock (MHz):	120MHz
+Readout System:	Merlin Quad
+Software Version:	0.67.0.9
+End	                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
+";
+        let inp_bytes = inp.as_bytes();
+        let header = QdAcquisitionHeader::parse_bytes(inp_bytes).unwrap();
+        assert_eq!(header.num_frames(), 16384);
+    }
+
+    #[test]
+    fn test_parse_acquisition_header_example_2_scan_xy() {
+        let inp = r"HDR,	
+Time and Date Stamp (day, mnth, yr, hr, min, s):	6/14/2024 11:31:21 AM
+Chip ID:	W530_D4,W530_E4,W530_C4,W530_L5
+Chip Type (Medipix 3.0, Medipix 3.1, Medipix 3RX):	Medipix 3RX
+Assembly Size (NX1, 2X2):	  2x2G
+Chip Mode  (SPM, CSM, CM, CSCM):	SPM
+Counter Depth (number):	12
+Gain:	SLGM
+Active Counters:	Alternating
+Thresholds (keV):	3.500000E+1,5.110000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0
+DACs:	052,511,000,000,000,000,000,000,100,255,100,125,100,100,081,100,074,030,128,004,255,136,128,188,176,000,000; 055,511,000,000,000,000,000,000,100,255,100,125,100,100,079,100,077,030,128,004,255,138,128,191,188,000,000; 052,511,000,000,000,000,000,000,100,255,100,125,100,100,077,100,075,030,128,004,255,140,128,197,177,000,000; 049,511,000,000,000,000,000,000,100,255,100,125,100,100,070,100,073,030,128,004,255,140,128,183,179,000,000
+bpc File:	c:\Merlin_Quad_Config\W530_D4\W530_D4_SPM.bpc,c:\Merlin_Quad_Config\W530_E4\W530_E4_SPM.bpc,c:\Merlin_Quad_Config\W530_C4\W530_C4_SPM.bpc,c:\Merlin_Quad_Config\W530_L5\W530_L5_SPM.bpc
+DAC File:	c:\Merlin_Quad_Config\W530_D4\W530_D4_SPM.dacs,c:\Merlin_Quad_Config\W530_E4\W530_E4_SPM.dacs,c:\Merlin_Quad_Config\W530_C4\W530_C4_SPM.dacs,c:\Merlin_Quad_Config\W530_L5\W530_L5_SPM.dacs
+Gap Fill Mode:	None
+Flat Field File:	C:\USERS\MERLIN\DOCUMENTS\SETUP\FLATFIELDS\200KV\FLATFIED_RDP_2024_06_13_200KV_T0_35.MIB
+Dead Time File:	Dummy (C:\<NUL>\)
+Acquisition Type (Normal, Th_scan, Config):	Normal
+Frames in Acquisition (Number):	580
+Frames per Trigger (Number):	580
+Trigger Start (Positive, Negative, Internal):	Rising Edge
+Trigger Stop (Positive, Negative, Internal):	Rising Edge
+Sensor Bias (V):	120 V
+Sensor Polarity (Positive, Negative):	Positive
+Temperature (C):	Board Temp 50.630413 Deg C
+Humidity (%):	Board Humidity -0.590759 
+Medipix Clock (MHz):	160MHz
+Readout System:	Merlin Quad
+Software Version:	1.6.1.217
+ScanX:	28
+ScanY:	20
+End	                                                                                                
+";
+        let inp_bytes = inp.as_bytes();
+        let header = QdAcquisitionHeader::parse_bytes(inp_bytes).unwrap();
+        assert_eq!(header.num_frames(), 580);
+        assert_eq!(header.get_scan_x(), Some(28));
+        assert_eq!(header.get_scan_y(), Some(20));
+
+        assert_eq!(header.nav_shape(), Some((20, 28)));
+    }
+
+    #[test]
+    fn test_parse_acquisition_header_example_3_quad_u16() {
+        let inp = r"HDR,	
+Time and Date Stamp (day, mnth, yr, hr, min, s):	28/03/2022 15:01:56
+Chip ID:	W530_J6,W530_L6,W530_K5,W530_B5
+Chip Type (Medipix 3.0, Medipix 3.1, Medipix 3RX):	Medipix 3RX
+Assembly Size (NX1, 2X2):	   2x2
+Chip Mode  (SPM, CSM, CM, CSCM):	SPM
+Counter Depth (number):	12
+Gain:	SLGM
+Active Counters:	Alternating
+Thresholds (keV):	1.000000E+1,5.000000E+2,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0,0.000000E+0
+DACs:	033,511,000,000,000,000,000,000,100,255,100,125,100,100,065,100,069,030,128,004,255,145,128,199,191,511,511; 030,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,064,030,128,004,255,143,128,201,193,511,511; 032,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,071,030,128,004,255,147,128,191,184,511,511; 030,511,000,000,000,000,000,000,100,255,100,125,100,100,066,100,074,030,128,004,255,147,128,191,184,511,511
+bpc File:	c:\MERLIN_Quad_Config\W530_J6\W530_J6_SPM.bpc,c:\MERLIN_Quad_Config\W530_L6\W530_L6_SPM.bpc,c:\MERLIN_Quad_Config\W530_K5\W530_K5_SPM.bpc,c:\MERLIN_Quad_Config\W530_B5\W530_B5_SPM.bpc
+DAC File:	c:\MERLIN_Quad_Config\W530_J6\W530_J6_SPM.dacs,c:\MERLIN_Quad_Config\W530_L6\W530_L6_SPM.dacs,c:\MERLIN_Quad_Config\W530_K5\W530_K5_SPM.dacs,c:\MERLIN_Quad_Config\W530_B5\W530_B5_SPM.dacs
+Gap Fill Mode:	None
+Flat Field File:	None
+Dead Time File:	Dummy (C:\<NUL>\)
+Acquisition Type (Normal, Th_scan, Config):	Normal
+Frames in Acquisition (Number):	65536
+Frames per Trigger (Number):	65536
+Trigger Start (Positive, Negative, Internal):	Rising Edge
+Trigger Stop (Positive, Negative, Internal):	Rising Edge
+Sensor Bias (V):	120 V
+Sensor Polarity (Positive, Negative):	Positive
+Temperature (C):	Board Temp 52.389328 Deg C
+Humidity (%):	Board Humidity 0.103516 
+Medipix Clock (MHz):	120MHz
+Readout System:	Merlin Quad
+Software Version:	0.77.0.16
+End	                                                                                                                                                                                                         
+";
+        let inp_bytes = inp.as_bytes();
+        let header = QdAcquisitionHeader::parse_bytes(inp_bytes).unwrap();
+        assert_eq!(header.num_frames(), 65536);
+        assert_eq!(header.get_scan_x(), None);
+        assert_eq!(header.get_scan_y(), None);
+    }
+}
diff --git a/libertem_qd_mpx/src/decoder.rs b/libertem_qd_mpx/src/decoder.rs
new file mode 100644
index 00000000..cb70a3f4
--- /dev/null
+++ b/libertem_qd_mpx/src/decoder.rs
@@ -0,0 +1,1104 @@
+use std::fmt::Debug;
+
+use common::decoder::{
+    decode_ints_be, try_cast_if_safe, try_cast_primitive, Decoder, DecoderError,
+    DecoderTargetPixelType,
+};
+use log::trace;
+use num::{cast::AsPrimitive, Bounded, Num, NumCast, PrimInt, ToPrimitive};
+use numpy::ndarray::s;
+use zerocopy::FromBytes;
+
+use crate::base_types::{DType, Layout, QdFrameMeta};
+
+#[derive(Debug, Default)]
+pub struct QdDecoder {}
+
+impl Decoder for QdDecoder {
+    type FrameMeta = QdFrameMeta;
+
+    fn decode<T>(
+        &self,
+        shm: &ipc_test::SharedSlabAllocator,
+        input: &common::frame_stack::FrameStackHandle<Self::FrameMeta>,
+        output: &mut numpy::ndarray::ArrayViewMut3<'_, T>,
+        start_idx: usize,
+        end_idx: usize,
+    ) -> Result<(), DecoderError>
+    where
+        T: DecoderTargetPixelType,
+        u8: AsPrimitive<T>,
+        u16: AsPrimitive<T>,
+    {
+        // Data comes in different pixel formats:
+        //
+        // 1) Big endian unsigned integer (u8, u16, u32)
+        // 2) "scrambled" raw formats
+        // 2.1) r1: raw 1bit per pixel packed integers - a single u64 contains
+        //      data for 64 pixels
+        // 2.2) r6: raw 6bit per pixel integers - pixels need to be re-ordered
+        //      in groups of 8
+        // 2.3) r12: raw 12bit per pixel integers - pixels need to be re-ordered
+        //      in groups of 4; big endian
+        // 2.4) r24: sent as "two consecutive frames" on the network (not fully
+        //      implemented yet!)
+        //
+        // Then, there is the other dimension of layout: in case multiple
+        // sensors are put together, what is their geometry? We have these
+        // layout possibilities:
+        //
+        // 1x1
+        // 2x2
+        // Nx1
+        // 2x2G
+        // Nx1G
+        //
+        // The G suffix means that two "gap" pixels are inserted between the sensors,
+        //
+        // For now, we support 1x1 and 2x2 layouts (including the G suffix),
+        // others TBD.
+        //
+        // If the resulting array looks like this:
+        //
+        // _________
+        // | 1 | 2 |
+        // ---------
+        // | 3 | 4 |
+        // ---------
+        //
+        // Without taking chunk encoding into account, it maps to the input data like this:
+        //
+        // [4 | 3 | 2 | 1]
+        //
+        // This is an array of shape (256, 1024), where the first row contains
+        // the first rows of all quadrants, but quadrants 3 and 4 are also
+        // flipped in x and y direction.
+        //
+
+        input.with_slot(shm, |slot| {
+            for ((frame_meta, out_idx), in_idx) in
+                input.get_meta().iter().zip(0..).zip(start_idx..end_idx)
+            {
+                let mut out_view = output.slice_mut(s![out_idx, .., ..]);
+                let out_slice =
+                    out_view
+                        .as_slice_mut()
+                        .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                            msg: "out slice not C-order contiguous".to_owned(),
+                        })?;
+                let raw_input_data = input.get_slice_for_frame(in_idx, slot);
+                self.decode_frame(frame_meta, raw_input_data, out_slice)?;
+            }
+            Ok(())
+        })
+    }
+
+    fn zero_copy_available(
+        &self,
+        _handle: &common::frame_stack::FrameStackHandle<Self::FrameMeta>,
+    ) -> Result<bool, DecoderError> {
+        // FIXME: add zero copy support, only really important for the u8 case
+        // (as the other integer formats are big endian, so on most system they
+        // need to be converted)
+        Ok(false)
+    }
+}
+
+impl QdDecoder {
+    pub fn decode_frame<O>(
+        &self,
+        frame_meta: &QdFrameMeta,
+        input: &[u8],
+        output: &mut [O],
+    ) -> Result<(), DecoderError>
+    where
+        O: DecoderTargetPixelType + 'static,
+        u8: AsPrimitive<O>,
+        u16: AsPrimitive<O>,
+    {
+        match &frame_meta.layout {
+            crate::base_types::Layout::L1x1 => {
+                self.decode_frame_single_chip(frame_meta, input, output)
+            }
+            crate::base_types::Layout::L2x2 => self.decode_frame_quad(frame_meta, input, output),
+            crate::base_types::Layout::L2x2G => self.decode_frame_quad(frame_meta, input, output),
+            crate::base_types::Layout::LNx1 | crate::base_types::Layout::LNx1G => {
+                self.decode_frame_eels(frame_meta, input, output)
+            }
+        }
+    }
+
+    fn decode_frame_eels<O>(
+        &self,
+        frame_meta: &QdFrameMeta,
+        input: &[u8],
+        output: &mut [O],
+    ) -> Result<(), DecoderError>
+    where
+        O: DecoderTargetPixelType,
+    {
+        match frame_meta.dtype {
+            // this one could be zero-copy:
+            DType::U01 | DType::U08 => try_cast_if_safe(input, output),
+
+            // these need byte swaps:
+            DType::U16 => decode_ints_be::<_, u16>(input, output),
+            DType::U32 => decode_ints_be::<_, u32>(input, output),
+            DType::U64 => decode_ints_be::<_, u64>(input, output),
+
+            // this is any of the raw formats; dispatch on the "original counter depth" value:
+            DType::R64 => Err(DecoderError::FrameDecodeFailed {
+                msg: format!(
+                    "unsupported layout for raw decoding: {:?}",
+                    frame_meta.layout
+                ),
+            }),
+        }
+    }
+
+    fn decode_frame_quad<O>(
+        &self,
+        frame_meta: &QdFrameMeta,
+        input: &[u8],
+        output: &mut [O],
+    ) -> Result<(), DecoderError>
+    where
+        O: DecoderTargetPixelType + 'static,
+        u8: AsPrimitive<O>,
+        u16: AsPrimitive<O>,
+    {
+        match frame_meta.dtype {
+            // this one could be zero-copy:
+            DType::U01 | DType::U08 => try_cast_if_safe(input, output),
+
+            // these need byte swaps:
+            DType::U16 => decode_ints_be::<_, u16>(input, output),
+            DType::U32 => decode_ints_be::<_, u32>(input, output),
+            DType::U64 => decode_ints_be::<_, u64>(input, output),
+
+            // this is any of the raw formats; dispatch on the "original counter depth" value:
+            DType::R64 => {
+                if let Some(mq1a) = &frame_meta.mq1a {
+                    match mq1a.counter_depth {
+                        1 => R1::decode_2x2_raw::<_, 64>(input, output, &frame_meta.layout),
+                        6 => R6::decode_2x2_raw::<_, 8>(input, output, &frame_meta.layout),
+                        12 => R12::decode_2x2_raw::<_, 4>(input, output, &frame_meta.layout),
+                        // 24 => decode_r24(input, output),
+                        _ => Err(DecoderError::FrameDecodeFailed {
+                            msg: format!("unsupported counter depth: {}", mq1a.counter_depth),
+                        }),
+                    }
+                } else {
+                    Err(DecoderError::FrameDecodeFailed { msg: "in raw mode, but no M1QA header available - counter depth not available!".to_owned() })
+                }
+            }
+        }
+    }
+
+    fn decode_frame_single_chip<O>(
+        &self,
+        frame_meta: &QdFrameMeta,
+        input: &[u8],
+        output: &mut [O],
+    ) -> Result<(), DecoderError>
+    where
+        O: DecoderTargetPixelType,
+        u8: AsPrimitive<O>,
+        u16: AsPrimitive<O>,
+    {
+        match frame_meta.dtype {
+            // this one could be zero-copy:
+            DType::U01 | DType::U08 => try_cast_if_safe(input, output),
+
+            // these need byte swaps:
+            DType::U16 => decode_ints_be::<_, u16>(input, output),
+            DType::U32 => decode_ints_be::<_, u32>(input, output),
+            DType::U64 => decode_ints_be::<_, u64>(input, output),
+
+            // this is any of the raw formats; dispatch on the "original counter depth" value:
+            DType::R64 => {
+                if let Some(mq1a) = &frame_meta.mq1a {
+                    match mq1a.counter_depth {
+                        1 => R1::decode_all::<_, 64>(input, output),
+                        6 => R6::decode_all::<_, 8>(input, output),
+                        12 => R12::decode_all::<_, 4>(input, output),
+                        // 24 => decode_r24(input, output),
+                        _ => Err(DecoderError::FrameDecodeFailed {
+                            msg: format!("unsupported counter depth: {}", mq1a.counter_depth),
+                        }),
+                    }
+                } else {
+                    Err(DecoderError::FrameDecodeFailed { msg: "in raw mode, but no M1QA header available - counter depth not available!".to_owned() })
+                }
+            }
+        }
+    }
+}
+
+pub trait RawType {
+    type Intermediate: Num;
+
+    /// Bits per pixel excluding padding
+    const COUNTER_DEPTH: usize;
+
+    /// how many output pixel values are contained in a single 8-byte chunk?
+    const PIXELS_PER_CHUNK: usize;
+
+    /// How many bytes per row on a single chip in raw encoding?
+    const BYTES_PER_CHIP_ROW: usize;
+
+    /// take a raw input chunk (8 bytes) and decode it into an output slice
+    fn decode_chunk<'a, O, const OUT_PER_CHUNK: usize>(
+        input: &[u8; 8],
+        output: &mut [O; OUT_PER_CHUNK],
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Bounded + Debug + 'a + 'static,
+        Self::Intermediate: AsPrimitive<O>;
+
+    /// Decode all values from `input` into `output`.
+    fn decode_all<'a, O, const OUT_PER_CHUNK: usize>(
+        input: &[u8],
+        output: &mut [O],
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Num + Bounded + Debug + 'a + 'static,
+        Self::Intermediate: AsPrimitive<O>,
+    {
+        if input.len() % 8 != 0 {
+            return Err(DecoderError::FrameDecodeFailed {
+                msg: format!("input length {} is not divisible by 8", input.len()),
+            });
+        }
+
+        let output_pixels = output.len();
+        let input_pixels = (input.len() / 8) * Self::PIXELS_PER_CHUNK;
+
+        if output_pixels != input_pixels {
+            return Err(DecoderError::FrameDecodeFailed {
+                msg: format!(
+                    "output length {} should match input pixels ({}; length={})",
+                    output_pixels,
+                    input_pixels,
+                    input.len()
+                ),
+            });
+        }
+        let chunks = input.chunks_exact(8);
+        for (in_chunk, out_chunk) in chunks.zip(output.chunks_exact_mut(OUT_PER_CHUNK)) {
+            <Self as RawType>::decode_chunk::<_, OUT_PER_CHUNK>(
+                in_chunk.try_into().expect("chunked by 8"),
+                out_chunk.try_into().unwrap(),
+            )?;
+        }
+
+        Ok(())
+    }
+
+    fn decode_2x2_raw<O, const OUT_PER_CHUNK: usize>(
+        input: &[u8],
+        output: &mut [O],
+        layout: &Layout,
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Debug + Num + Bounded + 'static,
+        Self::Intermediate: AsPrimitive<O>,
+    {
+        let rows_per_chip: usize = 256; // FIXME: `ROIROWS` support means this would be dynamic? how does that work with raw format?
+        const COLS_PER_CHIP: usize = 256;
+        let output_row_size: usize = if *layout == Layout::L2x2G { 514 } else { 512 };
+
+        trace!(
+            "input.len()={}, output.len()={}",
+            input.len(),
+            &mut output.len(),
+        );
+
+        let (out_top, out_bottom) = output.split_at_mut(output_row_size * rows_per_chip);
+
+        let out_bottom = if *layout == Layout::L2x2G {
+            &mut out_bottom[2 * output_row_size..]
+        } else {
+            out_bottom
+        };
+
+        let mut bottom_rows_rev = out_bottom.chunks_exact_mut(output_row_size).rev();
+        let mut top_rows = out_top.chunks_exact_mut(output_row_size);
+
+        // input rows come interleaved: Q4/Q3/Q2/Q1 -> we need to de-interleave here!
+        // iterate over input rows by four, while taking one row from top and one from bottom:
+        let in_by_four_rows = input.chunks_exact(4 * Self::BYTES_PER_CHIP_ROW);
+
+        let col_gap_offset = if *layout == Layout::L2x2G { 2 } else { 0 };
+
+        trace!(
+            "top_len={} bottom_len={} in_by_four_len={}",
+            top_rows.len(),
+            bottom_rows_rev.len(),
+            in_by_four_rows.len()
+        );
+
+        for four_rows in in_by_four_rows {
+            let bottom_row =
+                bottom_rows_rev
+                    .next()
+                    .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                        msg: "eof bottom".to_owned(),
+                    })?;
+            // Q4:
+            let mut tmp: [O; COLS_PER_CHIP] = [O::zero(); COLS_PER_CHIP];
+            Self::decode_all::<_, OUT_PER_CHUNK>(
+                &four_rows[0..Self::BYTES_PER_CHIP_ROW],
+                &mut tmp,
+            )?;
+            for (o, i) in bottom_row[COLS_PER_CHIP + col_gap_offset..]
+                .iter_mut()
+                .rev()
+                .zip(tmp.into_iter())
+            {
+                *o = i;
+            }
+
+            // Q3:
+            let mut tmp: [O; COLS_PER_CHIP] = [O::zero(); COLS_PER_CHIP];
+            Self::decode_all::<_, OUT_PER_CHUNK>(
+                &four_rows[Self::BYTES_PER_CHIP_ROW..2 * Self::BYTES_PER_CHIP_ROW],
+                &mut tmp,
+            )?;
+            for (o, i) in bottom_row[0..COLS_PER_CHIP]
+                .iter_mut()
+                .rev()
+                .zip(tmp.into_iter())
+            {
+                *o = i;
+            }
+
+            let top_row = top_rows
+                .next()
+                .ok_or_else(|| DecoderError::FrameDecodeFailed {
+                    msg: "eof top".to_owned(),
+                })?;
+
+            // Q2:
+            Self::decode_all::<_, OUT_PER_CHUNK>(
+                &four_rows[2 * Self::BYTES_PER_CHIP_ROW..3 * Self::BYTES_PER_CHIP_ROW],
+                &mut top_row[COLS_PER_CHIP + col_gap_offset..],
+            )?;
+            // Q1:
+            Self::decode_all::<_, OUT_PER_CHUNK>(
+                &four_rows[3 * Self::BYTES_PER_CHIP_ROW..],
+                &mut top_row[0..COLS_PER_CHIP],
+            )?;
+        }
+
+        Ok(())
+    }
+
+    fn encode_2x2_raw<'a, I>(input: &[I], output: &mut [u8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + PrimInt + NumCast + Debug + 'a,
+    {
+        let cols_per_chip: usize = 256;
+        let rows_per_chip: usize = 256;
+
+        let input_chunks = input.chunks_exact(2 * cols_per_chip);
+
+        // encode q1 and q2 into the right part of the [4 | 3 | 2 | 1] array of shape (256, 1024):
+        for (row_out, row_in) in output
+            .chunks_exact_mut(Self::BYTES_PER_CHIP_ROW * 4)
+            .zip(input_chunks)
+        {
+            let (_row_out_q4q3, row_out_q2q1) =
+                &mut row_out.split_at_mut(2 * Self::BYTES_PER_CHIP_ROW);
+            let (row_out_q2, row_out_q1) = row_out_q2q1.split_at_mut(Self::BYTES_PER_CHIP_ROW);
+
+            // in the input, q1 comes first:
+            let (in_q1, in_q2) = row_in.split_at(cols_per_chip);
+
+            Self::encode_all(&mut in_q1.iter(), row_out_q1)?;
+            Self::encode_all(&mut in_q2.iter(), row_out_q2)?;
+        }
+
+        let input_chunks = input.chunks_exact(2 * cols_per_chip).skip(rows_per_chip);
+
+        // q3 and q4:
+        // encode q1 and q2 into the right part of the [4 | 3 | 2 | 1] array of shape (256, 1024):
+        // reverse the rows to flip in y direction:
+        for (row_out, row_in) in output
+            .chunks_exact_mut(Self::BYTES_PER_CHIP_ROW * 4)
+            .rev()
+            .zip(input_chunks)
+        {
+            let (row_out_q4q3, _row_out_q2q1) =
+                &mut row_out.split_at_mut(2 * Self::BYTES_PER_CHIP_ROW);
+            let (row_out_q4, row_out_q3) = row_out_q4q3.split_at_mut(Self::BYTES_PER_CHIP_ROW);
+
+            // in the input, q3 comes first:
+            Self::encode_all(&mut row_in[..cols_per_chip].iter().rev(), row_out_q3)?;
+            Self::encode_all(&mut row_in[cols_per_chip..].iter().rev(), row_out_q4)?;
+        }
+
+        Ok(())
+    }
+
+    fn encode_chunk<'a, I, II>(input: &mut II, output: &mut [u8; 8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + PrimInt + NumCast + Debug + 'a,
+        II: Iterator<Item = &'a I> + ExactSizeIterator;
+
+    fn encode_all<'a, I, II>(input: &mut II, output: &mut [u8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + PrimInt + NumCast + Debug + 'a,
+        II: Iterator<Item = &'a I> + ExactSizeIterator,
+    {
+        if output.len() % 8 != 0 {
+            return Err(DecoderError::FrameDecodeFailed {
+                msg: format!("output length {} is not divisible by 8", output.len()),
+            });
+        }
+
+        let output_pixels = (output.len() / 8) * Self::PIXELS_PER_CHUNK;
+        let input_pixels = input.len();
+
+        if output_pixels != input_pixels {
+            return Err(DecoderError::FrameDecodeFailed {
+                msg: format!(
+                    "output length {} should match input pixels ({}; length={})",
+                    output_pixels,
+                    input_pixels,
+                    input.len()
+                ),
+            });
+        }
+
+        let chunks = output.chunks_exact_mut(8);
+        for out_chunk in chunks {
+            let mut tmp_chunk = [0u8; 8];
+            <Self as RawType>::encode_chunk(input, &mut tmp_chunk)?;
+            out_chunk.copy_from_slice(&tmp_chunk);
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug)]
+pub struct R1 {}
+
+impl RawType for R1 {
+    type Intermediate = u8;
+
+    const COUNTER_DEPTH: usize = 1;
+    const PIXELS_PER_CHUNK: usize = 64;
+    const BYTES_PER_CHIP_ROW: usize = 32;
+
+    fn decode_chunk<'a, O, const OUT_PER_CHUNK: usize>(
+        input: &[u8; 8],
+        output: &mut [O; OUT_PER_CHUNK],
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Debug + 'a + 'static,
+        Self::Intermediate: AsPrimitive<O>,
+    {
+        let value = u64::from_be_bytes(*input);
+        for (out_dest, bin_digit) in output.iter_mut().zip(0..OUT_PER_CHUNK) {
+            let out_value = ((value >> bin_digit) & 0x1) as Self::Intermediate;
+            *out_dest = (out_value).as_();
+        }
+
+        Ok(())
+    }
+
+    fn encode_chunk<'a, I, II>(input: &mut II, output: &mut [u8; 8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + PrimInt + NumCast + Debug + 'a,
+        II: Iterator<Item = &'a I> + ExactSizeIterator,
+    {
+        let mut dest: u64 = 0;
+        for (value, idx) in input.take(64).zip(0..64) {
+            // clamp to 1bit value and shift into place:
+            let tmp = *value & I::from(0x1).unwrap();
+            let tmp: u64 = try_cast_primitive(tmp)?;
+            dest |= tmp << idx;
+        }
+        output.copy_from_slice(&dest.to_be_bytes());
+
+        Ok(())
+    }
+}
+
+#[derive(Debug)]
+pub struct R6 {}
+
+impl RawType for R6 {
+    type Intermediate = u8;
+
+    const COUNTER_DEPTH: usize = 6;
+    const PIXELS_PER_CHUNK: usize = 8;
+    const BYTES_PER_CHIP_ROW: usize = 256;
+
+    fn decode_chunk<'a, O, const OUT_PER_CHUNK: usize>(
+        input: &[u8; 8],
+        output: &mut [O; OUT_PER_CHUNK],
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Bounded + Debug + 'a + 'static,
+        Self::Intermediate: AsPrimitive<O>,
+    {
+        assert!(output.len() >= 8);
+        for (i, o) in input.iter().rev().zip(output.iter_mut()) {
+            *o = (*i).as_();
+        }
+        Ok(())
+    }
+
+    fn encode_chunk<'a, I, II>(input: &mut II, output: &mut [u8; 8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + NumCast + Debug + 'a,
+        II: Iterator<Item = &'a I> + ExactSizeIterator,
+    {
+        assert!(input.len() >= 8, "input.len() = {}", input.len());
+
+        for (i, o) in input.take(8).zip(output.iter_mut().rev()) {
+            *o = try_cast_primitive(*i)?;
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug)]
+pub struct R12 {}
+
+impl RawType for R12 {
+    type Intermediate = u16;
+
+    const COUNTER_DEPTH: usize = 12;
+    const PIXELS_PER_CHUNK: usize = 4;
+    const BYTES_PER_CHIP_ROW: usize = 512;
+
+    fn decode_chunk<'a, O, const OUT_PER_CHUNK: usize>(
+        input: &[u8; 8],
+        output: &mut [O; OUT_PER_CHUNK],
+    ) -> Result<(), DecoderError>
+    where
+        O: Copy + ToPrimitive + NumCast + Debug + 'a + 'static,
+        Self::Intermediate: AsPrimitive<O>,
+    {
+        let values: &[u16; 4] = FromBytes::ref_from(input).unwrap();
+        #[cfg(target_endian = "little")]
+        {
+            output[3] = values[0].swap_bytes().as_();
+            output[2] = values[1].swap_bytes().as_();
+            output[1] = values[2].swap_bytes().as_();
+            output[0] = values[3].swap_bytes().as_();
+        }
+
+        #[cfg(target_endian = "big")]
+        {
+            output[3] = values[0].as_();
+            output[2] = values[1].as_();
+            output[1] = values[2].as_();
+            output[0] = values[3].as_();
+        }
+
+        Ok(())
+    }
+
+    fn encode_chunk<'a, I, II>(input: &mut II, output: &mut [u8; 8]) -> Result<(), DecoderError>
+    where
+        I: Copy + ToPrimitive + NumCast + Debug + 'a,
+        II: Iterator<Item = &'a I> + ExactSizeIterator,
+    {
+        assert!(input.len() >= 4, "input.len() = {}", input.len());
+
+        for (i, o) in input.take(4).zip(output.chunks_exact_mut(2).rev()) {
+            o.copy_from_slice(&u16::to_be_bytes(try_cast_primitive(*i)?))
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::path::PathBuf;
+
+    use common::{
+        decoder::{Decoder, DecoderError},
+        frame_stack::FrameStackForWriting,
+    };
+    use ipc_test::SharedSlabAllocator;
+    use num::cast::AsPrimitive;
+    use numpy::ndarray::Array3;
+    use rand::Rng;
+    use tempfile::{tempdir, TempDir};
+
+    use crate::{
+        base_types::{DType, Layout, QdFrameMeta},
+        decoder::R6,
+    };
+
+    use super::{QdDecoder, RawType, R1, R12};
+
+    fn generic_encode_decode<
+        R: RawType,
+        const ENCODED_SIZE: usize,
+        const SIZE_PX: usize,
+        const OUT_PER_CHUNK: usize,
+    >()
+    where
+        R::Intermediate: AsPrimitive<u32>,
+    {
+        let mut rng = rand::thread_rng();
+        let mut input_data = vec![0; SIZE_PX];
+        input_data.fill_with(|| rng.gen::<u32>() % (2u32.pow(R::COUNTER_DEPTH as u32)));
+
+        let input_data = input_data;
+
+        let mut encoded = vec![0u8; ENCODED_SIZE];
+        R::encode_all(&mut input_data.iter(), &mut encoded).unwrap();
+
+        eprintln!("input_data: {:X?}", &input_data[..]);
+        eprintln!("encoded: {:X?}", &encoded[..]);
+
+        let mut output_data = vec![0; SIZE_PX];
+        R::decode_all::<_, OUT_PER_CHUNK>(&encoded, &mut output_data).unwrap();
+
+        assert_eq!(output_data, input_data);
+    }
+
+    fn generic_encode_decode_chunk<R: RawType, const SIZE_PX: usize, const OUT_PER_CHUNK: usize>()
+    where
+        R::Intermediate: AsPrimitive<u32>,
+    {
+        const ENCODED_SIZE: usize = 16;
+
+        let mut rng = rand::thread_rng();
+        let mut input_data = vec![0; SIZE_PX];
+        input_data.fill_with(|| rng.gen::<u32>() % (2u32.pow(R::COUNTER_DEPTH as u32)));
+
+        let input_data = input_data;
+
+        let mut encoded = [0u8; ENCODED_SIZE];
+        let mut input_iter = input_data.iter();
+
+        let mut chunk = [0u8; 8];
+        R::encode_chunk(&mut input_iter, &mut chunk).unwrap();
+        encoded[0..8].copy_from_slice(&chunk);
+        eprintln!("encoded c1: {:x?}", &encoded[0..8]);
+
+        R::encode_chunk(&mut input_iter, &mut chunk).unwrap();
+        encoded[8..].copy_from_slice(&chunk);
+        eprintln!("encoded c2: {:x?}", &encoded[8..]);
+
+        eprintln!("input_data: {:X?}", &input_data[..]);
+        eprintln!("encoded: {:X?}", &encoded[..]);
+
+        let mut output_data = vec![0; SIZE_PX];
+        R::decode_all::<_, OUT_PER_CHUNK>(&encoded, &mut output_data).unwrap();
+
+        assert_eq!(output_data, input_data);
+    }
+
+    fn generic_quad_encode_decode<
+        R: RawType,
+        const ENCODED_SIZE: usize,
+        const SIZE_PX: usize,
+        const OUT_PER_CHUNK: usize,
+    >(
+        layout: &Layout,
+    ) where
+        R::Intermediate: AsPrimitive<u32>,
+    {
+        let mut rng = rand::thread_rng();
+        let mut input_data = vec![0; SIZE_PX];
+        input_data.fill_with(|| rng.gen::<u32>() % (2u32.pow(R::COUNTER_DEPTH as u32)));
+
+        let mut encoded = vec![0u8; ENCODED_SIZE];
+        R::encode_2x2_raw(&input_data, &mut encoded).unwrap();
+
+        let mut output_data = vec![0; SIZE_PX];
+        R::decode_2x2_raw::<_, OUT_PER_CHUNK>(&encoded, &mut output_data, layout).unwrap();
+
+        let matches = output_data
+            .iter()
+            .zip(input_data.iter())
+            .fold(0, |acc, (out, exp)| if out == exp { acc + 1 } else { acc });
+
+        eprintln!("number of matches: {}", matches);
+        eprintln!("input_data: {:X?}", &input_data[..]);
+        eprintln!("encoded: {:X?}", &encoded[..]);
+
+        assert_eq!(output_data, input_data);
+    }
+
+    /// Generate 512x512 input data, encode, then decode with a gap
+    /// (the encode part is not so interesting, it's mostly about
+    /// synthesizing the zero'd gap pixels)
+    fn generic_quad_encode_decode_with_gap<
+        R: RawType,
+        const ENCODED_SIZE: usize,
+        const OUT_PER_CHUNK: usize,
+    >()
+    where
+        R::Intermediate: AsPrimitive<u32>,
+    {
+        const SIZE_PX_PAYLOAD: usize = 512 * 512;
+        const SIZE_PX_W_GAP: usize = 514 * 514;
+        let layout = Layout::L2x2G;
+
+        let mut rng = rand::thread_rng();
+        let input_data = {
+            let mut input = vec![0; SIZE_PX_PAYLOAD];
+            input.fill_with(|| rng.gen::<u32>() % (2u32.pow(R::COUNTER_DEPTH as u32)));
+            input
+        };
+
+        let encoded = {
+            let mut encoded = vec![0u8; ENCODED_SIZE];
+            R::encode_2x2_raw(&input_data, &mut encoded).unwrap();
+            encoded
+        };
+
+        {
+            // to double check, try the no-gap variant, too:
+            let mut output_data_no_gap = vec![0; SIZE_PX_PAYLOAD];
+            R::decode_2x2_raw::<_, OUT_PER_CHUNK>(&encoded, &mut output_data_no_gap, &Layout::L2x2)
+                .unwrap();
+            assert_eq!(output_data_no_gap, input_data);
+        }
+
+        // from the non-gapped input data, build the expected, gapped, data:
+        let expected_data = {
+            let mut expected = vec![0u32; SIZE_PX_W_GAP];
+
+            let top_rows_out = expected.chunks_exact_mut(514).take(256);
+            let mut sanity = 0;
+            for (o, i) in top_rows_out.zip(input_data.chunks_exact(512).take(256)) {
+                // skip two columns here:
+                o[0..256].copy_from_slice(&i[0..256]);
+                o[256 + 2..].copy_from_slice(&i[256..]);
+                sanity += 1;
+            }
+            assert_eq!(sanity, 256);
+
+            // skip two rows in the output starting from 256 (see the `.skip(..) calls below`)
+            let bottom_rows_out = expected.chunks_exact_mut(514).skip(256 + 2);
+            let mut sanity = 0;
+            for (o, i) in bottom_rows_out.zip(input_data.chunks_exact(512).skip(256)) {
+                // skip two columns here:
+                o[0..256].copy_from_slice(&i[0..256]);
+                o[256 + 2..].copy_from_slice(&i[256..]);
+                sanity += 1;
+            }
+            assert_eq!(sanity, 256);
+
+            expected
+        };
+
+        // double check that there are zero'd rows...
+        for v in expected_data[514 * 256..514 * (256 + 2)].iter() {
+            assert_eq!(*v, 0);
+        }
+
+        // and cols:
+        for row in expected_data.chunks_exact(514) {
+            assert_eq!(&row[256..256 + 2], &[0, 0]);
+        }
+
+        let output_data = {
+            let mut output_data = vec![0; SIZE_PX_W_GAP];
+            R::decode_2x2_raw::<_, OUT_PER_CHUNK>(&encoded, &mut output_data, &layout).unwrap();
+            output_data
+        };
+
+        let matches = output_data
+            .iter()
+            .zip(expected_data.iter())
+            .fold(0, |acc, (out, exp)| if out == exp { acc + 1 } else { acc });
+
+        eprintln!("number of matches: {}", matches);
+
+        // before checking all output_data, check the stuff that should be zero:
+        for v in output_data[514 * 256..514 * (256 + 2)].iter() {
+            assert_eq!(*v, 0);
+        }
+        for row in output_data.chunks_exact(514) {
+            assert_eq!(&row[256..256 + 2], &[0, 0]);
+        }
+
+        eprintln!("input_data: {:X?}", &input_data[..]);
+        eprintln!("encoded: {:X?}", &encoded[..]);
+
+        assert_eq!(output_data, expected_data);
+    }
+
+    #[test]
+    fn test_r1_encode_decode() {
+        generic_encode_decode::<R1, 8192, 65536, 64>();
+    }
+
+    #[test]
+    fn test_r1_encode_decode_small() {
+        generic_encode_decode::<R1, 16, 128, 64>();
+    }
+
+    #[test]
+    fn test_r1_encode_decode_small_chunk() {
+        generic_encode_decode_chunk::<R1, 128, 64>();
+    }
+
+    #[test]
+    fn test_r6_encode_decode() {
+        generic_encode_decode::<R6, 65536, 65536, 8>();
+    }
+
+    #[test]
+    fn test_r6_encode_decode_small() {
+        generic_encode_decode::<R6, 16, 16, 8>();
+    }
+
+    #[test]
+    fn test_r6_encode_decode_small_chunk() {
+        generic_encode_decode_chunk::<R6, 16, 8>();
+    }
+
+    #[test]
+    fn test_r12_encode_decode() {
+        generic_encode_decode::<R12, 131072, 65536, 4>();
+    }
+
+    #[test]
+    fn test_r12_encode_decode_small() {
+        generic_encode_decode::<R12, 32, 16, 4>();
+    }
+
+    #[test]
+    fn test_r12_encode_decode_small_chunk() {
+        generic_encode_decode_chunk::<R12, 8, 4>();
+    }
+
+    #[test]
+    fn test_r1_encode_decode_quad_raw() {
+        const ENCODED_SIZE: usize = 512 * 512 / 8;
+        const SIZE_PX: usize = 512 * 512;
+        generic_quad_encode_decode::<R1, ENCODED_SIZE, SIZE_PX, 64>(&Layout::L2x2);
+    }
+
+    #[test]
+    fn test_r6_encode_decode_quad_raw() {
+        const ENCODED_SIZE: usize = 512 * 512;
+        const SIZE_PX: usize = 512 * 512;
+        generic_quad_encode_decode::<R6, ENCODED_SIZE, SIZE_PX, 8>(&Layout::L2x2);
+    }
+
+    #[test]
+    fn test_r12_encode_decode_quad_raw() {
+        const ENCODED_SIZE: usize = 512 * 512 * 2;
+        const SIZE_PX: usize = 512 * 512;
+        generic_quad_encode_decode::<R12, ENCODED_SIZE, SIZE_PX, 4>(&Layout::L2x2);
+    }
+
+    #[test]
+    fn test_r1_encode_decode_quad_raw_layout_g() {
+        const ENCODED_SIZE: usize = 512 * 512 / 8;
+        generic_quad_encode_decode_with_gap::<R1, ENCODED_SIZE, 64>();
+    }
+
+    #[test]
+    fn test_r6_encode_decode_quad_raw_layout_g() {
+        const ENCODED_SIZE: usize = 512 * 512;
+        generic_quad_encode_decode_with_gap::<R6, ENCODED_SIZE, 8>();
+    }
+
+    #[test]
+    fn test_r12_encode_decode_quad_raw_layout_g() {
+        const ENCODED_SIZE: usize = 512 * 512 * 2;
+        generic_quad_encode_decode_with_gap::<R12, ENCODED_SIZE, 4>();
+    }
+
+    fn get_socket_path() -> (TempDir, PathBuf) {
+        let socket_dir = tempdir().unwrap();
+        let socket_as_path = socket_dir.path().join("stuff.socket");
+
+        (socket_dir, socket_as_path)
+    }
+
+    fn make_test_frame_meta(
+        dtype: &DType,
+        layout: &Layout,
+        counter_depth: u8,
+        frame_size_bytes: usize,
+    ) -> QdFrameMeta {
+        let (width_raw, height_raw, width, height, num_chips) = match layout {
+            Layout::L1x1 => (256, 256, 256, 256, 1),
+            Layout::L2x2 => (1024, 256, 512, 512, 4),
+            Layout::LNx1 => (1024, 256, 1024, 256, 2), // eels-like setup
+            Layout::L2x2G => (1024, 256, 514, 514, 4),
+            Layout::LNx1G => (1024, 256, 1024, 256, 2), // eels-like setup; FIXME: 2px gap?
+        };
+
+        QdFrameMeta::new(
+            768 + 1 + frame_size_bytes,
+            1,
+            768,
+            num_chips,
+            width_raw,
+            height_raw,
+            width,
+            height,
+            dtype.clone(),
+            layout.clone(),
+            // FIXME: this is a lie, but the value is not used yet in decoding, so we get away with it..
+            0xFF,
+            "".to_owned(),
+            0.0,
+            0,
+            crate::base_types::ColourMode::Single,
+            crate::base_types::Gain::HGM,
+            Some(crate::base_types::MQ1A {
+                timestamp_ext: "".to_owned(),
+                acquisition_time_shutter_ns: 0,
+                counter_depth,
+            }),
+        )
+    }
+
+    #[test]
+    fn test_decoder_single_chip() {
+        let bytes_per_frame = 256 * 256 * 2;
+        let decoder = QdDecoder::default();
+        let frame_meta = make_test_frame_meta(&DType::R64, &Layout::L1x1, 12, bytes_per_frame);
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 1, bytes_per_frame);
+
+        let input_data: Vec<u16> = (0..256 * 256).map(|i| (i % 0xFFFF) as u16).collect();
+
+        fs.write_frame(&frame_meta, |buf| {
+            assert_eq!(buf.len(), bytes_per_frame);
+            R12::encode_all(&mut input_data.iter(), buf).unwrap();
+            Ok::<_, ()>(())
+        })
+        .unwrap();
+        let fsh = fs.writing_done(&mut shm).unwrap();
+
+        let mut decoded = Array3::from_shape_simple_fn([1, 256, 256], || 0u16);
+
+        decoder
+            .decode(&shm, &fsh, &mut decoded.view_mut(), 0, 1)
+            .unwrap();
+
+        assert_eq!(input_data, decoded.as_slice().unwrap());
+    }
+
+    #[test]
+    fn test_decoder_quad_l2x2() {
+        let bytes_per_frame = 512 * 512 * 2;
+        let decoder = QdDecoder::default();
+        let frame_meta = make_test_frame_meta(&DType::R64, &Layout::L2x2, 12, bytes_per_frame);
+
+        let (_socket_dir, socket_as_path) = get_socket_path();
+
+        let slot_size = bytes_per_frame;
+        let mut shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+        let slot = shm.get_mut().expect("get a free shm slot");
+        let mut fs = FrameStackForWriting::new(slot, 1, bytes_per_frame);
+
+        let input_data: Vec<u16> = (0..512 * 512).map(|i| (i % 0xFFFF) as u16).collect();
+
+        fs.write_frame(&frame_meta, |buf| {
+            assert_eq!(buf.len(), bytes_per_frame);
+            R12::encode_2x2_raw(&input_data, buf).unwrap();
+            Ok::<_, ()>(())
+        })
+        .unwrap();
+        let fsh = fs.writing_done(&mut shm).unwrap();
+
+        let mut decoded = Array3::from_shape_simple_fn([1, 512, 512], || 0u16);
+
+        decoder
+            .decode(&shm, &fsh, &mut decoded.view_mut(), 0, 1)
+            .unwrap();
+
+        assert_eq!(input_data, decoded.as_slice().unwrap());
+    }
+
+    #[test]
+    fn test_decoder_quad_unsupported_variants() {
+        fn _inner(layout: &Layout) {
+            let bytes_per_frame = 512 * 512 * 2;
+            let decoder = QdDecoder::default();
+            let frame_meta = make_test_frame_meta(&DType::R64, layout, 12, bytes_per_frame);
+
+            let (_socket_dir, socket_as_path) = get_socket_path();
+
+            let slot_size = bytes_per_frame;
+            let mut shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+            let slot = shm.get_mut().expect("get a free shm slot");
+            let mut fs = FrameStackForWriting::new(slot, 1, bytes_per_frame);
+
+            let input_data: Vec<u16> = (0..512 * 512).map(|i| (i % 0xFFFF) as u16).collect();
+
+            fs.write_frame(&frame_meta, |buf| {
+                assert_eq!(buf.len(), bytes_per_frame);
+                R12::encode_2x2_raw(&input_data, buf).unwrap();
+                Ok::<_, ()>(())
+            })
+            .unwrap();
+            let fsh = fs.writing_done(&mut shm).unwrap();
+
+            let mut decoded = Array3::from_shape_simple_fn([1, 512, 512], || 0u16);
+
+            assert!(matches!(
+                decoder.decode(&shm, &fsh, &mut decoded.view_mut(), 0, 1),
+                Err(DecoderError::FrameDecodeFailed { msg: _ })
+            ))
+        }
+        _inner(&Layout::LNx1);
+        _inner(&Layout::LNx1G);
+    }
+
+    #[test]
+    fn test_decoder_eels_like() {
+        fn _inner(layout: &Layout) {
+            let bytes_per_frame = 1024 * 256 * 2;
+            let decoder = QdDecoder::default();
+            let frame_meta = make_test_frame_meta(&DType::U16, layout, 12, bytes_per_frame);
+
+            let (_socket_dir, socket_as_path) = get_socket_path();
+
+            let slot_size = bytes_per_frame;
+            let mut shm = SharedSlabAllocator::new(1, slot_size, false, &socket_as_path).unwrap();
+            let slot = shm.get_mut().expect("get a free shm slot");
+            let mut fs = FrameStackForWriting::new(slot, 1, bytes_per_frame);
+
+            let input_data: Vec<u16> = (0..1024 * 256).map(|i| (i % 0xFFFF) as u16).collect();
+
+            fs.write_frame(&frame_meta, |buf| {
+                assert_eq!(buf.len(), bytes_per_frame);
+
+                for (i, o) in input_data.iter().zip(buf.chunks_exact_mut(2)) {
+                    o.copy_from_slice(&i.to_be_bytes());
+                }
+
+                Ok::<_, ()>(())
+            })
+            .unwrap();
+            let fsh = fs.writing_done(&mut shm).unwrap();
+
+            let mut decoded = Array3::from_shape_simple_fn([1, 1024, 256], || 0u16);
+            decoder
+                .decode(&shm, &fsh, &mut decoded.view_mut(), 0, 1)
+                .unwrap();
+            assert_eq!(input_data, decoded.as_slice().unwrap());
+        }
+        _inner(&Layout::LNx1);
+        _inner(&Layout::LNx1G);
+    }
+}
diff --git a/libertem_qd_mpx/src/lib.rs b/libertem_qd_mpx/src/lib.rs
new file mode 100644
index 00000000..e2c37620
--- /dev/null
+++ b/libertem_qd_mpx/src/lib.rs
@@ -0,0 +1,4 @@
+pub mod background_thread;
+pub mod base_types;
+pub mod decoder;
+pub mod main_py;
diff --git a/libertem_qd_mpx/src/main_py.rs b/libertem_qd_mpx/src/main_py.rs
new file mode 100644
index 00000000..4f411f1d
--- /dev/null
+++ b/libertem_qd_mpx/src/main_py.rs
@@ -0,0 +1,182 @@
+use std::{str::FromStr, time::Duration};
+
+use common::generic_connection::GenericConnection;
+use numpy::PyUntypedArray;
+use pyo3::{
+    exceptions::PyValueError, pyclass, pymethods, pymodule, types::PyModule, Bound, PyResult,
+    Python,
+};
+
+use common::{impl_py_cam_client, impl_py_connection};
+
+use crate::base_types::{QdAcquisitionConfig, QdDetectorConnConfig, QdFrameMeta, RecoveryStrategy};
+use crate::decoder::QdDecoder;
+use crate::{background_thread::QdBackgroundThread, base_types::QdAcquisitionHeader};
+
+#[pymodule]
+fn libertem_qd_mpx(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
+    m.add_class::<QdConnection>()?;
+    m.add_class::<QdFrameStack>()?;
+    m.add_class::<CamClient>()?;
+    m.add_class::<QdAcquisitionHeader>()?;
+    m.add_class::<QdAcquisitionConfig>()?;
+
+    let env = env_logger::Env::default()
+        .filter_or("LIBERTEM_QD_LOG_LEVEL", "error")
+        .write_style_or("LIBERTEM_QD_LOG_STYLE", "always");
+    env_logger::Builder::from_env(env)
+        .format_timestamp_micros()
+        .init();
+
+    Ok(())
+}
+
+impl_py_connection!(
+    _PyQdConnection,
+    QdFrameStack,
+    QdFrameMeta,
+    QdBackgroundThread,
+    QdAcquisitionConfig,
+    libertem_qd_mpx
+);
+
+#[pyclass]
+struct QdConnection {
+    conn: _PyQdConnection,
+}
+
+#[pymethods]
+impl QdConnection {
+    #[new]
+    #[allow(clippy::too_many_arguments)]
+    fn new(
+        data_host: &str,
+        data_port: usize,
+        frame_stack_size: usize,
+        shm_handle_path: &str,
+        drain: Option<bool>,
+        num_slots: Option<usize>,
+        bytes_per_frame: Option<usize>,
+        huge: Option<bool>,
+        recovery_strategy: Option<&str>,
+    ) -> PyResult<Self> {
+        // NOTE: these values don't have to be exact and are mostly important
+        // for performance tuning
+        let num_slots = num_slots.unwrap_or(2000);
+        let bytes_per_frame = bytes_per_frame.unwrap_or(256 * 256 * 2);
+
+        let drain = if drain.unwrap_or(false) {
+            Some(Duration::from_millis(100))
+        } else {
+            None
+        };
+
+        let recovery_strategy = recovery_strategy
+            .map_or_else(
+                || Ok(RecoveryStrategy::default()),
+                RecoveryStrategy::from_str,
+            )
+            .map_err(|e| PyValueError::new_err(e.to_string()))?;
+
+        let config = QdDetectorConnConfig::new(
+            data_host,
+            data_port,
+            frame_stack_size,
+            bytes_per_frame,
+            num_slots,
+            huge.unwrap_or(false),
+            shm_handle_path,
+            drain,
+            recovery_strategy,
+        );
+
+        let shm =
+            GenericConnection::<QdBackgroundThread, QdAcquisitionConfig>::shm_from_config(&config)
+                .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+
+        let bg_thread = QdBackgroundThread::spawn(&config, &shm)
+            .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+        let generic_conn =
+            GenericConnection::<QdBackgroundThread, QdAcquisitionConfig>::new(bg_thread, &shm)
+                .map_err(|e| PyConnectionError::new_err(e.to_string()))?;
+
+        let conn = _PyQdConnection::new(shm, generic_conn);
+
+        Ok(QdConnection { conn })
+    }
+
+    fn wait_for_arm(
+        &mut self,
+        timeout: Option<f32>,
+        py: Python<'_>,
+    ) -> PyResult<Option<QdAcquisitionConfig>> {
+        self.conn.wait_for_arm(timeout, py)
+    }
+
+    fn get_socket_path(&self) -> PyResult<String> {
+        self.conn.get_socket_path()
+    }
+
+    fn is_running(&self) -> PyResult<bool> {
+        self.conn.is_running()
+    }
+
+    fn start_passive(&mut self, timeout: Option<f32>, py: Python<'_>) -> PyResult<()> {
+        self.conn.start_passive(timeout, py)
+    }
+
+    fn close(&mut self) -> PyResult<()> {
+        self.conn.close()
+    }
+
+    fn get_next_stack(
+        &mut self,
+        max_size: usize,
+        py: Python<'_>,
+    ) -> PyResult<Option<QdFrameStack>> {
+        self.conn.get_next_stack(max_size, py)
+    }
+}
+
+impl_py_cam_client!(
+    _PyQdCamClient,
+    QdDecoder,
+    QdFrameStack,
+    QdFrameMeta,
+    libertem_qd_mpx
+);
+
+#[pyclass]
+struct CamClient {
+    inner: _PyQdCamClient,
+}
+
+#[pymethods]
+impl CamClient {
+    #[new]
+    fn new(handle_path: &str) -> PyResult<Self> {
+        Ok(Self {
+            inner: _PyQdCamClient::new(handle_path)?,
+        })
+    }
+
+    fn decode_range_into_buffer<'py>(
+        &self,
+        input: &QdFrameStack,
+        out: &Bound<'py, PyUntypedArray>,
+        start_idx: usize,
+        end_idx: usize,
+        py: Python<'py>,
+    ) -> PyResult<()> {
+        self.inner
+            .decode_range_into_buffer(input, out, start_idx, end_idx, py)
+    }
+
+    fn done(&mut self, handle: &mut QdFrameStack) -> PyResult<()> {
+        self.inner.frame_stack_done(handle)
+    }
+
+    fn close(&mut self) -> PyResult<()> {
+        self.inner.close()
+    }
+}
diff --git a/playegui/src/app.rs b/playegui/src/app.rs
index 7301c076..57ce9203 100644
--- a/playegui/src/app.rs
+++ b/playegui/src/app.rs
@@ -294,7 +294,7 @@ impl TemplateApp {
             .iter()
             .map(|result_id| (result_id.udf_name.clone(), result_id.channel_name.clone()))
             .collect::<HashSet<_>>();
-        let mut pairs = Vec::from_iter(pairs.into_iter());
+        let mut pairs = Vec::from_iter(pairs);
         pairs.sort();
 
         pairs
diff --git a/playegui/src/background.rs b/playegui/src/background.rs
index 4be52e9a..d1378857 100644
--- a/playegui/src/background.rs
+++ b/playegui/src/background.rs
@@ -28,7 +28,7 @@ pub fn decompress_into<T>(
 ) -> Option<()> {
     let out_ptr = dest.as_mut_ptr();
     let out_size = out_size[0] * out_size[1];
-    match bs_sys::decompress_lz4_into(data, out_ptr, out_size, None) {
+    match unsafe { bs_sys::decompress_lz4_into(data, out_ptr, out_size, None) } {
         Ok(_) => Some(()),
         Err(e) => {
             error!("decompression failed: {e:?}");
@@ -48,7 +48,6 @@ pub struct ChannelResult {
     udf_name: String,
 }
 
-///
 #[derive(Debug, Clone)]
 pub struct AcquisitionData {
     channel_results: HashMap<(String, String), ChannelResult>,
@@ -341,7 +340,8 @@ pub fn background_thread(
 
         // some stuff that needs to be moved to the bg thread:
         let recv_stop_event_bg = Arc::clone(&recv_stop_event);
-        let recv_control_channel = control_channel;
+        let recv_control_channel = control_channel.clone();
+        let inner_status = Arc::clone(&status);
 
         std::thread::Builder::new()
             .name("receiver".to_string())
@@ -350,7 +350,7 @@ pub fn background_thread(
                     recv_control_channel,
                     part_sender,
                     recv_stop_event_bg,
-                    status,
+                    inner_status,
                 );
             })
             .unwrap();
@@ -368,7 +368,13 @@ pub fn background_thread(
 
             let num_pending = bg_state.pending_messages.len();
             // FIXME: reconnect on errors here?
-            let process_results = bg_state.process_pending().unwrap();
+            let process_results = match bg_state.process_pending() {
+                Ok(res) => res,
+                Err(e) => {
+                    error!("error processing pending messages: {:?} reloading...", e);
+                    break;
+                }
+            };
 
             // for each result and each channel, send an `UpdatedData`
             process_results.processed.par_iter().for_each(|data| {
diff --git a/playegui/src/messages.rs b/playegui/src/messages.rs
index 9f482f6b..0bcf521e 100644
--- a/playegui/src/messages.rs
+++ b/playegui/src/messages.rs
@@ -145,7 +145,7 @@ pub enum CommError {
     /// An unexpected message was received which we don't know how to handle in this state
     UnknownMessageError,
 
-    ///
+    /// An unknown event was received
     UnknownEventError { event: String },
 
     /// An error occured while deserializing json data
diff --git a/playegui/src/receiver.rs b/playegui/src/receiver.rs
index 64d82b58..b37c4874 100644
--- a/playegui/src/receiver.rs
+++ b/playegui/src/receiver.rs
@@ -1,5 +1,4 @@
 use std::{
-    fmt::Debug,
     sync::{
         atomic::{AtomicBool, Ordering},
         Arc, Mutex,
@@ -16,7 +15,7 @@ use url::Url;
 
 use crate::{
     app::ConnectionStatus,
-    messages::{CommError, ControlMessage, MessagePart, UpdateParams, UpdateParamsInner},
+    messages::{CommError, ControlMessage, MessagePart, UpdateParams},
 };
 
 fn message_part_from_msg(
diff --git a/serval-client/Cargo.toml b/serval-client/Cargo.toml
index 813c9c02..8e7b4096 100644
--- a/serval-client/Cargo.toml
+++ b/serval-client/Cargo.toml
@@ -1,5 +1,7 @@
 [package]
 name = "serval-client"
+authors = ["Alexander Clausen <a.clausen@fz-juelich.de>"]
+license = "MIT"
 version = "0.1.0"
 edition = "2021"
 
diff --git a/serval-client/src/lib.rs b/serval-client/src/lib.rs
index 006b764c..321a4e98 100644
--- a/serval-client/src/lib.rs
+++ b/serval-client/src/lib.rs
@@ -297,14 +297,4 @@ impl ServalClient {
 }
 
 #[cfg(test)]
-mod test {
-    use crate::ServalClient;
-
-    #[test]
-    fn test_stuff() {
-        let client = ServalClient::new("http://localhost:8080");
-
-        println!("{:?}", client.get_detector_config().unwrap());
-        panic!("at the disco?");
-    }
-}
+mod test {}


Test patch:
diff --git a/ipc_test/Cargo.toml b/ipc_test/Cargo.toml
index 4df8ad71..70a92298 100644
--- a/ipc_test/Cargo.toml
+++ b/ipc_test/Cargo.toml
@@ -10,6 +10,7 @@ rust-version = "1.66"
 
 [dependencies]
 anyhow = "1.0.66"
+thiserror = "1.0.59"
 bincode = "1.3.3"
 bytemuck = "1.12.3"
 clap = { version = "4.0.29", features = ["derive"] }
diff --git a/ipc_test/src/backend_memfd.rs b/ipc_test/src/backend_memfd.rs
index 5eb6bb7d..a645b070 100644
--- a/ipc_test/src/backend_memfd.rs
+++ b/ipc_test/src/backend_memfd.rs
@@ -1,4 +1,4 @@
-///! Raw memory backend using memfd with huge page support
+//! Raw memory backend using memfd with huge page support
 // TODO:
 // #![forbid(clippy::unwrap_used)]
 use std::{
diff --git a/ipc_test/src/slab.rs b/ipc_test/src/slab.rs
index 713cfa27..75c68e53 100644
--- a/ipc_test/src/slab.rs
+++ b/ipc_test/src/slab.rs
@@ -6,7 +6,6 @@ use std::{
     time::Duration,
 };
 
-use anyhow::Result;
 use crossbeam::channel::{bounded, Sender};
 use raw_sync::locks::{LockImpl, LockInit, Mutex};
 use serde::{Deserialize, Serialize};
@@ -70,6 +69,12 @@ pub struct SHMHandle {
 
 impl SHMHandle {}
 
+#[derive(Debug, thiserror::Error)]
+pub enum ShmError {
+    #[error("no slot available")]
+    NoSlotAvailable,
+}
+
 /// Additional information needed to re-crate a `SharedSlabAllocator` in a
 /// different process
 #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
@@ -94,6 +99,9 @@ pub struct SharedSlabAllocator {
     bg_thread: Option<(JoinHandle<()>, Sender<()>)>,
 }
 
+#[derive(thiserror::Error, Debug)]
+pub enum SlabInitError {}
+
 ///
 /// Single-producer multiple consumer communication via shared memory
 ///
@@ -140,7 +148,7 @@ impl SharedSlabAllocator {
         slot_size: usize,
         huge_pages: bool,
         shm_path: &Path,
-    ) -> Result<Self> {
+    ) -> Result<Self, SlabInitError> {
         let free_list_size = std::mem::size_of::<usize>() * (1 + num_slots);
         let slot_sync_size = Self::SYNC_SLOT_SIZE * num_slots;
         let overhead: usize = align_to(
@@ -169,7 +177,7 @@ impl SharedSlabAllocator {
         shm: Shm,
         slab_info: SlabInfo,
         init_structures: bool,
-    ) -> Result<Self> {
+    ) -> Result<Self, SlabInitError> {
         let ptr = shm.as_mut_ptr();
         let free_list_ptr = unsafe { ptr.offset(Self::MUTEX_SIZE.try_into().unwrap()) };
 
@@ -194,6 +202,7 @@ impl SharedSlabAllocator {
             let (cleanup_chan_s, cleanup_chan_r) = bounded::<()>(0);
 
             // crimes to ship the pointers to the bg thread:
+            #[derive(Clone)]
             struct B {
                 mutex_ptr: *mut u8,
                 data_ptr: *mut u8,
@@ -204,7 +213,7 @@ impl SharedSlabAllocator {
                 data_ptr: free_list_ptr,
             };
             let j = std::thread::spawn(move || {
-                let b = b;
+                let b = b.clone();
                 let _mtx = unsafe { Mutex::from_existing(b.mutex_ptr, b.data_ptr) };
                 // we are done initializing...
                 init_chan_s.send(()).unwrap();
@@ -237,12 +246,12 @@ impl SharedSlabAllocator {
         })
     }
 
-    pub fn connect(handle_path: &str) -> Result<Self> {
+    pub fn connect(handle_path: &str) -> Result<Self, SlabInitError> {
         let (shm, slab_info): (_, SlabInfo) = Shm::connect(handle_path);
         Self::from_shm_and_slab_info(shm, slab_info, false)
     }
 
-    pub fn clone_and_connect(&self) -> Result<Self> {
+    pub fn clone_and_connect(&self) -> Result<Self, SlabInitError> {
         let handle = self.get_handle();
         Self::connect(&handle.os_handle)
     }
@@ -287,6 +296,10 @@ impl SharedSlabAllocator {
         })
     }
 
+    pub fn try_get_mut(&mut self) -> Result<SlotForWriting, ShmError> {
+        self.get_mut().ok_or(ShmError::NoSlotAvailable)
+    }
+
     /// Exchange the `SlotForWriting` token into
     /// a `SlotInfo` that can be sent to readers
     /// which can not be used to write anymore