diff --git a/src/calng/CalibrationManager.py b/src/calng/CalibrationManager.py
index bd519b395bdd93340064383b8214f31e0978d16e..59f485f0d7425f47475dfbea27cd2f913756e8e4 100644
--- a/src/calng/CalibrationManager.py
+++ b/src/calng/CalibrationManager.py
@@ -1194,78 +1194,24 @@ class CalibrationManager(DeviceClientBase, Device):
 
                     background(_activate_bridge(bridge_device_id))
 
-        # Instantiate preview layer matchers and assemblers.
+        # Instantiate preview assemblers.
+        geometry_device_id = self.geometryDevice.value
         for layer, output_pipeline, server in self.previewLayers.value:
-            # Preview matcher.
-            matcher_device_id = device_id_templates['previewMatcher'].format(
+            assembler_device_id = device_id_templates['assembler'].format(
                 layer=layer)
 
             config = Hash()
-            config['channels'] = [
-                f'{device_id}:{output_pipeline}'
-                for device_id in correct_device_id_by_module.values()]
             config['fastSources'] = [
                 Hash('fsSelect', True,
                      'fsSource',
                      f'{input_source_by_module[virtual_id]}')
                 for (virtual_id, device_id)
                 in correct_device_id_by_module.items()]
-            config['pathToStack'] = 'data.adc'
-
-            if not await self._instantiate_device(
-                server, class_ids['previewMatcher'], matcher_device_id, config
-            ):
-                return
-
-            # Preview assembler.
-            assembler_device_id = device_id_templates['assembler'].format(
-                layer=layer)
-
-            config = Hash()
-            config['input.connectedOutputChannels'] = [
-                f'{matcher_device_id}:output']
-            config['modules'] = [
-                Hash('source', input_source_by_module.get('Q1M1', ''),
-                     'offX', 474, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q1M2', ''),
-                     'offX', 316, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q1M3', ''),
-                     'offX', 158, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q1M4', ''),
-                     'offX', 0, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q2M1', ''),
-                     'offX', 1136, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q2M2', ''),
-                     'offX', 978, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q2M3', ''),
-                     'offX', 820, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q2M4', ''),
-                     'offX', 662, 'offY', 612, 'rot', 90),
-                Hash('source', input_source_by_module.get('Q3M1', ''),
-                     'offX', 712, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q3M2', ''),
-                     'offX', 870, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q3M3', ''),
-                     'offX', 1028, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q3M4', ''),
-                     'offX', 1186, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q4M1', ''),
-                     'offX', 50, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q4M2', ''),
-                     'offX', 208, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q4M3', ''),
-                     'offX', 366, 'offY', 0, 'rot', 270),
-                Hash('source', input_source_by_module.get('Q4M4', ''),
-                     'offX', 524, 'offY', 0, 'rot', 270),
-            ]
-            config['pathsToCombine'] = ['data.adc']
-            config['trainIdPath'] = 'image.trainId'
-            config['pulseIdPath'] = 'image.pulseId'
-            config['preview.enablePreview'] = True
-            config['preview.pathToPreview'] = 'data.adc'
-            config['preview.downSample'] = 2
-            config['badpixelPath'] = 'image.bad_pixels'
-            config['rotated90Grad'] = True
+            config['channels'] = [
+                f'{device_id}:{output_pipeline}'
+                for device_id in correct_device_id_by_module.values()]
+            config['geometryInput.connectedOutputChannels'] = [
+                f'{geometry_device_id}:geometryOutput']
 
             if not await self._instantiate_device(
                 server, class_ids['assembler'], assembler_device_id, config
diff --git a/src/calng/ManualAgipdGeometry.py b/src/calng/ManualAgipdGeometry.py
new file mode 100644
index 0000000000000000000000000000000000000000..b01e96bc43bfad767fc02690156244768a77ab10
--- /dev/null
+++ b/src/calng/ManualAgipdGeometry.py
@@ -0,0 +1,26 @@
+import extra_geom
+from karabo.bound import KARABO_CLASSINFO
+
+from ._version import version as deviceVersion
+from .manual_geometry_base import ManualGeometryBase
+
+
+@KARABO_CLASSINFO("ManualAgipdGeometry", deviceVersion)
+class ManualAgipdGeometry(ManualGeometryBase):
+    geometry_class = extra_geom.AGIPD_1MGeometry
+
+    @staticmethod
+    def expectedParameters(expected):
+        super(ManualAgipdGeometry, ManualAgipdGeometry).expectedParameters(expected)
+
+        expected.setDefaultValue("quadrantCorners.Q1.x", -525)
+        expected.setDefaultValue("quadrantCorners.Q1.y", 625)
+
+        expected.setDefaultValue("quadrantCorners.Q2.x", -550)
+        expected.setDefaultValue("quadrantCorners.Q2.y", -10)
+
+        expected.setDefaultValue("quadrantCorners.Q3.x", 520)
+        expected.setDefaultValue("quadrantCorners.Q3.y", -160)
+
+        expected.setDefaultValue("quadrantCorners.Q4.x", 542.5)
+        expected.setDefaultValue("quadrantCorners.Q4.y", 475)
diff --git a/src/calng/ManualDsscGeometry.py b/src/calng/ManualDsscGeometry.py
new file mode 100644
index 0000000000000000000000000000000000000000..27f951d126e42fad23c252e44453d18df5ff8ff6
--- /dev/null
+++ b/src/calng/ManualDsscGeometry.py
@@ -0,0 +1,26 @@
+import extra_geom
+from karabo.bound import KARABO_CLASSINFO
+
+from ._version import version as deviceVersion
+from .manual_geometry_base import ManualGeometryBase
+
+
+@KARABO_CLASSINFO("ManualDsscGeometry", deviceVersion)
+class ManualDsscGeometry(ManualGeometryBase):
+    geometry_class = extra_geom.DSSC_1MGeometry
+
+    @staticmethod
+    def expectedParameters(expected):
+        super(ManualDsscGeometry, ManualDsscGeometry).expectedParameters(expected)
+
+        expected.setDefaultValue("quadrantCorners.Q1.x", -130)
+        expected.setDefaultValue("quadrantCorners.Q1.y", 5)
+
+        expected.setDefaultValue("quadrantCorners.Q2.x", -130)
+        expected.setDefaultValue("quadrantCorners.Q2.y", -125)
+
+        expected.setDefaultValue("quadrantCorners.Q3.x", 5)
+        expected.setDefaultValue("quadrantCorners.Q3.y", -125)
+
+        expected.setDefaultValue("quadrantCorners.Q4.x", 5)
+        expected.setDefaultValue("quadrantCorners.Q4.y", 5)
diff --git a/src/calng/SimpleAssembler.py b/src/calng/SimpleAssembler.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f7d752326624f4da283bbc43db189476a01895d
--- /dev/null
+++ b/src/calng/SimpleAssembler.py
@@ -0,0 +1,186 @@
+import functools
+import pickle
+import re
+
+import numpy as np
+from karabo.bound import (
+    FLOAT_ELEMENT,
+    IMAGEDATA_ELEMENT,
+    INPUT_CHANNEL,
+    KARABO_CLASSINFO,
+    OUTPUT_CHANNEL,
+    OVERWRITE_ELEMENT,
+    STRING_ELEMENT,
+    UINT64_ELEMENT,
+    ChannelMetaData,
+    Dims,
+    Encoding,
+    Epochstamp,
+    Hash,
+    ImageData,
+    MetricPrefix,
+    Schema,
+    Timestamp,
+    Trainstamp,
+    Unit,
+)
+from karabo.common.api import KARABO_SCHEMA_DISPLAY_TYPE_SCENES as DT_SCENES
+from TrainMatcher import TrainMatcher, scenes as trainmatcher_scenes
+
+from . import scenes
+from ._version import version as deviceVersion
+from calng import utils
+
+
+preview_schema = Schema()
+(
+    IMAGEDATA_ELEMENT(preview_schema).key("image").commit(),
+    UINT64_ELEMENT(preview_schema).key("trainId").readOnly().commit(),
+)
+
+xtdf_source_re = re.compile(r".*\/DET\/(\d+)CH0:xtdf")
+
+# TODO: merge scene with TrainMatcher's nice overview
+@KARABO_CLASSINFO("SimpleAssembler", deviceVersion)
+class SimpleAssembler(TrainMatcher.TrainMatcher):
+    @staticmethod
+    def expectedParameters(expected):
+        (
+            OVERWRITE_ELEMENT(expected)
+            .key("availableScenes")
+            # TODO: add assemblerOverview scene
+            .setNewDefaultValue(["scene", "assemblerOverview"])
+            .commit(),
+            FLOAT_ELEMENT(expected)
+            .key("processingTime")
+            .unit(Unit.SECOND)
+            .metricPrefix(MetricPrefix.MILLI)
+            .readOnly()
+            .initialValue(0)
+            .warnHigh(500)
+            .info("Cannot keep up with GUI limit")
+            .needsAcknowledging(False)
+            .commit(),
+            FLOAT_ELEMENT(expected)
+            .key("timeOfFlight")
+            .unit(Unit.SECOND)
+            .metricPrefix(MetricPrefix.MILLI)
+            .readOnly()
+            .initialValue(0)
+            .warnHigh(1000)
+            .info("Time of flight exceeding 1 s")
+            .needsAcknowledging(False)
+            .commit(),
+            STRING_ELEMENT(expected)
+            .key("pathToStack")
+            .assignmentOptional()
+            .defaultValue("image.data")
+            .commit(),
+            INPUT_CHANNEL(expected)
+            .key("geometryInput")
+            .displayedName("Geometry input")
+            .commit(),
+            OUTPUT_CHANNEL(expected)  # can OVERWRITE_ELEMENT even do this?
+            .key("output")
+            .dataSchema(preview_schema)
+            .commit(),
+        )
+
+    def initialization(self):
+        super().initialization()
+
+        # TODO: match inside device, fill multiple independent buffers
+
+        self._path_to_stack = self.get("pathToStack")
+        self.geometry = None
+        self.input_buffer = None
+
+        self.KARABO_ON_DATA("geometryInput", self.receive_geometry)
+        self.KARABO_SLOT(self.requestScene)
+
+        self.start()
+
+    def requestScene(self, params):
+        # TODO: unify with TrainMatcher overview
+        scene_name = params.get("name", default="")
+        if scene_name == "assemblerOverview":
+            payload = Hash("name", scene_name, "success", True)
+            payload["data"] = scenes.simple_assembler_overview(
+                device_id=self.getInstanceId(),
+                geometry_device_id=self.get("geometryInput.connectedOutputChannels")[
+                    0
+                ].split(":")[0],
+            )
+            self.reply(
+                Hash(
+                    "type",
+                    "deviceScene",
+                    "origin",
+                    self.getInstanceId(),
+                    "payload",
+                    payload,
+                )
+            )
+        else:
+            return super().requestScene(params)
+
+    def receive_geometry(self, data, metadata):
+        self.log.INFO("Received a new geometry")
+        self.geometry = pickle.loads(data.get("pickledGeometry"))
+        self.input_buffer = np.zeros(self.geometry.expected_data_shape)
+
+    def _send(self, train_id, sources):
+        # TODO: adapt to appropriate hook for new TrainMatcher (no _send)
+        if self.geometry is None:
+            self.log.WARN("Have not received a geometry yet")
+            return
+
+        timestamp = Timestamp(Epochstamp(), Trainstamp(train_id))
+
+        module_indices_unfilled = set(range(self.input_buffer.shape[0]))
+        for source, (data, metadata) in sources.items():
+            # TODO: handle failure to "parse" source, get data out
+            module_index = self._source_to_index(source)
+            self.input_buffer[module_index] = np.squeeze(data.get(self._path_to_stack))
+            module_indices_unfilled.discard(module_index)
+
+        for unfilled_module in module_indices_unfilled:
+            self.input_buffer[module_index].fill(0)
+            # TODO: configurable treatment of missing modules
+
+        # TODO: reusable output buffer to save on allocation
+        assembled, _ = self.geometry.position_modules_fast(self.input_buffer)
+
+        # TODO: optionally include control data
+        out_hash = Hash(
+            "image",
+            ImageData(
+                # TODO: get around this being mirrored...
+                (assembled[::-1, ::-1]).astype(np.int32),
+                Dims(*assembled.shape),
+                Encoding.GRAY,
+            ),
+            "trainId",
+            train_id,
+        )
+        channel = self.signalSlotable.getOutputChannel("output")
+        channel.write(out_hash, ChannelMetaData(self.getInstanceId(), timestamp))
+        channel.update()
+        self.rate_out.update()
+
+    def _update_rate(self):
+        self._buffered_status_update.set(
+            "processingTime", self._processing_time_ema.get()
+        )
+        self._buffered_status_update.set("timeOfFlight", self._time_of_flight_ema.get())
+        self._buffered_status_update.set("rate", self._rate_tracker.get())
+        self.set(self._buffered_status_update)
+
+    @functools.lru_cache
+    def _source_to_index(self, source):
+        # note: cache means warning only shows up once (also not performance-critical)
+        match = xtdf_source_re.match(source)
+        if match is None:
+            self.log.WARN(f"Couldn't figure out index for source {source}")
+            return 0
+        return int(match.group(1))
diff --git a/src/calng/manual_geometry_base.py b/src/calng/manual_geometry_base.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7629f3a390c7a1cdea969ac5e681993f8f9f479
--- /dev/null
+++ b/src/calng/manual_geometry_base.py
@@ -0,0 +1,161 @@
+import pickle
+
+from karabo.bound import (
+    DOUBLE_ELEMENT,
+    KARABO_CLASSINFO,
+    IMAGEDATA_ELEMENT,
+    NODE_ELEMENT,
+    OUTPUT_CHANNEL,
+    SLOT_ELEMENT,
+    VECTOR_CHAR_ELEMENT,
+    VECTOR_STRING_ELEMENT,
+    Encoding,
+    Hash,
+    ImageData,
+    PythonDevice,
+    Schema,
+    State,
+)
+from karabo.common.api import KARABO_SCHEMA_DISPLAY_TYPE_SCENES as DT_SCENES
+import numpy as np
+from matplotlib.backends.backend_agg import FigureCanvasAgg
+import matplotlib.pyplot as plt
+
+from . import scenes
+from ._version import version as deviceVersion
+
+
+geometry_schema = Schema()
+(
+    VECTOR_CHAR_ELEMENT(geometry_schema)
+    .key("pickledGeometry")
+    .displayedName("Pickled geometry")
+    .assignmentOptional()
+    .defaultValue([])
+    .commit()
+)
+
+preview_schema = Schema()
+(IMAGEDATA_ELEMENT(preview_schema).key("overview").commit())
+
+
+@KARABO_CLASSINFO("ManualGeometryBase", deviceVersion)
+class ManualGeometryBase(PythonDevice):
+    @staticmethod
+    def expectedParameters(expected):
+        # "mandatory" for geometry serving device
+        (
+            OUTPUT_CHANNEL(expected)
+            .key("geometryOutput")
+            .dataSchema(geometry_schema)
+            .commit(),
+            SLOT_ELEMENT(expected).key("pleaseSendYourGeometry").commit(),
+            SLOT_ELEMENT(expected).key("reloadScenes").commit(),
+            OUTPUT_CHANNEL(expected)
+            .key("previewOutput")
+            .dataSchema(preview_schema)
+            .commit(),
+            IMAGEDATA_ELEMENT(expected).key("layoutPreview").commit(),
+        )
+
+        # configuring this one manually
+        # subclasses should set better defaults
+        (NODE_ELEMENT(expected).key("quadrantCorners").commit(),)
+        for q in range(1, 5):
+            (
+                NODE_ELEMENT(expected).key(f"quadrantCorners.Q{q}").commit(),
+                DOUBLE_ELEMENT(expected)
+                .key(f"quadrantCorners.Q{q}.x")
+                .assignmentOptional()
+                .defaultValue(0)
+                .reconfigurable()
+                .commit(),
+                DOUBLE_ELEMENT(expected)
+                .key(f"quadrantCorners.Q{q}.y")
+                .assignmentOptional()
+                .defaultValue(0)
+                .reconfigurable()
+                .commit(),
+            )
+
+        # scenes are fun
+        # TODO: add the scene
+        (
+            VECTOR_STRING_ELEMENT(expected)
+            .key("availableScenes")
+            .setSpecialDisplayType(DT_SCENES)
+            .readOnly()
+            .initialValue(["overview"])
+            .commit(),
+        )
+
+    def __init__(self, config):
+        super().__init__(config)
+
+        self.KARABO_SLOT(self.pleaseSendYourGeometry)
+        self.KARABO_SLOT(self.requestScene)
+        self.KARABO_SLOT(self.reloadScenes)
+        self.update_geom()
+        plt.switch_backend("agg")
+        self.updateState(State.ON)
+
+    def requestScene(self, params):
+        payload = Hash()
+        scene_name = params.get("name", default="")
+        payload["name"] = scene_name
+        payload["success"] = True
+        if scene_name == "overview":
+            payload["data"] = scenes.manual_geometry_overview(
+                device_id=self.getInstanceId()
+            )
+        else:
+            payload["success"] = False
+        response = Hash()
+        response["type"] = "deviceScene"
+        response["origin"] = self.getInstanceId()
+        response["payload"] = payload
+        self.reply(response)
+
+    def update_geom(self):
+        self.quadrant_corners = tuple(
+            (self.get(f"quadrantCorners.Q{q}.x"), self.get(f"quadrantCorners.Q{q}.y"))
+            for q in range(1, 5)
+        )
+        self.geom = self.geometry_class.from_quad_positions(self.quadrant_corners)
+        self.pickled = pickle.dumps(self.geom)
+        # TODO: send to anyone who asks? make slot for that?
+        self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled))
+
+    def reloadScenes(self):
+        global scenes
+        import importlib
+
+        scenes = importlib.reload(scenes)
+
+    def pleaseSendYourGeometry(self):
+        self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled))
+        axis = self.geom.inspect()
+        axis.figure.tight_layout(pad=0)
+        axis.figure.set_facecolor("none")
+        # axis.figure.set_size_inches(6, 6)
+        # axis.figure.set_dpi(300)
+        canvas = FigureCanvasAgg(axis.figure)
+        canvas.draw()
+        image_buffer = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(
+            canvas.get_width_height()[::-1] + (4,)
+        )
+        self.set(
+            "layoutPreview",
+            ImageData(image_buffer, encoding=Encoding.RGBA, bitsPerPixel=3 * 8),
+        )
+        # self.writeChannel("previewOutput", Hash("overview", ImageData()))
+
+    def preReconfigure(self, config):
+        self._prereconfigure_update_hash = config
+
+    def postReconfigure(self):
+        if any(
+            path.startswith("quadrantCorners")
+            for path in self._prereconfigure_update_hash.getPaths()
+        ):
+            self.update_geom()