diff --git a/setup.py b/setup.py
index 01d3fadbe09d764a055f20c63593b829e7b1681b..21caef8e7f104d4eb63837f0d14f1590c684774e 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,9 @@ setup(name='calng',
           ],
 
           'karabo.middlelayer_device': [
-              'CalibrationManager = calng.CalibrationManager:CalibrationManager'
+              'CalibrationManager = calng.CalibrationManager:CalibrationManager',
+              'MdlAgipd1MGeometry = calng.mdl_geometry_base:MdlAgipd1MGeometry',
+              'MdlJungfrauGeometry = calng.mdl_geometry_base:MdlJungfrauGeometry',
           ],
       },
       package_data={'': ['kernels/*']},
diff --git a/src/calng/mdl_geometry_base.py b/src/calng/mdl_geometry_base.py
new file mode 100644
index 0000000000000000000000000000000000000000..c56125f733a6caee568811d437c6cd2fa9fbaf32
--- /dev/null
+++ b/src/calng/mdl_geometry_base.py
@@ -0,0 +1,313 @@
+import contextlib
+import logging
+import pickle
+
+import extra_geom
+from karabo.middlelayer import (
+    AccessMode,
+    Assignment,
+    Bool,
+    Configurable,
+    Device,
+    Double,
+    EncodingType,
+    Hash,
+    Image,
+    ImageData,
+    Int32,
+    Node,
+    OutputChannel,
+    Slot,
+    State,
+    String,
+    VectorChar,
+    VectorHash,
+)
+import matplotlib.pyplot as plt
+from matplotlib.backends.backend_agg import FigureCanvasAgg
+import numpy as np
+
+from ._version import version as deviceVersion
+
+
+class GeometrySchema(Configurable):
+    pickledGeometry = VectorChar(
+        displayedName="Pickled geometry",
+        assignment=Assignment.OPTIONAL,
+        defaultValue=[],
+    )
+
+
+class GeometryFileNode(Configurable):
+    filePath = String(
+        defaultValue="",
+        displayedName="File path",
+        description="Full path (including filename and suffix) to the desired geometry "
+        "file. Keep in mind that the default directory is $KARABO/var/data on device "
+        "server node, so it's probably wise to give absolute path.",
+        assignment=Assignment.OPTIONAL,
+        accessMode=AccessMode.RECONFIGURABLE,
+    )
+    updateManualOnLoad = Bool(
+        defaultValue=True,
+        displayedName="Update manual settings",
+        description="If this flag is on, the manual settings on this device will be "
+        "updated according to the loaded geometry file. This is useful when you want "
+        "to load a file and then tweak the geometry a bit.",
+        assignment=Assignment.OPTIONAL,
+        accessMode=AccessMode.RECONFIGURABLE,
+    )
+
+
+class ManualGeometryBase(Device):
+    __version__ = deviceVersion
+    geometry_class = None  # subclass must set
+    # subclass must add slot setManual
+
+    geometryPreview = Image(
+        ImageData(np.empty(0, dtype=np.uint32)),
+        displayedName="Geometry preview",
+        encoding=EncodingType.RGBA,
+    )
+
+    geometryOutput = OutputChannel(GeometrySchema)
+
+    geometryFile = Node(
+        GeometryFileNode,
+        displayedName="Geometry file",
+        description="Allows loading geometry from CrystFEL geometry file",
+    )
+
+    @Slot(
+        displayedName="Send geometry",
+        description="Send current geometry on output channel. This output channel is "
+        "typically used by assembler devices waiting for new geometries. Updating the "
+        "geometry will usually automatically cause update, but hit this slot in case "
+        "geometry is missing somewhere.",
+        allowedStates=[State.ACTIVE],
+    )
+    async def sendGeometry(self):
+        self.geometryOutput.schema.pickledGeometry = self.pickled_geometry
+        await self.geometryOutput.writeData()
+
+    @Slot(
+        displayedName="Update preview",
+        allowedStates=[State.ACTIVE],
+    )
+    async def updatePreview(self):
+        axis = self.geometry.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.geometryPreview = ImageData(
+            image_buffer, encoding=EncodingType.RGBA, bitsPerPixel=3 * 8
+        )
+        self._set_status("Preview updated")
+
+    @Slot(
+        displayedName="Load from file",
+        allowedStates=[State.ACTIVE],
+    )
+    async def loadFromFile(self):
+        with self.push_state(State.CHANGING):
+            geometry = None
+
+            file_path = self.geometryFile.filePath.value
+            self._set_status(f"Loading geometry from {file_path}...")
+
+            try:
+                geometry = self.geometry_class.from_crystfel_geom(file_path)
+            except FileNotFoundError:
+                self._set_status("Geometry file not found", level=logging.WARN)
+            except RuntimeError as e:
+                self._set_status(
+                    f"Failed to load geometry file: {e}", level=logging.WARN
+                )
+            except Exception as e:
+                self._set_status(
+                    f"Misc. exception when loading geometry file: {e}",
+                    level=logging.WARN,
+                )
+            else:
+                await self._set_geometry(geometry)
+                self._set_status("Successfully loaded geometry from file")
+                if self.geometryFile.updateManualOnLoad.value:
+                    self._set_status(
+                        "Updating manual settings on device to reflect loaded geometry"
+                    )
+                    self._update_manual_from_current()
+                return True
+
+        return False
+
+    def _update_manual_from_current(self):
+        # subclass should implement (neat when loading from CrystFEL geom)
+        raise NotImplementedError()
+
+    async def _set_geometry(self, geometry, update_preview=True, send=True):
+        self.geometry = geometry
+        self.pickled_geometry = pickle.dumps(self.geometry)
+        if update_preview:
+            await self.updatePreview()
+        if send:
+            await self.sendGeometry()
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        plt.switch_backend("agg")  # plotting backend which works for preview hack
+
+    async def onInitialization(self):
+        self.state = State.INIT
+        # TODO: try to load file if set
+        await self.setManual()
+        self.state = State.ACTIVE
+
+    @contextlib.contextmanager
+    def push_state(self, state):
+        previous_state = self.state
+        self.state = state
+        try:
+            yield
+        finally:
+            self.state = previous_state
+
+    def _set_status(self, text, level=logging.INFO):
+        """Add and log a status message.
+
+        Suppresses throttling from the gui server.
+        """
+
+        self.status = text
+        self.logger.log(level, text)
+
+
+def makeXYCoordinateNode(default_x, default_y):
+    class XYCoordinate(Configurable):
+        x = Double(
+            defaultValue=default_x,
+            accessMode=AccessMode.RECONFIGURABLE,
+            assignment=Assignment.OPTIONAL,
+        )
+        y = Double(
+            defaultValue=default_y,
+            accessMode=AccessMode.RECONFIGURABLE,
+            assignment=Assignment.OPTIONAL,
+        )
+
+    return Node(XYCoordinate)
+
+
+def makeQuadrantCornersNode(default_values):
+    assert len(default_values) == 4
+    assert all(len(x) == 2 for x in default_values)
+
+    class QuadrantCornersNode(Configurable):
+        Q1 = makeXYCoordinateNode(*default_values[0])
+        Q2 = makeXYCoordinateNode(*default_values[1])
+        Q3 = makeXYCoordinateNode(*default_values[2])
+        Q4 = makeXYCoordinateNode(*default_values[3])
+
+    return Node(QuadrantCornersNode)
+
+
+class ManualQuadrantsGeometryBase(ManualGeometryBase):
+    quadrantCorners = None  # subclass must define (with nice defaults)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._quadrant_corners = [
+            self.quadrantCorners.Q1,
+            self.quadrantCorners.Q2,
+            self.quadrantCorners.Q3,
+            self.quadrantCorners.Q4,
+        ]
+
+    @Slot(
+        displayedName="Set from device",
+        allowedStates=[State.ACTIVE],
+    )
+    async def setManual(self):
+        self._set_status("Updating geometry from manual configuration")
+        with self.push_state(State.CHANGING):
+            geometry = self.geometry_class.from_quad_positions(
+                [(Q.x.value, Q.y.value) for Q in self._quadrant_corners]
+            )
+            await self._set_geometry(geometry)
+
+    def _update_manual_from_current(self):
+        for corner, (x, y) in zip(
+            self._quadrant_corners, self.geometry.quad_positions()
+        ):
+            corner.x = x
+            corner.y = y
+
+
+class MdlAgipd1MGeometry(ManualQuadrantsGeometryBase):
+    geometry_class = extra_geom.AGIPD_1MGeometry
+    quadrantCorners = makeQuadrantCornersNode(
+        [
+            (-525, 625),
+            (-550, -10),
+            (520, -160),
+            (542.5, 475),
+        ]
+    )
+
+
+class ModuleListItem(Configurable):
+    posX = Double(defaultValue=0)
+    posY = Double(defaultValue=0)
+    orientX = Int32(defaultValue=1)
+    orientY = Int32(defaultValue=1)
+
+
+class ManualModuleListGeometryBase(ManualGeometryBase):
+    moduleList = None  # subclass must define (with nice defaults)
+
+    @Slot(
+        displayedName="Set from device",
+        allowedStates=[State.ACTIVE],
+    )
+    async def setManual(self):
+        self._set_status("Updating geometry from manual configuration")
+        with self.push_state(State.CHANGING):
+            self._geometry = self.geometry_class.from_module_positions(
+                [(x, y) for (x, y, _, _) in self.moduleList.value],
+                [
+                    (orient_x, orient_y)
+                    for (_, _, orient_x, orient_y) in self.moduleList.value
+                ],
+            )
+            self._pickled_geometry = pickle.dumps(self._geometry)
+
+    def _update_manual_from_current(self):
+        self._set_status("Not yet able to update manual config from file")
+
+
+class MdlJungfrauGeometry(ManualModuleListGeometryBase):
+    geometry_class = extra_geom.JUNGFRAUGeometry
+    moduleList = VectorHash(
+        displayedName="Modules",
+        rows=ModuleListItem,
+        defaultValue=[
+            Hash("posX", x, "posY", y, "orientX", ox, "orientY", oy)
+            for (x, y, ox, oy) in [
+                (95, 564, -1, -1),
+                (95, 17, -1, -1),
+                (95, -530, -1, -1),
+                (95, -1077, -1, -1),
+                (-1125, -1078, 1, 1),
+                (-1125, -531, 1, 1),
+                (-1125, 16, 1, 1),
+                (-1125, 563, 1, 1),
+            ]
+        ],
+        accessMode=AccessMode.RECONFIGURABLE,
+        assignment=Assignment.OPTIONAL,
+    )