diff --git a/src/calng/DetectorAssembler.py b/src/calng/DetectorAssembler.py
index c288d0c051821b50e1f7f6cca351c0e86d9c02fc..7da3cc2efa5ba9174d71df31160e04032cab7b52 100644
--- a/src/calng/DetectorAssembler.py
+++ b/src/calng/DetectorAssembler.py
@@ -243,7 +243,7 @@ class DetectorAssembler(TrainMatcher.TrainMatcher):
                         continue
                     geometry_device = geometry_device_list[0].split(":")[0]
                     self.log.INFO(f"Asking {geometry_device} for a geometry")
-                    self.signalSlotable.call(geometry_device, "pleaseSendYourGeometry")
+                    self.signalSlotable.call(geometry_device, "sendGeometry")
                     time.sleep(1)
 
                 if self._geometry is not None:
diff --git a/src/calng/base_geometry.py b/src/calng/base_geometry.py
index ee3748fc3eb95f5d374ba10693fe9eee3679b63f..1e5f12d2a1a899b5d7ea9cc2b97d92526e2b5eca 100644
--- a/src/calng/base_geometry.py
+++ b/src/calng/base_geometry.py
@@ -1,15 +1,19 @@
+import contextlib
 import pickle
 
 import matplotlib.pyplot as plt
 import numpy as np
 from karabo.bound import (
+    BOOL_ELEMENT,
     DOUBLE_ELEMENT,
     IMAGEDATA_ELEMENT,
     INT32_ELEMENT,
     KARABO_CLASSINFO,
     NODE_ELEMENT,
     OUTPUT_CHANNEL,
+    OVERWRITE_ELEMENT,
     SLOT_ELEMENT,
+    STRING_ELEMENT,
     TABLE_ELEMENT,
     VECTOR_CHAR_ELEMENT,
     VECTOR_STRING_ELEMENT,
@@ -73,8 +77,23 @@ ModuleColumn = Schema()
 
 @KARABO_CLASSINFO("ManualGeometryBase", deviceVersion)
 class ManualGeometryBase(PythonDevice):
+    geometry_class = None  # concrete device subclass must set this
+
     @staticmethod
     def expectedParameters(expected):
+        # Karabo things
+        (
+            OVERWRITE_ELEMENT(expected)
+            .key("state")
+            .setNewDefaultValue(State.INIT)
+            .commit(),
+
+            OVERWRITE_ELEMENT(expected)
+            .key("doNotCompressEvents")
+            .setNewDefaultValue(True)
+            .commit(),
+        )
+
         # "mandatory" for geometry serving device
         (
             OUTPUT_CHANNEL(expected)
@@ -82,7 +101,23 @@ class ManualGeometryBase(PythonDevice):
             .dataSchema(geometry_schema)
             .commit(),
 
-            SLOT_ELEMENT(expected).key("pleaseSendYourGeometry").commit(),
+            SLOT_ELEMENT(expected)
+            .key("sendGeometry")
+            .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.ON)
+            .commit(),
+
+            SLOT_ELEMENT(expected)
+            .key("updatePreview")
+            .displayedName("Update preview")
+            .allowedStates(State.ON)
+            .commit(),
 
             OUTPUT_CHANNEL(expected)
             .key("previewOutput")
@@ -92,6 +127,53 @@ class ManualGeometryBase(PythonDevice):
             IMAGEDATA_ELEMENT(expected).key("layoutPreview").commit(),
         )
 
+        # options to load from file
+        # future plan: pick option from manual / file / encoder or other devices
+        (
+            NODE_ELEMENT(expected)
+            .key("geometryFile")
+            .displayedName("Geometry file")
+            .description("Allows loading geometry from CrystFEL geometry file")
+            .commit(),
+
+            STRING_ELEMENT(expected)
+            .key("geometryFile.filePath")
+            .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."
+            )
+            .assignmentOptional()
+            .defaultValue("")
+            .reconfigurable()
+            .commit(),
+
+            SLOT_ELEMENT(expected)
+            .key("geometryFile.load")
+            .displayedName("Load file")
+            .description(
+                "Trigger loading from file. Automatically called after starting device "
+                "if geometryFile.filePath is set. Should be manually called after "
+                "changing file path. In case loading the file fails, manual settings "
+                "will be used."
+            )
+            .allowedStates(State.ON)
+            .commit(),
+
+            BOOL_ELEMENT(expected)
+            .key("geometryFile.updateManualOnLoad")
+            .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."
+            )
+            .assignmentOptional()
+            .defaultValue(True)
+            .commit(),
+        )
+
         # scenes are fun
         (
             VECTOR_STRING_ELEMENT(expected)
@@ -102,21 +184,75 @@ class ManualGeometryBase(PythonDevice):
             .commit(),
         )
 
-    def update_geom(self):
-        raise NotImplementedError()
-
     def __init__(self, config):
         super().__init__(config)
 
-        self.KARABO_SLOT(self.pleaseSendYourGeometry)
+        # slots go in __init__
+        self.KARABO_SLOT(self.sendGeometry)
+        self.KARABO_SLOT(self.updatePreview)
         self.KARABO_SLOT(self.requestScene)
-        self.update_geom()
-        plt.switch_backend("agg")
+        self.KARABO_SLOT(self.load_geometry_from_file, slotName="geometryFile_load")
+
+        plt.switch_backend("agg")  # plotting backend which works for preview hack
+
+        # these will be set by load_geometry_from_file or get_manual_geometry
+        self.geometry = None
+        self.pickled_geometry = None
         self.registerInitialFunction(self._initialization)
 
     def _initialization(self):
+        if self.get("geometryFile.filePath"):
+            self.log_status_info("geometryFile.filePath set, will try to load geometry")
+            self.load_geometry_from_file()
+        if self.geometry is None:
+            # no file path or loading failed
+            self.get_manual_geometry()
+
         self.updateState(State.ON)
-        self.pleaseSendYourGeometry()
+
+    def _set_geometry(self, geometry, update_preview=True, send=True):
+        self.geometry = geometry
+        self.pickled_geometry = pickle.dumps(self.geometry)
+        if update_preview:
+            self.updatePreview()
+        if send:
+            self.sendGeometry()
+
+    def get_manual_geometry(self):
+        # subclass must implement this
+        raise NotImplementedError()
+
+    def _update_manual_from_current(self):
+        # subclass should implement this
+        # TODO: figure out for JUNGFRAUGeometry
+        raise NotImplementedError()
+
+    def load_geometry_from_file(self):
+        with self.push_state(State.CHANGING):
+            geometry = None
+
+            file_path = self.get("geometryFile.filePath")
+            self.log_status_info(f"Loading geometry from {file_path}...")
+
+            try:
+                geometry = self.geometry_class.from_crystfel_geom(file_path)
+            except FileNotFoundError:
+                self.log_status_warn("Geometry file not found")
+            except RuntimeError as e:
+                self.log_status_warn(f"Failed to load geometry file: {e}")
+            except Exception as e:
+                self.log_status_warn(f"Misc. exception when loading geometry file: {e}")
+            else:
+                self._set_geometry(geometry)
+                self.log_status_info("Successfully loaded geometry from file")
+                if self.get("geometryFile.updateManualOnLoad"):
+                    self.log_status_info(
+                        "Updating manual settings on device to reflect loaded geometry"
+                    )
+                    self._update_manual_from_current()
+                return True
+
+        return False
 
     def requestScene(self, params):
         payload = Hash()
@@ -135,10 +271,13 @@ class ManualGeometryBase(PythonDevice):
         response["payload"] = payload
         self.reply(response)
 
-    def pleaseSendYourGeometry(self):
-        self.update_geom()
-        self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled))
-        axis = self.geom.inspect()
+    def sendGeometry(self):
+        self.writeChannel(
+            "geometryOutput", Hash("pickledGeometry", self.pickled_geometry)
+        )
+
+    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)
@@ -152,6 +291,7 @@ class ManualGeometryBase(PythonDevice):
             "layoutPreview",
             ImageData(image_buffer, encoding=Encoding.RGBA, bitsPerPixel=3 * 8),
         )
+        self.log_status_info("Preview updated")
 
     def preReconfigure(self, config):
         self._prereconfigure_update_hash = config
@@ -159,6 +299,23 @@ class ManualGeometryBase(PythonDevice):
     def postReconfigure(self):
         del self._prereconfigure_update_hash
 
+    def log_status_info(self, msg):
+        self.log.INFO(msg)
+        self.set("status", msg)
+
+    def log_status_warn(self, msg):
+        self.log.WARN(msg)
+        self.set("status", msg)
+
+    @contextlib.contextmanager
+    def push_state(self, state):
+        previous_state = self.get("state")
+        self.updateState(state)
+        try:
+            yield
+        finally:
+            self.updateState(previous_state)
+
 
 @KARABO_CLASSINFO("ManualQuadrantsGeometryBase", deviceVersion)
 class ManualQuadrantsGeometryBase(ManualGeometryBase):
@@ -189,19 +346,29 @@ class ManualQuadrantsGeometryBase(ManualGeometryBase):
             path.startswith("quadrantCorners")
             for path in self._prereconfigure_update_hash.getPaths()
         ):
-            self.update_geom()
+            self.get_manual_geometry()
 
         super().postReconfigure()
 
-    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? send on connect?
-        self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled))
+    def get_manual_geometry(self):
+        self.log_status_info("Updating geometry from manual configuration")
+        with self.push_state(State.CHANGING):
+            self.quadrant_corners = tuple(
+                (
+                    self.get(f"quadrantCorners.Q{q}.x"),
+                    self.get(f"quadrantCorners.Q{q}.y"),
+                )
+                for q in range(1, 5)
+            )
+            geometry = self.geometry_class.from_quad_positions(self.quadrant_corners)
+            self._set_geometry(geometry)
+
+    def _update_manual_from_current(self):
+        update = Hash()
+        for (x, y), quadrant in zip(self.geometry.quad_positions(), range(1, 5)):
+            update.set(f"quadrantCorners.Q{quadrant}.x", x)
+            update.set(f"quadrantCorners.Q{quadrant}.y", y)
+        self.set(update)
 
 
 @KARABO_CLASSINFO("ManualModulesGeometryBase", deviceVersion)
@@ -216,24 +383,31 @@ class ManualModulesGeometryBase(ManualGeometryBase):
             .defaultValue([])
             .reconfigurable()
             .commit(),
+
+            OVERWRITE_ELEMENT(expected)
+            .key("geometryFile.updateManualOnLoad")
+            .setNewDefaultValue(False)
+            .commit(),
         )
 
     def postReconfigure(self):
         if self._prereconfigure_update_hash.has("modules"):
-            self.update_geom()
+            self.get_manual_geometry()
 
         super().postReconfigure()
 
-    def update_geom(self):
-        modules = self.get("modules")
-        module_pos = [(module.get("posX"), module.get("posY")) for module in modules]
-        orientations = [
-            (module.get("orientationX"), module.get("orientationY"))
-            for module in modules
-        ]
-        self.geom = self.geometry_class.from_module_positions(
-            module_pos, orientations=orientations
-        )
-        self.pickled = pickle.dumps(self.geom)
-        # TODO: send to anyone who asks? make slot for that?
-        self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled))
+    def get_manual_geometry(self):
+        self.log_status_info("Updating geometry from manual configuration")
+        with self.push_state(State.CHANGING):
+            modules = self.get("modules")
+            module_pos = [
+                (module.get("posX"), module.get("posY")) for module in modules
+            ]
+            orientations = [
+                (module.get("orientationX"), module.get("orientationY"))
+                for module in modules
+            ]
+            geometry = self.geometry_class.from_module_positions(
+                module_pos, orientations=orientations
+            )
+            self._set_geometry(geometry)
diff --git a/src/calng/scenes.py b/src/calng/scenes.py
index e2de2737b908d165b9e0e961cab05566187007b2..e830bb7e0e43ce65e2fbd56555de9dbdea06eb53 100644
--- a/src/calng/scenes.py
+++ b/src/calng/scenes.py
@@ -810,7 +810,7 @@ def detector_assembler_overview(device_id, geometry_device_id):
                     height=BASE_INC,
                 ),
                 DisplayCommandModel(
-                    keys=[f"{geometry_device_id}.pleaseSendYourGeometry"],
+                    keys=[f"{geometry_device_id}.sendGeometry"],
                     width=14 * BASE_INC,
                     height=BASE_INC,
                 ),