Repository: linien-org/linien (language: python)

Issue:
Adapt to RedPitaya OS 2
In redpitaya os 2 a new method for flashing FPGA was introduced: https://forum.redpitaya.com/viewtopic.php?p=33494&sid=5132bf6e33709b1a7daa948f8e8dcdb1#p33494

This PR fixes #322


Discussion / hints:

Fix patch:
diff --git a/.github/actions/build-linien/action.yml b/.github/actions/build-linien/action.yml
new file mode 100644
index 00000000..ffd85d8c
--- /dev/null
+++ b/.github/actions/build-linien/action.yml
@@ -0,0 +1,38 @@
+name: Build Linien
+description: Build all linien packages
+
+runs:
+  using: "composite"
+  steps:
+    - name: Set up Python 3.10
+      uses: actions/setup-python@v4
+      with:
+        python-version: "3.10"
+
+    - name: Update pip
+      shell: bash
+      run: python -m pip install --upgrade pip
+
+    - name: Install build dependencies
+      shell: bash
+      run: pip install build wheel
+
+    - name: Build linien-common
+      shell: bash
+      working-directory: linien-common
+      run: python -m build
+
+    - name: Build linien-server
+      shell: bash
+      working-directory: linien-server
+      run: python -m build
+
+    - name: Build linien-client
+      shell: bash
+      working-directory: linien-client
+      run: python -m build
+
+    - name: Build linien-gui
+      shell: bash
+      working-directory: linien-gui
+      run: python -m build
diff --git a/.github/actions/install-linien/action.yml b/.github/actions/install-linien/action.yml
index eaa44c98..a828dddb 100644
--- a/.github/actions/install-linien/action.yml
+++ b/.github/actions/install-linien/action.yml
@@ -5,10 +5,14 @@ runs:
   using: "composite"
   steps:
     - name: Set up Python 3.10
-      uses: actions/setup-python@v3
+      uses: actions/setup-python@v4
       with:
         python-version: "3.10"
 
+    - name: Update pip
+      shell: bash
+      run: python -m pip install --upgrade pip
+
     - name: Install linien-common
       shell: bash
       working-directory: linien-common
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index a97d5282..c217168a 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -4,31 +4,38 @@ on:
   workflow_dispatch:
   push:
     paths:
+      - .github/workflows/mypy.yml
+      - .github/actions/install-linien/**
       - linien-common/**
+      - linien-client/**
+      - linien-gui/**
+      - linien-server/**
 
 permissions:
   contents: read
 
 jobs:
-  build:
+  mypy:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
-      - name: Set up Python 3.10
-        uses: actions/setup-python@v3
-        with:
-          python-version: "3.10"
+      - name: Check out repository
+        uses: actions/checkout@v3
 
-      - name: Install linien-common
-        working-directory: linien-common
-        run: pip install .
+      - name: Install Linien
+        uses: ./.github/actions/install-linien
 
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install mypy types-appdirs  
+      - name: Install additional dependencies
+        run: pip install mypy types-appdirs pyqt5-stubs types-requests
 
       - name: mypy for linien-common
-        run: mypy linien-common
+        run: mypy linien-common/linien_common
 
+      - name: mypy for linien-server
+        run: mypy linien-server/linien_server
+
+      - name: mypy for linien-client
+        run: mypy linien-client/linien_client
+
+      - name: mypy for linien-gui
+        run: mypy linien-gui/linien_gui      
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index 589c5da8..63fe6037 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -1,4 +1,4 @@
-name: Publish Packages
+name: Upload Packages to PyPI
 
 on:
   workflow_dispatch:
@@ -15,52 +15,29 @@ jobs:
         with:
           lfs: "true"
 
-      - name: Set up Python
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.x"
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install build wheel
-
-      - name: Build linien-common
-        working-directory: linien-common
-        run: python -m build
-
-      - name: Build linien-server
-        working-directory: linien-server
-        run: python -m build
-
-      - name: Build linien-client
-        working-directory: linien-client
-        run: python -m build
-
-      - name: Build linien-gui
-        working-directory: linien-gui
-        run: python -m build
+      - name: Build Linien
+        uses: ./.github/actions/build-linien
 
       - name: Publish linien-common
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-common/dist/
+          packages-dir: linien-common/dist/
           password: ${{ secrets.PYPI_API_TOKEN_COMMON }}
 
       - name: Publish linien-server
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-server/dist/
+          packages-dir: linien-server/dist/
           password: ${{ secrets.PYPI_API_TOKEN_SERVER }}
 
       - name: Publish linien-client
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-client/dist/
+          packages-dir: linien-client/dist/
           password: ${{ secrets.PYPI_API_TOKEN_CLIENT }}
 
       - name: Publish linien-gui
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-gui/dist/
+          packages-dir: linien-gui/dist/
           password: ${{ secrets.PYPI_API_TOKEN_GUI }}
diff --git a/.github/workflows/pyinstaller-linux.yml b/.github/workflows/pyinstaller-linux.yml
index 8d380cae..93173268 100644
--- a/.github/workflows/pyinstaller-linux.yml
+++ b/.github/workflows/pyinstaller-linux.yml
@@ -21,10 +21,10 @@ jobs:
         with:
           python-version: "3.10"
 
-      - name: Install dependencies
-        run: sudo apt-get install -y qt5-default qttools5-dev-tools binutils libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libegl1-mesa
-
-      - run: pip install pyinstaller
+      - name: Install additional dependencies
+        run: |
+          sudo apt-get install -y qt5-default qttools5-dev-tools binutils libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libegl1-mesa
+          pip install pyinstaller
 
       - name: Install linien-common
         working-directory: linien-common
diff --git a/.github/workflows/pyinstaller-windows.yml b/.github/workflows/pyinstaller-windows.yml
index 7fc3438e..f6f78604 100644
--- a/.github/workflows/pyinstaller-windows.yml
+++ b/.github/workflows/pyinstaller-windows.yml
@@ -4,6 +4,8 @@ on:
   workflow_dispatch:
   push:
     paths:
+      - .github/workflows/pyinstaller-windows.yml
+      - .github/actions/install-linien/**      
       - linien-common/**
       - linien-client/**
       - linien-gui/**
@@ -17,24 +19,11 @@ jobs:
         with:
           fetch-depth: 0
 
-      - name: Set up Python
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.10"
-
-      - run: pip install pyinstaller
-
-      - name: Install linien-common
-        working-directory: linien-common
-        run: pip install .
+      - name: Install Linien
+        uses: ./.github/actions/install-linien
 
-      - name: Install linien-client
-        working-directory: linien-client
-        run: pip install .
-
-      - name: Install linien-gui
-        working-directory: linien-gui
-        run: pip install .
+      - name: Install Pyinstaller
+        run: pip install pyinstaller
 
       - run: pyinstaller pyinstaller.spec
         working-directory: linien-gui
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e780d054..4ff264dc 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 23.1.0
+    rev: 23.3.0
     hooks:
       - id: black
         exclude: ^(gateware/logic/|gateware/lowlevel/|gateware/linien_module.py|linien-server/linien_server/csrmap.py)
diff --git a/README.md b/README.md
index 7bbff236..3215e82b 100644
--- a/README.md
+++ b/README.md
@@ -26,13 +26,11 @@ Features
 -   **Lock detection**: Linien is capable of detecting loss of lock (temporarily disabled, use [v0.3.2](https://github.com/linien-org/linien/releases/tag/v0.3.2) if you rely in this feature
 -   **Automatic relocking**: if lock is lost, it relocks autonomously (temporarily disabled, use [v0.3.2](https://github.com/linien-org/linien/releases/tag/v0.3.2) if you rely in this feature)
 -   **Machine learning** is used to tune the spectroscopy parameters in order to optimize the signal
--   **Remote-controllable**: the client libraries can be used to control or monitor the spectroscopy lock with python.
+-   **Remote-controllable**: the client libraries can be used to control or monitor the spectroscopy lock with Python.
 -   **Combined FMS+MTS**: Linien supports dual-channel spectroscopy that can be
     used to implement [combined
     FMS+MTS](https://arxiv.org/pdf/1701.01918.pdf)
--   **Logging**: Use
-    [linien-influxdb](https://github.com/linien-org/linien-influxdb)
-    to log the lock status to influxdb.
+-   **Logging**: Lock status and parameters can be logged to InfluxDB v2.
 -   **Second integrator** for slow control of piezo in an ECDL
 -   **Additional analog outputs** may be used using the GUI or python client (ANALOG_OUT 1, 2 and 3)
 -   **16 GPIO outputs** may be programmed (e.g. for controlling other devices)
@@ -49,9 +47,6 @@ These binaries run on your lab PC and contain everything to get Linien running o
 
 If you want to use the python interface you should [install it using pip](#installation-with-pip).
 
-| :exclamation: Note that Linien currently does not work with RedPitaya OS 2.00. Please make sure that your RedPitaya is running version 1.04.|
-|---------------------------------------------------------------------------------------------------------------------------------------------|
-
 ### Standalone binary
 
 You can download standalone binaries for Windows on [the
@@ -132,12 +127,9 @@ The bright red line is the demodulated spectroscopy signal. The dark red area is
 
 ### Fast Mode
 
-| :exclamation: Please make sure that the modulation frequency/amplitude are set to 0 if using fast mode. See [this issue](https://github.com/linien-org/linien/issues/314). |
-|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-
-Fast mode is intended for bare PID operation (no demodulation or filtering), bypassing most of the FPGA functionality. If enabled, the signal flow is FAST IN 1 → PID → FAST OUT 2. This is useful, if aiming for a high control bandwidth: fast mode reduces propagation delay from 320 ns to 125 ns which may make a difference when phase-locking lasers.
+Fast mode is intended for bare PID operation (no demodulation, filtering or offset), bypassing most of the FPGA functionality. If enabled, the signal flow is FAST IN 1 → PID → FAST OUT 2. This is useful, if aiming for a high control bandwidth: fast mode reduces propagation delay from 320 ns to 125 ns which may make a difference when phase-locking lasers.
 
-### Optimization of spectroscopy parameters using machine learning (optional)
+### Optimization of spectroscopy parameters using machine learning
 
 Linien may use machine learning to maximize the slope of a line. As for the autolock, click and drag over the line you want to optimize. Then, the line is centered and the optimization starts. Please note that this only works if initially a distinguished zero-crossing is visible.
 
@@ -154,7 +146,7 @@ The following options are available:
  * **Check lock**: Directly after turning on the lock, the control signal is investigated. If it shifts too much, the lock is assumed to have failed.
  * **Watch lock**: This option tells the Linien to continuously watch the control signal when the laser is locked. If steep changes are detected, a relock is initiated.
 
-If you experience trouble with the autolock, this is most likely due to a bad signal to noise ratio or strong laser jitter.
+If you experience trouble with the autolock, this is most likely due to a bad signal-to-noise ratio or strong laser jitter.
 
 #### Autolock algorithms
 
@@ -170,6 +162,12 @@ Linien implements two different autolock algorithms:
 
 If you have problems with the autolock, you may also lock manually. Activate the *Manual* tab and use the controls in the top (*Zoom* and *Position*) to center the line you want to lock to. Choose whether the target slope is rising or falling and click the green button.
 
+### Logging
+
+Linien has to option to log the lock status and parameters to an InfluxDB. Currently, only InfluxDB 2.x is supported. Logging can be configured via the Logging menu in the Linien GUI, but logging will continue even if the client is closed. Time stamps of the data points are determined by the InfluxDB, not by the RedPitaya. If updating/checking the InfluxDB credentials fails, there is additional information in the tool-tip of the fail indicator ❌.
+
+The parameter names are documented in [`parameters.py`](https://github.com/linien-org/linien/blob/master/linien-server/linien_server/parameters.py). The `signal_stats` parameter does contain statistics of the input and output signals, e.g. `control_signal_mean` or `monitor_signal_max`.
+
 Transfer function
 -----------------
 
@@ -185,19 +183,21 @@ Note that this equation does not account for filtering before the PID (cf. *Modu
 Scripting interface
 -------------------
 
-In addition to the GUI, Linien can also be controlled using python. For that purpose, installation via pip is required (see above).
+In addition to the GUI, Linien can also be controlled using Python. For that purpose, installation via pip is required (see above).
 
-Then, you should start the Linien server on your RedPitaya. This can be done by running the GUI client and connecting to the device (see above). Alternatively, `LinienClient` has the option `autostart_server`.
+Then, you should start the Linien server on your RedPitaya. This can be done by running the GUI client and connecting to the device (see above). Alternatively, the `connect` method of `LinienClient` has the option `autostart_server`.
 
 Once the server is up and running, you can connect using python:
 ```python
 from linien_client.connection import LinienClient
-from linien_common.common import  MHz, Vpp
+from linien_common.common import  MHz, Vpp, ANALOG_OUT_V
+
 c = LinienClient(
-    {'host': 'rp-XXXXXX.local', 'username': 'root', 'password': 'change-it-to-something-else!'},
-    # starts the server if it is not running
-    autostart_server=True
+    host="rp-xxxxxx.local",
+    user="root",
+    password="root"
 )
+c.connect(autostart_server=True, use_parameter_cache=True)
 
 # read out the modulation frequency
 print(c.parameters.modulation_frequency.value / MHz)
@@ -228,19 +228,19 @@ c.parameters.gpio_p_out.value = 0b01010101 # 4 on, 4 off
 c.connection.root.write_registers()
 
 # it is also possible to set up a callback function that is called whenever a
-# parameter changes (remember to call `call_listeners()` periodically)
-def on_change(value):
+# parameter changes (remember to call `check_for_changed_parameters()` periodically)
+def callback(value):
     # this function is called whenever `my_param` changes on the server.
-    # note that this only works if `call_listeners` is called from
+    # note that this only works if `check_for_changed_parameters` is called from
     # time to time as this function is responsible for checking for
     # changed parameters.
     print('parameter arrived!', value)
 
-c.parameters.modulation_amplitude.on_change(on_change)
+c.parameters.modulation_amplitude.add_callback(callback)
 
 from time import sleep
 for i in range(10):
-    c.parameters.call_listeners()
+    c.parameters.check_for_changed_parameters()
     if i == 2:
         c.parameters.modulation_amplitude.value = 0.1 * Vpp
     sleep(.1)
@@ -287,9 +287,11 @@ from matplotlib import pyplot as plt
 from time import sleep
 
 c = LinienClient(
-    {"host": "HOST", "username": "USER", "password": "PASSWORD"},
-    autostart_server=False,
+    host="rp-xxxxxx.local",
+    user="root",
+    password="root"
 )
+c.connect(autostart_server=True, use_parameter_cache=True)
 
 c.parameters.autolock_mode_preference.value = FAST_AUTOLOCK
 
@@ -347,7 +349,7 @@ c.connection.root.start_autolock(x0, x1, pickle.dumps(error_signal))
 try:
     wait_for_lock_status(True)
     print("locking the laser worked \o/")
-except:
+except Exception:
     print("locking the laser failed :(")
 
 
diff --git a/gateware/linien_module.py b/gateware/linien_module.py
index 1abacd5c..f885100e 100644
--- a/gateware/linien_module.py
+++ b/gateware/linien_module.py
@@ -18,7 +18,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import ANALOG_OUT0
+from linien_common.common import OutputChannel
 from migen import (
     Array,
     Cat,
@@ -351,17 +351,18 @@ def connect_everything(self, width, signal_width, coeff_width, chain_factor_bits
         self.comb += [
             analog_out.eq(
                 Mux(
-                    self.logic.sweep_channel.storage == ANALOG_OUT0,
+                    self.logic.sweep_channel.storage == OutputChannel.ANALOG_OUT0,
                     self.logic.sweep.y,
                     0,
                 )
                 + Mux(
-                    self.logic.sweep_channel.storage == ANALOG_OUT0,
+                    self.logic.sweep_channel.storage == OutputChannel.ANALOG_OUT0,
                     self.logic.out_offset_signed,
                     0,
                 )
                 + Mux(
-                    self.logic.slow_control_channel.storage == ANALOG_OUT0,
+                    self.logic.slow_control_channel.storage
+                    == OutputChannel.ANALOG_OUT0,
                     self.slow_chain.output,
                     0,
                 )
diff --git a/gateware/logic/autolock.py b/gateware/logic/autolock.py
index d060730a..df50be62 100644
--- a/gateware/logic/autolock.py
+++ b/gateware/logic/autolock.py
@@ -16,11 +16,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import (
-    AUTOLOCK_MAX_N_INSTRUCTIONS,
-    FAST_AUTOLOCK,
-    ROBUST_AUTOLOCK,
-)
+from linien_common.common import AUTOLOCK_MAX_N_INSTRUCTIONS, AutolockMode
 from migen import Array, If, Module, Signal, bits_for
 from misoc.interconnect.csr import AutoCSR, CSRStatus, CSRStorage
 
@@ -60,13 +56,13 @@ def __init__(self, width=14, N_points=16383, max_delay=16383):
             If(
                 self.request_lock.storage
                 & self.fast.turn_on_lock
-                & (self.autolock_mode.storage == FAST_AUTOLOCK),
+                & (self.autolock_mode.storage == AutolockMode.FAST),
                 self.lock_running.status.eq(1),
             ),
             If(
                 self.request_lock.storage
                 & self.robust.turn_on_lock
-                & (self.autolock_mode.storage == ROBUST_AUTOLOCK),
+                & (self.autolock_mode.storage == AutolockMode.ROBUST),
                 self.lock_running.status.eq(1),
             ),
         ]
@@ -93,7 +89,10 @@ def __init__(self, width=14):
         self.comb += [target_position_signed.eq(self.target_position.storage)]
 
         self.sync += [
-            If(~self.request_lock, self.turn_on_lock.eq(0),).Else(
+            If(
+                ~self.request_lock,
+                self.turn_on_lock.eq(0),
+            ).Else(
                 self.turn_on_lock.eq(
                     (
                         self.sweep_value
diff --git a/gateware/logic/limit.py b/gateware/logic/limit.py
index f02435d3..e3c0315d 100644
--- a/gateware/logic/limit.py
+++ b/gateware/logic/limit.py
@@ -34,9 +34,20 @@ def __init__(self, width):
         ###
 
         self.comb += [
-            If(self.x >= self.max, self.y.eq(self.max), self.railed.eq(1))
-            .Elif(self.x <= self.min, self.y.eq(self.min), self.railed.eq(1))
-            .Else(self.y.eq(self.x), self.railed.eq(0))
+            If(
+                self.x >= self.max,
+                self.y.eq(self.max),
+                self.railed.eq(1),
+            )
+            .Elif(
+                self.x <= self.min,
+                self.y.eq(self.min),
+                self.railed.eq(1),
+            )
+            .Else(
+                self.y.eq(self.x),
+                self.railed.eq(0),
+            )
         ]
 
 
diff --git a/linien-client/linien_client/communication.py b/linien-client/linien_client/communication.py
new file mode 100644
index 00000000..c32592df
--- /dev/null
+++ b/linien-client/linien_client/communication.py
@@ -0,0 +1,102 @@
+# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+#
+# This file is part of Linien and based on redpid.
+#
+# Linien is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Linien is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List, Tuple
+
+from linien_common.influxdb import InfluxDBCredentials
+from typing_extensions import Protocol
+
+
+class LinienControlService(Protocol):
+    def exposed_get_server_version(self) -> str:
+        ...
+
+    def exposed_get_param(self, param_name: str) -> bytes:
+        ...
+
+    def exposed_set_param(self, param_name: str, value: bytes) -> None:
+        ...
+
+    def exposed_reset_param(self, param_name: str) -> None:
+        ...
+
+    def exposed_init_parameter_sync(self, uuid: str) -> bytes:
+        ...
+
+    def exposed_register_remote_listener(self, uuid: str, param_name: str) -> None:
+        ...
+
+    def exposed_register_remote_listeners(
+        self, uuid: str, param_names: List[str]
+    ) -> None:
+        ...
+
+    def exposed_get_changed_parameters_queue(self, uuid: str) -> bytes:
+        ...
+
+    def exposed_write_registers(self) -> None:
+        ...
+
+    def exposed_start_optimization(self, x0, x1, spectrum) -> None:
+        ...
+
+    def exposed_start_psd_acquisition(self) -> None:
+        ...
+
+    def exposed_start_pid_optimization(self) -> None:
+        ...
+
+    def exposed_start_sweep(self) -> None:
+        ...
+
+    def exposed_start_lock(self) -> None:
+        ...
+
+    def exposed_shutdown(self) -> None:
+        ...
+
+    def exposed_pause_acquisition(self) -> None:
+        ...
+
+    def exposed_continue_acquisition(self) -> None:
+        ...
+
+    def exposed_set_csr_direct(self, key: str, value: int) -> None:
+        ...
+
+    def exposed_set_parameter_log(self, param_name: str, value: bool) -> None:
+        ...
+
+    def exposed_get_parameter_log(self, param_name: str) -> bool:
+        ...
+
+    def exposed_update_influxdb_credentials(
+        self, credentials: InfluxDBCredentials
+    ) -> Tuple[bool, int, str]:
+        ...
+
+    def exposed_get_influxdb_credentials(self) -> bytes:
+        ...
+
+    def exposed_start_logging(self, interval: float) -> None:
+        ...
+
+    def exposed_stop_logging(self) -> None:
+        ...
+
+    def exposed_get_logging_status(self) -> bool:
+        ...
diff --git a/linien-client/linien_client/connection.py b/linien-client/linien_client/connection.py
index db49743c..6a02e100 100644
--- a/linien-client/linien_client/connection.py
+++ b/linien-client/linien_client/connection.py
@@ -21,40 +21,31 @@
 from socket import gaierror
 from time import sleep
 from traceback import print_exc
-from typing import Callable
+from typing import Callable, Optional
 
-import linien_client
 import rpyc
-from linien_client.deploy import start_remote_server
-from linien_client.exceptions import (
-    GeneralConnectionErrorException,
+from linien_common.config import DEFAULT_SERVER_PORT
+
+from . import __version__
+from .communication import LinienControlService
+from .deploy import hash_username_and_password, start_remote_server
+from .exceptions import (
+    GeneralConnectionError,
     InvalidServerVersionException,
     RPYCAuthenticationException,
     ServerNotRunningException,
 )
-from linien_client.remote_parameters import RemoteParameters
-from linien_common.common import hash_username_and_password
-from linien_common.config import DEFAULT_SERVER_PORT
-
-
-class RPYCClientWithAuthentication(rpyc.Service):
-    """
-    An rpyc client that authenticates using a hash.
+from .remote_parameters import RemoteParameters
 
-    This class is run on the client side and exposes the client's unique id to the
-    server.
-    """
 
-    def __init__(self, uuid, user, password):
+class ServiceWithAuth(rpyc.Service):
+    def __init__(self, uuid: str, user: str, password: str) -> None:
         super().__init__()
-
         self.exposed_uuid = uuid
-        self.auth_hash = hash_username_and_password(user, password).encode()
+        self.auth_hash = hash_username_and_password(user, password).encode("utf-8")
 
     def _connect(self, channel, config):
-        # send auth hash before rpyc takes over
-        channel.stream.sock.send(self.auth_hash)
-
+        channel.stream.sock.send(self.auth_hash)  # send hash before rpyc takes over
         return super()._connect(channel, config)
 
 
@@ -82,13 +73,13 @@ def __init__(
         self.uuid = "".join(random.choice(string.ascii_lowercase) for _ in range(10))
 
         # for exposing client's uuid to server
-        self.client_service = RPYCClientWithAuthentication(self.uuid, user, password)
+        self.client_service = ServiceWithAuth(self.uuid, self.user, self.password)
 
     def connect(
         self,
         autostart_server: bool,
         use_parameter_cache: bool,
-        call_on_error: Callable = None,
+        call_on_error: Optional[Callable] = None,
     ) -> None:
         self.connection = None
 
@@ -97,11 +88,23 @@ def connect(
             i += 1
             try:
                 print(f"Try to connect to {self.host}:{self.port}")
-                self._connect_rpyc(
-                    use_parameter_cache,
-                    call_on_error=call_on_error,
+
+                self.connection = rpyc.connect(
+                    self.host,
+                    self.port,
+                    service=self.client_service,
+                    config={"allow_pickle": True},
                 )
-                self.control = self.connection.root
+
+                cls = RemoteParameters
+                if call_on_error:
+                    cls = self._catch_network_errors(cls, call_on_error)
+
+                self.parameters = cls(
+                    self.connection.root, self.uuid, use_parameter_cache
+                )
+
+                self.control: LinienControlService = self.connection.root
                 break
             except gaierror:
                 # host not found
@@ -110,7 +113,7 @@ def connect(
             except EOFError:
                 print("EOFError! Probably authentication failed")
                 raise RPYCAuthenticationException()
-            except Exception:
+            except ConnectionRefusedError:
                 if not autostart_server:
                     raise ServerNotRunningException()
 
@@ -130,11 +133,11 @@ def connect(
                         break
 
         if self.connection is None:
-            raise GeneralConnectionErrorException()
+            raise GeneralConnectionError()
 
         # now check that the remote version is the same as ours
         remote_version = self.connection.root.exposed_get_server_version().split("+")[0]
-        local_version = linien_client.__version__.split("+")[0]
+        local_version = __version__.split("+")[0]
 
         if (remote_version != local_version) and not ("dev" in local_version):
             raise InvalidServerVersionException(local_version, remote_version)
@@ -142,20 +145,10 @@ def connect(
         self.connected = True
         print("Connection established!")
 
-    def _connect_rpyc(self, use_parameter_cache, call_on_error=None):
-        """Connect to the server using rpyc and instanciate `RemoteParameters`."""
-        self.connection = rpyc.connect(
-            self.host,
-            self.port,
-            service=self.client_service,
-            config={"allow_pickle": True},
-        )
-
-        cls = RemoteParameters
-        if call_on_error:
-            cls = self._catch_network_errors(cls, call_on_error)
-
-        self.parameters = cls(self.connection.root, self.uuid, use_parameter_cache)
+    def disconnect(self) -> None:
+        if self.connection is not None:
+            self.connection.close()
+        self.connected = False
 
     def _catch_network_errors(self, cls, call_on_error):
         """
@@ -184,7 +177,3 @@ def wrapped(*args, method=method, **kwargs):
                     setattr(cls, attr_name, wrapped)
 
         return cls
-
-    def disconnect(self) -> None:
-        self.connection.close()
-        self.connected = False
diff --git a/linien-client/linien_client/deploy.py b/linien-client/linien_client/deploy.py
index 6f043278..9b112d37 100644
--- a/linien-client/linien_client/deploy.py
+++ b/linien-client/linien_client/deploy.py
@@ -25,6 +25,7 @@
     InvalidServerVersionException,
     ServerNotInstalledException,
 )
+from linien_common.communication import hash_username_and_password
 
 
 def read_remote_version(
@@ -71,6 +72,16 @@ def start_remote_server(
         if (local_version != remote_version) and not ("dev" in local_version):
             raise InvalidServerVersionException(local_version, remote_version)
 
+        print("Sending credentials")
+        conn.run(
+            'python3 -c "from linien_common.communication import write_hash_to_file;'
+            f"write_hash_to_file('{hash_username_and_password(user, password)}')\"",
+            out_stream=out_stream,
+            err_stream=out_stream,
+            warn=True,
+        )
+
+        print("Starting server")
         conn.run(
             "linien_start_server.sh",
             out_stream=out_stream,
diff --git a/linien-client/linien_client/exceptions.py b/linien-client/linien_client/exceptions.py
index c345546b..9bd549d0 100644
--- a/linien-client/linien_client/exceptions.py
+++ b/linien-client/linien_client/exceptions.py
@@ -48,5 +48,5 @@ class ServerNotInstalledException(Exception):
     pass
 
 
-class GeneralConnectionErrorException(Exception):
+class GeneralConnectionError(Exception):
     pass
diff --git a/linien-client/linien_client/remote_parameters.py b/linien-client/linien_client/remote_parameters.py
index 65fa5afc..0ed9ed8f 100644
--- a/linien-client/linien_client/remote_parameters.py
+++ b/linien-client/linien_client/remote_parameters.py
@@ -16,244 +16,204 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from typing import Callable
+from typing import Any, Callable, Dict, Iterator, List, Tuple, Union
 
-import rpyc
-from linien_common.common import pack, unpack
+from linien_common.communication import pack, unpack
 from rpyc import async_
+from rpyc.core.async_ import AsyncResult
+
+from .communication import LinienControlService
+
+
+class RemoteParameter:
+    """A helper class for `RemoteParameters`, representing a single remote parameter."""
+
+    def __init__(
+        self,
+        parent: "RemoteParameters",
+        name: str,
+        use_cache: bool,
+        restorable: bool,
+        loggable: bool,
+        log: bool,
+    ):
+        self.name = name
+        self.parent = parent
+        self.use_cache = use_cache
+        self.restorable = restorable
+        self.loggable = loggable
+        self.log = log
+
+    @property
+    def value(self) -> Any:
+        """Return the locally cached value (if it exists). Otherwise ask the server."""
+        if hasattr(self, "_cached_value"):
+            return self._cached_value
+        return unpack(self.parent.remote.exposed_get_param(self.name))
+
+    @value.setter
+    def value(self, value: Any):
+        """Notify the server of the new value"""
+        return self.parent.remote.exposed_set_param(self.name, pack(value))
+
+    @property
+    def log(self) -> bool:
+        return self.parent.remote.exposed_get_parameter_log(self.name)
+
+    @log.setter
+    def log(self, value: bool) -> None:
+        self.parent.remote.exposed_set_parameter_log(self.name, value)
+
+    def add_callback(self, callback: Callable, call_with_first_value: bool = True):
+        """
+        Register a callback function that is called whenever the parameter changes.
+        """
+
+        if self.name not in self.parent._callbacks and not self.use_cache:
+            # Make sure that the server knows that we want to be notified about changes.
+            # Parameters that use the cache are already registered, see `__init__`.
+            self.parent._listeners_pending_remote_registration.append(self.name)
+
+        self.parent._callbacks.setdefault(self.name, [])
+        self.parent._callbacks[self.name].append(callback)
+
+        if call_with_first_value:
+            callback(self.value)
+
+    def reset(self) -> None:
+        """Reset the value to its initial value"""
+        self.parent.remote.exposed_reset_param(self.name)
+
+    def update_cache(self, value: Any) -> None:
+        self._cached_value = value
 
 
 class RemoteParameters:
-    """
-    A class that provides access to a remote `parameters.Parameters` instance.
-
-    It clones the functionality of the remote `Parameters` instance. E.g.:
-
-        # on the remote side
-        p = Parameters(...)
-        p.my_param.value = 123
-
-        # on the client side
-        r = RemoteParameters(...)
-
-        # RemoteParameters allows for accessing the remote value:
-        print(r.my_param.value) # outputs 123
-
-        # it's also possible to set the value (this change is automatically
-        # propagated to the server)
-        r.my_param.value = 123
-
-        # and we can set up a callback function that is called whenever a
-        # parameter changes
-        def on_change(value):
-            # this function is called whenever `my_param` changes on the server.
-            # note that this only works if `call_listeners` is called from
-            # time to time as this function is responsible for checking for
-            # changed parameters.
-            print('parameter arrived!', value)
-        r.my_param.on_change(on_change)
-        while True:
-            r.call_listeners()
-            sleep(.1)
-
-    The arguments for __init__ are:
-
-        `remote`:    The root of the rpyc connection ot the server
-        `uuid`:      A random unique identifier for this client
-        `use_cache`: A boolean indicating whether (most) parameters should be
-                     cached locally. If this is not enabled, every access of
-                     `r.my_param.value` results in a request to the server.
-                     If `use_cache` is enabled, a local cache is used instead.
-                     For that purpose, a listener is installed such that the
-                     server automatically pushes changes to the client and
-                     thus updates the cache. No matter how often you access
-                     `r.my_param.value`, each parameter value is only transmitted
-                     once (after it was changed).
-                     Note that calling `call_listeners` is required for this.
-    """
-
-    def __init__(self, remote, uuid: str, use_cache: bool):
+    def __init__(self, remote: LinienControlService, uuid: str, use_cache: bool):
+        """
+        Provides access to a remote `Parameters` instance and minics its functionality.
+
+        :param remote: root of the rpyc connection to the server
+        :param uuid: random unique identifier for this client
+        :param use_cache: A boolean indicating whether (most) parameters should be
+            cached locally. If this is not enabled, every access of `r.my_param.value`
+            results in a request to the server. If `use_cache` is enabled, a local cache
+            is used instead. For that purpose, a listener is installed such that the
+            server notifies the client about changed parameters, whenever the
+            `check_for_changed_parameters` method is called.
+        """
         self.remote = remote
         self.uuid = uuid
-        self._async_listener_queue = None
-        self._async_listener_registering = None
 
-        self._listeners_pending_remote_registration = []
-        self._listeners = {}
+        self._async_changed_parameters_queue: Union[AsyncResult, None] = None
+        self._async_listener_registering: Union[AsyncResult, None] = None
 
-        self._mimic_remote_parameters(use_cache)
-        self._attributes_locked = True
-
-        self.call_listeners()
-
-    def __setattr__(self, name, value):
-        """In order to set the value of a parameter,
+        self._listeners_pending_remote_registration: List[str] = []
+        self._callbacks: Dict[str, List[Callable]] = {}
 
-            parameters.my_param.value = 123
+        # mimic functionality of `parameters.Parameters`:
+        all_parameters = unpack(self.remote.exposed_init_parameter_sync(self.uuid))
+        for name, value, can_be_cached, restorable, loggable, log in all_parameters:
+            param = RemoteParameter(
+                parent=self,
+                name=name,
+                use_cache=use_cache and can_be_cached,
+                restorable=restorable,
+                loggable=loggable,
+                log=log,
+            )
+            setattr(self, name, param)
+            if param.use_cache:
+                param.update_cache(value)
+        self._attributes_locked = True
 
-        is used. In order to prevent accidentally forgetting the .value part, i.e.
+        self.check_for_changed_parameters()
 
-            parameters.my_param = 123
+    def __iter__(self) -> Iterator[Tuple[str, "RemoteParameter"]]:
+        for param_name, param in self.__dict__.items():
+            if isinstance(param, RemoteParameter):
+                yield param_name, param
 
-        we raise an error in this case."""
+    def __setattr__(self, name: str, value: Any) -> None:
+        # Prevents accidentally overwriting parameters.
         if (
             hasattr(self, "_attributes_locked")
             and self._attributes_locked
             and not name.startswith("_")
         ):
-            raise Exception(
-                "Parameters are locked! Did you mean to set the value of this "
-                "parameter instead, i.e. parameters.%s.value = %s" % (name, value)
+            raise AttributeError(
+                "Parameters are locked! Did you mean to set the value of this parameter"
+                f" instead, i.e. parameters.{name}.value = {value}"
             )
         super().__setattr__(name, value)
 
-    def _mimic_remote_parameters(self, use_cache: bool):
-        """For every remote parameter, instanciate a `RemoteParameter` object
-        that allows to mimics the functionality of the remote parameter."""
-        # when directly iterating over `exposed_init_parameter_sync`, each iteration
-        # triggers a request as it is a netref over an iterator
-        # --> the `list` call prevents this and improves startup performance
-        all_parameters = unpack(self.remote.exposed_init_parameter_sync(self.uuid))
+    def check_for_changed_parameters(self) -> None:
+        """
+        Ask the server for changed parameters and trigger the respective callbacks.
+
+        This call takes place asynchronously, i.e. the first run of
+        `check_for_changed_parameters` just issues the call but does not wait for it in
+        order not to block the GUI. The following calls check whether a result has
+        arrived (also not blocking GUI).
+
+        In Linien GUI client, this function is called periodically. If you use the
+        python client and want to use callbacks for changed parameters you have to call
+        this method manually from time to time.
+        """
+
+        if self._async_changed_parameters_queue is None:
+            # This means that the async call was not started yet --> start it. The next
+            # call to `check_for_changed_parameters` will then check whether the result
+            # is ready. Issues an asynchronous call (that does not block the GUI) to the
+            # server in order to retrieve a batch of changed parameters.
+            self._async_changed_parameters_queue = async_(
+                self.remote.exposed_get_changed_parameters_queue
+            )(self.uuid)
 
-        for name, param, value, can_be_cached in all_parameters:
-            param = RemoteParameter(self, param, name, use_cache and can_be_cached)
-            setattr(self, name, param)
-            if use_cache and can_be_cached:
-                # obtain takes care that we really don't deal with netrefs
-                # (np.float64 is not automatically serialized)
-                param._update_cache(rpyc.classic.obtain(value))
-
-        self._attributes_locked = True
-
-    def _register_listener(self, param, callback: Callable):
-        """Tells the server to notify our client (identified by `self.uuid`)
-        when `param` changes. Registers a function `callback` that will be
-        called in this case."""
-        if param.name not in self._listeners:
-            # parameters that use the cache don't have to be registered on remote
-            # side because the client automatically listens for changes.
-            # This happens using `init_parameter_sync`
-            if not param.use_cache:
-                self._listeners_pending_remote_registration.append(param.name)
-
-        self._listeners.setdefault(param.name, [])
-        self._listeners[param.name].append(callback)
-
-    def call_listeners(self):
-        """Ask the server for changed parameters and call the respective
-        callback functions. This call takes place asynchronously, i.e. the first
-        run of `call_listeners` just issues the call but does not wait for it
-        in order not to block the GUI. The following calls check whether a
-        result has arrived (also not blocking GUI).
-
-        In Linien GUI client, this function is called periodically.
-        If you use the python client and want to use callbacks for changed
-        parameters you have to call this method manually from time to time."""
-
-        def _get_listener_queue_async():
-            """Issues an asynchronous call (that does not block the GUI) to the
-            server in order to retrieve a batch of changed parameters."""
-            self._async_listener_queue = async_(self.remote.get_listener_queue)(
-                self.uuid
-            )
-
-        def _register_listeners_async():
-            """Issues an asynchronous call to the server containing all the
-            parameters that this client wants to be notified of in case of a
-            changed value."""
+        if self._async_listener_registering is None:
+            # Issues an asynchronous call to the server containing all the parameters
+            # that this client wants to be notified of in case of a changed value.
             pending = self._listeners_pending_remote_registration
             if pending:
-                # this copies the list before clearing it below. Otherwise
-                # we just transmit an epmty list in the async call
+                # This copies the list before clearing it below. Otherwise we just
+                # transmit an empty list in the async call
                 pending = pending[:]
                 self._async_listener_registering = async_(
                     self.remote.exposed_register_remote_listeners
                 )(self.uuid, pending)
                 self._listeners_pending_remote_registration.clear()
 
-        if self._async_listener_queue is None:
-            # this means that the async call was not started yet --> start it
-            # the next call to `call_listeners` will then check whether the
-            # the result is ready.
-            _get_listener_queue_async()
-
-        if self._async_listener_registering is None:
-            _register_listeners_async()
-
-        if self._async_listener_queue is not None and self._async_listener_queue.ready:
-            # we have a result
-            queue = unpack(self._async_listener_queue.value)
+        if (
+            self._async_changed_parameters_queue is not None
+            and self._async_changed_parameters_queue.ready
+        ):
+            # We have a result.
+            queue: List[Tuple[str, Any]] = unpack(
+                self._async_changed_parameters_queue.value
+            )
 
-            # now that we have our result, we can start the next call
-            _get_listener_queue_async()
+            # Now that we have our result, we can start the next call.
+            self._async_changed_parameters_queue = async_(
+                self.remote.exposed_get_changed_parameters_queue
+            )(self.uuid)
 
-            # before calling listeners, we update cache for all received parameters
-            # at once
+            # Before calling listeners, we update cache for all received parameters at
+            # once.
             for param_name, value in queue:
-                param = getattr(self, param_name)
+                param: RemoteParameter = getattr(self, param_name)
                 if param.use_cache:
-                    param._update_cache(value)
+                    param.update_cache(value)
 
-            # iterate over all canged parameters and call the respective
-            # callback functions
+            # Iterate over all canged parameters and call respective callback functions.
             for param_name, value in queue:
-                if param_name in self._listeners:
-                    for listener in self._listeners[param_name]:
-                        listener(value)
+                if param_name in self._callbacks:
+                    for callback in self._callbacks[param_name]:
+                        callback(value)
 
         if (
             self._async_listener_registering is not None
             and self._async_listener_registering.ready
         ):
-            # registration of listeners was successful on the remote side
-            # now we can clear the async call object such that a new one may
-            # be issued (if required)
+            # Registration of listeners was successful on the remote side. Now we can
+            # clear the async call object such that a new one may be issued if required.
             self._async_listener_registering = None
-
-    def _get_param(self, param_name):
-        return unpack(self.remote.exposed_get_param(param_name))
-
-    def _set_param(self, param_name, value):
-        return self.remote.exposed_set_param(param_name, pack(value))
-
-
-class RemoteParameter:
-    """A helper class for `RemoteParameters`, representing a single remote
-    parameter."""
-
-    def __init__(
-        self, parent: RemoteParameters, remote_param, name: str, use_cache: bool
-    ):
-        self._remote_param = remote_param
-        self.name = name
-        self.parent = parent
-        self.use_cache = use_cache
-
-    @property
-    def value(self):
-        """Return the locally cached value (if it exists). Otherwise ask the
-        server."""
-        if hasattr(self, "_cached_value"):
-            return self._cached_value
-        return self.parent._get_param(self.name)
-
-    @value.setter
-    def value(self, value):
-        """Notify the server of the new value"""
-        return self.parent._set_param(self.name, value)
-
-    def on_change(self, callback_on_change, call_listener_with_first_value=True):
-        """Tells the server that `callback_on_change` should be called whenever
-        the parameter changes."""
-        self.parent._register_listener(self, callback_on_change)
-
-        if call_listener_with_first_value:
-            # call the callback with the initial value
-            callback_on_change(self.value)
-
-    def reset(self):
-        """Reset the value to its initial value"""
-        self._remote_param.reset()
-
-    def _update_cache(self, value):
-        self._cached_value = value
diff --git a/linien-client/setup.py b/linien-client/setup.py
index d2b0174f..94e418a9 100644
--- a/linien-client/setup.py
+++ b/linien-client/setup.py
@@ -18,7 +18,7 @@
 
 from setuptools import find_packages, setup
 
-version = "0.7.0"
+version = "0.8.0"
 
 setup(
     name="linien-client",
@@ -42,7 +42,6 @@
         "fabric>=2.7.0",
         "numpy>=1.22",
         "patchwork>=1.0.1",
-        "rpyc>=4.0,<5.0",
         "scipy>=1.4.1",
         f"linien-common=={version}",
     ],
diff --git a/linien-common/linien_common/common.py b/linien-common/linien_common/common.py
index b55a46ef..01de6409 100644
--- a/linien-common/linien_common/common.py
+++ b/linien-common/linien_common/common.py
@@ -18,9 +18,9 @@
 
 """This file contains stuff that is required by the server as well as the client."""
 
-import hashlib
-import pickle
+from enum import Enum, IntEnum
 from time import time
+from typing import Tuple
 
 import numpy as np
 from scipy.signal import correlate, resample
@@ -30,26 +30,33 @@
 # conversion of bits to V
 ANALOG_OUT_V = 1.8 / ((2**15) - 1)
 
-LOW_PASS_FILTER = 0
-HIGH_PASS_FILTER = 1
-
-FAST_OUT1 = 0
-FAST_OUT2 = 1
-ANALOG_OUT0 = 2
+AUTOLOCK_MAX_N_INSTRUCTIONS = 32
 
 DECIMATION = 8
-assert DECIMATION % 2 == 0 or DECIMATION == 1
 MAX_N_POINTS = 16384
 N_POINTS = int(MAX_N_POINTS / DECIMATION)
 
-AUTOLOCK_MAX_N_INSTRUCTIONS = 32
 
-AUTO_DETECT_AUTOLOCK_MODE = 0
-ROBUST_AUTOLOCK = 1
-FAST_AUTOLOCK = 2
+class FilterType(IntEnum):
+    LOW_PASS = 0
+    HIGH_PASS = 1
+
+
+class OutputChannel(IntEnum):
+    FAST_OUT1 = 0
+    FAST_OUT2 = 1
+    ANALOG_OUT0 = 2
+
+
+class AutolockMode(IntEnum):
+    AUTO_DETECT = 0
+    ROBUST = 1
+    FAST = 2
 
-PSD_ALGORITHM_WELCH = "welch"
-PSD_ALGORITHM_LPSD = "lpsd"
+
+class PSDAlgorithm(str, Enum):
+    WELCH = "welch"
+    LPSD = "lpsd"
 
 
 class SpectrumUncorrelatedException(Exception):
@@ -251,7 +258,7 @@ def get_lock_point(error_signal, x0, x1, final_zoom_factor=1.5):
     )
 
 
-def convert_channel_mixing_value(value):
+def convert_channel_mixing_value(value: int) -> Tuple[int, int]:
     if value <= 0:
         a_value = 128
         b_value = 128 + value
@@ -278,7 +285,7 @@ def combine_error_signal(
     return np.array([v + combined_offset for v in signal])
 
 
-def check_plot_data(is_locked, plot_data):
+def check_plot_data(is_locked: bool, plot_data) -> bool:
     if is_locked:
         if "error_signal" not in plot_data or "control_signal" not in plot_data:
             return False
@@ -288,23 +295,6 @@ def check_plot_data(is_locked, plot_data):
     return True
 
 
-def pack(value):
-    try:
-        return pickle.dumps(value)
-    except Exception:
-        # this happens when un-pickleable objects (e.g. functions) are assigned
-        # to a parameter. In this case, we don't pickle it but transfer a netref
-        # instead
-        return value
-
-
-def unpack(value):
-    try:
-        return pickle.loads(value)
-    except Exception:
-        return value
-
-
 def get_signal_strength_from_i_q(i, q):
     i = i.astype(np.int64)
     q = q.astype(np.int64)
@@ -312,8 +302,3 @@ def get_signal_strength_from_i_q(i, q):
     q_squared = q**2
     signal_strength = np.sqrt(i_squared + q_squared)
     return signal_strength
-
-
-def hash_username_and_password(username, password):
-    secret = hashlib.sha256((username + "/" + password).encode()).hexdigest()
-    return secret
diff --git a/linien-common/linien_common/communication.py b/linien-common/linien_common/communication.py
new file mode 100644
index 00000000..a06b4d32
--- /dev/null
+++ b/linien-common/linien_common/communication.py
@@ -0,0 +1,81 @@
+# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+#
+# This file is part of Linien and based on redpid.
+#
+# Linien is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Linien is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+
+import hashlib
+import pickle
+from socket import socket
+from typing import Any, Tuple
+
+from rpyc.utils.authenticators import AuthenticationError
+
+from .config import USER_DATA_PATH
+
+HASH_FILE_NAME = "auth_hash.txt"
+
+
+def pack(value: Any) -> bytes:
+    try:
+        return pickle.dumps(value)
+    # FIXME: Replace with TypeError, AttributeError and maybe more
+    except Exception:
+        # this happens when un-pickleable objects (e.g. functions) are assigned to a
+        # parameter. In this case, we don't pickle it but transfer a netref instead.
+        return value
+
+
+def unpack(value: Any) -> Any:
+    try:
+        return pickle.loads(value)
+    # FIXME: Replace with TypeError, AttributeError and maybe more
+    except Exception:
+        return value
+
+
+def hash_username_and_password(username: str, password: str) -> str:
+    return hashlib.sha256((username + "/" + password).encode()).hexdigest()
+
+
+def no_authenticator(sock: socket) -> Tuple[socket, None]:
+    """
+    Simply reads out the authentication hash so that the connection does not get stuck.
+    """
+    _ = sock.recv(64)
+    return sock, None
+
+
+def username_and_password_authenticator(sock: socket) -> Tuple[socket, None]:
+    """
+    Authenticate a client using username and password.
+    """
+    rpyc_hash = sock.recv(64).decode()
+    try:
+        with open(str(USER_DATA_PATH / HASH_FILE_NAME), "r") as f:
+            file_hash = f.read()
+    except FileNotFoundError:
+        raise AuthenticationError(
+            "No authentication hash found. Start the server  via the client or with the"
+            " `--no-auth` flag."
+        )
+    if file_hash != rpyc_hash:
+        raise AuthenticationError("Authentication hashes do not match.")
+    return sock, None
+
+
+def write_hash_to_file(hash: str) -> None:
+    USER_DATA_PATH.mkdir(parents=True, exist_ok=True)
+    with open(str(USER_DATA_PATH / HASH_FILE_NAME), "w") as f:
+        f.write(hash)
diff --git a/linien-common/linien_common/config.py b/linien-common/linien_common/config.py
index 309e1cdf..3a2489b9 100644
--- a/linien-common/linien_common/config.py
+++ b/linien-common/linien_common/config.py
@@ -1,4 +1,5 @@
 # Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
+# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -15,17 +16,12 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
+from pathlib import Path
+
+from appdirs import AppDirs
+
 ACQUISITION_PORT = 19321
 DEFAULT_SERVER_PORT = 18862
 DEFAULT_SWEEP_SPEED = (125 * 2048) << 6
-# IMPORTANT: DEFAULT_COLORS and N_COLORS have to be here, not in client.config
-# because the server needs them and shouldn't import client config as it requires
-# additional packages
-DEFAULT_COLORS = [
-    (200, 0, 0, 200),
-    (0, 200, 0, 200),
-    (0, 0, 200, 200),
-    (200, 200, 0, 200),
-    (200, 0, 200, 200),
-]
-N_COLORS = len(DEFAULT_COLORS)
+
+USER_DATA_PATH = Path(AppDirs("linien").user_data_dir)
diff --git a/linien-common/linien_common/influxdb.py b/linien-common/linien_common/influxdb.py
new file mode 100644
index 00000000..a9a7adef
--- /dev/null
+++ b/linien-common/linien_common/influxdb.py
@@ -0,0 +1,83 @@
+# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+#
+# This file is part of Linien and based on redpid.
+#
+# Linien is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Linien is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+
+import json
+
+from .config import USER_DATA_PATH
+
+CREDENTIAL_STORE_FILENAME = "influxdb_credentials.json"
+
+
+class InfluxDBCredentials:
+    def __init__(
+        self,
+        url: str = "http://localhost:8086",
+        org: str = "my-org",
+        token: str = "my-token",
+        bucket: str = "my-bucket",
+        measurement: str = "my-measurement",
+    ) -> None:
+        self.url = url
+        self.org = org
+        self.token = token
+        self.bucket = bucket
+        self.measurement = measurement
+
+    def __str__(self) -> str:
+        return "url: %s, org: %s, token: %s, bucket: %s, measurement: %s" % (
+            self.url,
+            self.org,
+            self.token,
+            self.bucket,
+            self.measurement,
+        )
+
+
+def save_credentials(credentials: InfluxDBCredentials) -> None:
+    """Save the credentials to disk."""
+    USER_DATA_PATH.mkdir(parents=True, exist_ok=True)
+    filename = str(USER_DATA_PATH / CREDENTIAL_STORE_FILENAME)
+    with open(filename, "w") as f:
+        json.dump(
+            {
+                "url": credentials.url,
+                "org": credentials.org,
+                "token": credentials.token,
+                "bucket": credentials.bucket,
+                "measurement": credentials.measurement,
+            },
+            f,
+            indent=2,
+        )
+    print("Saved InfluxDB credentials to ", filename)
+
+
+def restore_credentials() -> InfluxDBCredentials:
+    """When the server starts, it tries to restore the credentials."""
+    filename = USER_DATA_PATH / CREDENTIAL_STORE_FILENAME
+    try:
+        with open(str(filename), "r") as f:
+            data = json.load(f)
+        return InfluxDBCredentials(
+            url=data["url"],
+            org=data["org"],
+            token=data["token"],
+            bucket=data["bucket"],
+            measurement=data["measurement"],
+        )
+    except FileNotFoundError:
+        return InfluxDBCredentials()
diff --git a/linien-common/setup.py b/linien-common/setup.py
index ecba2cde..896150ec 100644
--- a/linien-common/setup.py
+++ b/linien-common/setup.py
@@ -19,7 +19,7 @@
 
 setup(
     name="linien-common",
-    version="0.7.0",
+    version="0.8.0",
     author="Benjamin Wiegand",
     author_email="highwaychile@posteo.de",
     maintainer="Bastian Leykauf",
@@ -36,8 +36,9 @@
     ],
     python_requires=">=3.5",
     install_requires=[
+        "importlib_metadata>=2.1.3",
         "numpy>=1.11.0",
+        "rpyc>=4.0,<5.0",
         "scipy>=0.17.0",
-        "importlib_metadata>=2.1.3",
     ],
 )
diff --git a/linien-gui/linien_gui/app.py b/linien-gui/linien_gui/app.py
index 5d198dbc..4f943216 100644
--- a/linien-gui/linien_gui/app.py
+++ b/linien-gui/linien_gui/app.py
@@ -20,39 +20,37 @@
 from traceback import print_exc
 
 import click
-import linien_gui
+from linien_client.connection import LinienClient
+from linien_gui import __version__
+from linien_gui.config import load_settings
 from linien_gui.ui.device_manager import DeviceManager
 from linien_gui.ui.main_window import MainWindow
 from linien_gui.ui.psd_window import PSDWindow
 from linien_gui.ui.version_checker import VersionCheckerThread
-from linien_gui.utils import set_window_icon
-from linien_gui.widgets import UI_PATH, CustomWidget
+from linien_gui.widgets import UI_PATH
 from PyQt5 import QtWidgets
+from PyQt5.QtCore import pyqtSignal
 from pyqtgraph.Qt import QtCore
 
 sys.path += [str(UI_PATH)]
 
 
-class QTApp(QtCore.QObject):
-    def __init__(self):
-        self.app = QtWidgets.QApplication(sys.argv)
+class LinienApp(QtWidgets.QApplication):
+    connection_established = pyqtSignal()
 
-        self.main_window = MainWindow()
-        self.main_window.app = self
-        set_window_icon(self.main_window)
+    def __init__(self, *args, **kwargs):
+        super(LinienApp, self).__init__(*args, **kwargs)
 
-        self.device_manager = DeviceManager()
-        self.device_manager.app = self
-        self.device_manager.show()
+        self.settings = load_settings()
 
+        self.main_window = MainWindow()
+        self.device_manager = DeviceManager()
         self.psd_window = PSDWindow()
-        self.psd_window.app = self
-
-        self.app.aboutToQuit.connect(lambda: self.app.quit())
+        self.device_manager.show()
 
-        super().__init__()
+        self.aboutToQuit.connect(self.quit)
 
-    def client_connected(self, client):
+    def client_connected(self, client: LinienClient):
         self.device_manager.hide()
         self.main_window.show(client.host, client.name)
 
@@ -60,44 +58,25 @@ def client_connected(self, client):
         self.control = client.control
         self.parameters = client.parameters
 
-        for instance in CustomWidget.instances:
-            try:
-                instance.on_connection_established()
-            except Exception:
-                print(
-                    (
-                        "The error below happend when calling connection_established "
-                        "of a widget. This may happen if the widget was recently "
-                        "destroyed."
-                    )
-                )
-                print_exc()
+        self.connection_established.emit()
 
-        self.call_listeners()
+        self.periodically_check_for_changed_parameters()
 
         self.check_for_new_version()
 
-    def call_listeners(self):
+    def periodically_check_for_changed_parameters(self):
         if hasattr(self, "client") and self.client and self.client.connected:
             try:
-                self.parameters.call_listeners()
+                self.parameters.check_for_changed_parameters()
             except AttributeError:
-                print("call_listeners() failed")
+                print("check_for_changed_parameters() failed")
                 print_exc()
 
-            QtCore.QTimer.singleShot(50, self.call_listeners)
-
-    def get_widget(self, name, window=None):
-        """Queries a widget by name."""
-        window = window or self.main_window
-        return window.findChild(QtCore.QObject, name)
-
-    def close(self):
-        self.app.quit()
+            QtCore.QTimer.singleShot(50, self.periodically_check_for_changed_parameters)
 
     def shutdown(self):
-        self.client.control.shutdown()
-        self.close()
+        self.client.control.exposed_shutdown()
+        self.quit()
 
     def open_psd_window(self):
         # first hiding it, then showing it brings it to foregroud if it is in background
@@ -129,13 +108,13 @@ def new_version_available(self, new_version_available):
 
 
 @click.command()
-@click.version_option(linien_gui.__version__)
+@click.version_option(__version__)
 def run_application():
-    gui = QTApp()
+    app = LinienApp(sys.argv)
 
     # catch ctrl-c and shutdown
     signal.signal(signal.SIGINT, signal.SIG_DFL)
-    sys.exit(gui.app.exec_())
+    sys.exit(app.exec_())
 
 
 if __name__ == "__main__":
diff --git a/linien-gui/linien_gui/config.py b/linien-gui/linien_gui/config.py
index 0662b1b1..69224c29 100644
--- a/linien-gui/linien_gui/config.py
+++ b/linien-gui/linien_gui/config.py
@@ -15,16 +15,26 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-import os
+import json
 import pickle
 from enum import Enum
+from typing import Callable, Iterator, List, Tuple
 
-import appdirs
 import rpyc
+from linien_common.config import USER_DATA_PATH
 
 # don't plot more often than once per `DEFAULT_PLOT_RATE_LIMIT` seconds
 DEFAULT_PLOT_RATE_LIMIT = 0.1
 
+DEFAULT_COLORS = [
+    (200, 0, 0, 200),
+    (0, 200, 0, 200),
+    (0, 0, 200, 200),
+    (200, 200, 0, 200),
+    (200, 0, 200, 200),
+]
+N_COLORS = len(DEFAULT_COLORS)
+
 
 class Color(Enum):
     SPECTRUM1 = 0
@@ -36,27 +46,99 @@ class Color(Enum):
     MONITOR_SIGNAL_HISTORY = 4
 
 
-def get_data_folder():
-    folder_name = appdirs.user_data_dir("linien")
-
-    if not os.path.exists(folder_name):
-        os.makedirs(folder_name)
-
-    return folder_name
-
+class Setting:
+    def __init__(
+        self,
+        min_=None,
+        max_=None,
+        start=None,
+    ):
+        self.min = min_
+        self.max = max_
+        self._value = start
+        self.start = start
+        self._callbacks = set()
+
+    @property
+    def value(self):
+        return self._value
+
+    @value.setter
+    def value(self, value):
+        if self.min is not None and value < self.min:
+            value = self.min
+        if self.max is not None and value > self.max:
+            value = self.max
+        self._value = value
+
+        # We copy it because a listener could remove a listener --> this would cause an
+        # error in this loop.
+        for callback in self._callbacks.copy():
+            callback(value)
+
+    def add_callback(self, function: Callable, call_with_first_value: bool = True):
+        self._callbacks.add(function)
+
+        if call_with_first_value:
+            if self._value is not None:
+                function(self._value)
+
+    def remove_callback(self, function):
+        if function in self._callbacks:
+            self._callbacks.remove(function)
+
+
+class Settings:
+    def __init__(self):
+        self.plot_line_width = Setting(start=2, min_=0.1, max_=100)
+        self.plot_line_opacity = Setting(start=230, min_=0, max_=255)
+        self.plot_fill_opacity = Setting(start=70, min_=0, max_=255)
+        self.plot_color_0 = Setting(start=DEFAULT_COLORS[0])
+        self.plot_color_1 = Setting(start=DEFAULT_COLORS[1])
+        self.plot_color_2 = Setting(start=DEFAULT_COLORS[2])
+        self.plot_color_3 = Setting(start=DEFAULT_COLORS[3])
+        self.plot_color_4 = Setting(start=DEFAULT_COLORS[4])
+
+        # save changed settings to disk
+        for _, setting in self:
+            setting.add_callback(
+                lambda _: save_settings(self), call_with_first_value=False
+            )
+
+    def __iter__(self) -> Iterator[Tuple[str, Setting]]:
+        for name, setting in self.__dict__.items():
+            if isinstance(setting, Setting):
+                yield name, setting
+
+
+def save_settings(settings: Settings) -> None:
+    data = {name: setting.value for name, setting in settings}
+    with open(USER_DATA_PATH / "settings.json", "w") as f:
+        json.dump(data, f, indent=0)
+
+
+def load_settings() -> Settings:
+    settings = Settings()
+    try:
+        with open(USER_DATA_PATH / "settings.json", "r") as f:
+            data = json.load(f)
+            for name, value in data.items():
+                if name in settings.__dict__:
+                    getattr(settings, name).value = value
+    except FileNotFoundError:
+        save_settings(settings)
 
-def get_devices_filename():
-    return os.path.join(get_data_folder(), "devices")
+    return settings
 
 
-def save_device_data(devices):
-    with open(get_devices_filename(), "wb") as f:
+def save_device_data(devices) -> None:
+    with open(USER_DATA_PATH / "devices", "wb") as f:
         pickle.dump(devices, f)
 
 
-def load_device_data():
+def load_device_data() -> List[dict]:
     try:
-        with open(get_devices_filename(), "rb") as f:
+        with open(USER_DATA_PATH / "devices", "rb") as f:
             devices = pickle.load(f)
     except (FileNotFoundError, pickle.UnpicklingError, EOFError):
         devices = []
@@ -64,29 +146,30 @@ def load_device_data():
     return devices
 
 
-def save_parameter(device_key, param, value, delete=False):
+def save_parameter(
+    device_key: dict, param_name: str, value: object, delete: bool = False
+):
     devices = load_device_data()
     device = [d for d in devices if d["key"] == device_key][0]
     device.setdefault("params", {})
 
     if not delete:
         # FIXME: This is the only part where rpyc is used in linien-gui. Remove it if
-        # possible.
-        # rpyc obtain is for ensuring that we don't try to save a netref here
+        # possible. rpyc obtain is for ensuring that we don't try to save a netref here
         try:
-            device["params"][param] = rpyc.classic.obtain(value)
+            device["params"][param_name] = rpyc.classic.obtain(value)
         except Exception:
-            print("unable to obtain and save parameter", param)
+            print("unable to obtain and save parameter", param_name)
     else:
         try:
-            del device["params"][param]
+            del device["params"][param_name]
         except KeyError:
             pass
 
     save_device_data(devices)
 
 
-def get_saved_parameters(device_key):
+def get_saved_parameters(device_key: dict):
     devices = load_device_data()
     device = [d for d in devices if d["key"] == device_key][0]
     device.setdefault("params", {})
diff --git a/linien-gui/linien_gui/dialogs.py b/linien-gui/linien_gui/dialogs.py
index fd16c4b1..d28f4fa6 100644
--- a/linien-gui/linien_gui/dialogs.py
+++ b/linien-gui/linien_gui/dialogs.py
@@ -15,11 +15,9 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from time import sleep
 from typing import Callable
 
-from linien_gui.threads import RemoteServerInstallationThread
-from PyQt5.QtCore import QThread, pyqtSignal
+from PyQt5.QtCore import pyqtSignal
 from PyQt5.QtWidgets import (
     QDialog,
     QListWidget,
@@ -30,16 +28,18 @@
 )
 from pyqtgraph import QtCore
 
+from .threads import RemoteServerInstallationThread
+
 
 class SSHCommandOutputWidget(QListWidget):
     command_finished = pyqtSignal()
     enable_button = pyqtSignal()
 
     def __init__(self, parent: QWidget):
-        super().__init__(parent)
+        super(SSHCommandOutputWidget, self).__init__(parent)
         self.setSelectionMode(self.NoSelection)
 
-    def run(self, thread: QThread):
+    def run(self, thread):
         self.thread = thread
         self.thread.out_stream.new_item.connect(self.on_new_item_in_out_stream)
         self.thread.finished.connect(self.on_thread_finished)
@@ -86,7 +86,7 @@ class LoadingDialog(QMessageBox):
     aborted = pyqtSignal()
 
     def __init__(self, parent: QWidget, host: str):
-        super().__init__(parent)
+        super(LoadingDialog, self).__init__(parent)
 
         self.setIcon(QMessageBox.Information)
         self.setText(f"Connecting to {host}")
diff --git a/linien-gui/linien_gui/threads.py b/linien-gui/linien_gui/threads.py
index 9b221e40..59288834 100644
--- a/linien-gui/linien_gui/threads.py
+++ b/linien-gui/linien_gui/threads.py
@@ -21,15 +21,16 @@
 from linien_client.connection import LinienClient
 from linien_client.deploy import install_remote_server
 from linien_client.exceptions import (
-    GeneralConnectionErrorException,
+    GeneralConnectionError,
     InvalidServerVersionException,
     RPYCAuthenticationException,
     ServerNotInstalledException,
 )
 from linien_common.config import DEFAULT_SERVER_PORT
-from linien_gui.config import get_saved_parameters, save_parameter
 from PyQt5.QtCore import QObject, QThread, pyqtSignal
 
+from .config import get_saved_parameters, save_parameter
+
 
 class RemoteOutStream(QObject):
     new_item = pyqtSignal(str)
@@ -45,9 +46,9 @@ def flush(self):
 
 
 class RemoteServerInstallationThread(QThread):
-    def __init__(self, device):
+    def __init__(self, device: dict):
         """A thread that installs the linien server on a remote machine."""
-        super().__init__()
+        super(RemoteServerInstallationThread, self).__init__()
         self.device = device
 
     out_stream = RemoteOutStream()
@@ -62,8 +63,8 @@ def run(self):
 
 
 class ConnectionThread(QThread):
-    def __init__(self, device):
-        super().__init__()
+    def __init__(self, device: dict):
+        super(ConnectionThread, self).__init__()
         self.device = device
 
     client_connected = pyqtSignal(object)
@@ -101,7 +102,7 @@ def run(self):
                 # because we don't want to override our local settings with the remote
                 # one --> we wait until user has answered whether local parameters or
                 # remote ones should be used.
-                self.continuously_write_parameters_to_disk()
+                self.write_restorable_parameters_to_disk_on_change()
 
         except ServerNotInstalledException:
             self.server_not_installed_exception_raised.emit()
@@ -114,7 +115,7 @@ def run(self):
         except RPYCAuthenticationException:
             self.authentication_exception_raised.emit()
 
-        except GeneralConnectionErrorException:
+        except GeneralConnectionError:
             self.general_connection_exception_raised.emit()
 
         except Exception:
@@ -128,7 +129,7 @@ def answer_whether_to_restore_parameters(self, should_restore):
         if should_restore:
             self.restore_parameters(dry_run=False)
 
-        self.continuously_write_parameters_to_disk()
+        self.write_restorable_parameters_to_disk_on_change()
 
     def restore_parameters(self, dry_run=False):
         """
@@ -139,8 +140,7 @@ def restore_parameters(self, dry_run=False):
               local parameters differ from the ones on the server
             * `False`, the local parameters are uploaded to the server
         """
-        device_key = self.device["key"]
-        params = get_saved_parameters(device_key)
+        params = get_saved_parameters(self.device["key"])
         print("restoring parameters")
 
         differences = False
@@ -159,24 +159,23 @@ def restore_parameters(self, dry_run=False):
                 # This may happen if the settings were written with a different version
                 # of linien.
                 print(f"Unable to restore parameter {k}. Delete the cached value.")
-                save_parameter(device_key, k, None, delete=True)
+                save_parameter(self.device["key"], k, None, delete=True)
 
         if not dry_run:
             self.client.control.write_registers()
 
         return differences
 
-    def continuously_write_parameters_to_disk(self):
+    def write_restorable_parameters_to_disk_on_change(self):
         """
         Listens for changes of some parameters and permanently saves their values on the
         client's disk. This data can be used to restore the status later, if the client
         tries to connect to the server but it doesn't run anymore.
         """
-        params = self.client.parameters.remote.exposed_get_restorable_parameters()
-
-        for param in params:
+        for parameter_name, parameter in self.client.parameters:
+            if parameter.restorable:
 
-            def on_change(value, param=param):
-                save_parameter(self.device["key"], param, value)
+                def on_change(value, parameter_name: str = parameter_name) -> None:
+                    save_parameter(self.device["key"], parameter_name, value)
 
-            getattr(self.client.parameters, param).on_change(on_change)
+                parameter.add_callback(on_change)
diff --git a/linien-gui/linien_gui/ui/device_manager.py b/linien-gui/linien_gui/ui/device_manager.py
index 50f0fc7b..61825521 100644
--- a/linien-gui/linien_gui/ui/device_manager.py
+++ b/linien-gui/linien_gui/ui/device_manager.py
@@ -27,20 +27,19 @@
 )
 from linien_gui.threads import ConnectionThread
 from linien_gui.ui.new_device_dialog import NewDeviceDialog
-from linien_gui.utils import set_window_icon
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtCore, QtWidgets
+from linien_gui.utils import get_linien_app_instance, set_window_icon
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtCore, QtWidgets, uic
 
 
-class DeviceManager(QtWidgets.QMainWindow, CustomWidget):
+class DeviceManager(QtWidgets.QMainWindow):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("device_manager.ui")
+        super(DeviceManager, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "device_manager.ui", self)
         self.setWindowTitle(f"Linien spectroscopy lock v{linien_gui.__version__}")
         set_window_icon(self)
-
-    def ready(self):
-        self.load_device_data(autoload=True)
+        self.app = get_linien_app_instance()
+        QtCore.QTimer.singleShot(100, lambda: self.load_device_data(autoload=True))
 
     def keyPressEvent(self, event):
         key = event.key()
@@ -50,7 +49,7 @@ def keyPressEvent(self, event):
 
     def load_device_data(self, autoload=False):
         devices = load_device_data()
-        lst = self.ids.deviceList
+        lst = self.deviceList
         lst.clear()
 
         for device in devices:
@@ -67,7 +66,7 @@ def connect(self):
         else:
             self.connect_to_device(devices[self.get_list_index()])
 
-    def connect_to_device(self, device):
+    def connect_to_device(self, device: dict):
         loading_dialog = LoadingDialog(self, device["host"])
         loading_dialog.show()
 
@@ -163,7 +162,7 @@ def ask_for_parameter_restore():
 
         def handle_connection_lost():
             error_dialog(self, "Lost connection to the server!")
-            self.app.close()
+            self.app.quit()
 
         # Connect slots to signals -----------------------------------------------------
         self.connection_thread.client_connected.connect(on_client_connected)
@@ -240,10 +239,10 @@ def move_device(self, direction):
         devices = devices[:new_index] + [device] + devices[new_index:]
         save_device_data(devices)
         self.load_device_data()
-        self.ids.deviceList.setCurrentRow(new_index)
+        self.deviceList.setCurrentRow(new_index)
 
     def get_list_index(self):
-        return self.ids.deviceList.currentIndex().row()
+        return self.deviceList.currentIndex().row()
 
     def remove_device(self):
         devices = load_device_data()
@@ -267,10 +266,10 @@ def selected_device_changed(self):
                 disable_buttons = False
 
         for btn in [
-            self.ids.connectButton,
-            self.ids.removeButton,
-            self.ids.editButton,
-            self.ids.moveUpButton,
-            self.ids.moveDownButton,
+            self.connectButton,
+            self.removeButton,
+            self.editButton,
+            self.moveUpButton,
+            self.moveDownButton,
         ]:
             btn.setEnabled(not disable_buttons)
diff --git a/linien-gui/linien_gui/ui/general_panel.py b/linien-gui/linien_gui/ui/general_panel.py
index 9b569cc2..91076ab2 100644
--- a/linien-gui/linien_gui/ui/general_panel.py
+++ b/linien-gui/linien_gui/ui/general_panel.py
@@ -18,21 +18,21 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import (
-    ANALOG_OUT0,
     ANALOG_OUT_V,
-    FAST_OUT1,
-    FAST_OUT2,
+    OutputChannel,
     convert_channel_mixing_value,
 )
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class GeneralPanel(QtWidgets.QWidget, CustomWidget):
+class GeneralPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
         super(GeneralPanel, self).__init__(*args, **kwargs)
-        self.load_ui("general_panel.ui")
+        uic.loadUi(UI_PATH / "general_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         self.channelMixingSlider.valueChanged.connect(self.on_channel_mixing_changed)
         self.fastModeCheckBox.stateChanged.connect(self.on_fast_mode_changed)
@@ -59,9 +59,7 @@ def __init__(self, *args, **kwargs):
             self.on_polarity_analog_out0_changed
         )
 
-        for idx in range(4):
-            if idx == 0:
-                continue
+        for idx in range(1, 4):
             element = getattr(self, f"analogOutComboBox{idx}")
             element.setKeyboardTracking(False)
             element.valueChanged.connect(
@@ -105,15 +103,13 @@ def on_dual_channel_changed(value):
         param2ui(self.parameters.polarity_fast_out2, self.polarityComboBoxFastOut2)
         param2ui(self.parameters.polarity_analog_out0, self.polarityComboBoxAnalogOut0)
 
-        self.parameters.control_channel.on_change(self.show_polarity_settings)
-        self.parameters.sweep_channel.on_change(self.show_polarity_settings)
-        self.parameters.mod_channel.on_change(self.show_polarity_settings)
-        self.parameters.slow_control_channel.on_change(self.show_polarity_settings)
-        self.parameters.pid_on_slow_enabled.on_change(self.show_polarity_settings)
+        self.parameters.control_channel.add_callback(self.show_polarity_settings)
+        self.parameters.sweep_channel.add_callback(self.show_polarity_settings)
+        self.parameters.mod_channel.add_callback(self.show_polarity_settings)
+        self.parameters.slow_control_channel.add_callback(self.show_polarity_settings)
+        self.parameters.pid_on_slow_enabled.add_callback(self.show_polarity_settings)
 
-        for idx in range(4):
-            if idx == 0:
-                continue
+        for idx in range(1, 4):
             param2ui(
                 getattr(self.parameters, f"analog_out_{idx}"),
                 getattr(self, f"analogOutComboBox{idx}"),
@@ -129,7 +125,7 @@ def on_fast_mode_changed(fast_mode_enabled):
             for widget in widgets_to_disable:
                 widget.setEnabled(not fast_mode_enabled)
 
-        self.parameters.fast_mode.on_change(on_fast_mode_changed)
+        self.parameters.fast_mode.add_callback(on_fast_mode_changed)
 
     def on_analog_out_changed(self, idx):
         getattr(self.parameters, f"analog_out_{idx}").value = int(
@@ -200,6 +196,12 @@ def show_polarity_settings(self, *args):
             used_channels.add(self.parameters.slow_control_channel.value)
 
         self.polaritySelectorGroupBox.setVisible(len(used_channels) > 1)
-        self.polarityContainerFastOut1.setVisible(FAST_OUT1 in used_channels)
-        self.polarityContainerFastOut2.setVisible(FAST_OUT2 in used_channels)
-        self.polarityContainerAnalogOut0.setVisible(ANALOG_OUT0 in used_channels)
+        self.polarityContainerFastOut1.setVisible(
+            OutputChannel.FAST_OUT1 in used_channels
+        )
+        self.polarityContainerFastOut2.setVisible(
+            OutputChannel.FAST_OUT2 in used_channels
+        )
+        self.polarityContainerAnalogOut0.setVisible(
+            OutputChannel.ANALOG_OUT0 in used_channels
+        )
diff --git a/linien-gui/linien_gui/ui/general_panel.ui b/linien-gui/linien_gui/ui/general_panel.ui
index 7063aec6..1daa3528 100644
--- a/linien-gui/linien_gui/ui/general_panel.ui
+++ b/linien-gui/linien_gui/ui/general_panel.ui
@@ -23,7 +23,7 @@
       <item>
        <widget class="QLabel" name="label_2">
         <property name="text">
-         <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Fast mode is intended for bare PID operation (no demodulation or filtering). If enabled, the signal flow is FAST IN 1 → PID → FAST OUT 2&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+         <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Fast mode is intended for bare PID operation (no demodulation,  filtering or offsets). If enabled, the signal flow is FAST IN 1 → PID → FAST OUT 2&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
         </property>
         <property name="wordWrap">
          <bool>true</bool>
diff --git a/linien-gui/linien_gui/ui/lock_status_panel.py b/linien-gui/linien_gui/ui/lock_status_panel.py
index 3c91b8ba..bb11b259 100644
--- a/linien-gui/linien_gui/ui/lock_status_panel.py
+++ b/linien-gui/linien_gui/ui/lock_status_panel.py
@@ -16,20 +16,23 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance, param2ui
+from PyQt5 import QtCore, QtWidgets
 
 
-class LockStatusPanel(QtWidgets.QWidget, CustomWidget):
+class LockStatusPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+        super(LockStatusPanel, self).__init__(*args, **kwargs)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+        QtCore.QTimer.singleShot(100, self.ready)
 
     def ready(self):
-        self.ids.stop_lock.clicked.connect(self.stop_lock)
-        self.ids.control_signal_history_length.setKeyboardTracking(False)
-        self.ids.control_signal_history_length.valueChanged.connect(
-            self.control_signal_history_length_changed
+        self.parent = self.parent()
+        self.parent.stopLockPushButton.clicked.connect(self.on_stop_lock)
+        self.parent.controlSignalHistoryLengthSpinBox.setKeyboardTracking(False)
+        self.parent.controlSignalHistoryLengthSpinBox.valueChanged.connect(
+            self.on_control_signal_history_length_changed
         )
 
     def on_connection_established(self):
@@ -57,7 +60,7 @@ def update_status(_):
                 watching = False
 
             def set_text(text):
-                self.ids.lock_status.setText(text)
+                self.parent.lock_status.setText(text)
 
             if not running and locked:
                 set_text("Locked!")
@@ -65,9 +68,7 @@ def set_text(text):
                 set_text("Locked! Watching continuously...")
             if running and not watching and not locked and preparing:
                 if not retrying:
-                    set_text(
-                        "Autolock is running... Analyzing data (%d %%)" % percentage
-                    )
+                    set_text(f"Autolock is running... Analyzing data ({percentage} %%)")
                 else:
                     set_text("Trying again to lock...")
 
@@ -82,14 +83,14 @@ def set_text(text):
             self.parameters.autolock_retrying,
             self.parameters.autolock_percentage,
         ):
-            param.on_change(update_status)
+            param.add_callback(update_status)
 
         param2ui(
             self.parameters.control_signal_history_length,
-            self.ids.control_signal_history_length,
+            self.parent.controlSignalHistoryLengthSpinBox,
         )
 
-    def stop_lock(self):
+    def on_stop_lock(self):
         self.parameters.fetch_additional_signals.value = True
 
         if self.parameters.task.value is not None:
@@ -99,7 +100,7 @@ def stop_lock(self):
 
         self.control.exposed_start_sweep()
 
-    def control_signal_history_length_changed(self):
+    def on_control_signal_history_length_changed(self):
         self.parameters.control_signal_history_length.value = (
-            self.ids.control_signal_history_length.value()
+            self.parent.controlSignalHistoryLengthSpinBox.value()
         )
diff --git a/linien-gui/linien_gui/ui/locking_panel.py b/linien-gui/linien_gui/ui/locking_panel.py
index f3f277d1..0aa68ba1 100644
--- a/linien-gui/linien_gui/ui/locking_panel.py
+++ b/linien-gui/linien_gui/ui/locking_panel.py
@@ -16,42 +16,38 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import FAST_AUTOLOCK
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_common.common import AutolockMode
+from linien_gui.utils import get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class LockingPanel(QtWidgets.QWidget, CustomWidget):
+class LockingPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("locking_panel.ui")
-
-    def ready(self):
-        self.ids.kp.valueChanged.connect(self.kp_changed)
-        self.ids.ki.valueChanged.connect(self.ki_changed)
-        self.ids.kd.valueChanged.connect(self.kd_changed)
-        # self.ids.checkLockCheckbox.stateChanged.connect(self.check_lock_changed)
-        # self.ids.watchLockCheckbox.stateChanged.connect(self.watch_lock_changed)
-        # self.ids.watch_lock_threshold.valueChanged.connect(
-        #    self.watch_lock_threshold_changed
-        # )
-        self.ids.lock_control_container.currentChanged.connect(self.lock_mode_changed)
-
-        self.ids.selectLineToLock.clicked.connect(self.start_autolock_selection)
-        self.ids.abortLineSelection.clicked.connect(self.stop_autolock_selection)
-
-        self.ids.manualLockButton.clicked.connect(self.start_manual_lock)
-        self.ids.autoOffsetCheckbox.stateChanged.connect(self.auto_offset_changed)
-
-        self.ids.pid_on_slow_strength.setKeyboardTracking(False)
-        self.ids.pid_on_slow_strength.valueChanged.connect(
+        super(LockingPanel, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "locking_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+
+        self.kpSpinBox.valueChanged.connect(self.kp_changed)
+        self.kiSpinBox.valueChanged.connect(self.ki_changed)
+        self.kdSpinBox.valueChanged.connect(self.kd_changed)
+        self.lock_control_container.currentChanged.connect(self.lock_mode_changed)
+
+        self.selectLineToLock.clicked.connect(self.start_autolock_selection)
+        self.abortLineSelection.clicked.connect(self.stop_autolock_selection)
+
+        self.manualLockButton.clicked.connect(self.start_manual_lock)
+        self.autoOffsetCheckbox.stateChanged.connect(self.auto_offset_changed)
+
+        self.pid_on_slow_strength.setKeyboardTracking(False)
+        self.pid_on_slow_strength.valueChanged.connect(
             self.pid_on_slow_strength_changed
         )
 
-        self.ids.reset_lock_failed_state.clicked.connect(self.reset_lock_failed)
+        self.reset_lock_failed_state.clicked.connect(self.reset_lock_failed)
 
-        self.ids.autolock_mode_preference.currentIndexChanged.connect(
+        self.autolock_mode_preference.currentIndexChanged.connect(
             self.autolock_mode_preference_changed
         )
 
@@ -59,31 +55,22 @@ def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
-        param2ui(self.parameters.p, self.ids.kp)
-        param2ui(self.parameters.i, self.ids.ki)
-        param2ui(self.parameters.d, self.ids.kd)
-
-        # param2ui(params.check_lock, self.ids.checkLockCheckbox)
-        # param2ui(params.watch_lock, self.ids.watchLockCheckbox)
-        # param2ui(
-        #    params.watch_lock_threshold,
-        #    self.ids.watch_lock_threshold,
-        #    lambda v: v * 100,
-        # )
-        param2ui(self.parameters.autolock_determine_offset, self.ids.autoOffsetCheckbox)
+        param2ui(self.parameters.p, self.kpSpinBox)
+        param2ui(self.parameters.i, self.kiSpinBox)
+        param2ui(self.parameters.d, self.kdSpinBox)
+
+        param2ui(self.parameters.autolock_determine_offset, self.autoOffsetCheckbox)
         param2ui(
             self.parameters.automatic_mode,
-            self.ids.lock_control_container,
+            self.lock_control_container,
             lambda value: 0 if value else 1,
         )
-        param2ui(self.parameters.pid_on_slow_strength, self.ids.pid_on_slow_strength)
+        param2ui(self.parameters.pid_on_slow_strength, self.pid_on_slow_strength)
 
         def slow_pid_visibility(*args):
-            self.ids.slow_pid_group.setVisible(
-                self.parameters.pid_on_slow_enabled.value
-            )
+            self.slow_pid_group.setVisible(self.parameters.pid_on_slow_enabled.value)
 
-        self.parameters.pid_on_slow_enabled.on_change(slow_pid_visibility)
+        self.parameters.pid_on_slow_enabled.add_callback(slow_pid_visibility)
 
         def lock_status_changed(_):
             locked = self.parameters.lock.value
@@ -92,11 +79,11 @@ def lock_status_changed(_):
             task_running = (task is not None) and (not al_failed)
 
             if locked or task_running or al_failed:
-                self.ids.lock_control_container.hide()
+                self.lock_control_container.hide()
             else:
-                self.ids.lock_control_container.show()
+                self.lock_control_container.show()
 
-            self.ids.lock_failed.setVisible(al_failed)
+            self.lock_failed.setVisible(al_failed)
 
         for param in (
             self.parameters.lock,
@@ -105,35 +92,37 @@ def lock_status_changed(_):
             self.parameters.autolock_failed,
             self.parameters.autolock_locked,
         ):
-            param.on_change(lock_status_changed)
+            param.add_callback(lock_status_changed)
 
-        param2ui(self.parameters.target_slope_rising, self.ids.button_slope_rising)
+        param2ui(self.parameters.target_slope_rising, self.button_slope_rising)
         param2ui(
             self.parameters.target_slope_rising,
-            self.ids.button_slope_falling,
+            self.button_slope_falling,
             lambda value: not value,
         )
 
         def autolock_selection_status_changed(value):
-            self.ids.auto_mode_activated.setVisible(value)
-            self.ids.auto_mode_not_activated.setVisible(not value)
+            self.auto_mode_activated.setVisible(value)
+            self.auto_mode_not_activated.setVisible(not value)
 
-        self.parameters.autolock_selection.on_change(autolock_selection_status_changed)
+        self.parameters.autolock_selection.add_callback(
+            autolock_selection_status_changed
+        )
 
         param2ui(
-            self.parameters.autolock_mode_preference, self.ids.autolock_mode_preference
+            self.parameters.autolock_mode_preference, self.autolock_mode_preference
         )
 
     def kp_changed(self):
-        self.parameters.p.value = self.ids.kp.value()
+        self.parameters.p.value = self.kpSpinBox.value()
         self.control.write_registers()
 
     def ki_changed(self):
-        self.parameters.i.value = self.ids.ki.value()
+        self.parameters.i.value = self.kiSpinBox.value()
         self.control.write_registers()
 
     def kd_changed(self):
-        self.parameters.d.value = self.ids.kd.value()
+        self.parameters.d.value = self.kdSpinBox.value()
         self.control.write_registers()
 
     def lock_mode_changed(self, idx):
@@ -143,24 +132,20 @@ def autolock_mode_preference_changed(self, idx):
         self.parameters.autolock_mode_preference.value = idx
 
     def start_manual_lock(self):
-        self.parameters.target_slope_rising.value = (
-            self.ids.button_slope_rising.isChecked()
-        )
+        self.parameters.target_slope_rising.value = self.button_slope_rising.isChecked()
         self.parameters.fetch_additional_signals.value = False
-        self.parameters.autolock_mode.value = FAST_AUTOLOCK
+        self.parameters.autolock_mode.value = AutolockMode.FAST
         self.parameters.autolock_target_position.value = 0
         self.control.write_registers()
         self.control.start_lock()
 
     def auto_offset_changed(self):
         self.parameters.autolock_determine_offset.value = int(
-            self.ids.autoOffsetCheckbox.checkState()
+            self.autoOffsetCheckbox.checkState()
         )
 
     def pid_on_slow_strength_changed(self):
-        self.parameters.pid_on_slow_strength.value = (
-            self.ids.pid_on_slow_strength.value()
-        )
+        self.parameters.pid_on_slow_strength.value = self.pid_on_slow_strength.value()
         self.control.write_registers()
 
     def start_autolock_selection(self):
@@ -169,10 +154,5 @@ def start_autolock_selection(self):
     def stop_autolock_selection(self):
         self.parameters.autolock_selection.value = False
 
-    """def watch_lock_threshold_changed(self):
-        self.parameters.watch_lock_threshold.value = (
-            self.ids.watch_lock_threshold.value() / 100.0
-        )"""
-
     def reset_lock_failed(self):
         self.parameters.autolock_failed.value = False
diff --git a/linien-gui/linien_gui/ui/locking_panel.ui b/linien-gui/linien_gui/ui/locking_panel.ui
index 5f537395..543e233e 100644
--- a/linien-gui/linien_gui/ui/locking_panel.ui
+++ b/linien-gui/linien_gui/ui/locking_panel.ui
@@ -79,7 +79,7 @@
           </widget>
          </item>
          <item>
-          <widget class="CustomSpinBox" name="kp">
+          <widget class="CustomSpinBox" name="kpSpinBox">
            <property name="keyboardTracking">
             <bool>false</bool>
            </property>
@@ -157,7 +157,7 @@
           </widget>
          </item>
          <item>
-          <widget class="CustomSpinBox" name="ki">
+          <widget class="CustomSpinBox" name="kiSpinBox">
            <property name="keyboardTracking">
             <bool>false</bool>
            </property>
@@ -238,7 +238,7 @@
           </widget>
          </item>
          <item>
-          <widget class="CustomSpinBox" name="kd">
+          <widget class="CustomSpinBox" name="kdSpinBox">
            <property name="sizePolicy">
             <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
              <horstretch>0</horstretch>
@@ -695,7 +695,7 @@
        </widget>
       </item>
       <item>
-       <widget class="QPushButton" name="stop_lock">
+       <widget class="QPushButton" name="stopLockPushButton">
         <property name="palette">
          <palette>
           <active>
@@ -1132,7 +1132,7 @@
         </property>
         <layout class="QHBoxLayout" name="horizontalLayout_9">
          <item>
-          <widget class="CustomSpinBox" name="control_signal_history_length">
+          <widget class="CustomSpinBox" name="controlSignalHistoryLengthSpinBox">
            <property name="minimum">
             <number>10</number>
            </property>
diff --git a/linien-gui/linien_gui/ui/logging_panel.py b/linien-gui/linien_gui/ui/logging_panel.py
new file mode 100644
index 00000000..32add53e
--- /dev/null
+++ b/linien-gui/linien_gui/ui/logging_panel.py
@@ -0,0 +1,156 @@
+# Copyright 2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+#
+# This file is part of Linien.
+#
+# Linien is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Linien is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+
+import pickle
+
+from linien_client.remote_parameters import RemoteParameters
+from linien_common.influxdb import InfluxDBCredentials
+from linien_gui.utils import get_linien_app_instance
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
+from PyQt5.QtCore import pyqtSignal
+
+START_LOG_BUTTON_TEXT = "Start Logging"
+STOP_LOG_BUTTON_TEXT = "Stop Logging"
+
+
+class LoggingPanel(QtWidgets.QWidget):
+    set_parameter_log = pyqtSignal(str, bool)
+    influx_credentials_update = pyqtSignal(bool, int, str)
+
+    logParametersToolButton: "LoggedParametersToolButton"
+    lineEditURL: QtWidgets.QLineEdit
+    lineEditOrg: QtWidgets.QLineEdit
+    lineEditToken: QtWidgets.QLineEdit
+    lineEditBucket: QtWidgets.QLineEdit
+    lineEditMeas: QtWidgets.QLineEdit
+    influxUpdateButton: QtWidgets.QPushButton
+    influxTestIndicator: QtWidgets.QLabel
+    logIntervalSpinBox: QtWidgets.QSpinBox
+    logPushButton: QtWidgets.QPushButton
+
+    def __init__(self, *args, **kwargs) -> None:
+        super(LoggingPanel, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "logging_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+
+        self.logParametersToolButton.setPopupMode(QtWidgets.QToolButton.InstantPopup)
+        self.loggedParametersMenu = LoggedParametersMenu()
+
+    def on_connection_established(self) -> None:
+        self.parameters = self.app.parameters
+        self.control = self.app.control
+
+        self.loggedParametersMenu.create_menu_entries(self.parameters)
+        self.logParametersToolButton.setMenu(self.loggedParametersMenu)
+
+        self.loggedParametersMenu.item_clicked.connect(
+            self.on_parameter_log_status_changed
+        )
+        self.logPushButton.clicked.connect(self.on_logging_button_clicked)
+        self.influxUpdateButton.clicked.connect(self.on_influx_update_button_clicked)
+        self.influx_credentials_update.connect(self.on_influxdb_credentials_updated)
+
+        # getting the influxdb credentials from the remote
+        credentials = pickle.loads(self.control.exposed_get_influxdb_credentials())
+        print("Received InfluxDB credentials from server.")
+        self.lineEditURL.setText(credentials.url)
+        self.lineEditOrg.setText(credentials.org)
+        self.lineEditToken.setText(credentials.token)
+        self.lineEditBucket.setText(credentials.bucket)
+        self.lineEditMeas.setText(credentials.measurement)
+
+        # getting the logging status from the remote
+        log_status = self.control.exposed_get_logging_status()
+        button_text = STOP_LOG_BUTTON_TEXT if log_status else START_LOG_BUTTON_TEXT
+        self.logPushButton.setChecked(log_status)
+        self.logPushButton.setText(button_text)
+        self.logIntervalSpinBox.setEnabled(not log_status)
+
+    def on_parameter_log_status_changed(self, param_name: str, status: bool) -> None:
+        self.control.exposed_set_parameter_log(param_name, status)
+
+    def on_logging_button_clicked(self) -> None:
+        if self.logPushButton.isChecked():
+            self.control.exposed_start_logging(self.logIntervalSpinBox.value())
+            self.logPushButton.setText(STOP_LOG_BUTTON_TEXT)
+            self.logIntervalSpinBox.setEnabled(False)
+        else:
+            self.control.exposed_stop_logging()
+            self.logPushButton.setText(START_LOG_BUTTON_TEXT)
+            self.logIntervalSpinBox.setEnabled(True)
+
+    def on_influx_update_button_clicked(self) -> None:
+        credentials = InfluxDBCredentials(
+            url=self.lineEditURL.text(),
+            org=self.lineEditOrg.text(),
+            token=self.lineEditToken.text(),
+            bucket=self.lineEditBucket.text(),
+            measurement=self.lineEditMeas.text(),
+        )
+        sucess, status_code, message = self.control.exposed_update_influxdb_credentials(
+            credentials
+        )
+        print(f"Update of InfluxDB credentials successful: {sucess}")
+        if not sucess:
+            print(f"Status code: {status_code}")
+            print(f"Message: {message}")
+        self.influx_credentials_update.emit(sucess, status_code, message)
+
+    def on_influxdb_credentials_updated(
+        self, success: bool, status_code: int, message: str
+    ) -> None:
+        if success:
+            self.influxTestIndicator.setText("✅")
+            self.influxTestIndicator.setToolTip("Connection successful")
+        else:
+            self.influxTestIndicator.setText("❌")
+            self.influxTestIndicator.setToolTip(
+                f"Connection failed: {message} (Status {status_code})"
+            )
+
+
+# checkable menu for logged parameters, inspired by
+# https://stackoverflow.com/a/22775990/2750945
+class LoggedParametersToolButton(QtWidgets.QToolButton):
+    def __init__(self, *args, **kwargs):
+        super(LoggedParametersToolButton, self).__init__(*args, **kwargs)
+
+
+class LoggedParametersMenu(QtWidgets.QMenu):
+    item_clicked = pyqtSignal(str, bool)
+
+    def __init__(self, *args, **kwargs) -> None:
+        super(LoggedParametersMenu, self).__init__(*args, **kwargs)
+
+    def create_menu_entries(self, parameters: RemoteParameters) -> None:
+        # remove all previous actions that might be present from a previous connection
+        for action in self.actions():
+            self.removeAction(action)
+        param_dict = {name: param for name, param in parameters}
+        param_dict = dict(sorted(param_dict.items()))  # sort alphabetically
+        for name, param in param_dict.items():
+            if param.loggable:
+                action = QtWidgets.QAction(name, parent=self, checkable=True)  # type: ignore[call-overload] # noqa: E501
+                action.setChecked(param.log)
+                self.addAction(action)
+        self.triggered.connect(self.on_item_selected)
+
+    def on_item_selected(self, action: QtWidgets.QAction) -> None:
+        param_name = action.text()
+        self.item_clicked.emit(param_name, action.isChecked())
diff --git a/linien-gui/linien_gui/ui/logging_panel.ui b/linien-gui/linien_gui/ui/logging_panel.ui
new file mode 100644
index 00000000..3f9c893a
--- /dev/null
+++ b/linien-gui/linien_gui/ui/logging_panel.ui
@@ -0,0 +1,246 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Form</class>
+ <widget class="QWidget" name="Form">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>264</width>
+    <height>542</height>
+   </rect>
+  </property>
+  <property name="sizePolicy">
+   <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
+    <horstretch>0</horstretch>
+    <verstretch>0</verstretch>
+   </sizepolicy>
+  </property>
+  <property name="minimumSize">
+   <size>
+    <width>100</width>
+    <height>100</height>
+   </size>
+  </property>
+  <property name="windowTitle">
+   <string>Form</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QGroupBox" name="influxCredentialGroupBox">
+     <property name="title">
+      <string>InfluxDB v2 credentials</string>
+     </property>
+     <layout class="QVBoxLayout" name="verticalLayout_4">
+      <item>
+       <layout class="QFormLayout" name="influxFormLayout">
+        <item row="0" column="0">
+         <widget class="QLabel" name="labelURL">
+          <property name="text">
+           <string>URL</string>
+          </property>
+         </widget>
+        </item>
+        <item row="0" column="1">
+         <widget class="QLineEdit" name="lineEditURL">
+          <property name="text">
+           <string>http://localhost:8086</string>
+          </property>
+         </widget>
+        </item>
+        <item row="1" column="0">
+         <widget class="QLabel" name="labelOrg">
+          <property name="text">
+           <string>Organisation</string>
+          </property>
+         </widget>
+        </item>
+        <item row="1" column="1">
+         <widget class="QLineEdit" name="lineEditOrg">
+          <property name="text">
+           <string>my-org</string>
+          </property>
+         </widget>
+        </item>
+        <item row="2" column="0">
+         <widget class="QLabel" name="labelToken">
+          <property name="text">
+           <string>Token</string>
+          </property>
+         </widget>
+        </item>
+        <item row="2" column="1">
+         <widget class="QLineEdit" name="lineEditToken">
+          <property name="text">
+           <string>my-token</string>
+          </property>
+         </widget>
+        </item>
+        <item row="3" column="0">
+         <widget class="QLabel" name="labelBucket">
+          <property name="text">
+           <string>Bucket</string>
+          </property>
+         </widget>
+        </item>
+        <item row="3" column="1">
+         <widget class="QLineEdit" name="lineEditBucket">
+          <property name="text">
+           <string>my-bucket</string>
+          </property>
+         </widget>
+        </item>
+        <item row="4" column="0">
+         <widget class="QLabel" name="labelMeas">
+          <property name="text">
+           <string>Measurement</string>
+          </property>
+         </widget>
+        </item>
+        <item row="4" column="1">
+         <widget class="QLineEdit" name="lineEditMeas">
+          <property name="text">
+           <string>my-measurement</string>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </item>
+      <item>
+       <layout class="QHBoxLayout" name="influxTestButtonLayout">
+        <item>
+         <widget class="QPushButton" name="influxUpdateButton">
+          <property name="text">
+           <string>Update / Check</string>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <widget class="QLabel" name="influxTestIndicator">
+          <property name="text">
+           <string/>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <spacer name="horizontalSpacer_3">
+          <property name="orientation">
+           <enum>Qt::Horizontal</enum>
+          </property>
+          <property name="sizeHint" stdset="0">
+           <size>
+            <width>40</width>
+            <height>20</height>
+           </size>
+          </property>
+         </spacer>
+        </item>
+       </layout>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <widget class="QGroupBox" name="loggingGroupBox">
+     <property name="title">
+      <string>Logging Parameters</string>
+     </property>
+     <layout class="QVBoxLayout" name="loggingLayout">
+      <item>
+       <layout class="QHBoxLayout" name="logParameterToolButtonLayout">
+        <item>
+         <widget class="LoggedParametersToolButton" name="logParametersToolButton">
+          <property name="sizePolicy">
+           <sizepolicy hsizetype="MinimumExpanding" vsizetype="Fixed">
+            <horstretch>0</horstretch>
+            <verstretch>0</verstretch>
+           </sizepolicy>
+          </property>
+          <property name="text">
+           <string>Logged Parameters</string>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </item>
+      <item>
+       <layout class="QHBoxLayout" name="loggingLayout_2">
+        <item>
+         <widget class="QLabel" name="logIntervalLabel">
+          <property name="text">
+           <string>Interval</string>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <spacer name="horizontalSpacer">
+          <property name="orientation">
+           <enum>Qt::Horizontal</enum>
+          </property>
+          <property name="sizeHint" stdset="0">
+           <size>
+            <width>40</width>
+            <height>20</height>
+           </size>
+          </property>
+         </spacer>
+        </item>
+        <item>
+         <widget class="QDoubleSpinBox" name="logIntervalSpinBox">
+          <property name="suffix">
+           <string> s</string>
+          </property>
+          <property name="decimals">
+           <number>1</number>
+          </property>
+          <property name="maximum">
+           <double>1000.000000000000000</double>
+          </property>
+          <property name="value">
+           <double>1.000000000000000</double>
+          </property>
+         </widget>
+        </item>
+       </layout>
+      </item>
+      <item>
+       <widget class="QPushButton" name="logPushButton">
+        <property name="text">
+         <string>Start Logging</string>
+        </property>
+        <property name="checkable">
+         <bool>true</bool>
+        </property>
+        <property name="checked">
+         <bool>false</bool>
+        </property>
+       </widget>
+      </item>
+     </layout>
+    </widget>
+   </item>
+   <item>
+    <spacer name="verticalSpacer">
+     <property name="orientation">
+      <enum>Qt::Vertical</enum>
+     </property>
+     <property name="sizeHint" stdset="0">
+      <size>
+       <width>20</width>
+       <height>40</height>
+      </size>
+     </property>
+    </spacer>
+   </item>
+  </layout>
+ </widget>
+ <customwidgets>
+  <customwidget>
+   <class>LoggedParametersToolButton</class>
+   <extends>QToolButton</extends>
+   <header location="global">logging_panel.h</header>
+  </customwidget>
+ </customwidgets>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/linien-gui/linien_gui/ui/main_window.py b/linien-gui/linien_gui/ui/main_window.py
index bc2df639..2cb1a42b 100644
--- a/linien-gui/linien_gui/ui/main_window.py
+++ b/linien-gui/linien_gui/ui/main_window.py
@@ -24,12 +24,11 @@
 import linien_gui
 import numpy as np
 from linien_common.common import check_plot_data
-from linien_common.config import N_COLORS
-from linien_gui.config import Color
+from linien_gui.config import N_COLORS, Color
 from linien_gui.ui.plot_widget import INVALID_POWER
-from linien_gui.utils import color_to_hex
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import color_to_hex, get_linien_app_instance, set_window_icon
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 ZOOM_STEP = 0.9
 MAX_ZOOM = 50
@@ -40,58 +39,130 @@ def sweep_amplitude_to_zoom_step(amplitude):
     return round(log(amplitude, ZOOM_STEP))
 
 
-class MainWindow(QtWidgets.QMainWindow, CustomWidget):
+class MainWindow(QtWidgets.QMainWindow):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("main_window.ui")
+        super(MainWindow, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "main_window.ui", self)
+        set_window_icon(self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         self.reset_std_history()
 
-    def show(self, host, name):
-        self.setWindowTitle(
-            "Linien spectroscopy lock %s: %s (%s)"
-            % (linien_gui.__version__, name, host)
-        )
-        super().show()
-
-    def closeEvent(self, *args, **kwargs):
-        self.app.close_all_secondary_windows()
-        super().closeEvent(*args, **kwargs)
-
-    def ready(self):
         # handle keyboard events
         self.setFocus()
 
-        self.ids.export_parameters_button.clicked.connect(
+        self.export_parameters_button.clicked.connect(
             self.export_parameters_select_file
         )
-        self.ids.import_parameters_button.clicked.connect(self.import_parameters)
+        self.import_parameters_button.clicked.connect(self.import_parameters)
 
         def display_power(power, element):
             if power == INVALID_POWER:
                 element.hide()
             else:
                 element.show()
-                text = "%.2f" % power
-                element.setText(text)
+                element.setText(f"{power:.2f}")
 
         def display_power_channel_1(power):
-            el = self.ids.power_channel_1
+            el = self.power_channel_1
             display_power(power, el)
 
         def display_power_channel_2(power):
-            el = self.ids.power_channel_2
+            el = self.power_channel_2
             display_power(power, el)
 
-        self.ids.graphicsView.signal_power1.connect(display_power_channel_1)
-        self.ids.graphicsView.signal_power2.connect(display_power_channel_2)
-        self.ids.graphicsView.keyPressed.connect(self.handle_key_press)
+        self.graphicsView.signal_power1.connect(display_power_channel_1)
+        self.graphicsView.signal_power2.connect(display_power_channel_2)
+        self.graphicsView.keyPressed.connect(self.handle_key_press)
 
         # by default we hide it and just show when a new version is available
-        self.ids.new_version_available_label.hide()
+        self.newVersionAvailableLabel.hide()
+
+    def on_connection_established(self):
+        self.control = self.app.control
+        self.parameters = self.app.parameters
+
+        self.parameters.lock.add_callback(self.change_sweep_control_visibility)
+        self.parameters.autolock_running.add_callback(
+            self.change_sweep_control_visibility
+        )
+        self.parameters.optimization_running.add_callback(
+            self.change_sweep_control_visibility
+        )
+
+        self.parameters.to_plot.add_callback(self.update_std)
+
+        self.parameters.pid_on_slow_enabled.add_callback(
+            lambda v: self.legend_slow_signal_history.setVisible(v)
+        )
+        self.parameters.dual_channel.add_callback(
+            lambda v: self.legend_monitor_signal_history.setVisible(not v)
+        )
+
+        self.settings_toolbox.setCurrentIndex(0)
+
+        self.parameters.lock.add_callback(lambda *args: self.reset_std_history())
+
+        for color_idx in range(N_COLORS):
+            getattr(self.app.settings, f"plot_color_{color_idx}").add_callback(
+                self.update_legend_color
+            )
+
+        self.parameters.dual_channel.add_callback(self.update_legend_text)
+
+    def change_sweep_control_visibility(self, *args):
+        al_running = self.parameters.autolock_running.value
+        optimization = self.parameters.optimization_running.value
+        locked = self.parameters.lock.value
+
+        self.sweepControlWidget.setVisible(
+            not al_running and not locked and not optimization
+        )
+        self.top_lock_panel.setVisible(locked)
+        self.statusbar_unlocked.setVisible(
+            not al_running and not locked and not optimization
+        )
+
+    def update_legend_color(self, *args):
+        def set_color(el, color: Color):
+            return el.setStyleSheet(
+                "color: "
+                + color_to_hex(
+                    getattr(self.app.settings, f"plot_color_{color.value}").value
+                )
+            )
+
+        set_color(self.legend_spectrum_1, Color.SPECTRUM1)
+        set_color(self.legend_spectrum_2, Color.SPECTRUM2)
+        set_color(self.legend_spectrum_combined, Color.SPECTRUM_COMBINED)
+        set_color(self.legend_error_signal, Color.SPECTRUM_COMBINED)
+        set_color(self.legend_control_signal, Color.CONTROL_SIGNAL)
+        set_color(self.legend_control_signal_history, Color.CONTROL_SIGNAL_HISTORY)
+        set_color(self.legend_slow_signal_history, Color.SLOW_HISTORY)
+        set_color(self.legend_monitor_signal_history, Color.MONITOR_SIGNAL_HISTORY)
+
+    def update_legend_text(self, dual_channel):
+        self.legend_spectrum_1.setText(
+            "error signal" if not dual_channel else "error signal 1"
+        )
+        self.legend_spectrum_2.setText(
+            "monitor" if not dual_channel else "error signal 2"
+        )
+        self.legend_spectrum_combined.setVisible(dual_channel)
+
+    def show(self, host, name):
+        self.setWindowTitle(
+            f"Linien spectroscopy lock {linien_gui.__version__}: {name} ({host})"
+        )
+        super().show()
+
+    def closeEvent(self, *args, **kwargs):
+        self.app.close_all_secondary_windows()
+        super().closeEvent(*args, **kwargs)
 
     def show_new_version_available(self):
-        self.ids.new_version_available_label.show()
+        self.newVersionAvailableLabel.show()
 
     def handle_key_press(self, key):
         print("key pressed", key)
@@ -116,114 +187,38 @@ def export_parameters_select_file(self):
                     {
                         "linien-version": linien_gui.__version__,
                         "time": time(),
-                        "parameters": dict(
-                            (k, getattr(self.parameters, k).value)
-                            for k in self.parameters.remote.exposed_get_restorable_parameters()  # noqa: E501
-                        ),
+                        "parameters": {
+                            name: param.value
+                            for name, param in self.parameters
+                            if param.restorable
+                        },
                     },
                     f,
                 )
 
     def import_parameters(self):
         options = QtWidgets.QFileDialog.Options()
-        # options |= QtWidgets.QFileDialog.DontUseNativeDialog
-        default_ext = ".json"
         fn, _ = QtWidgets.QFileDialog.getOpenFileName(
             self,
             "QFileDialog.getSaveFileName()",
             "",
-            "JSON (*%s)" % default_ext,
+            "JSON (*.json)",
             options=options,
         )
         if fn:
             with open(fn, "r") as f:
                 data = json.load(f)
 
-            assert "linien-version" in data, "invalid parameter file"
+            if "linien-version" not in data:
+                raise Exception("invalid parameter file")
 
-            restorable = self.parameters.remote.exposed_get_restorable_parameters()
-            for k, v in data["parameters"].items():
-                if k not in restorable:
-                    print("ignore key", k)
-                    continue
-
-                print("restoring", k)
-                getattr(self.parameters, k).value = v
+            for name, value in data["parameters"].items():
+                param = getattr(self.parameters, name)
+                if param.restorable:
+                    param.value = value
 
             self.control.write_registers()
 
-    def on_connection_established(self):
-        self.control = self.app.control
-        self.parameters = self.app.parameters
-
-        def change_sweep_control_visibility(*args):
-            al_running = self.parameters.autolock_running.value
-            optimization = self.parameters.optimization_running.value
-            locked = self.parameters.lock.value
-
-            self.get_widget("sweep_control").setVisible(
-                not al_running and not locked and not optimization
-            )
-            self.get_widget("top_lock_panel").setVisible(locked)
-            self.get_widget("statusbar_unlocked").setVisible(
-                not al_running and not locked and not optimization
-            )
-
-        self.parameters.lock.on_change(change_sweep_control_visibility)
-        self.parameters.autolock_running.on_change(change_sweep_control_visibility)
-        self.parameters.optimization_running.on_change(change_sweep_control_visibility)
-
-        self.parameters.to_plot.on_change(self.update_std)
-
-        self.parameters.pid_on_slow_enabled.on_change(
-            lambda v: self.ids.legend_slow_signal_history.setVisible(v)
-        )
-        self.parameters.dual_channel.on_change(
-            lambda v: self.ids.legend_monitor_signal_history.setVisible(not v)
-        )
-
-        self.ids.settings_toolbox.setCurrentIndex(0)
-
-        self.parameters.lock.on_change(lambda *args: self.reset_std_history())
-
-        def update_legend_color(*args):
-            def set_color(el, color: Color):
-                return el.setStyleSheet(
-                    "color: "
-                    + color_to_hex(
-                        getattr(self.parameters, f"plot_color_{color.value}").value
-                    )
-                )
-
-            set_color(self.ids.legend_spectrum_1, Color.SPECTRUM1)
-            set_color(self.ids.legend_spectrum_2, Color.SPECTRUM2)
-            set_color(self.ids.legend_spectrum_combined, Color.SPECTRUM_COMBINED)
-            set_color(self.ids.legend_error_signal, Color.SPECTRUM_COMBINED)
-            set_color(self.ids.legend_control_signal, Color.CONTROL_SIGNAL)
-            set_color(
-                self.ids.legend_control_signal_history, Color.CONTROL_SIGNAL_HISTORY
-            )
-            set_color(self.ids.legend_slow_signal_history, Color.SLOW_HISTORY)
-            set_color(
-                self.ids.legend_monitor_signal_history, Color.MONITOR_SIGNAL_HISTORY
-            )
-
-        for color_idx in range(N_COLORS):
-            getattr(self.parameters, "plot_color_%d" % color_idx).on_change(
-                update_legend_color
-            )
-
-        def update_legend_text(dual_channel):
-            self.ids.legend_spectrum_1.setText(
-                "error signal" if not dual_channel else "error signal 1"
-            )
-            self.ids.legend_spectrum_2.setText(
-                "monitor" if not dual_channel else "error signal 2"
-            )
-            self.ids.legend_spectrum_combined.setVisible(dual_channel)
-
-        self.parameters.dual_channel.on_change(update_legend_text)
-
     def update_std(self, to_plot, max_std_history_length=10):
         if self.parameters.lock.value and to_plot:
             to_plot = pickle.loads(to_plot)
@@ -242,10 +237,8 @@ def update_std(self, to_plot, max_std_history_length=10):
                 ]
 
                 if error_signal is not None and control_signal is not None:
-                    self.ids.error_std.setText("%.2f" % np.mean(self.error_std_history))
-                    self.ids.control_std.setText(
-                        "%.2f" % np.mean(self.control_std_history)
-                    )
+                    self.error_std.setText("%.2f" % np.mean(self.error_std_history))
+                    self.control_std.setText(f"{np.mean(self.control_std_history):.2f}")
 
     def reset_std_history(self):
         self.error_std_history = []
diff --git a/linien-gui/linien_gui/ui/main_window.ui b/linien-gui/linien_gui/ui/main_window.ui
index c90b2926..934ff308 100644
--- a/linien-gui/linien_gui/ui/main_window.ui
+++ b/linien-gui/linien_gui/ui/main_window.ui
@@ -33,10 +33,10 @@
        <item>
         <layout class="QVBoxLayout" name="verticalLayout_17">
          <item>
-          <widget class="SweepControlWidget" name="sweep_control" native="true">
+          <widget class="SweepControlWidget" name="sweepControlWidget" native="true">
            <layout class="QHBoxLayout" name="horizontalLayout_3">
             <item>
-             <widget class="QLabel" name="label_9">
+             <widget class="QLabel" name="sweepLabel">
               <property name="font">
                <font>
                 <weight>75</weight>
@@ -49,7 +49,7 @@
              </widget>
             </item>
             <item>
-             <widget class="QPushButton" name="sweep_start_stop_button">
+             <widget class="QPushButton" name="sweepStartStopPushButton">
               <property name="toolTip">
                <string>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
 &lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
@@ -79,7 +79,7 @@ p, li { white-space: pre-wrap; }
              </spacer>
             </item>
             <item>
-             <widget class="SweepSlider" name="sweep_slider">
+             <widget class="SweepSlider" name="sweepSlider">
               <property name="minimumSize">
                <size>
                 <width>350</width>
@@ -108,7 +108,7 @@ p, li { white-space: pre-wrap; }
              </spacer>
             </item>
             <item>
-             <widget class="QLabel" name="label_2">
+             <widget class="QLabel" name="sweepCenterLabel">
               <property name="font">
                <font>
                 <weight>75</weight>
@@ -121,7 +121,7 @@ p, li { white-space: pre-wrap; }
              </widget>
             </item>
             <item>
-             <widget class="CustomDoubleSpinBox" name="sweep_center">
+             <widget class="CustomDoubleSpinBox" name="sweepCenterSpinBox">
               <property name="minimumSize">
                <size>
                 <width>65</width>
@@ -159,7 +159,7 @@ p, li { white-space: pre-wrap; }
              </widget>
             </item>
             <item>
-             <widget class="QLabel" name="label_3">
+             <widget class="QLabel" name="sweepAmplitudeLabel">
               <property name="font">
                <font>
                 <weight>75</weight>
@@ -172,7 +172,7 @@ p, li { white-space: pre-wrap; }
              </widget>
             </item>
             <item>
-             <widget class="CustomDoubleSpinBox" name="sweep_amplitude">
+             <widget class="CustomDoubleSpinBox" name="sweepAmplitudeSpinBox">
               <property name="minimumSize">
                <size>
                 <width>65</width>
@@ -526,7 +526,7 @@ p, li { white-space: pre-wrap; }
       </property>
       <layout class="QVBoxLayout" name="verticalLayout_11">
        <item>
-        <widget class="QLabel" name="new_version_available_label">
+        <widget class="QLabel" name="newVersionAvailableLabel">
          <property name="text">
           <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p align=&quot;center&quot;&gt;&lt;span style=&quot; font-size:14pt; font-weight:600; color:#3ebd46;&quot;&gt;New Linien version available:&lt;/span&gt;&lt;/p&gt;&lt;p align=&quot;center&quot;&gt;&lt;a href=&quot;https://github.com/linien-org/linien/&quot;&gt;&lt;span style=&quot; font-size:14pt; font-weight:600; text-decoration: underline; color:#1a5fb4;&quot;&gt;Download&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
          </property>
@@ -1044,7 +1044,7 @@ p, li { white-space: pre-wrap; }
           </size>
          </property>
          <property name="currentIndex">
-          <number>2</number>
+          <number>3</number>
          </property>
          <widget class="QWidget" name="generalPanel">
           <property name="enabled">
@@ -1054,8 +1054,8 @@ p, li { white-space: pre-wrap; }
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>98</width>
-            <height>28</height>
+            <width>332</width>
+            <height>400</height>
            </rect>
           </property>
           <attribute name="label">
@@ -1072,8 +1072,8 @@ p, li { white-space: pre-wrap; }
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>98</width>
-            <height>28</height>
+            <width>332</width>
+            <height>400</height>
            </rect>
           </property>
           <attribute name="label">
@@ -1091,7 +1091,7 @@ p, li { white-space: pre-wrap; }
             <x>0</x>
             <y>0</y>
             <width>332</width>
-            <height>427</height>
+            <height>400</height>
            </rect>
           </property>
           <attribute name="label">
@@ -1103,13 +1103,28 @@ p, li { white-space: pre-wrap; }
            </item>
           </layout>
          </widget>
+         <widget class="QWidget" name="logging">
+          <attribute name="label">
+           <string>Logging</string>
+          </attribute>
+          <widget class="LoggingPanel" name="widget_7" native="true">
+           <property name="geometry">
+            <rect>
+             <x>0</x>
+             <y>0</y>
+             <width>321</width>
+             <height>401</height>
+            </rect>
+           </property>
+          </widget>
+         </widget>
          <widget class="QWidget" name="viewPanel">
           <property name="geometry">
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>98</width>
-            <height>28</height>
+            <width>332</width>
+            <height>400</height>
            </rect>
           </property>
           <property name="sizePolicy">
@@ -1132,8 +1147,8 @@ p, li { white-space: pre-wrap; }
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>98</width>
-            <height>28</height>
+            <width>332</width>
+            <height>400</height>
            </rect>
           </property>
           <property name="sizePolicy">
@@ -1229,6 +1244,12 @@ p, li { white-space: pre-wrap; }
    <extends>QDoubleSpinBox</extends>
    <header location="global">spin_box.h</header>
   </customwidget>
+  <customwidget>
+   <class>LoggingPanel</class>
+   <extends>QWidget</extends>
+   <header>logging_panel.h</header>
+   <container>1</container>
+  </customwidget>
  </customwidgets>
  <resources/>
  <connections/>
diff --git a/linien-gui/linien_gui/ui/modulation_sweep_panel.py b/linien-gui/linien_gui/ui/modulation_sweep_panel.py
index f4733102..78afd44a 100644
--- a/linien-gui/linien_gui/ui/modulation_sweep_panel.py
+++ b/linien-gui/linien_gui/ui/modulation_sweep_panel.py
@@ -17,28 +17,29 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import MHz, Vpp
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class ModulationAndSweepPanel(QtWidgets.QWidget, CustomWidget):
+class ModulationAndSweepPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("modulation_sweep_panel.ui")
+        super(ModulationAndSweepPanel, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "modulation_sweep_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
-    def ready(self):
-        self.ids.modulation_frequency.setKeyboardTracking(False)
-        self.ids.modulation_frequency.valueChanged.connect(
+        self.modulationFrequencySpinBox.setKeyboardTracking(False)
+        self.modulationFrequencySpinBox.valueChanged.connect(
             self.change_modulation_frequency
         )
-        self.ids.modulation_amplitude.setKeyboardTracking(False)
-        self.ids.modulation_amplitude.valueChanged.connect(
+        self.modulationAmplitudeSpinBox.setKeyboardTracking(False)
+        self.modulationAmplitudeSpinBox.valueChanged.connect(
             self.change_modulation_amplitude
         )
-        self.ids.sweep_speed.currentIndexChanged.connect(self.change_sweep_speed)
+        self.sweepSpeedComboBox.currentIndexChanged.connect(self.change_sweep_speed)
 
-        self.ids.spectroscopyTabs.setCurrentIndex(0)
+        self.spectroscopyTabs.setCurrentIndex(0)
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
@@ -46,51 +47,51 @@ def on_connection_established(self):
 
         param2ui(
             self.parameters.modulation_frequency,
-            self.ids.modulation_frequency,
+            self.modulationFrequencySpinBox,
             lambda value: value / MHz,
         )
         param2ui(
             self.parameters.modulation_amplitude,
-            self.ids.modulation_amplitude,
+            self.modulationAmplitudeSpinBox,
             lambda value: value / Vpp,
         )
-        param2ui(self.parameters.sweep_speed, self.ids.sweep_speed)
+        param2ui(self.parameters.sweep_speed, self.sweepSpeedComboBox)
 
-        self.parameters.dual_channel.on_change(self.dual_channel_changed)
+        self.parameters.dual_channel.add_callback(self.dual_channel_changed)
 
         def fast_mode_changed(fast_mode_enabled):
             """Disables controls that are irrelevant if fast mode is enabled"""
             widgets_to_disable = (
-                self.ids.modulation_frequency_group,
-                self.ids.modulation_amplitude_group,
-                self.ids.spectroscopyTabs,
+                self.modulation_frequency_group,
+                self.modulation_amplitude_group,
+                self.spectroscopyTabs,
             )
             for widget in widgets_to_disable:
                 widget.setEnabled(not fast_mode_enabled)
 
-        self.parameters.fast_mode.on_change(fast_mode_changed)
+        self.parameters.fast_mode.add_callback(fast_mode_changed)
 
         def fast_mode_changed(fast_mode_enabled):
             """Disables controls that are irrelevant if fast mode is enabled"""
             widgets_to_disable = (
-                self.ids.modulation_frequency_group,
-                self.ids.modulation_amplitude_group,
-                self.ids.spectroscopyTabs,
+                self.modulation_frequency_group,
+                self.modulation_amplitude_group,
+                self.spectroscopyTabs,
             )
             for widget in widgets_to_disable:
                 widget.setEnabled(not fast_mode_enabled)
 
-        self.parameters.fast_mode.on_change(fast_mode_changed)
+        self.parameters.fast_mode.add_callback(fast_mode_changed)
 
     def change_modulation_frequency(self):
         self.parameters.modulation_frequency.value = (
-            self.ids.modulation_frequency.value() * MHz
+            self.modulationFrequencySpinBox.value() * MHz
         )
         self.control.write_registers()
 
     def change_modulation_amplitude(self):
         self.parameters.modulation_amplitude.value = (
-            self.ids.modulation_amplitude.value() * Vpp
+            self.modulationAmplitudeSpinBox.value() * Vpp
         )
         self.control.write_registers()
 
@@ -99,5 +100,5 @@ def change_sweep_speed(self, decimation):
         self.control.write_registers()
 
     def dual_channel_changed(self, value):
-        self.ids.spectroscopyBPanel.setEnabled(value)
-        self.ids.spectroscopyBPanelDisabled.setVisible(not value)
+        self.spectroscopyBPanel.setEnabled(value)
+        self.spectroscopyBPanelDisabled.setVisible(not value)
diff --git a/linien-gui/linien_gui/ui/modulation_sweep_panel.ui b/linien-gui/linien_gui/ui/modulation_sweep_panel.ui
index 99b5b5b5..6d8af146 100644
--- a/linien-gui/linien_gui/ui/modulation_sweep_panel.ui
+++ b/linien-gui/linien_gui/ui/modulation_sweep_panel.ui
@@ -9,7 +9,7 @@
    <rect>
     <x>0</x>
     <y>0</y>
-    <width>400</width>
+    <width>314</width>
     <height>496</height>
    </rect>
   </property>
@@ -26,7 +26,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_6">
         <item>
-         <widget class="CustomDoubleSpinBoxNoSign" name="modulation_frequency">
+         <widget class="CustomDoubleSpinBoxNoSign" name="modulationFrequencySpinBox">
           <property name="decimals">
            <number>5</number>
           </property>
@@ -44,7 +44,7 @@
       <item>
        <widget class="QLabel" name="label_3">
         <property name="text">
-         <string>Set modulation frequency to 0 to display the DC signal.</string>
+         <string>Set modulation frequency to 0 to disable modulation/demodulation</string>
         </property>
         <property name="wordWrap">
          <bool>true</bool>
@@ -63,7 +63,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_2">
         <item>
-         <widget class="CustomDoubleSpinBoxNoSign" name="modulation_amplitude">
+         <widget class="CustomDoubleSpinBoxNoSign" name="modulationAmplitudeSpinBox">
           <property name="decimals">
            <number>3</number>
           </property>
@@ -96,7 +96,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_5">
         <item>
-         <widget class="QComboBox" name="sweep_speed">
+         <widget class="QComboBox" name="sweepSpeedComboBox">
           <property name="sizePolicy">
            <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
             <horstretch>0</horstretch>
diff --git a/linien-gui/linien_gui/ui/new_device_dialog.py b/linien-gui/linien_gui/ui/new_device_dialog.py
index 51f5eb1d..fa25a930 100644
--- a/linien-gui/linien_gui/ui/new_device_dialog.py
+++ b/linien-gui/linien_gui/ui/new_device_dialog.py
@@ -20,22 +20,22 @@
 
 from linien_common.config import DEFAULT_SERVER_PORT
 from linien_gui.config import load_device_data, save_device_data
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class NewDeviceDialog(QtWidgets.QDialog, CustomWidget):
+class NewDeviceDialog(QtWidgets.QDialog):
     def __init__(self, initial_device=None):
-        super().__init__()
-        self.load_ui("new_device_dialog.ui")
+        super(NewDeviceDialog, self).__init__()
+        uic.loadUi(UI_PATH / "new_device_dialog.ui", self)
 
         if initial_device is not None:
-            self.ids.deviceName.setText(initial_device["name"])
-            self.ids.host.setText(initial_device["host"])
-            self.ids.username.setText(initial_device["username"])
-            self.ids.password.setText(initial_device["password"])
-            self.ids.port.setValue(initial_device.get("port", DEFAULT_SERVER_PORT))
-            self.ids.explain_host.setVisible(False)
+            self.deviceName.setText(initial_device["name"])
+            self.host.setText(initial_device["host"])
+            self.username.setText(initial_device["username"])
+            self.password.setText(initial_device["password"])
+            self.port.setValue(initial_device.get("port", DEFAULT_SERVER_PORT))
+            self.explain_host.setVisible(False)
             self.key = initial_device["key"]
         else:
             self.key = "".join(random.choice(string.ascii_lowercase) for i in range(10))
@@ -43,11 +43,11 @@ def __init__(self, initial_device=None):
     def add_new_device(self):
         device = {
             "key": self.key,
-            "name": self.ids.deviceName.text(),
-            "host": self.ids.host.text(),
-            "username": self.ids.username.text(),
-            "password": self.ids.password.text(),
-            "port": self.ids.port.value(),
+            "name": self.deviceName.text(),
+            "host": self.host.text(),
+            "username": self.username.text(),
+            "password": self.password.text(),
+            "port": self.port.value(),
             "params": {},
         }
 
diff --git a/linien-gui/linien_gui/ui/optimization_panel.py b/linien-gui/linien_gui/ui/optimization_panel.py
index 1a6100a2..e182999e 100644
--- a/linien-gui/linien_gui/ui/optimization_panel.py
+++ b/linien-gui/linien_gui/ui/optimization_panel.py
@@ -17,30 +17,27 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 from linien_common.common import MHz, Vpp
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class OptimizationPanel(QtWidgets.QWidget, CustomWidget):
+class OptimizationPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("optimization_panel.ui")
-
-    def ready(self):
-        self.ids.start_optimization_btn.clicked.connect(self.start_optimization)
-        self.ids.optimization_use_new_parameters.clicked.connect(
-            self.use_new_parameters
-        )
-        self.ids.optimization_abort.clicked.connect(self.abort)
-        self.ids.abortOptimizationLineSelection.clicked.connect(self.abort_selection)
-        self.ids.abortOptimizationPreparing.clicked.connect(self.abort_preparation)
-        self.ids.optimization_channel_selector.currentIndexChanged.connect(
+        super(OptimizationPanel, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "optimization_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+
+        self.startOptimizationPushButton.clicked.connect(self.start_optimization)
+        self.useOptimizedParametersPushButton.clicked.connect(self.use_new_parameters)
+        self.optimization_abort.clicked.connect(self.abort)
+        self.abortOptimizationLineSelection.clicked.connect(self.abort_selection)
+        self.abortOptimizationPreparing.clicked.connect(self.abort_preparation)
+        self.optimizationChannelComboBox.currentIndexChanged.connect(
             self.channel_changed
         )
-        self.ids.optimization_reset_failed_state.clicked.connect(
-            self.reset_failed_state
-        )
+        self.optimization_reset_failed_state.clicked.connect(self.reset_failed_state)
 
         for param_name in (
             "optimization_mod_freq_min",
@@ -48,7 +45,7 @@ def ready(self):
             "optimization_mod_amp_min",
             "optimization_mod_amp_max",
         ):
-            element = getattr(self.ids, param_name)
+            element = getattr(self, param_name)
             element.setKeyboardTracking(False)
 
             def write_parameter(*args, param_name=param_name, element=element):
@@ -60,10 +57,10 @@ def write_parameter(*args, param_name=param_name, element=element):
 
             def optim_enabled_changed(_, param_name=param_name):
                 getattr(self.parameters, param_name + "_enabled").value = int(
-                    getattr(self.ids, param_name).checkState()
+                    getattr(self, param_name).checkState()
                 )
 
-            getattr(self.ids, param_name).stateChanged.connect(optim_enabled_changed)
+            getattr(self, param_name).stateChanged.connect(optim_enabled_changed)
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
@@ -74,33 +71,33 @@ def opt_running_changed(_):
             approaching = self.parameters.optimization_approaching.value
             failed = self.parameters.optimization_failed.value
 
-            self.ids.optimization_not_running_container.setVisible(
+            self.optimization_not_running_container.setVisible(
                 not failed and not running
             )
-            self.ids.optimization_running_container.setVisible(
+            self.optimization_running_container.setVisible(
                 not failed and running and not approaching
             )
-            self.ids.optimization_preparing.setVisible(
+            self.optimization_preparing.setVisible(
                 not failed and running and approaching
             )
-            self.ids.optimization_failed.setVisible(failed)
+            self.optimization_failed.setVisible(failed)
 
-        self.parameters.optimization_running.on_change(opt_running_changed)
-        self.parameters.optimization_approaching.on_change(opt_running_changed)
-        self.parameters.optimization_failed.on_change(opt_running_changed)
+        self.parameters.optimization_running.add_callback(opt_running_changed)
+        self.parameters.optimization_approaching.add_callback(opt_running_changed)
+        self.parameters.optimization_failed.add_callback(opt_running_changed)
 
         def opt_selection_changed(value):
-            self.ids.optimization_selecting.setVisible(value)
-            self.ids.optimization_not_selecting.setVisible(not value)
+            self.optimization_selecting.setVisible(value)
+            self.optimization_not_selecting.setVisible(not value)
 
-        self.parameters.optimization_selection.on_change(opt_selection_changed)
+        self.parameters.optimization_selection.add_callback(opt_selection_changed)
 
         def mod_param_changed(_):
             dual_channel = self.parameters.dual_channel.value
             channel = self.parameters.optimization_channel.value
             optimized = self.parameters.optimization_optimized_parameters.value
 
-            self.ids.optimization_display_parameters.setText(
+            self.optimization_display_parameters.setText(
                 (
                     "<br />\n"
                     "<b>current parameters</b>: "
@@ -122,56 +119,56 @@ def mod_param_changed(_):
                 )
             )
 
-        for p in (
+        for param in (
             self.parameters.modulation_amplitude,
             self.parameters.modulation_frequency,
             self.parameters.demodulation_phase_a,
         ):
-            p.on_change(mod_param_changed)
+            param.add_callback(mod_param_changed)
 
         def improvement_changed(improvement):
-            self.ids.optimization_improvement.setText("%d %%" % (improvement * 100))
+            self.optimization_improvement.setText(f"{improvement * 100} %%")
 
-        self.parameters.optimization_improvement.on_change(improvement_changed)
+        self.parameters.optimization_improvement.add_callback(improvement_changed)
 
         param2ui(
             self.parameters.optimization_mod_freq_enabled,
-            self.ids.optimization_mod_freq,
+            self.optimization_mod_freq,
         )
         param2ui(
             self.parameters.optimization_mod_freq_min,
-            self.ids.optimization_mod_freq_min,
+            self.optimization_mod_freq_min,
         )
         param2ui(
             self.parameters.optimization_mod_freq_max,
-            self.ids.optimization_mod_freq_max,
+            self.optimization_mod_freq_max,
         )
         param2ui(
-            self.parameters.optimization_mod_amp_enabled, self.ids.optimization_mod_amp
+            self.parameters.optimization_mod_amp_enabled, self.optimization_mod_amp
         )
         param2ui(
-            self.parameters.optimization_mod_amp_min, self.ids.optimization_mod_amp_min
+            self.parameters.optimization_mod_amp_min, self.optimization_mod_amp_min
         )
         param2ui(
-            self.parameters.optimization_mod_amp_max, self.ids.optimization_mod_amp_max
+            self.parameters.optimization_mod_amp_max, self.optimization_mod_amp_max
         )
 
         def dual_channel_changed(value):
-            self.ids.optimization_channel_selector_box.setVisible(value)
+            self.optimization_channel_selector_box.setVisible(value)
 
-        self.parameters.dual_channel.on_change(dual_channel_changed)
+        self.parameters.dual_channel.add_callback(dual_channel_changed)
 
         def fast_mode_changed(fast_mode_enabled):
             """Disable this panel if fast mode is enabled (nothing to optimize)."""
             self.setEnabled(not fast_mode_enabled)
 
-        self.parameters.fast_mode.on_change(fast_mode_changed)
+        self.parameters.fast_mode.add_callback(fast_mode_changed)
 
         def fast_mode_changed(fast_mode_enabled):
             """Disable this panel if fast mode is enabled (nothing to optimize)."""
             self.setEnabled(not fast_mode_enabled)
 
-        self.parameters.fast_mode.on_change(fast_mode_changed)
+        self.parameters.fast_mode.add_callback(fast_mode_changed)
 
     def start_optimization(self):
         self.parameters.optimization_selection.value = True
diff --git a/linien-gui/linien_gui/ui/optimization_panel.ui b/linien-gui/linien_gui/ui/optimization_panel.ui
index f6709a70..3bf165fc 100644
--- a/linien-gui/linien_gui/ui/optimization_panel.ui
+++ b/linien-gui/linien_gui/ui/optimization_panel.ui
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>400</width>
-    <height>1261</height>
+    <height>1086</height>
    </rect>
   </property>
   <property name="windowTitle">
@@ -196,7 +196,7 @@
              </widget>
             </item>
             <item>
-             <widget class="QComboBox" name="optimization_channel_selector">
+             <widget class="QComboBox" name="optimizationChannelComboBox">
               <property name="sizePolicy">
                <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
                 <horstretch>0</horstretch>
@@ -219,7 +219,7 @@
           </widget>
          </item>
          <item>
-          <widget class="QPushButton" name="start_optimization_btn">
+          <widget class="QPushButton" name="startOptimizationPushButton">
            <property name="minimumSize">
             <size>
              <width>0</width>
@@ -340,7 +340,7 @@
        </widget>
       </item>
       <item>
-       <widget class="QPushButton" name="optimization_use_new_parameters">
+       <widget class="QPushButton" name="useOptimizedParametersPushButton">
         <property name="minimumSize">
          <size>
           <width>0</width>
diff --git a/linien-gui/linien_gui/ui/plot_widget.py b/linien-gui/linien_gui/ui/plot_widget.py
index 7baab1bf..3c125706 100644
--- a/linien-gui/linien_gui/ui/plot_widget.py
+++ b/linien-gui/linien_gui/ui/plot_widget.py
@@ -32,9 +32,8 @@
     get_signal_strength_from_i_q,
     update_signal_history,
 )
-from linien_common.config import N_COLORS
-from linien_gui.config import DEFAULT_PLOT_RATE_LIMIT, Color
-from linien_gui.widgets import CustomWidget
+from linien_gui.config import DEFAULT_PLOT_RATE_LIMIT, N_COLORS, Color
+from linien_gui.utils import get_linien_app_instance
 from PyQt5 import QtGui, QtWidgets
 from PyQt5.QtCore import pyqtSignal
 from pyqtgraph.Qt import QtCore
@@ -60,32 +59,22 @@ def peak_voltage_to_dBm(voltage):
     return 10 + 20 * np.log10(voltage)
 
 
-class TimeXAxis(pg.AxisItem, CustomWidget):
+class TimeXAxis(pg.AxisItem):
     """Plots x axis as time in seconds instead of point number."""
 
     def __init__(self, *args, parent=None, **kwargs):
         pg.AxisItem.__init__(self, *args, **kwargs)
         self.parent = parent
-
-    @property
-    def parameters(self):
-        return self.parent.parameters
-
-    @property
-    def sweep_speed(self):
-        return self.parameters.sweep_speed
-
-    @property
-    def lock(self):
-        return self.parameters.lock
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
     def on_connection_established(self):
         # we have to wait until parameters (of parent) is available
         QtCore.QTimer.singleShot(100, self.listen_to_parameter_changes)
 
     def listen_to_parameter_changes(self):
-        self.sweep_speed.on_change(self.force_repaint_tick_strings)
-        self.lock.on_change(self.force_repaint_tick_strings)
+        self.parent.parameters.sweep_speed.add_callback(self.force_repaint_tick_strings)
+        self.parent.parameters.lock.add_callback(self.force_repaint_tick_strings)
         self.force_repaint_tick_strings()
 
     def force_repaint_tick_strings(self, *args):
@@ -93,8 +82,8 @@ def force_repaint_tick_strings(self, *args):
         self.update()
 
     def tickStrings(self, values, scale, spacing):
-        locked = self.lock.value
-        sweep_speed = self.sweep_speed.value if not locked else 0
+        locked = self.parent.parameters.lock.value
+        sweep_speed = self.parent.parameters.sweep_speed.value if not locked else 0
         time_between_points = (1 / 125e6) * 2 ** (sweep_speed) * DECIMATION
         values = [v * time_between_points for v in values]
         spacing *= time_between_points
@@ -111,20 +100,21 @@ def tickStrings(self, values, scale, spacing):
         return strings
 
 
-class PlotWidget(pg.PlotWidget, CustomWidget):
+class PlotWidget(pg.PlotWidget):
     signal_power1 = pyqtSignal(float)
     signal_power2 = pyqtSignal(float)
     keyPressed = pyqtSignal(int)
 
     def __init__(self, *args, **kwargs):
-        super().__init__(
+        super(PlotWidget, self).__init__(
             *args,
-            axisItems={"bottom": TimeXAxis(orientation="bottom", parent=self)},
+            axisItems={"bottom": TimeXAxis(parent=self, orientation="bottom")},
             **kwargs,
         )
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         self.getAxis("bottom").enableAutoSIPrefix(False)
-
         self.showGrid(x=True, y=True)
         self.setLabel("bottom", "time", units="s")
 
@@ -216,17 +206,12 @@ def __init__(self, *args, **kwargs):
         self._cached_plot_data = []
         self._should_reposition_reset_view_button = False
 
-    def _to_data_coords(self, event):
-        pos = self.plotItem.vb.mapSceneToView(event.pos())
-        x, y = pos.x(), pos.y()
-        return x, y
-
     def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
         def set_pens(*args):
-            pen_width = self.parameters.plot_line_width.value
+            pen_width = self.app.settings.plot_line_width.value
 
             for curve, color in {
                 self.signal1: Color.SPECTRUM1,
@@ -237,21 +222,21 @@ def set_pens(*args):
                 self.slow_history: Color.SLOW_HISTORY,
                 self.monitor_signal_history: Color.MONITOR_SIGNAL_HISTORY,
             }.items():
-                r, g, b, *stuff = getattr(
-                    self.parameters, f"plot_color_{color.value}"
+                r, g, b, _ = getattr(
+                    self.app.settings, f"plot_color_{color.value}"
                 ).value
-                a = self.parameters.plot_line_opacity.value
+                a = self.app.settings.plot_line_opacity.value
                 curve.setPen(pg.mkPen((r, g, b, a), width=pen_width))
 
         for color_idx in range(N_COLORS):
-            getattr(self.parameters, "plot_color_%d" % color_idx).on_change(set_pens)
-        self.parameters.plot_line_width.on_change(set_pens)
-        self.parameters.plot_line_opacity.on_change(set_pens)
+            getattr(self.app.settings, f"plot_color_{color_idx}").add_callback(set_pens)
+        self.app.settings.plot_line_width.add_callback(set_pens)
+        self.app.settings.plot_line_opacity.add_callback(set_pens)
 
         self.control_signal_history_data = self.parameters.control_signal_history.value
         self.monitor_signal_history_data = self.parameters.monitor_signal_history.value
 
-        self.parameters.to_plot.on_change(self.replot)
+        self.parameters.to_plot.add_callback(self.replot)
 
         def autolock_selection_changed(value):
             if value:
@@ -262,7 +247,7 @@ def autolock_selection_changed(value):
                 self.disable_area_selection()
                 self.resume_plot_and_clear_cache()
 
-        self.parameters.autolock_selection.on_change(autolock_selection_changed)
+        self.parameters.autolock_selection.add_callback(autolock_selection_changed)
 
         def optimization_selection_changed(value):
             if value:
@@ -273,12 +258,19 @@ def optimization_selection_changed(value):
                 self.disable_area_selection()
                 self.resume_plot_and_clear_cache()
 
-        self.parameters.optimization_selection.on_change(optimization_selection_changed)
+        self.parameters.optimization_selection.add_callback(
+            optimization_selection_changed
+        )
 
         def show_or_hide_crosshair(automatic_mode):
             self.crosshair.setVisible(not automatic_mode)
 
-        self.parameters.automatic_mode.on_change(show_or_hide_crosshair)
+        self.parameters.automatic_mode.add_callback(show_or_hide_crosshair)
+
+    def _to_data_coords(self, event):
+        pos = self.plotItem.vb.mapSceneToView(event.pos())
+        x, y = pos.x(), pos.y()
+        return x, y
 
     def mouseMoveEvent(self, event):
         if not self.selection_running:
@@ -508,7 +500,9 @@ def replot(self, to_plot):
                 self.plot_data_unlocked((s1, s2), combined_error_signal)
                 self.plot_autolock_target_line(combined_error_signal)
 
-                if self.parameters.modulation_frequency.value != 0:
+                if (self.parameters.modulation_frequency.value != 0) and (
+                    not self.parameters.fast_mode.value
+                ):
                     # check whether to plot signal strengths using quadratures
                     s1q = to_plot.get("error_signal_1_quadrature")
                     s2q = to_plot.get("error_signal_2_quadrature")
@@ -530,7 +524,7 @@ def replot(self, to_plot):
                                 self.signal_strength_a2,
                                 self.signal_strength_a_fill,
                                 self.parameters.offset_a.value,
-                                self.parameters.plot_color_0.value,
+                                self.app.settings.plot_color_0.value,
                             )
                             / V
                         )
@@ -556,7 +550,7 @@ def replot(self, to_plot):
                                 self.signal_strength_b2,
                                 self.signal_strength_b_fill,
                                 self.parameters.offset_b.value,
-                                self.parameters.plot_color_1.value,
+                                self.app.settings.plot_color_1.value,
                             )
                             / V
                         )
@@ -608,7 +602,7 @@ def plot_signal_strength(
         upper = (channel_offset / V) + signal_strength_scaled
         lower = (channel_offset / V) - 1 * signal_strength_scaled
 
-        brush = pg.mkBrush(r, g, b, self.parameters.plot_fill_opacity.value)
+        brush = pg.mkBrush(r, g, b, self.app.settings.plot_fill_opacity.value)
         fill.setBrush(brush)
 
         invisible_pen = pg.mkPen("k", width=0.00001)
diff --git a/linien-gui/linien_gui/ui/psd_plot_widget.py b/linien-gui/linien_gui/ui/psd_plot_widget.py
index c17c800e..89964231 100644
--- a/linien-gui/linien_gui/ui/psd_plot_widget.py
+++ b/linien-gui/linien_gui/ui/psd_plot_widget.py
@@ -19,7 +19,7 @@
 import numpy as np
 import pyqtgraph as pg
 from linien_gui.ui.plot_widget import V
-from linien_gui.widgets import CustomWidget
+from linien_gui.utils import get_linien_app_instance
 
 
 class CustomLogAxis(pg.AxisItem):
@@ -73,9 +73,9 @@ def logTickStrings(self, values, scale, spacing):
         return dstrings
 
 
-class PSDPlotWidget(pg.PlotWidget, CustomWidget):
+class PSDPlotWidget(pg.PlotWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(
+        super(PSDPlotWidget, self).__init__(
             *args,
             axisItems={
                 "bottom": CustomLogAxis(orientation="bottom"),
@@ -83,6 +83,8 @@ def __init__(self, *args, **kwargs):
             },
             **kwargs
         )
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         self.curves = {}
 
diff --git a/linien-gui/linien_gui/ui/psd_table_widget.py b/linien-gui/linien_gui/ui/psd_table_widget.py
index 3e0c4ee1..f276bbdd 100644
--- a/linien-gui/linien_gui/ui/psd_table_widget.py
+++ b/linien-gui/linien_gui/ui/psd_table_widget.py
@@ -18,17 +18,18 @@
 
 from datetime import datetime
 
-from linien_gui.utils import color_to_hex
-from linien_gui.widgets import CustomWidget
+from linien_gui.utils import color_to_hex, get_linien_app_instance
 from PyQt5 import QtCore, QtWidgets
 from PyQt5.QtCore import pyqtSignal
 
 
-class PSDTableWidget(QtWidgets.QTableWidget, CustomWidget):
+class PSDTableWidget(QtWidgets.QTableWidget):
     show_or_hide_curve = pyqtSignal(str, bool)
 
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+        super(PSDTableWidget, self).__init__(*args, **kwargs)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
         self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
diff --git a/linien-gui/linien_gui/ui/psd_window.py b/linien-gui/linien_gui/ui/psd_window.py
index 0c7acfcc..cc3c33db 100644
--- a/linien-gui/linien_gui/ui/psd_window.py
+++ b/linien-gui/linien_gui/ui/psd_window.py
@@ -20,90 +20,94 @@
 from time import time
 
 import linien_gui
-from linien_common.common import PSD_ALGORITHM_LPSD, PSD_ALGORITHM_WELCH
+from linien_common.common import PSDAlgorithm
 from linien_gui.dialogs import error_dialog
-from linien_gui.utils import RandomColorChoser, param2ui, set_window_icon
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import (
+    RandomColorChoser,
+    get_linien_app_instance,
+    param2ui,
+    set_window_icon,
+)
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
 
 
-class PSDWindow(QtWidgets.QMainWindow, CustomWidget):
+class PSDWindow(QtWidgets.QMainWindow):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("psd_window.ui")
+        super(PSDWindow, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "psd_window.ui", self)
         self.setWindowTitle("Linien: Noise analysis")
         set_window_icon(self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+
         self.random_color_choser = RandomColorChoser()
         self.colors = {}
         self.data = {}
         self.complete_uids = []
 
-    def ready(self):
-        self.ids.start_psd_button.clicked.connect(self.start_psd)
-        """self.ids.start_pid_optimization_button.clicked.connect(
-            self.start_pid_optimization
-        )"""
-        self.ids.stop_psd_button.clicked.connect(self.stop_psd)
+        self.start_psd_button.clicked.connect(self.start_psd)
+        self.stop_psd_button.clicked.connect(self.stop_psd)
 
-        self.ids.curve_table.show_or_hide_curve.connect(
-            self.ids.psd_plot_widget.show_or_hide_curve
+        self.curve_table.show_or_hide_curve.connect(
+            self.psd_plot_widget.show_or_hide_curve
         )
-        self.ids.delete_curve_button.clicked.connect(self.delete_curve)
-        self.ids.export_psd_button.clicked.connect(self.export_psd)
-        self.ids.import_psd_button.clicked.connect(self.import_psd)
+        self.delete_curve_button.clicked.connect(self.delete_curve)
+        self.export_psd_button.clicked.connect(self.export_psd)
+        self.import_psd_button.clicked.connect(self.import_psd)
 
-        self.ids.maximum_measurement_time.currentIndexChanged.connect(
+        self.maximum_measurement_time.currentIndexChanged.connect(
             self.change_maximum_measurement_time
         )
 
-        self.ids.psd_algorithm.currentIndexChanged.connect(self.change_psd_algorithm)
-
-    def closeEvent(self, event, *args, **kwargs):
-        # we never realy want to close the window (which destroys its content)
-        # but just to hide it
-        event.ignore()
-        self.hide()
-
-    def change_maximum_measurement_time(self, index):
-        self.parameters.psd_acquisition_max_decimation.value = 12 + index
-
-    def change_psd_algorithm(self, index):
-        self.parameters.psd_algorithm.value = [PSD_ALGORITHM_LPSD, PSD_ALGORITHM_WELCH][
-            index
-        ]
+        self.psd_algorithm.currentIndexChanged.connect(self.change_psd_algorithm)
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
-        self.parameters.psd_data_partial.on_change(
+        self.parameters.psd_data_partial.add_callback(
             self.psd_data_received,
         )
-        self.parameters.psd_data_complete.on_change(
+        self.parameters.psd_data_complete.add_callback(
             self.psd_data_received,
         )
 
         param2ui(
             self.parameters.psd_acquisition_max_decimation,
-            self.ids.maximum_measurement_time,
+            self.maximum_measurement_time,
             lambda max_decimation: max_decimation - 12,
         )
         param2ui(
             self.parameters.psd_algorithm,
-            self.ids.psd_algorithm,
-            lambda algo: {PSD_ALGORITHM_LPSD: 0, PSD_ALGORITHM_WELCH: 1}[algo],
+            self.psd_algorithm,
+            lambda algo: {PSDAlgorithm.LPSD: 0, PSDAlgorithm.WELCH: 1}[algo],
         )
 
         def update_status(_):
             psd_running = self.parameters.psd_acquisition_running.value
             if psd_running:
-                self.ids.container_psd_running.show()
-                self.ids.container_psd_not_running.hide()
+                self.container_psd_running.show()
+                self.container_psd_not_running.hide()
             else:
-                self.ids.container_psd_running.hide()
-                self.ids.container_psd_not_running.show()
+                self.container_psd_running.hide()
+                self.container_psd_not_running.show()
 
-        self.parameters.psd_acquisition_running.on_change(update_status)
+        self.parameters.psd_acquisition_running.add_callback(update_status)
+
+    def closeEvent(self, event, *args, **kwargs):
+        # we never realy want to close the window (which destroys its content)  but just
+        # to hide it
+        event.ignore()
+        self.hide()
+
+    def change_maximum_measurement_time(self, index):
+        self.parameters.psd_acquisition_max_decimation.value = 12 + index
+
+    def change_psd_algorithm(self, index):
+        self.parameters.psd_algorithm.value = [PSDAlgorithm.LPSD, PSDAlgorithm.WELCH][
+            index
+        ]
 
     def psd_data_received(self, data_pickled):
         if data_pickled is None:
@@ -130,8 +134,8 @@ def psd_data_received(self, data_pickled):
         else:
             color = self.colors[curve_uuid]
 
-        self.ids.psd_plot_widget.plot_curve(curve_uuid, data["psds"], color)
-        self.ids.curve_table.add_curve(curve_uuid, data, color)
+        self.psd_plot_widget.plot_curve(curve_uuid, data["psds"], color)
+        self.curve_table.add_curve(curve_uuid, data, color)
 
         self.data[curve_uuid] = data
 
@@ -150,9 +154,9 @@ def start_pid_optimization(self):
         self.control.start_pid_optimization()
 
     def delete_curve(self):
-        uuid = self.ids.curve_table.delete_selected_curve()
+        uuid = self.curve_table.delete_selected_curve()
         if uuid is not None:
-            self.ids.psd_plot_widget.delete_curve(uuid)
+            self.psd_plot_widget.delete_curve(uuid)
             del self.data[uuid]
 
     def export_psd(self):
diff --git a/linien-gui/linien_gui/ui/right_panel.py b/linien-gui/linien_gui/ui/right_panel.py
index 5ff64329..b907a79d 100644
--- a/linien-gui/linien_gui/ui/right_panel.py
+++ b/linien-gui/linien_gui/ui/right_panel.py
@@ -16,37 +16,44 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance
+from PyQt5 import QtCore, QtWidgets
 
 
-class RightPanel(QtWidgets.QWidget, CustomWidget):
+class RightPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+        super(RightPanel, self).__init__(*args, **kwargs)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
+        self.main_window = self.window()
+        QtCore.QTimer.singleShot(100, self.ready)
+
+    def ready(self):
+        self.main_window.closeButton.clicked.connect(self.app.quit)
+        self.main_window.shutdownButton.clicked.connect(self.shutdown_server)
+        self.main_window.openDeviceManagerButton.clicked.connect(
+            self.open_device_manager
+        )
+        self.main_window.pid_parameter_optimization_button.clicked.connect(
+            self.open_psd_window
+        )
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
-        self.parameters.autolock_running.on_change(self.autolock_status_changed)
-        self.parameters.optimization_running.on_change(self.optimization_status_changed)
-        self.parameters.lock.on_change(self.enable_or_disable_panels)
+        self.parameters.autolock_running.add_callback(self.autolock_status_changed)
+        self.parameters.optimization_running.add_callback(
+            self.optimization_status_changed
+        )
+        self.parameters.lock.add_callback(self.enable_or_disable_panels)
 
         def highlight_psd_button(locked):
-            self.ids.pid_parameter_optimization_button.setStyleSheet(
+            self.main_window.pid_parameter_optimization_button.setStyleSheet(
                 "background: #00aa00;" if locked else ""
             )
 
-        self.parameters.lock.on_change(highlight_psd_button)
-
-    def ready(self):
-        self.ids.closeButton.clicked.connect(self.close_app)
-        self.ids.shutdownButton.clicked.connect(self.shutdown_server)
-        self.ids.openDeviceManagerButton.clicked.connect(self.open_device_manager)
-        self.ids.pid_parameter_optimization_button.clicked.connect(self.open_psd_window)
-
-    def close_app(self):
-        self.app.close()
+        self.parameters.lock.add_callback(highlight_psd_button)
 
     def shutdown_server(self):
         self.app.shutdown()
@@ -59,13 +66,17 @@ def open_device_manager(self):
 
     def autolock_status_changed(self, value):
         if value:
-            self.ids.settings_toolbox.setCurrentWidget(self.ids.lockingPanel)
+            self.main_window.settings_toolbox.setCurrentWidget(
+                self.main_window.lockingPanel
+            )
 
         self.enable_or_disable_panels()
 
     def optimization_status_changed(self, value):
         if value:
-            self.ids.settings_toolbox.setCurrentWidget(self.ids.optimizationPanel)
+            self.main_window.settings_toolbox.setCurrentWidget(
+                self.main_window.optimizationPanel
+            )
 
         self.enable_or_disable_panels()
 
@@ -76,7 +87,7 @@ def enable_or_disable_panels(self, *args):
 
         def enable_panels(panel_names, condition):
             for panel_name in panel_names:
-                getattr(self.ids, panel_name).setEnabled(condition)
+                getattr(self.main_window, panel_name).setEnabled(condition)
 
         enable_panels(("generalPanel",), not autolock and not optimization and not lock)
         enable_panels(
diff --git a/linien-gui/linien_gui/ui/spectroscopy_panel.py b/linien-gui/linien_gui/ui/spectroscopy_panel.py
index 6da34ea3..0c7fa9e9 100644
--- a/linien-gui/linien_gui/ui/spectroscopy_panel.py
+++ b/linien-gui/linien_gui/ui/spectroscopy_panel.py
@@ -16,142 +16,155 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import HIGH_PASS_FILTER, LOW_PASS_FILTER
-from linien_gui.utils import param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from typing import Union
 
+from linien_client.remote_parameters import RemoteParameter
+from linien_common.common import FilterType
+from linien_gui.ui.spin_box import CustomDoubleSpinBox, CustomDoubleSpinBoxNoSign
+from linien_gui.utils import get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtWidgets, uic
+
+
+class SpectroscopyPanel(QtWidgets.QWidget):
+    CHANNEL: Union[str, None] = None
+
+    automaticFilterCheckBox: QtWidgets.QCheckBox
+    demodulationFrequencyComboBox: CustomDoubleSpinBoxNoSign
+    demodulationPhaseSpinBox: CustomDoubleSpinBoxNoSign
+    invertCheckBox: QtWidgets.QCheckBox
+    manualFilterWidget: QtWidgets.QWidget
+    signalOffsetSpinBox: CustomDoubleSpinBox
 
-class SpectroscopyPanel(QtWidgets.QWidget, CustomWidget):
     def __init__(self, *args):
-        super().__init__(*args)
-        self.load_ui("spectroscopy_panel.ui")
+        super(SpectroscopyPanel, self).__init__(*args)
+        uic.loadUi(UI_PATH / "spectroscopy_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
         def change_filter_frequency(filter_i):
-            self.get_param("filter_%d_frequency" % filter_i).value = getattr(
-                self.ids, "filter_%d_frequency" % filter_i
+            self.get_param(f"filter_{filter_i}_frequency").value = getattr(
+                self, f"filter_{filter_i}_frequency"
             ).value()
-            self.control.write_registers()
+            self.control.exposed_write_registers()
 
         def change_filter_enabled(filter_i):
             filter_enabled = int(
-                getattr(self.ids, "filter_%d_enabled" % filter_i).checkState() > 0
+                getattr(self, f"filter_{filter_i}_enabled").checkState() > 0
             )
-            self.get_param("filter_%d_enabled" % filter_i).value = filter_enabled
-            self.control.write_registers()
+            self.get_param(f"filter_{filter_i}_enabled").value = filter_enabled
+            self.control.exposed_write_registers()
 
         def change_filter_type(filter_i):
-            param = self.get_param("filter_%d_type" % filter_i)
-            current_idx = getattr(self.ids, "filter_%d_type" % filter_i).currentIndex()
-            param.value = (LOW_PASS_FILTER, HIGH_PASS_FILTER)[current_idx]
-            self.control.write_registers()
+            param = self.get_param(f"filter_{filter_i}_type")
+            current_idx = getattr(self, f"filter_{filter_i}_type").currentIndex()
+            param.value = (FilterType.LOW_PASS, FilterType.HIGH_PASS)[current_idx]
+            self.control.exposed_write_registers()
 
         for filter_i in [1, 2]:
             for key, fct in {
-                "change_filter_%d_frequency": change_filter_frequency,
-                "change_filter_%d_enabled": change_filter_enabled,
-                "change_filter_%d_type": change_filter_type,
+                f"change_filter_{filter_i}_frequency": change_filter_frequency,
+                f"change_filter_{filter_i}_enabled": change_filter_enabled,
+                f"change_filter_{filter_i}_type": change_filter_type,
             }.items():
                 setattr(
                     self,
-                    key % filter_i,
+                    key,
                     lambda *args, fct=fct, filter_i=filter_i: fct(filter_i),
                 )
 
-    def get_param(self, name):
-        return getattr(
-            self.parameters, "%s_%s" % (name, "a" if self.channel == 0 else "b")
-        )
-
-    def ready(self):
-        self.ids.signal_offset.setKeyboardTracking(False)
-        self.ids.signal_offset.valueChanged.connect(self.change_signal_offset)
-        self.ids.demodulation_phase.setKeyboardTracking(False)
-        self.ids.demodulation_phase.valueChanged.connect(self.change_demod_phase)
-        self.ids.demodulation_frequency.currentIndexChanged.connect(
+        self.signalOffsetSpinBox.setKeyboardTracking(False)
+        self.signalOffsetSpinBox.valueChanged.connect(self.change_signal_offset)
+        self.demodulationPhaseSpinBox.setKeyboardTracking(False)
+        self.demodulationPhaseSpinBox.valueChanged.connect(self.change_demod_phase)
+        self.demodulationFrequencyComboBox.currentIndexChanged.connect(
             self.change_demod_multiplier
         )
 
         for filter_i in [1, 2]:
             _get = lambda parent, attr, filter_i=filter_i: getattr(
-                parent, attr % filter_i
+                parent, attr.format(filter_i)
             )
-            _get(self.ids, "filter_%d_enabled").stateChanged.connect(
-                _get(self, "change_filter_%d_enabled")
+            _get(self, "filter_{}_enabled").stateChanged.connect(
+                _get(self, "change_filter_{}_enabled")
             )
-            freq_input = _get(self.ids, "filter_%d_frequency")
+            freq_input = _get(self, "filter_{}_frequency")
             freq_input.setKeyboardTracking(False)
-            freq_input.valueChanged.connect(_get(self, "change_filter_%d_frequency"))
-            _get(self.ids, "filter_%d_type").currentIndexChanged.connect(
-                _get(self, "change_filter_%d_type")
+            freq_input.valueChanged.connect(_get(self, "change_filter_{}_frequency"))
+            _get(self, "filter_{}_type").currentIndexChanged.connect(
+                _get(self, "change_filter_{}_type")
             )
 
         def automatic_changed(value):
             self.get_param("filter_automatic").value = value
-            self.control.write_registers()
+            self.control.exposed_write_registers()
 
-        self.ids.filter_automatic.stateChanged.connect(automatic_changed)
+        self.automaticFilterCheckBox.stateChanged.connect(automatic_changed)
 
         def invert_changed(value):
             self.get_param("invert").value = bool(value)
-            self.control.write_registers()
+            self.control.exposed_write_registers()
 
-        self.ids.invert.stateChanged.connect(invert_changed)
+        self.invertCheckBox.stateChanged.connect(invert_changed)
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
-        # self.close_button.clicked.connect(self.close_app)
-        # self.shutdown_button.clicked.connect(self.shutdown_server)
-
-        param2ui(self.get_param("demodulation_phase"), self.ids.demodulation_phase)
+        param2ui(self.get_param("demodulation_phase"), self.demodulationPhaseSpinBox)
         param2ui(
             self.get_param("demodulation_multiplier"),
-            self.ids.demodulation_frequency,
+            self.demodulationFrequencyComboBox,
             lambda value: value - 1,
         )
-        param2ui(self.get_param("offset"), self.ids.signal_offset, lambda v: v / 8191.0)
-        param2ui(self.get_param("invert"), self.ids.invert)
-        param2ui(self.get_param("filter_automatic"), self.ids.filter_automatic)
+        param2ui(
+            self.get_param("offset"), self.signalOffsetSpinBox, lambda v: v / 8191.0
+        )
+        param2ui(self.get_param("invert"), self.invertCheckBox)
+        param2ui(self.get_param("filter_automatic"), self.automaticFilterCheckBox)
 
         def filter_automatic_changed(value):
-            self.ids.automatic_filtering_enabled.setVisible(value)
-            self.ids.automatic_filtering_disabled.setVisible(not value)
+            self.manualFilterWidget.setVisible(value)
+            self.manualFilterWidget.setVisible(not value)
 
-        self.get_param("filter_automatic").on_change(filter_automatic_changed)
+        self.get_param("filter_automatic").add_callback(filter_automatic_changed)
 
         for filter_i in [1, 2]:
             param2ui(
-                self.get_param("filter_%d_enabled" % filter_i),
-                getattr(self.ids, "filter_%d_enabled" % filter_i),
+                self.get_param(f"filter_{filter_i}_enabled"),
+                getattr(self, f"filter_{filter_i}_enabled"),
             )
             param2ui(
-                self.get_param("filter_%d_frequency" % filter_i),
-                getattr(self.ids, "filter_%d_frequency" % filter_i),
+                self.get_param(f"filter_{filter_i}_frequency"),
+                getattr(self, f"filter_{filter_i}_frequency"),
             )
             param2ui(
-                self.get_param("filter_%d_type" % filter_i),
-                getattr(self.ids, "filter_%d_type" % filter_i),
-                lambda type_: {LOW_PASS_FILTER: 0, HIGH_PASS_FILTER: 1}[type_],
+                self.get_param(f"filter_{filter_i}_type"),
+                getattr(self, f"filter_{filter_i}_type"),
+                lambda type_: {FilterType.LOW_PASS: 0, FilterType.HIGH_PASS: 1}[type_],
             )
 
+    def get_param(self, name: str) -> RemoteParameter:
+        return getattr(self.parameters, f"{name}_{self.CHANNEL}")
+
     def change_signal_offset(self):
-        self.get_param("offset").value = self.ids.signal_offset.value() * 8191
-        self.control.write_registers()
+        self.get_param("offset").value = self.signalOffsetSpinBox.value() * 8191
+        self.control.exposed_write_registers()
 
     def change_demod_phase(self):
-        self.get_param("demodulation_phase").value = self.ids.demodulation_phase.value()
-        self.control.write_registers()
+        self.get_param(
+            "demodulation_phase"
+        ).value = self.demodulationPhaseSpinBox.value()
+        self.control.exposed_write_registers()
 
     def change_demod_multiplier(self, idx):
         self.get_param("demodulation_multiplier").value = idx + 1
-        self.control.write_registers()
+        self.control.exposed_write_registers()
 
 
 class SpectroscopyChannelAPanel(SpectroscopyPanel):
-    channel = 0
+    CHANNEL = "a"
 
 
 class SpectroscopyChannelBPanel(SpectroscopyPanel):
-    channel = 1
+    CHANNEL = "b"
diff --git a/linien-gui/linien_gui/ui/spectroscopy_panel.ui b/linien-gui/linien_gui/ui/spectroscopy_panel.ui
index 9aa30edf..da2fa03b 100644
--- a/linien-gui/linien_gui/ui/spectroscopy_panel.ui
+++ b/linien-gui/linien_gui/ui/spectroscopy_panel.ui
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>292</width>
-    <height>807</height>
+    <height>720</height>
    </rect>
   </property>
   <property name="sizePolicy">
@@ -35,7 +35,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_5">
         <item>
-         <widget class="QComboBox" name="demodulation_frequency">
+         <widget class="QComboBox" name="demodulationFrequencyComboBox">
           <property name="styleSheet">
            <string notr="true"/>
           </property>
@@ -87,7 +87,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_4">
         <item>
-         <widget class="CustomDoubleSpinBoxNoSign" name="demodulation_phase">
+         <widget class="CustomDoubleSpinBoxNoSign" name="demodulationPhaseSpinBox">
           <property name="maximum">
            <double>360.000000000000000</double>
           </property>
@@ -117,7 +117,7 @@
       <item>
        <layout class="QHBoxLayout" name="horizontalLayout_3">
         <item>
-         <widget class="CustomDoubleSpinBox" name="signal_offset">
+         <widget class="CustomDoubleSpinBox" name="signalOffsetSpinBox">
           <property name="decimals">
            <number>3</number>
           </property>
@@ -157,7 +157,7 @@
      </property>
      <layout class="QVBoxLayout" name="verticalLayout_2">
       <item>
-       <widget class="QCheckBox" name="filter_automatic">
+       <widget class="QCheckBox" name="automaticFilterCheckBox">
         <property name="text">
          <string>Automatic</string>
         </property>
@@ -177,7 +177,7 @@
        </widget>
       </item>
       <item>
-       <widget class="QWidget" name="automatic_filtering_disabled" native="true">
+       <widget class="QWidget" name="manualFilterWidget" native="true">
         <layout class="QVBoxLayout" name="verticalLayout_5">
          <property name="leftMargin">
           <number>0</number>
@@ -362,7 +362,7 @@
      </property>
      <layout class="QVBoxLayout" name="verticalLayout_4">
       <item>
-       <widget class="QCheckBox" name="invert">
+       <widget class="QCheckBox" name="invertCheckBox">
         <property name="text">
          <string>Invert the signal</string>
         </property>
@@ -375,13 +375,13 @@
  </widget>
  <customwidgets>
   <customwidget>
-   <class>CustomSpinBox</class>
-   <extends>QSpinBox</extends>
+   <class>CustomDoubleSpinBoxNoSign</class>
+   <extends>QDoubleSpinBox</extends>
    <header location="global">spin_box.h</header>
   </customwidget>
   <customwidget>
-   <class>CustomDoubleSpinBoxNoSign</class>
-   <extends>QDoubleSpinBox</extends>
+   <class>CustomSpinBox</class>
+   <extends>QSpinBox</extends>
    <header location="global">spin_box.h</header>
   </customwidget>
   <customwidget>
diff --git a/linien-gui/linien_gui/ui/sweep_control.py b/linien-gui/linien_gui/ui/sweep_control.py
index 299c0494..eba06128 100644
--- a/linien-gui/linien_gui/ui/sweep_control.py
+++ b/linien-gui/linien_gui/ui/sweep_control.py
@@ -16,37 +16,46 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import superqt
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtWidgets
+from linien_gui.utils import get_linien_app_instance
+from PyQt5 import QtCore, QtWidgets
 
 
-class SweepControlWidget(QtWidgets.QWidget, CustomWidget):
+class SweepControlWidget(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+        super(SweepControlWidget, self).__init__(*args, **kwargs)
+        self.app = get_linien_app_instance()
+        self.main_window = self.window()
+        self.app.connection_established.connect(self.on_connection_established)
+        QtCore.QTimer.singleShot(100, self.ready)
 
     def ready(self):
-        # initialize sweep slider boundaries
-        self.ids.sweep_slider.init()
+        self.main_window.sweepSlider.ready()  # initialize sweep slider boundaries
 
     def on_connection_established(self):
         self.control = self.app.control
         self.parameters = self.app.parameters
 
-        self.ids.sweep_slider.valueChanged.connect(self.update_sweep_range)
+        self.main_window.sweepSlider.valueChanged.connect(self.update_sweep_range)
         # NOTE: The keyboardTracking property of the QDoubleSpinBoxes has been set to
         # False, to avoid signal emission when editing the field. Signals are still
         # emitted when using the arrow buttons. See also the editingFinished method.
-        self.ids.sweep_center.valueChanged.connect(self.update_sweep_center)
-        self.ids.sweep_amplitude.valueChanged.connect(self.update_sweep_amplitude)
-        self.ids.sweep_start_stop_button.clicked.connect(self.pause_or_resume_sweep)
+        self.main_window.sweepCenterSpinBox.valueChanged.connect(
+            self.update_sweep_center
+        )
+        self.main_window.sweepAmplitudeSpinBox.valueChanged.connect(
+            self.update_sweep_amplitude
+        )
+        self.main_window.sweepStartStopPushButton.clicked.connect(
+            self.pause_or_resume_sweep
+        )
 
         # initialize sweep controls
         self.display_sweep_status()
 
         # change displayed values when sweep parameters change
-        self.parameters.sweep_center.on_change(self.display_sweep_status)
-        self.parameters.sweep_amplitude.on_change(self.display_sweep_status)
-        self.parameters.sweep_pause.on_change(self.display_sweep_status)
+        self.parameters.sweep_center.add_callback(self.display_sweep_status)
+        self.parameters.sweep_amplitude.add_callback(self.display_sweep_status)
+        self.parameters.sweep_pause.add_callback(self.display_sweep_status)
 
     def display_sweep_status(self, *args):
         center = self.parameters.sweep_center.value
@@ -56,21 +65,21 @@ def display_sweep_status(self, *args):
 
         # block signals to avoid infinite loops when changing sweep parameters, see also
         # param2ui
-        self.ids.sweep_slider.blockSignals(True)
-        self.ids.sweep_amplitude.blockSignals(True)
-        self.ids.sweep_center.blockSignals(True)
+        self.main_window.sweepSlider.blockSignals(True)
+        self.main_window.sweepAmplitudeSpinBox.blockSignals(True)
+        self.main_window.sweepCenterSpinBox.blockSignals(True)
 
-        self.ids.sweep_slider.setValue((min_, max_))
-        self.ids.sweep_center.setValue(center)
-        self.ids.sweep_amplitude.setValue(amplitude)
+        self.main_window.sweepSlider.setValue((min_, max_))
+        self.main_window.sweepCenterSpinBox.setValue(center)
+        self.main_window.sweepAmplitudeSpinBox.setValue(amplitude)
         if self.parameters.sweep_pause.value:
-            self.ids.sweep_start_stop_button.setText("Start")
+            self.main_window.sweepStartStopPushButton.setText("Start")
         else:
-            self.ids.sweep_start_stop_button.setText("Pause")
+            self.main_window.sweepStartStopPushButton.setText("Pause")
 
-        self.ids.sweep_slider.blockSignals(False)
-        self.ids.sweep_center.blockSignals(False)
-        self.ids.sweep_amplitude.blockSignals(False)
+        self.main_window.sweepSlider.blockSignals(False)
+        self.main_window.sweepCenterSpinBox.blockSignals(False)
+        self.main_window.sweepAmplitudeSpinBox.blockSignals(False)
 
     def pause_or_resume_sweep(self):
         # If sweep is paused, resume it or vice versa.
@@ -94,11 +103,11 @@ def update_sweep_range(self, range_):
         self.control.write_registers()
 
 
-class SweepSlider(superqt.QDoubleRangeSlider, CustomWidget):
+class SweepSlider(superqt.QDoubleRangeSlider):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+        super(SweepSlider, self).__init__(*args, **kwargs)
 
-    def init(self):
+    def ready(self):
         # set control boundaries
         self.setMinimum(-1.0)
         self.setMaximum(1.0)
diff --git a/linien-gui/linien_gui/ui/view_panel.py b/linien-gui/linien_gui/ui/view_panel.py
index 93612c67..d9569da0 100644
--- a/linien-gui/linien_gui/ui/view_panel.py
+++ b/linien-gui/linien_gui/ui/view_panel.py
@@ -21,70 +21,71 @@
 from os import path
 
 import numpy as np
-from linien_common.config import N_COLORS
-from linien_gui.utils import color_to_hex, param2ui
-from linien_gui.widgets import CustomWidget
-from PyQt5 import QtGui, QtWidgets
+from linien_gui.config import N_COLORS
+from linien_gui.utils import color_to_hex, get_linien_app_instance, param2ui
+from linien_gui.widgets import UI_PATH
+from PyQt5 import QtGui, QtWidgets, uic
 
 
-class ViewPanel(QtWidgets.QWidget, CustomWidget):
+class ViewPanel(QtWidgets.QWidget):
     def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.load_ui("view_panel.ui")
+        super(ViewPanel, self).__init__(*args, **kwargs)
+        uic.loadUi(UI_PATH / "view_panel.ui", self)
+        self.app = get_linien_app_instance()
+        self.app.connection_established.connect(self.on_connection_established)
 
-    def ready(self):
-        self.ids.export_select_file.clicked.connect(self.do_export_select_file)
-        self.ids.export_data.clicked.connect(self.do_export_data)
+        self.export_select_file.clicked.connect(self.do_export_select_file)
+        self.export_data.clicked.connect(self.do_export_data)
 
-        self.ids.plot_line_width.setKeyboardTracking(False)
-        self.ids.plot_line_width.valueChanged.connect(self.plot_line_width_changed)
+        self.plot_line_width.setKeyboardTracking(False)
+        self.plot_line_width.valueChanged.connect(self.plot_line_width_changed)
 
-        self.ids.plot_line_opacity.setKeyboardTracking(False)
-        self.ids.plot_line_opacity.valueChanged.connect(self.plot_line_opacity_changed)
+        self.plot_line_opacity.setKeyboardTracking(False)
+        self.plot_line_opacity.valueChanged.connect(self.plot_line_opacity_changed)
 
-        self.ids.plot_fill_opacity.setKeyboardTracking(False)
-        self.ids.plot_fill_opacity.valueChanged.connect(self.plot_fill_opacity_changed)
+        self.plot_fill_opacity.setKeyboardTracking(False)
+        self.plot_fill_opacity.valueChanged.connect(self.plot_fill_opacity_changed)
 
         for color_idx in range(N_COLORS):
-            getattr(self.ids, "edit_color_%d" % color_idx).clicked.connect(
+            getattr(self, f"edit_color_{color_idx}").clicked.connect(
                 lambda *args, color_idx=color_idx: self.edit_color(color_idx)
             )
 
     def edit_color(self, color_idx):
-        param = getattr(self.parameters, "plot_color_%d" % color_idx)
-
-        color = QtWidgets.QColorDialog.getColor(QtGui.QColor.fromRgb(*param.value))
+        setting = getattr(self.app.settings, f"plot_color_{color_idx}")
+        color = QtWidgets.QColorDialog.getColor(QtGui.QColor.fromRgb(*setting.value))
         r, g, b, a = color.getRgb()
-        print("set color", color_idx, color.getRgb())
-        param.value = (r, g, b, a)
+        getattr(self.app.settings, f"plot_color_{color_idx}").value = (r, g, b, a)
 
     def on_connection_established(self):
         self.parameters = self.app.parameters
         self.control = self.app.control
 
-        param2ui(self.parameters.plot_line_width, self.ids.plot_line_width)
-        param2ui(self.parameters.plot_line_opacity, self.ids.plot_line_opacity)
-        param2ui(self.parameters.plot_fill_opacity, self.ids.plot_fill_opacity)
+        param2ui(self.app.settings.plot_line_width, self.plot_line_width)
+        param2ui(self.app.settings.plot_line_opacity, self.plot_line_opacity)
+        param2ui(self.app.settings.plot_fill_opacity, self.plot_fill_opacity)
 
         def preview_colors(*args):
             for color_idx in range(N_COLORS):
-                element = getattr(self.ids, "display_color_%d" % color_idx)
-                param = getattr(self.parameters, "plot_color_%d" % color_idx)
-                element.setStyleSheet("background-color: " + color_to_hex(param.value))
+                element = getattr(self, f"display_color_{color_idx}")
+                setting = getattr(self.app.settings, f"plot_color_{color_idx}")
+                element.setStyleSheet(
+                    f"background-color: {color_to_hex(setting.value)}"
+                )
 
         for color_idx in range(N_COLORS):
-            getattr(self.parameters, "plot_color_%d" % color_idx).on_change(
+            getattr(self.app.settings, f"plot_color_{color_idx}").add_callback(
                 preview_colors
             )
 
     def plot_line_width_changed(self):
-        self.parameters.plot_line_width.value = self.ids.plot_line_width.value()
+        self.app.settings.plot_line_width.value = self.plot_line_width.value()
 
     def plot_line_opacity_changed(self):
-        self.parameters.plot_line_opacity.value = self.ids.plot_line_opacity.value()
+        self.app.settings.plot_line_opacity.value = self.plot_line_opacity.value()
 
     def plot_fill_opacity_changed(self):
-        self.parameters.plot_fill_opacity.value = self.ids.plot_fill_opacity.value()
+        self.app.settings.plot_fill_opacity.value = self.plot_fill_opacity.value()
 
     def do_export_select_file(self):
         options = QtWidgets.QFileDialog.Options()
@@ -94,17 +95,15 @@ def do_export_select_file(self):
             self,
             "QFileDialog.getSaveFileName()",
             "",
-            "JSON (*%s)" % default_ext,
+            f"JSON (*{default_ext})",
             options=options,
         )
         if fn:
             if not fn.endswith(default_ext):
                 fn = fn + default_ext
             self.export_fn = fn
-            self.ids.export_select_file.setText(
-                "File selected: %s" % path.split(fn)[-1]
-            )
-            self.ids.export_data.setEnabled(True)
+            self.export_select_file.setText(f"File selected: {path.split(fn)[-1]}")
+            self.export_data.setEnabled(True)
 
     def do_export_data(self):
         fn = self.export_fn
@@ -126,7 +125,7 @@ def do_export_data(self):
             except FileNotFoundError:
                 break
 
-        print("export data to", fn_with_suffix)
+        print(f"export data to {fn_with_suffix}")
 
         with open(fn_with_suffix, "w") as f:
             data = pickle.loads(self.parameters.to_plot.value)
diff --git a/linien-gui/linien_gui/utils.py b/linien-gui/linien_gui/utils.py
index 80c227a3..3cc14663 100644
--- a/linien-gui/linien_gui/utils.py
+++ b/linien-gui/linien_gui/utils.py
@@ -17,7 +17,10 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import os
+from typing import TYPE_CHECKING, Any, Callable
 
+from linien_client.remote_parameters import RemoteParameter
+from PyQt5 import QtWidgets
 from PyQt5.QtWidgets import (
     QCheckBox,
     QComboBox,
@@ -29,8 +32,19 @@
 )
 from pyqtgraph.Qt import QtGui
 
+if TYPE_CHECKING:
+    from linien_gui.app import LinienApp
 
-def param2ui(parameter, element, process_value=lambda x: x):
+
+def get_linien_app_instance() -> "LinienApp":
+    return QtWidgets.QApplication.instance()  # type: ignore[return-value]
+
+
+def param2ui(
+    parameter: RemoteParameter,
+    element: QtWidgets.QTabWidget,
+    process_value: Callable[[Any], Any] = lambda x: x,
+):
     """
     Updates ui elements according to parameter values.
 
@@ -57,7 +71,7 @@ def on_change(value, element=element):
 
         element.blockSignals(False)
 
-    parameter.on_change(on_change)
+    parameter.add_callback(on_change)
 
 
 def set_window_icon(window):
diff --git a/linien-gui/linien_gui/widgets.py b/linien-gui/linien_gui/widgets.py
index f69b0661..afccadfe 100644
--- a/linien-gui/linien_gui/widgets.py
+++ b/linien-gui/linien_gui/widgets.py
@@ -16,58 +16,6 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-import weakref
 from pathlib import Path
 
-from PyQt5 import uic
-from pyqtgraph.Qt import QtCore
-
 UI_PATH = Path(__file__).parents[0].resolve() / "ui"
-
-
-class IDSelector:
-    def __init__(self, parent):
-        self.parent = parent
-
-    def __getattr__(self, name):
-        return self.parent.get_widget(name)
-
-
-class CustomWidget:
-    instances = []
-
-    def __init__(self, *args, **kwargs):
-        self.__class__.instances.append(weakref.proxy(self))
-
-        super().__init__(*args, **kwargs)
-
-        self.ids = IDSelector(self)
-
-        QtCore.QTimer.singleShot(100, self.ready)
-
-    def ready(self):
-        pass
-
-    def on_connection_established(self):
-        # This is executed the client succesfully established a connection to the server
-        # and can be extended by inheritting classes.
-        pass
-
-    def get_widget(self, name):
-        """Queries a widget by name."""
-        return self.findChild(QtCore.QObject, name)
-
-    @property
-    def app(self):
-        # this property is set manually. Probably there is a more elegant way
-        # to solve this...
-        return self.window()._app
-
-    @app.setter
-    def app(self, app):
-        self._app = app
-
-    def load_ui(self, name):
-        assert name.endswith(".ui")
-        path = UI_PATH / name
-        uic.loadUi(str(path), self)
diff --git a/linien-gui/setup.py b/linien-gui/setup.py
index 4178bd75..a57e997e 100644
--- a/linien-gui/setup.py
+++ b/linien-gui/setup.py
@@ -18,7 +18,7 @@
 
 from setuptools import find_packages, setup
 
-version = "0.7.0"
+version = "0.8.0"
 
 setup(
     name="linien-gui",
@@ -40,7 +40,7 @@
     entry_points={"console_scripts": ["linien=linien_gui.app:run_application"]},
     python_requires=">=3.8",
     install_requires=[
-        "appdirs>=1.4.4",
+        "app_paths>=0.0.7 ",
         "click>=7.1.2",
         "pyqtgraph>=0.10.0",
         "PyQt5>=5.12.0",
diff --git a/linien-server/linien_server/acquisition.py b/linien-server/linien_server/acquisition.py
index b42c6edc..9f6cdb0a 100644
--- a/linien-server/linien_server/acquisition.py
+++ b/linien-server/linien_server/acquisition.py
@@ -1,5 +1,5 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2021-2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+# Copyright 2018-2023 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
+# Copyright 2021-2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -16,160 +16,321 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-import atexit
-import threading
-from enum import Enum
-from multiprocessing import Pipe, Process
+import pickle
+import shutil
+import subprocess
+from pathlib import Path
+from random import random
+from threading import Event, Thread
 from time import sleep
+from typing import Any, Dict, Optional, Tuple, Union
 
-import rpyc
+import numpy as np
+from linien_common.common import DECIMATION, MAX_N_POINTS, N_POINTS
 from linien_common.config import ACQUISITION_PORT
-from linien_server.utils import flash_fpga, start_nginx, stop_nginx
+from linien_server.csr import PythonCSR
+from pyrp3.board import RedPitaya  # type: ignore
+from pyrp3.instrument import TriggerSource  # type: ignore
+from rpyc import Service
+from rpyc.utils.server import ThreadedServer
 
 
-class AcquisitionConnectionError(Exception):
-    pass
+class AcquisitionService(Service):
+    def __init__(self):
+        super(AcquisitionService, self).__init__()
+        stop_nginx()
+        flash_fpga()
 
+        self.red_pitaya = RedPitaya()
+        self.csr = PythonCSR(self.red_pitaya)
+        self.csr_queue = []
+        self.csr_iir_queue = []
 
-class AcquisitionProcessSignals(Enum):
-    SHUTDOWN = 0
-    SET_SWEEP_SPEED = 2
-    SET_LOCK_STATUS = 3
-    SET_CSR = 4
-    SET_IIR_CSR = 5
-    PAUSE_ACQUISIITON = 5.5
-    CONTINUE_ACQUISITION = 6
-    FETCH_QUADRATURES = 7
-    SET_RAW_ACQUISITION = 8
-    SET_DUAL_CHANNEL = 9
+        self.data = pickle.dumps(None)
+        self.data_was_raw = False
+        self.data_hash = None
+        self.data_uuid = None
 
+        self.locked = False
+        self.exposed_set_sweep_speed(9)
+        # when self.locked is set to True, this doesn't mean that the lock is really on.
+        # It just means that the lock is requested and that the gateware waits until the
+        # sweep is at the correct position for the lock. Therefore, when self.locked is
+        # set, the acquisition process waits for confirmation from the gateware that the
+        # lock is actually running.
+        self.confirmed_that_in_lock = False
 
-class AcquisitionMaster:
-    def __init__(self, use_ssh, host):
-        self.on_new_data_received = None
+        self.fetch_additional_signals = True
+        self.raw_acquisition_enabled = False
+        self.raw_acquisition_decimation = 0
 
-        def receive_acquired_data(conn):
-            while True:
-                is_raw, received_data, data_uuid = conn.recv()
-                if self.on_new_data_received is not None:
-                    self.on_new_data_received(is_raw, received_data, data_uuid)
+        self.dual_channel = False
 
-        self.parent_conn, child_conn = Pipe()
-        process = Process(
-            target=self.connect_acquisition_process, args=(child_conn, use_ssh, host)
+        self.stop_event = Event()
+        self.pause_event = Event()
+        self.skip_next_data_event = Event()
+
+        self.thread = Thread(
+            target=self._acquisition_loop,
+            args=(
+                self.stop_event,
+                self.pause_event,
+                self.skip_next_data_event,
+            ),
+            daemon=True,
         )
-        process.daemon = True
-        process.start()
+        self.thread.start()
 
-        # wait until connection is established
-        self.parent_conn.recv()
+    def _acquisition_loop(
+        self, stop_event: Event, pause_event: Event, skip_next_data_event: Event
+    ) -> None:
+        while not stop_event.is_set():
+            while self.csr_queue:
+                key, value = self.csr_queue.pop(0)
+                self.csr.set(key, value)
 
-        thread = threading.Thread(
-            target=receive_acquired_data, args=(self.parent_conn,)
-        )
-        thread.daemon = True
-        thread.start()
+            while self.csr_iir_queue:
+                args = self.csr_iir_queue.pop(0)
+                self.csr.set_iir(*args)
+
+            if self.locked and not self.confirmed_that_in_lock:
+                self.confirmed_that_in_lock = self.csr.get(
+                    "logic_autolock_lock_running"
+                )
+                if not self.confirmed_that_in_lock:
+                    sleep(0.05)
+                    continue
+
+            if pause_event.is_set():
+                sleep(0.05)
+                continue
+
+            # check that scope is triggered; copied from
+            # https://github.com/RedPitaya/RedPitaya/blob/14cca62dd58f29826ee89f4b28901602f5cdb1d8/api/src/oscilloscope.c#L115  # noqa: E501
+            if not (self.red_pitaya.scope.read(0x1 << 2) & 0x4) <= 0:
+                sleep(0.05)
+                continue
+
+            if self.raw_acquisition_enabled:
+                data_raw = self.read_data_raw(
+                    0x10000, self.red_pitaya.scope.write_pointer_trigger, MAX_N_POINTS
+                )
+                is_raw = True
+            else:
+                data = self.read_data()
+                is_raw = False
+
+            if pause_event.is_set():
+                # it may seem strange that we check this here a second time. Reason:
+                # `read_data` takes some time and if in the mean time acquisition
+                # was paused, we do not want to send the data
+                continue
+
+            if skip_next_data_event.is_set():
+                skip_next_data_event.clear()
+            else:
+                if self.raw_acquisition_enabled:
+                    self.data = pickle.dumps(data_raw)
+                else:
+                    self.data = pickle.dumps(data)
+                self.data_was_raw = is_raw
+                self.data_hash = random()
+
+            self.program_acquisition_and_rearm()
+
+    def read_data(self) -> Dict[str, np.ndarray]:
+        signals = []
+
+        channel_offsets = [0x10000]
+        if self.fetch_additional_signals or self.locked:
+            channel_offsets.append(0x20000)
+
+        for channel_offset in channel_offsets:
+            channel_data = self.read_data_raw(
+                channel_offset,
+                self.red_pitaya.scope.write_pointer_trigger,
+                N_POINTS,
+            )
+
+            for sub_channel_idx in (0, 1):
+                signals.append(channel_data[sub_channel_idx])
+
+        signals_named = {}
+
+        if not self.locked:
+            signals_named["error_signal_1"] = signals[0]
+
+            if self.fetch_additional_signals and len(signals) >= 3:
+                signals_named["error_signal_1_quadrature"] = signals[2]
+
+            if self.dual_channel:
+                signals_named["error_signal_2"] = signals[1]
+                if self.fetch_additional_signals and len(signals) >= 3:
+                    signals_named["error_signal_2_quadrature"] = signals[3]
+            else:
+                signals_named["monitor_signal"] = signals[1]
+
+        else:
+            signals_named["error_signal"] = signals[0]
+            signals_named["control_signal"] = signals[1]
+
+            if not self.dual_channel and len(signals) >= 3:
+                signals_named["monitor_signal"] = signals[2]
+
+        slow_out = self.csr.get("logic_slow_value")
+        slow_out = slow_out if slow_out <= 8191 else slow_out - 16384
+        signals_named["slow_control_signal"] = slow_out
+
+        return signals_named
+
+    def read_data_raw(
+        self, offset: int, addr: int, data_length: int
+    ) -> Tuple[Any, ...]:
+        max_data_length = 16383
+        if data_length + addr > max_data_length:
+            to_read_later = data_length + addr - max_data_length
+            data_length -= to_read_later
+        else:
+            to_read_later = 0
+
+        # raw data contains two signals:
+        #   2'h0,adc_b_rd,2'h0,adc_a_rd
+        #   i.e.: 2 zero bits, channel b (14 bit), 2 zero bits, channel a (14 bit)
+        # .copy() is required, because np.frombuffer returns a readonly array
+        raw_data = self.red_pitaya.scope.reads(offset + (4 * addr), data_length).copy()
+
+        # raw_data is an array of 32-bit ints. We cast it to 16 bit --> each original
+        # int is split into two ints
+        raw_data.dtype = np.int16
+
+        # sign bit is at position 14, but we have 16 bit ints
+        raw_data[raw_data >= 2**13] -= 2**14
+
+        # order is such that we have first the signal a then signal b
+        signals = tuple(raw_data[signal_idx::2] for signal_idx in (0, 1))
+
+        if to_read_later > 0:
+            additional_raw_data = self.read_data_raw(offset, 0, to_read_later)
+            signals = tuple(
+                np.append(signals[signal_idx], additional_raw_data[signal_idx])
+                for signal_idx in (0, 1)
+            )
 
-        atexit.register(self.shutdown)
+        return signals
 
-    def run_data_acquisition(self, on_new_data_received):
-        self.on_new_data_received = on_new_data_received
+    def program_acquisition_and_rearm(self, trigger_delay=16384):
+        """Program the acquisition settings and rearm acquisition."""
+        if not self.locked:
+            target_decimation = 2 ** (self.sweep_speed + int(np.log2(DECIMATION)))
 
-    def connect_acquisition_process(self, pipe, use_ssh, host):
-        if use_ssh:
-            # for debugging, acquisition process may be launched manually on the server
-            # and rpyc can be used to connect to it
-            acquisition_rpyc = rpyc.connect(host, ACQUISITION_PORT)
-            acquisition = acquisition_rpyc.root
+            self.red_pitaya.scope.data_decimation = target_decimation
+            self.red_pitaya.scope.trigger_delay = int(trigger_delay / DECIMATION) - 1
+
+        elif self.raw_acquisition_enabled:
+            self.red_pitaya.scope.data_decimation = 2**self.raw_acquisition_decimation
+            self.red_pitaya.scope.trigger_delay = trigger_delay
+
+        else:
+            self.red_pitaya.scope.data_decimation = 1
+            self.red_pitaya.scope.trigger_delay = int(trigger_delay / DECIMATION) - 1
+
+        self.red_pitaya.scope.rearm(trigger_source=TriggerSource.ext_posedge)
+
+    def exposed_return_data(
+        self, last_hash: Optional[float]
+    ) -> Tuple[
+        bool,
+        Union[float, None],
+        Union[bool, None],
+        Union[bytes, None],
+        Union[float, None],
+    ]:
+        no_data_available = self.data_hash is None
+        data_not_changed = self.data_hash == last_hash
+        if data_not_changed or no_data_available or self.pause_event.is_set():
+            return False, None, None, None, None
         else:
-            # This is what happens in production mode
-            from linien_server.acquisition_process import DataAcquisitionService
-
-            stop_nginx()
-            flash_fpga()
-            acquisition = DataAcquisitionService()
-
-        # tell the main thread that we're ready
-        pipe.send(True)
-
-        # Run a loop that listens for acquired data and transmits them to the main
-        # thread. Also redirects calls from the main thread sto the acquiry process.
-        last_hash = None
-        while True:
-            # check whether the main thread sent a command to the acquiry process
-            while pipe.poll():
-                data = pipe.recv()
-                if data[0] == AcquisitionProcessSignals.SHUTDOWN:
-                    raise SystemExit()
-                elif data[0] == AcquisitionProcessSignals.SET_SWEEP_SPEED:
-                    speed = data[1]
-                    acquisition.exposed_set_sweep_speed(speed)
-                elif data[0] == AcquisitionProcessSignals.SET_LOCK_STATUS:
-                    acquisition.exposed_set_lock_status(data[1])
-                elif data[0] == AcquisitionProcessSignals.FETCH_QUADRATURES:
-                    acquisition.exposed_set_fetch_additional_signals(data[1])
-                elif data[0] == AcquisitionProcessSignals.SET_RAW_ACQUISITION:
-                    acquisition.exposed_set_raw_acquisition(data[1])
-                elif data[0] == AcquisitionProcessSignals.SET_DUAL_CHANNEL:
-                    acquisition.exposed_set_dual_channel(data[1])
-                elif data[0] == AcquisitionProcessSignals.SET_CSR:
-                    acquisition.exposed_set_csr(*data[1])
-                elif data[0] == AcquisitionProcessSignals.SET_IIR_CSR:
-                    acquisition.exposed_set_iir_csr(*data[1])
-                elif data[0] == AcquisitionProcessSignals.PAUSE_ACQUISIITON:
-                    acquisition.exposed_pause_acquisition()
-                elif data[0] == AcquisitionProcessSignals.CONTINUE_ACQUISITION:
-                    acquisition.exposed_continue_acquisition(data[1])
-
-            # load acquired data and send it to the main thread
-            (
-                new_data_returned,
-                new_hash,
-                data_was_raw,
-                new_data,
-                data_uuid,
-            ) = acquisition.exposed_return_data(last_hash)
-            if new_data_returned:
-                last_hash = new_hash
-                pipe.send((data_was_raw, new_data, data_uuid))
-
-            sleep(0.05)
-
-    def shutdown(self):
-        if self.parent_conn:
-            self.parent_conn.send((AcquisitionProcessSignals.SHUTDOWN,))
+            return True, self.data_hash, self.data_was_raw, self.data, self.data_uuid
+
+    def exposed_set_sweep_speed(self, speed):
+        self.sweep_speed = speed
+        # if a slow acqisition is currently running and we change the sweep speed we
+        # don't want to wait until it finishes
+        self.program_acquisition_and_rearm()
 
+    def exposed_set_lock_status(self, locked: bool) -> None:
+        self.locked = locked
+        self.confirmed_that_in_lock = False
+
+    def exposed_set_fetch_additional_signals(self, fetch: bool) -> None:
+        self.fetch_additional_signals = fetch
+
+    def exposed_set_raw_acquisition(self, enabled: bool, decimation: int) -> None:
+        self.raw_acquisition_enabled = enabled
+        self.raw_acquisition_decimation = decimation
+
+    def exposed_set_dual_channel(self, dual_channel):
+        self.dual_channel = dual_channel
+
+    def exposed_set_csr(self, key: str, value: int) -> None:
+        self.csr_queue.append((key, value))
+
+    def exposed_set_iir_csr(self, *args):
+        self.csr_iir_queue.append(args)
+
+    def exposed_stop_acquisition(self) -> None:
+        self.stop_event.set()
+        self.thread.join()
         start_nginx()
 
-    def set_sweep_speed(self, speed):
-        self.parent_conn.send((AcquisitionProcessSignals.SET_SWEEP_SPEED, speed))
+    def exposed_pause_acquisition(self):
+        self.pause_event.set()
+        self.data_hash = None
+        self.data = None
 
-    def lock_status_changed(self, status):
-        if self.parent_conn:
-            self.parent_conn.send((AcquisitionProcessSignals.SET_LOCK_STATUS, status))
+    def exposed_continue_acquisition(self, uuid: Optional[float]) -> None:
+        self.program_acquisition_and_rearm()
+        sleep(0.01)
+        # resetting data here is not strictly required but we want to be on the safe
+        # side
+        self.data_hash = None
+        self.data = None
+        self.pause_event.clear()
+        self.data_uuid = uuid
+        # if we are sweeping, we have to skip one data set because an incomplete sweep
+        # may have been recorded. When locked, this does not matter
+        if self.confirmed_that_in_lock:
+            self.skip_next_data_event.clear()
+        else:
+            self.skip_next_data_event.set()
 
-    def fetch_additional_signals_changed(self, status):
-        if self.parent_conn:
-            self.parent_conn.send((AcquisitionProcessSignals.FETCH_QUADRATURES, status))
 
-    def set_csr(self, key, value):
-        self.parent_conn.send((AcquisitionProcessSignals.SET_CSR, (key, value)))
+def flash_fpga():
+    filepath = Path(__file__).resolve().parent / "linien.bin"
 
-    def set_iir_csr(self, *args):
-        self.parent_conn.send((AcquisitionProcessSignals.SET_IIR_CSR, args))
+    # On redpitaya os < 2, flashing fpga works by copying the bit file /dev/xdevcfg. On
+    # recent versions, there is a dedicated command for this
+    # cf. https://forum.redpitaya.com/viewtopic.php?p=33494&sid=5132bf6e33709b1a7daa948f8e8dcdb1#p33494  # noqa: E501
+    fpga_dev_file = Path("/dev/xdevcfg")
 
-    def pause_acquisition(self):
-        self.parent_conn.send((AcquisitionProcessSignals.PAUSE_ACQUISIITON, True))
+    if fpga_dev_file.exists():
+        print("Copying gateware to %s" % fpga_dev_file)
+        shutil.copy(str(filepath), str(fpga_dev_file))
+    else:
+        print("Using fpautil to deploy gateware.")
+        subprocess.Popen(["/opt/redpitaya/bin/fpgautil", "-b", str(filepath)]).wait()
 
-    def continue_acquisition(self, uuid):
-        self.parent_conn.send((AcquisitionProcessSignals.CONTINUE_ACQUISITION, uuid))
 
-    def set_raw_acquisition(self, enabled, decimation=None):
-        if decimation is None:
-            decimation = 0
-        self.parent_conn.send(
-            (AcquisitionProcessSignals.SET_RAW_ACQUISITION, (enabled, decimation))
-        )
+def start_nginx():
+    subprocess.Popen(["systemctl", "start", "redpitaya_nginx.service"])
+
+
+def stop_nginx():
+    subprocess.Popen(["systemctl", "stop", "redpitaya_nginx.service"]).wait()
+    subprocess.Popen(["systemctl", "stop", "redpitaya_scpi.service"]).wait()
+
 
-    def set_dual_channel(self, enabled):
-        self.parent_conn.send((AcquisitionProcessSignals.SET_DUAL_CHANNEL, enabled))
+if __name__ == "__main__":
+    threaded_server = ThreadedServer(AcquisitionService(), port=ACQUISITION_PORT)
+    print("Starting AcquisitionService on port ", ACQUISITION_PORT)
+    threaded_server.start()
diff --git a/linien-server/linien_server/acquisition_process.py b/linien-server/linien_server/acquisition_process.py
deleted file mode 100644
index 7e08b277..00000000
--- a/linien-server/linien_server/acquisition_process.py
+++ /dev/null
@@ -1,282 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2021-2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
-#
-# This file is part of Linien and based on redpid.
-#
-# Linien is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Linien is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
-
-import _thread
-import os
-import pickle
-import threading
-from random import random
-from time import sleep
-
-import numpy as np
-from linien_common.common import DECIMATION, MAX_N_POINTS, N_POINTS
-from linien_common.config import ACQUISITION_PORT
-from pyrp3.board import RedPitaya
-from rpyc import Service
-from rpyc.utils.server import OneShotServer
-
-from .csr import PythonCSR
-
-
-def shutdown():
-    _thread.interrupt_main()
-    os._exit(0)
-
-
-class DataAcquisitionService(Service):
-    def __init__(self):
-        self.red_pitaya = RedPitaya()
-        self.csr = PythonCSR(self.red_pitaya)
-        self.csr_queue = []
-        self.csr_iir_queue = []
-
-        self.data = pickle.dumps(None)
-        self.data_was_raw = False
-        self.data_hash = None
-        self.data_uuid = None
-
-        super(DataAcquisitionService, self).__init__()
-
-        self.locked = False
-        self.exposed_set_sweep_speed(9)
-        # when self.locked is set to True, this doesn't mean that the lock is really on.
-        # It just means that the lock is requested and that the gateware waits until the
-        # sweep is at the correct position for the lock. Therefore, when self.locked is
-        # set, the acquisition process waits for confirmation from the gateware that the
-        # lock is actually running.
-        self.confirmed_that_in_lock = False
-
-        self.fetch_additional_signals = True
-        self.raw_acquisition_enabled = False
-        self.raw_acquisition_decimation = 0
-
-        self.dual_channel = False
-
-        self.acquisition_paused = False
-        self.skip_next_data = False
-
-        self.run()
-
-    def run(self):
-        def run_acquiry_loop():
-            while True:
-                while self.csr_queue:
-                    key, value = self.csr_queue.pop(0)
-                    self.csr.set(key, value)
-
-                while self.csr_iir_queue:
-                    args = self.csr_iir_queue.pop(0)
-                    self.csr.set_iir(*args)
-
-                if self.locked and not self.confirmed_that_in_lock:
-                    self.confirmed_that_in_lock = self.csr.get(
-                        "logic_autolock_lock_running"
-                    )
-                    if not self.confirmed_that_in_lock:
-                        sleep(0.05)
-                        continue
-
-                if self.acquisition_paused:
-                    sleep(0.05)
-                    continue
-
-                # copied from https://github.com/RedPitaya/RedPitaya/blob/14cca62dd58f29826ee89f4b28901602f5cdb1d8/api/src/oscilloscope.c#L115  # noqa: E501
-                # check whether scope was triggered
-                not_triggered = (self.red_pitaya.scope.read(0x1 << 2) & 0x4) > 0
-                if not_triggered:
-                    sleep(0.05)
-                    continue
-
-                data, is_raw = self.read_data()
-
-                if self.acquisition_paused:
-                    # it may seem strange that we check this here a second time. Reason:
-                    # `read_data` takes some time and if in the mean time acquisition
-                    # was paused, we do not want to send the data
-                    continue
-
-                if self.skip_next_data:
-                    self.skip_next_data = False
-                else:
-                    self.data = pickle.dumps(data)
-                    self.data_was_raw = is_raw
-                    self.data_hash = random()
-
-                self.program_acquisition_and_rearm()
-
-        self.thread = threading.Thread(target=run_acquiry_loop, args=())
-        self.thread.daemon = True
-        self.thread.start()
-
-    def program_acquisition_and_rearm(self, trigger_delay=16384):
-        """Programs the acquisition settings and rearms acquisition."""
-        if not self.locked:
-            target_decimation = 2 ** (self.sweep_speed + int(np.log2(DECIMATION)))
-
-            self.red_pitaya.scope.data_decimation = target_decimation
-            self.red_pitaya.scope.trigger_delay = int(trigger_delay / DECIMATION) - 1
-
-        elif self.raw_acquisition_enabled:
-            self.red_pitaya.scope.data_decimation = 2**self.raw_acquisition_decimation
-            self.red_pitaya.scope.trigger_delay = trigger_delay
-
-        else:
-            self.red_pitaya.scope.data_decimation = 1
-            self.red_pitaya.scope.trigger_delay = int(trigger_delay / DECIMATION) - 1
-
-        # trigger_source=6 means external trigger positive edge
-        self.red_pitaya.scope.rearm(trigger_source=6)
-
-    def exposed_return_data(self, last_hash):
-        no_data_available = self.data_hash is None
-        data_not_changed = self.data_hash == last_hash
-        if data_not_changed or no_data_available or self.acquisition_paused:
-            return False, None, None, None, None
-        else:
-            return True, self.data_hash, self.data_was_raw, self.data, self.data_uuid
-
-    def exposed_set_sweep_speed(self, speed):
-        self.sweep_speed = speed
-        # if a slow acqisition is currently running and we change the sweep speed we
-        # don't want to wait until it finishes
-        self.program_acquisition_and_rearm()
-
-    def exposed_set_lock_status(self, locked):
-        self.locked = locked
-        self.confirmed_that_in_lock = False
-
-    def exposed_set_fetch_additional_signals(self, fetch):
-        self.fetch_additional_signals = fetch
-
-    def exposed_set_raw_acquisition(self, data):
-        self.raw_acquisition_enabled = data[0]
-        self.raw_acquisition_decimation = data[1]
-
-    def exposed_set_dual_channel(self, dual_channel):
-        self.dual_channel = dual_channel
-
-    def exposed_set_csr(self, key, value):
-        self.csr_queue.append((key, value))
-
-    def exposed_set_iir_csr(self, *args):
-        self.csr_iir_queue.append(args)
-
-    def exposed_pause_acquisition(self):
-        self.acquisition_paused = True
-        self.data_hash = None
-        self.data = None
-
-    def exposed_continue_acquisition(self, uuid):
-        self.program_acquisition_and_rearm()
-        sleep(0.01)
-        # resetting data here is not strictly required but we want to be on the safe
-        # side
-        self.data_hash = None
-        self.data = None
-        self.acquisition_paused = False
-        self.data_uuid = uuid
-        # if we are sweeping, we have to skip one data set because an incomplete sweep
-        # may have been recorded. When locked, this does not matter
-        self.skip_next_data = not self.confirmed_that_in_lock
-
-    def read_data(self):
-        write_pointer = self.red_pitaya.scope.write_pointer_trigger
-
-        if self.raw_acquisition_enabled:
-            return self.read_data_raw(0x10000, write_pointer, MAX_N_POINTS), True
-
-        else:
-            signals = []
-
-            channel_offsets = [0x10000]
-            if self.fetch_additional_signals or self.locked:
-                channel_offsets.append(0x20000)
-
-            for channel_offset in channel_offsets:
-                channel_data = self.read_data_raw(
-                    channel_offset, write_pointer, N_POINTS
-                )
-
-                for sub_channel_idx in (0, 1):
-                    signals.append(channel_data[sub_channel_idx])
-
-            signals_named = {}
-
-            if not self.locked:
-                signals_named["error_signal_1"] = signals[0]
-
-                if self.fetch_additional_signals and len(signals) >= 3:
-                    signals_named["error_signal_1_quadrature"] = signals[2]
-
-                if self.dual_channel:
-                    signals_named["error_signal_2"] = signals[1]
-                    if self.fetch_additional_signals and len(signals) >= 3:
-                        signals_named["error_signal_2_quadrature"] = signals[3]
-                else:
-                    signals_named["monitor_signal"] = signals[1]
-
-            else:
-                signals_named["error_signal"] = signals[0]
-                signals_named["control_signal"] = signals[1]
-
-                if not self.dual_channel and len(signals) >= 3:
-                    signals_named["monitor_signal"] = signals[2]
-
-            slow_out = self.csr.get("logic_slow_value")
-            slow_out = slow_out if slow_out <= 8191 else slow_out - 16384
-            signals_named["slow_control_signal"] = slow_out
-
-            return signals_named, False
-
-    def read_data_raw(self, offset, addr, data_length):
-        max_data_length = 16383
-        if data_length + addr > max_data_length:
-            to_read_later = data_length + addr - max_data_length
-            data_length -= to_read_later
-        else:
-            to_read_later = 0
-
-        # raw data contains two signals:
-        #   2'h0,adc_b_rd,2'h0,adc_a_rd
-        #   i.e.: 2 zero bits, channel b (14 bit), 2 zero bits, channel a (14 bit)
-        # .copy() is required, because np.frombuffer returns a readonly array
-        raw_data = self.red_pitaya.scope.reads(offset + (4 * addr), data_length).copy()
-
-        # raw_data is an array of 32-bit ints. We cast it to 16 bit --> each original
-        # int is split into two ints
-        raw_data.dtype = np.int16
-
-        # sign bit is at position 14, but we have 16 bit ints
-        raw_data[raw_data >= 2**13] -= 2**14
-
-        # order is such that we have first the signal a then signal b
-        signals = tuple(raw_data[signal_idx::2] for signal_idx in (0, 1))
-
-        if to_read_later > 0:
-            additional_raw_data = self.read_data_raw(offset, 0, to_read_later)
-            signals = tuple(
-                np.append(signals[signal_idx], additional_raw_data[signal_idx])
-                for signal_idx in (0, 1)
-            )
-
-        return signals
-
-
-if __name__ == "__main__":
-    t = OneShotServer(DataAcquisitionService(), port=ACQUISITION_PORT)
-    t.start()
diff --git a/linien-server/linien_server/autolock/algorithm_selection.py b/linien-server/linien_server/autolock/algorithm_selection.py
index e975b2da..6ca43e40 100644
--- a/linien-server/linien_server/autolock/algorithm_selection.py
+++ b/linien-server/linien_server/autolock/algorithm_selection.py
@@ -15,13 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import (
-    AUTO_DETECT_AUTOLOCK_MODE,
-    FAST_AUTOLOCK,
-    N_POINTS,
-    ROBUST_AUTOLOCK,
-    determine_shift_by_correlation,
-)
+from linien_common.common import N_POINTS, AutolockMode, determine_shift_by_correlation
 
 
 class AutolockAlgorithmSelector:
@@ -41,7 +35,7 @@ def __init__(
         self.N_spectra_required = N_spectra_required
         self.line_width = line_width
 
-        if mode_preference != AUTO_DETECT_AUTOLOCK_MODE:
+        if mode_preference != AutolockMode.AUTO_DETECT:
             self.mode = mode_preference
             self.done = True
             return
@@ -69,8 +63,8 @@ def check(self):
             print("jitter / line width ratio:", max_shift / (self.line_width / 2))
 
             if max_shift <= self.line_width / 2:
-                self.mode = FAST_AUTOLOCK
+                self.mode = AutolockMode.FAST
             else:
-                self.mode = ROBUST_AUTOLOCK
+                self.mode = AutolockMode.ROBUST
 
             self.done = True
diff --git a/linien-server/linien_server/autolock/autolock.py b/linien-server/linien_server/autolock/autolock.py
index bcc59427..1fe903e2 100644
--- a/linien-server/linien_server/autolock/autolock.py
+++ b/linien-server/linien_server/autolock/autolock.py
@@ -128,13 +128,13 @@ def start_autolock(self, mode):
     def add_data_listener(self):
         if not self._data_listener_added:
             self._data_listener_added = True
-            self.parameters.to_plot.on_change(
-                self.react_to_new_spectrum, call_listener_with_first_value=False
+            self.parameters.to_plot.add_callback(
+                self.react_to_new_spectrum, call_with_first_value=False
             )
 
     def remove_data_listener(self):
         self._data_listener_added = False
-        self.parameters.to_plot.remove_listener(self.react_to_new_spectrum)
+        self.parameters.to_plot.remove_callback(self.react_to_new_spectrum)
 
     def react_to_new_spectrum(self, plot_data):
         """React to new spectrum data.
@@ -220,7 +220,7 @@ def record_first_error_signal(self, error_signal, auto_offset):
         self.central_y = int(mean_signal)
 
         if auto_offset:
-            self.control.pause_acquisition()
+            self.control.exposed_pause_acquisition()
             self.parameters.combined_offset.value = -1 * self.central_y
             error_signal -= self.central_y
             error_signal_rolled -= self.central_y
@@ -228,7 +228,7 @@ def record_first_error_signal(self, error_signal, auto_offset):
                 s - self.central_y for s in self.additional_spectra
             ]
             self.control.exposed_write_registers()
-            self.control.continue_acquisition()
+            self.control.exposed_continue_acquisition()
 
         self.parameters.target_slope_rising.value = target_slope_rising
         self.control.exposed_write_registers()
@@ -278,11 +278,11 @@ def exposed_stop(self):
         self.parameters.task.value = None
 
     def _reset_scan(self):
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
 
         self.parameters.sweep_amplitude.value = (
             self.parameters.autolock_initial_sweep_amplitude.value
         )
         self.control.exposed_start_sweep()
 
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
diff --git a/linien-server/linien_server/autolock/robust.py b/linien-server/linien_server/autolock/robust.py
index 73551d48..3ea5b679 100644
--- a/linien-server/linien_server/autolock/robust.py
+++ b/linien-server/linien_server/autolock/robust.py
@@ -34,7 +34,6 @@
     sign,
     sum_up_spectrum,
 )
-from linien_server.utils import sweep_speed_to_time
 
 
 class LockPositionNotFound(Exception):
@@ -149,7 +148,7 @@ def setup_timeout(self, N_acquisitions_to_wait=5):
             * sweep_speed_to_time(self.parameters.sweep_speed.value)
         )
 
-        self.parameters.ping.on_change(self.check_for_timeout)
+        self.parameters.ping.add_callback(self.check_for_timeout)
 
     def check_for_timeout(self, ping):
         min_time_to_wait = 5
@@ -162,7 +161,7 @@ def check_for_timeout(self, ping):
             self.parameters.task.value.exposed_stop()
 
     def stop_timeout(self):
-        self.parameters.ping.remove_listener(self.check_for_timeout)
+        self.parameters.ping.remove_callback(self.check_for_timeout)
 
     def after_lock(self):
         self.stop_timeout()
@@ -275,3 +274,12 @@ def get_lock_position_from_autolock_instructions(
                 return idx + final_wait_time
 
     raise LockPositionNotFound()
+
+
+def sweep_speed_to_time(sweep_speed):
+    """Sweep speed is an arbitrary unit (cf. `parameters.py`).
+    This function converts it to the duration of the sweep in seconds.
+    """
+    f_real = 3.8e3 / (2**sweep_speed)
+    duration = 1 / f_real
+    return duration
diff --git a/linien-server/linien_server/csr.py b/linien-server/linien_server/csr.py
index e3a6fd9f..ec4126eb 100644
--- a/linien-server/linien_server/csr.py
+++ b/linien-server/linien_server/csr.py
@@ -22,11 +22,20 @@
 from .iir_coeffs import get_params
 
 
-class PitayaCSR:
+class PythonCSR:
     map = csrmap.csr
     constants = csrmap.csr_constants
     offset = 0x40300000
 
+    def __init__(self, rp):
+        self.rp = rp
+
+    def set_one(self, addr: str, value: int) -> None:
+        self.rp.write(addr, value)
+
+    def get_one(self, addr):
+        return int(self.rp.read(addr))
+
     def set(self, name, value):
         map, addr, width, wr = self.map[name]
         assert wr, name
@@ -44,7 +53,7 @@ def set(self, name, value):
             v = (val >> (8 * (b - i - 1))) & 0xFF
             self.set_one(self.offset + (map << 11) + ((addr + i) << 2), v)
 
-    def get(self, name):
+    def get(self, name: str) -> int:
         if name in self.constants:
             return self.constants[name]
 
@@ -72,19 +81,5 @@ def set_iir(self, prefix, b, a, z=0):
                 self.set(n, 0)
                 self.set(prefix + "_a%i" % i, 0)
 
-    def signal(self, name):
-        return csrmap.signals.index(name)
-
     def states(self, *names):
         return sum(1 << csrmap.states.index(name) for name in names)
-
-
-class PythonCSR(PitayaCSR):
-    def __init__(self, rp):
-        self.rp = rp
-
-    def set_one(self, addr, value):
-        self.rp.write(addr, value)
-
-    def get_one(self, addr):
-        return int(self.rp.read(addr))
diff --git a/linien-server/linien_server/iir_coeffs.py b/linien-server/linien_server/iir_coeffs.py
index 1017851d..4997582d 100644
--- a/linien-server/linien_server/iir_coeffs.py
+++ b/linien-server/linien_server/iir_coeffs.py
@@ -2,9 +2,9 @@
 # Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
 # Copyright 2021-2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
-# This file is part of redpid.
+# This file is part of Linien.
 #
-# redpid is free software: you can redistribute it and/or modify
+# Linien is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
 # the Free Software Foundation, either version 3 of the License, or
 # (at your option) any later version.
@@ -15,12 +15,12 @@
 # GNU General Public License for more details.
 #
 # You should have received a copy of the GNU General Public License
-# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+# along with Linien. If not, see <http://www.gnu.org/licenses/>.
 
 import warnings
 from math import ceil, log2, pi
 
-import scipy.signal
+from scipy import signal
 
 
 def make_filter(name, k=1.0, f=0.0, g=1e20, q=0.5):
@@ -128,7 +128,10 @@ def quantize_filter(b, a, shift=None, width=25):
     for i in b + a:
         assert -m <= i < m, (hex(i), hex(m))
 
-    z, p, k = scipy.signal.tf2zpk(b, a)
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", category=signal.BadCoefficients)
+        warnings.simplefilter("ignore", category=RuntimeWarning)
+        z, p, k = signal.tf2zpk(b, a)
     if any(abs(_) > 1 for _ in p):
         warnings.warn(
             "unstable filter: z={}, p={}, k={}".format(z, p, k), RuntimeWarning
diff --git a/linien-server/linien_server/influxdb.py b/linien-server/linien_server/influxdb.py
new file mode 100644
index 00000000..8da87ddc
--- /dev/null
+++ b/linien-server/linien_server/influxdb.py
@@ -0,0 +1,124 @@
+# Copyright 2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+#
+# This file is part of Linien and based on redpid.
+#
+# Linien is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Linien is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
+
+from threading import Event, Thread
+from time import sleep
+from typing import Tuple
+
+import requests
+from linien_common.influxdb import InfluxDBCredentials, save_credentials
+from linien_server.parameters import Parameters
+
+
+class InfluxDBLogger:
+    def __init__(
+        self, credentials: InfluxDBCredentials, parameters: Parameters
+    ) -> None:
+        self.credentials = credentials
+        self.parameters = parameters
+        self.stop_event = Event()
+        self.stop_event.set()
+
+    @property
+    def credentials(self) -> InfluxDBCredentials:
+        return self._credentials
+
+    @credentials.setter
+    def credentials(self, value: InfluxDBCredentials) -> None:
+        self._credentials = value
+        save_credentials(value)
+
+    def start_logging(self, interval: float) -> None:
+        conn_success, status_code, message = self.test_connection(self.credentials)
+        if conn_success:
+            self.stop_event.clear()
+            self.thread = Thread(
+                target=self._logging_loop,
+                args=(interval,),
+                daemon=True,
+            )
+            self.thread.start()
+        else:
+            raise ConnectionError(
+                "Failed to connect to InfluxDB database: %s (Status code: %s)"
+                % (message, status_code)
+            )
+
+    def stop_logging(self) -> None:
+        self.stop_event.set()
+        self.thread.join()
+
+    def _logging_loop(self, interval: float) -> None:
+        while not self.stop_event.is_set():
+            data = {}
+            for name, param in self.parameters:
+                if param.log:
+                    if name == "signal_stats":
+                        for stat_name, stat_value in param.value.items():
+                            data[stat_name] = stat_value
+                    else:
+                        data[name] = param.value
+            self.write_data(self.credentials, data)
+            sleep(interval)
+
+    def test_connection(
+        self, credentials: InfluxDBCredentials
+    ) -> Tuple[bool, int, str]:
+        """Write empty data to the server to test the connection"""
+        try:
+            response = self.write_data(credentials, data={})
+            success = response.status_code == 204
+            status_code = response.status_code
+            text = response.text
+        except requests.exceptions.ConnectionError:
+            success = False
+            status_code = 404
+            text = "Failed to establish connection."
+        return success, status_code, text
+
+    def write_data(
+        self, credentials: InfluxDBCredentials, data: dict
+    ) -> requests.Response:
+        """Write data to the database"""
+        endpoint = credentials.url + "/api/v2/write"
+        headers = {
+            "Authorization": "Token " + credentials.token,
+            "Content-Type": "text/plain; charset=utf-8",
+            "Accept": "application/json",
+        }
+        params = {
+            "org": credentials.org,
+            "bucket": credentials.bucket,
+            "precision": "ns",
+        }
+
+        point = self._convert_to_line_protocol(data)
+
+        response = requests.post(endpoint, headers=headers, params=params, data=point)
+        return response
+
+    def _convert_to_line_protocol(self, data: dict) -> str:
+        if not data:
+            return ""
+        point = self.credentials.measurement
+        for i, (key, value) in enumerate(data.items()):
+            if i == 0:
+                point += " "
+            else:
+                point += ","
+            point += "%s=%s" % (key, value)
+        return point
diff --git a/linien-server/linien_server/linien_install_requirements.sh b/linien-server/linien_server/linien_install_requirements.sh
index 2ab5abd7..3d260677 100644
--- a/linien-server/linien_server/linien_install_requirements.sh
+++ b/linien-server/linien_server/linien_install_requirements.sh
@@ -7,7 +7,7 @@ fi
 
 # the server is started in a screen session
 echo 'installing screen...'
-apt-get install screen
+apt-get -y install screen
 
 # https://github.com/RedPitaya/RedPitaya/issues/205
 cd /tmp
diff --git a/linien-server/linien_server/approach_line.py b/linien-server/linien_server/optimization/approach_line.py
similarity index 96%
rename from linien-server/linien_server/approach_line.py
rename to linien-server/linien_server/optimization/approach_line.py
index 4be3b000..f3332d7b 100644
--- a/linien-server/linien_server/approach_line.py
+++ b/linien-server/linien_server/optimization/approach_line.py
@@ -138,7 +138,7 @@ def _decrease_scan_range(self):
         self.zoom_factor *= ZOOM_STEP
         self.time_last_zoom = time()
 
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
 
         self.parameters.sweep_amplitude.value /= ZOOM_STEP
         if self.allow_sweep_speed_change:
@@ -149,13 +149,13 @@ def _decrease_scan_range(self):
             )
             self.parameters.sweep_speed.value = new_sweep_speed
         self.control.exposed_write_registers()
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
 
     def _correct_current(self, shift):
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
         self.time_last_current_correction = time()
 
         self.parameters.sweep_center.value -= shift
 
         self.control.exposed_write_registers()
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
diff --git a/linien-server/linien_server/optimization/engine.py b/linien-server/linien_server/optimization/engine.py
index 2f86acd4..5db1fea9 100644
--- a/linien-server/linien_server/optimization/engine.py
+++ b/linien-server/linien_server/optimization/engine.py
@@ -17,8 +17,12 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import math
+import warnings
+
+with warnings.catch_warnings():
+    warnings.simplefilter("ignore")
+    import cma
 
-import cma
 from linien_common.common import MHz, Vpp
 from linien_server.optimization.utils import (
     FINAL_ZOOM_FACTOR,
@@ -173,7 +177,7 @@ def init_opt_with_bounds(self):
         ][len(self.bounds)]([[0, 1]] * len(self.bounds))
 
     def request_and_set_new_parameters(self, use_initial_parameters=False):
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
 
         if use_initial_parameters:
             for param, initial in zip(self.all_params, self.params_before_start):
@@ -192,7 +196,7 @@ def request_and_set_new_parameters(self, use_initial_parameters=False):
             self.last_parameters_internal = list(new_params)
 
         self.control.exposed_write_registers()
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
 
     def finished(self):
         return self.opt.finished()
diff --git a/linien-server/linien_server/optimization/optimization.py b/linien-server/linien_server/optimization/optimization.py
index a2912fac..95343770 100644
--- a/linien-server/linien_server/optimization/optimization.py
+++ b/linien-server/linien_server/optimization/optimization.py
@@ -21,9 +21,10 @@
 
 import numpy as np
 from linien_common.common import determine_shift_by_correlation, get_lock_point
-from linien_server.approach_line import Approacher
-from linien_server.optimization.engine import OptimizerEngine
-from linien_server.optimization.utils import FINAL_ZOOM_FACTOR
+
+from .approach_line import Approacher
+from .engine import OptimizerEngine
+from .utils import FINAL_ZOOM_FACTOR
 
 
 class OptimizeSpectroscopy:
@@ -58,7 +59,7 @@ def run(self, x0, x1, spectrum):
 
         params = self.parameters
         self.engine = OptimizerEngine(self.control, params)
-        params.to_plot.on_change(self.react_to_new_spectrum)
+        params.to_plot.add_callback(self.react_to_new_spectrum)
         params.optimization_running.value = True
         params.optimization_improvement.value = 0
 
@@ -164,17 +165,17 @@ def exposed_stop(self, use_new_parameters):
             self.engine.request_and_set_new_parameters(use_initial_parameters=True)
 
         self.parameters.optimization_running.value = False
-        self.parameters.to_plot.remove_listener(self.react_to_new_spectrum)
+        self.parameters.to_plot.remove_callback(self.react_to_new_spectrum)
         self.parameters.task.value = None
 
         self.reset_scan()
 
     def reset_scan(self):
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
 
         self.parameters.sweep_speed.value = self.initial_sweep_speed
         self.parameters.sweep_amplitude.value = self.initial_sweep_amplitude
         self.parameters.sweep_center.value = self.initial_sweep_center
         self.control.exposed_write_registers()
 
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
diff --git a/linien-server/linien_server/parameter_store.py b/linien-server/linien_server/parameter_store.py
deleted file mode 100644
index 21012164..00000000
--- a/linien-server/linien_server/parameter_store.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2021-2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
-#
-# This file is part of Linien and based on redpid.
-#
-# Linien is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Linien is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
-
-import atexit
-import pickle
-from time import time
-
-import linien_server
-
-PARAMETER_STORE_FN = "/linien_parameters.pickle"
-
-
-class ParameterStore:
-    """This class installs an `atexit` listener that persists parameters to disk
-    when the server shuts down. Once it restarts the parameters are restored."""
-
-    def __init__(self, parameters):
-        self.parameters = parameters
-        self.restore_parameters()
-        self.setup_listener()
-
-    def setup_listener(self):
-        """Listen for shutdown"""
-        atexit.register(self.save_parameters)
-
-    def restore_parameters(self):
-        """When the server starts, this method restores previously saved
-        parameters (if any)."""
-        try:
-            with open(PARAMETER_STORE_FN, "rb") as f:
-                data = pickle.load(f)
-        except (FileNotFoundError, pickle.UnpicklingError, EOFError):
-            return
-
-        print("restore parameters")
-
-        for param_name, value in data["parameters"].items():
-            try:
-                getattr(self.parameters, param_name).value = value
-            except AttributeError:
-                # ignore parameters that don't exist (anymore)
-                continue
-
-    def save_parameters(self):
-        """Gather all parameters and store them on disk."""
-        print("save parameters")
-        parameters = {}
-
-        for param_name in self.parameters._restorable_parameters:
-            param = getattr(self.parameters, param_name)
-            parameters[param_name] = param.value
-
-        try:
-            with open(PARAMETER_STORE_FN, "wb") as f:
-                pickle.dump(
-                    {
-                        "parameters": parameters,
-                        "time": time(),
-                        "version": linien_server.__version__,
-                    },
-                    f,
-                )
-        except PermissionError:
-            # this may happen if the server doesn't run on RedPitaya but on the
-            # developer's machine. As it is not a critical problem, just print
-            # the exception and ignore it
-            from traceback import print_exc
-
-            print_exc()
diff --git a/linien-server/linien_server/parameters.py b/linien-server/linien_server/parameters.py
index 89449f91..62312b16 100644
--- a/linien-server/linien_server/parameters.py
+++ b/linien-server/linien_server/parameters.py
@@ -17,18 +17,83 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-from linien_common.common import (
-    AUTO_DETECT_AUTOLOCK_MODE,
-    FAST_AUTOLOCK,
-    PSD_ALGORITHM_LPSD,
-    MHz,
-    Vpp,
-)
-from linien_common.config import DEFAULT_COLORS, N_COLORS
-from linien_server.parameters_base import BaseParameters, Parameter
-
-
-class Parameters(BaseParameters):
+import json
+from time import time
+from typing import Any, Callable, Dict, Iterator, List, Tuple
+
+import linien_server
+from linien_common.common import AutolockMode, MHz, PSDAlgorithm, Vpp
+from linien_common.config import USER_DATA_PATH
+
+PARAMETER_STORE_FILENAME = "linien_parameters.json"
+
+
+class Parameter:
+    """Represents a single parameter and is used by `Parameters`."""
+
+    def __init__(
+        self,
+        min_=None,
+        max_=None,
+        start=None,
+        wrap=False,
+        sync=True,
+        collapsed_sync=True,
+        restorable=False,
+        loggable=False,
+        log=False,
+    ):
+        self.min = min_
+        self.max = max_
+        self.wrap = wrap
+        self._value = start
+        self._start = start
+        self._callbacks = set()
+        self.can_be_cached = sync
+        self._collapsed_sync = collapsed_sync
+        self.restorable = restorable
+        self.loggable = loggable
+        self.log = log
+
+    @property
+    def value(self) -> Any:
+        return self._value
+
+    @value.setter
+    def value(self, value: Any) -> None:
+        # check bounds
+        if self.min is not None and value < self.min:
+            value = self.min if not self.wrap else self.max
+        if self.max is not None and value > self.max:
+            value = self.max if not self.wrap else self.min
+
+        self._value = value
+
+        # We copy it because a listener could remove a listener --> this would cause an
+        # error in this loop.
+        for callback in self._callbacks.copy():
+            callback(value)
+
+    def reset(self):
+        self.value = self._start
+
+    def add_callback(
+        self,
+        function: Callable[[Any], None],
+        call_with_first_value: bool = True,
+    ) -> None:
+        self._callbacks.add(function)
+
+        if call_with_first_value:
+            if self._value is not None:
+                function(self._value)
+
+    def remove_callback(self, function: Callable[[Any], None]) -> None:
+        if function in self._callbacks:
+            self._callbacks.remove(function)
+
+
+class Parameters:
     """
     This class defines the parameters of the Linien server. They represent the public
     interface and can be used to control the behavior of the server.
@@ -52,68 +117,10 @@ class Parameters(BaseParameters):
     """
 
     def __init__(self):
-        super().__init__()
-
-        # parameters whose values are saved by the client and restored if the client
-        # connects to the RedPitaya with no server running:
-        self._restorable_parameters = (
-            "fast_mode",
-            "modulation_amplitude",
-            "modulation_frequency",
-            "sweep_speed",
-            "demodulation_phase_a",
-            "demodulation_multiplier_a",
-            "demodulation_phase_b",
-            "demodulation_multiplier_b",
-            "offset_a",
-            "offset_b",
-            "invert_a",
-            "invert_b",
-            "filter_1_enabled_a",
-            "filter_1_enabled_b",
-            "filter_1_frequency_a",
-            "filter_1_frequency_b",
-            "filter_1_type_a",
-            "filter_1_type_b",
-            "filter_2_enabled_a",
-            "filter_2_enabled_b",
-            "filter_2_frequency_a",
-            "filter_2_frequency_b",
-            "filter_2_type_a",
-            "filter_2_type_b",
-            "filter_automatic_a",
-            "filter_automatic_b",
-            "p",
-            "i",
-            "d",
-            "watch_lock",
-            "watch_lock_threshold",
-            "dual_channel",
-            "channel_mixing",
-            "pid_on_slow_enabled",
-            "pid_on_slow_strength",
-            "mod_channel",
-            "control_channel",
-            "sweep_channel",
-            "slow_control_channel",
-            "polarity_fast_out1",
-            "polarity_fast_out2",
-            "polarity_analog_out0",
-            "check_lock",
-            "analog_out_1",
-            "analog_out_2",
-            "analog_out_3",
-            "plot_line_width",
-            "plot_color_0",
-            "plot_color_1",
-            "plot_color_2",
-            "plot_color_3",
-            "plot_line_opacity",
-            "plot_fill_opacity",
-            "autolock_determine_offset",
-            "autolock_mode_preference",
-            "psd_acquisition_max_decimation",
-        )
+        # Dict[str, List[Tuple[str, Any]]]
+        self._changed_parameters_queue = {}
+        # Dict[Tuple[Parameter, Callable[[Any], None]]]
+        self._remote_listener_callbacks = {}
 
         self.to_plot = Parameter(sync=False)
         """
@@ -139,7 +146,7 @@ def __init__(self):
               value is not an array but a single point.
         """
 
-        self.signal_stats = Parameter(sync=False)
+        self.signal_stats = Parameter(sync=False, loggable=True)
         """
         A dictionary that contains mean, standard deviation, minimum value and maximum
         value for all the signals contained in `to_plot`. Exemplary dictionary keys are
@@ -147,15 +154,15 @@ def __init__(self):
         `error_signal_2_max`.
         """
 
-        #           --------- GENERAL PARAMETERS ---------
+        # ------------------- GENERAL PARAMETERS ---------------------------------------
 
-        self.mod_channel = Parameter(start=0, min_=0, max_=1)
+        self.mod_channel = Parameter(start=0, min_=0, max_=1, restorable=True)
         """
         Configures the output of the modulation frequency. A value of 0 means FAST OUT 1
         and a value of 1 corresponds to FAST OUT 2
         """
 
-        self.sweep_channel = Parameter(start=1, min_=0, max_=2)
+        self.sweep_channel = Parameter(start=1, min_=0, max_=2, restorable=True)
         """
         Configures the output of the scan sweep:
             0 --> FAST OUT 1
@@ -163,18 +170,18 @@ def __init__(self):
             2 --> ANALOG OUT 0 (slow channel)
         """
 
-        self.control_channel = Parameter(start=1, min_=0, max_=1)
+        self.control_channel = Parameter(start=1, min_=0, max_=1, restorable=True)
         """
-        Configures the output of the lock signal. A value of 0 means FAST OUT 1 and a 
+        Configures the output of the lock signal. A value of 0 means FAST OUT 1 and a
         value of 1 corresponds to FAST OUT 2
         """
 
-        self.slow_control_channel = Parameter(start=2, min_=0, max_=2)
+        self.slow_control_channel = Parameter(start=2, min_=0, max_=2, restorable=True)
         """
         Configures the output of the slow PID control:
             0 --> FAST OUT 1
             1 --> FAST OUT 2
-            2 --> ANALOG OUT 0 (slow channel)        
+            2 --> ANALOG OUT 0 (slow channel)
         """
 
         self.gpio_p_out = Parameter(start=0, min_=0, max_=0b11111111)
@@ -191,43 +198,49 @@ def __init__(self):
         """
 
         # ANALOG_OUT0 is used for slow PID --> it can't be controlled manually
-        self.analog_out_1 = Parameter(start=0, min_=0, max_=(2**15) - 1)
+        self.analog_out_1 = Parameter(
+            start=0, min_=0, max_=(2**15) - 1, restorable=True
+        )
         """
         parameters for setting ANALOG_OUT 1 voltage.
         Usage: `parameters.analog_out_1.value = 1.2 * ANALOG_OUT_V`
         Minimum value is 0 and maximum 1.8 * ANALOG_OUT_V
         """
-        self.analog_out_2 = Parameter(start=0, min_=0, max_=(2**15) - 1)
+        self.analog_out_2 = Parameter(
+            start=0, min_=0, max_=(2**15) - 1, restorable=True
+        )
         """
         parameters for setting ANALOG_OUT 1 voltage.
         Usage: `parameters.analog_out_2.value = 1.2 * ANALOG_OUT_V`
         Minimum value is 0 and maximum 1.8 * ANALOG_OUT_V
         """
-        self.analog_out_3 = Parameter(start=0, min_=0, max_=(2**15) - 1)
+        self.analog_out_3 = Parameter(
+            start=0, min_=0, max_=(2**15) - 1, restorable=True
+        )
         """
         parameters for setting ANALOG_OUT 1 voltage.
         Usage: `parameters.analog_out_3.value = 1.2 * ANALOG_OUT_V`
         Minimum value is 0 and maximum 1.8 * ANALOG_OUT_V
         """
 
-        self.lock = Parameter(start=False)
+        self.lock = Parameter(start=False, loggable=True)
         """If `True`, this parameter turns off the sweep and starts the PID"""
 
-        self.polarity_fast_out1 = Parameter(start=False)
+        self.polarity_fast_out1 = Parameter(start=False, restorable=True)
         """
         Defines whether tuning the voltage up correspond to tuning the laser frequency
         up or down. Setting these values correctly is only required when using both, a
         fast out and a the slow output for PID
         """
 
-        self.polarity_fast_out2 = Parameter(start=False)
+        self.polarity_fast_out2 = Parameter(start=False, restorable=True)
         """
         Defines whether tuning the voltage up correspond to tuning the laser frequency
         up or down. Setting these values correctly is only required when using both, a
         fast out and a the slow output for PID
         """
 
-        self.polarity_analog_out0 = Parameter(start=False)
+        self.polarity_analog_out0 = Parameter(start=False, restorable=True)
         """
         Defines whether tuning the voltage up correspond to tuning the laser frequency
         up or down. Setting these values correctly is only required when using both, a
@@ -261,46 +274,52 @@ def __init__(self):
         self.ping = Parameter(start=0)
         """
         This is just a counter that is automatically increased every second. Its purpose
-        is to allow for periodic tasks on the server: just register an `on_change`
-        listener for this parameter.
+        is to allow for periodic tasks on the server: just register a callback with
+         `add_callback` for this parameter.
         """
 
-        #           --------- SWEEP PARAMETERS ---------
+        # ------------------- SWEEP PARAMETERS -----------------------------------------
 
-        self.sweep_amplitude = Parameter(min_=0.001, max_=1, start=1)
+        self.sweep_amplitude = Parameter(min_=0.001, max_=1, start=1, loggable=True)
         """
         Amplitude of the sweep in units of 0.5 * Vpp of the output (2 V for fast outputs
         (range +/- 1 V) and 0.9 V for slow outputs (range 0 V to 1.8 V). That means an
         amplitude of 1.0 corresponds to the full sweep range in both cases.
         """
 
-        self.sweep_center = Parameter(min_=-1, max_=1, start=0)
+        self.sweep_center = Parameter(min_=-1, max_=1, start=0, loggable=True)
         """
         The center position of the sweep. If a fast output is used for the sweep this is
         the sweep center position in volts. If the slow output is used the interval
         [-1, +1] of this parameter is mapped to the interval [0V, +1.8V].
         """
 
-        self.sweep_speed = Parameter(min_=0, max_=32, start=8)
+        self.sweep_speed = Parameter(
+            min_=0, max_=32, start=8, restorable=True, loggable=True
+        )
         """
         The sweep speed in internal units. The actual speed is given by
         f_real = 3.8 kHz / (2 ** sweep_speed)
         Allowed values are [0, ..., 16]
         """
 
-        self.sweep_pause = Parameter(start=False)
+        self.sweep_pause = Parameter(start=False, loggable=True)
         """If set to `True`, this parameter pauses the sweep at the `sweep_center`."""
 
-        #           --------- MODULATION PARAMETERS ---------
+        # ------------------- MODULATION PARAMETERS ------------------------------------
 
-        self.modulation_amplitude = Parameter(min_=0, max_=(1 << 14) - 1, start=1 * Vpp)
+        self.modulation_amplitude = Parameter(
+            min_=0, max_=(1 << 14) - 1, start=1 * Vpp, restorable=True, loggable=True
+        )
         """
         The amplitude of the modulation in internal units. Use Vpp for conversion to
         volts peak-peak, e.g: `parameters.modulation_amplitude.value = 0.5 * Vpp`
         Values between 0 and 2 * Vpp are allowed.
         """
 
-        self.modulation_frequency = Parameter(min_=0, max_=0xFFFFFFFF, start=15 * MHz)
+        self.modulation_frequency = Parameter(
+            min_=0, max_=0xFFFFFFFF, start=15 * MHz, restorable=True, loggable=True
+        )
         """
         Frequency of the modulation in internal units. Use MHz for conversion to
         human-readable frequency, e.g:
@@ -310,147 +329,212 @@ def __init__(self):
         the DAC.
         """
 
-        #           --------- DEMODULATION AND FILTER PARAMETERS ---------
-        self.fast_mode = Parameter(start=False)
+        # ------------------- DEMODULATION AND FILTER PARAMETERS -----------------------
+        self.fast_mode = Parameter(start=False, restorable=True)
         """
         Fast mode allows to bypass demodulation and IIR filtering of the fast channels.
         """
 
-        self.dual_channel = Parameter(start=False)
+        self.dual_channel = Parameter(start=False, restorable=True)
         """
         Linien allows for two simulataneous demodulation channels. By default, only one
         is enabled. This is controlled by `dual_channel`.
         """
 
-        self.channel_mixing = Parameter(start=0)
+        self.channel_mixing = Parameter(start=0, restorable=True, loggable=True)
         """
         If in dual channel mode, what is the mixing ratio between them? A value of 0
         corresponds to equal ratio
                    -128             only channel A being active
                    128              only channel B being active
-        Integer values [-128, ..., 128] are allowed.        
+        Integer values [-128, ..., 128] are allowed.
         """
 
         # The following parameters exist twice, i.e. once per channel
-        for channel in ("a", "b"):
-            setattr(
-                self,
-                "demodulation_phase_%s" % channel,
-                Parameter(min_=0, max_=360, start=0x0, wrap=True),
-            )
-            """The demodulation phase in degree (0-360)"""
+        self.demodulation_phase_a = Parameter(
+            min_=0, max_=360, start=0x0, wrap=True, restorable=True, loggable=True
+        )
+        """The demodulation phase for channel A in degree (0-360)"""
 
-            setattr(
-                self,
-                "demodulation_multiplier_%s" % channel,
-                Parameter(min_=0, max_=15, start=1),
-            )
-            """
-            This parameter allows for multi-f (e.g. 3f or 5f) demodulation. Default
-            value is 1, indicating that 1f demodulation is used.
-            """
+        self.demodulation_phase_b = Parameter(
+            min_=0, max_=360, start=0x0, wrap=True, restorable=True, loggable=True
+        )
+        """The demodulation phase for channel B in degree (0-360)"""
 
-            setattr(
-                self, "offset_%s" % channel, Parameter(min_=-8191, max_=8191, start=0)
-            )
+        self.demodulation_multiplier_a = Parameter(
+            min_=0, max_=15, start=1, restorable=True, loggable=True
+        )
+        """
+        This parameter allows for multi-f (e.g. 3f or 5f) demodulation. Default value is
+        1, indicating that 1f demodulation is used.
+        """
+
+        self.demodulation_multiplier_b = Parameter(
+            min_=0, max_=15, start=1, restorable=True, loggable=True
+        )
+        """
+        This parameter allows for multi-f (e.g. 3f or 5f) demodulation. Default value is
+        1, indicating that 1f demodulation is used.
+        """
+
+        self.offset_a = Parameter(
+            min_=-8191, max_=8191, start=0, restorable=True, loggable=True
+        )
+        """
+        The vertical offset for channel A. A value of -8191 shifts the data down by 1V,
+        a value of +8191 moves it up.
             """
-            The vertical offset for a channel. A value of -8191 shifts the data down by
-            1V, a value of +8191 moves it up.
+
+        self.offset_b = Parameter(
+            min_=-8191, max_=8191, start=0, restorable=True, loggable=True
+        )
+        """
+        The vertical offset for channel B. A value of -8191 shifts the data down by 1V,
+        a value of +8191 moves it up.
             """
 
-            setattr(self, "invert_%s" % channel, Parameter(start=False))
-            """A boolean indicating whether the channel data should be inverted."""
+        self.invert_a = Parameter(start=False, restorable=True)
+        """A boolean indicating whether the channel A data should be inverted."""
 
-            # - -----   FILTER PARAMETERS   -----
+        self.invert_b = Parameter(start=False, restorable=True)
+        """A boolean indicating whether the channel B data should be inverted."""
 
-            setattr(self, "filter_automatic_%s" % channel, Parameter(start=True))
-            """
-            After demodulation of the signal, Linien may apply up to two IIR filters.
-            `filter_automatic` is a boolean indicating whether Linien should
-            automatically determine suitable filter for a given modulation frequency or
-            whether the user may configure the filters himself. If automatic mode is
-            enabled, two low pass filters are installed with a frequency of half the
-            modulation frequency.            
-            """
+        # -------   FILTER PARAMETERS   ------------------------------------------------
+        self.filter_automatic_a = Parameter(start=True, restorable=True)
+        """
+        After demodulation of the signal, Linien may apply up to two IIR filters.
+        `filter_automatic` is a boolean indicating whether Linien should automatically
+        determine suitable filter for a given modulation frequency or whether the user
+        may configure the filters himself. If automatic mode is enabled, two low pass
+        filters are installed with a frequency of half the modulation frequency.
+        """
+
+        self.filter_automatic_b = Parameter(start=True, restorable=True)
+        """
+        After demodulation of the signal, Linien may apply up to two IIR filters.
+        `filter_automatic` is a boolean indicating whether Linien should automatically
+        determine suitable filter for a given modulation frequency or whether the user
+        may configure the filters himself. If automatic mode is enabled, two low pass
+        filters are installed with a frequency of half the modulation frequency.
+        """
 
-            for filter_i in (1, 2):
-                setattr(
-                    self,
-                    "filter_%d_enabled_%s" % (filter_i, channel),
-                    Parameter(start=False),
-                )
-                """
-                Should this filter be enabled? Note that disabling a filter does not
-                bypass it as this would change the propagation time of the signal
-                through the FPGA which is unfavorable as it leads to a mismatch of the
-                demodulation phase. Instead, a filter with unity gain is installed.
-                """
-
-                setattr(
-                    self, "filter_%d_type_%s" % (filter_i, channel), Parameter(start=0)
-                )
-                """
-                Either `LOW_PASS_FILTER` or `HIGH_PASS_FILTER` from
-                linien_common.common` module."""
-
-                setattr(
-                    self,
-                    "filter_%d_frequency_%s" % (filter_i, channel),
-                    Parameter(start=10000),
-                )
-                """The filter frequency in units of Hz"""
-
-        #           --------- LOCK AND PID PARAMETERS ---------
+        self.filter_1_enabled_a = Parameter(start=False, restorable=True)
+        """
+        Should this filter be enabled? Note that disabling a filter does not bypass it
+        as this would change the propagation time of the signal through the FPGA which
+        is unfavorable as it leads to a mismatch of the demodulation phase. Instead, a
+        filter with unity gain is installed.
+        """
+
+        self.filter_2_enabled_a = Parameter(start=False, restorable=True)
+        """
+        Should this filter be enabled? Note that disabling a filter does not bypass it
+        as this would change the propagation time of the signal through the FPGA which
+        is unfavorable as it leads to a mismatch of the demodulation phase. Instead, a
+        filter with unity gain is installed.
+        """
+
+        self.filter_1_enabled_b = Parameter(start=False, restorable=True)
+        """
+        Should this filter be enabled? Note that disabling a filter does not bypass it
+        as this would change the propagation time of the signal through the FPGA which
+        is unfavorable as it leads to a mismatch of the demodulation phase. Instead, a
+        filter with unity gain is installed.
+        """
+
+        self.filter_2_enabled_b = Parameter(start=False, restorable=True)
+        """
+        Should this filter be enabled? Note that disabling a filter does not bypass it
+        as this would change the propagation time of the signal through the FPGA which
+        is unfavorable as it leads to a mismatch of the demodulation phase. Instead, a
+        filter with unity gain is installed.
+        """
+
+        self.filter_1_frequency_a = Parameter(start=10000, restorable=True)
+        """The filter frequency in units of Hz"""
+
+        self.filter_2_frequency_a = Parameter(start=10000, restorable=True)
+        """The filter frequency in units of Hz"""
+
+        self.filter_1_frequency_b = Parameter(start=10000, restorable=True)
+        """The filter frequency in units of Hz"""
+
+        self.filter_2_frequency_b = Parameter(start=10000, restorable=True)
+        """The filter frequency in units of Hz"""
+
+        self.filter_1_type_a = Parameter(start=0, restorable=True)
+        """
+        Either `LOW_PASS` or `HIGH_PASS` of linien_common.common.FilterType` enum class.
+        """
+
+        self.filter_2_type_a = Parameter(start=0, restorable=True)
+        """
+        Either `LOW_PASS` or `HIGH_PASS` of linien_common.common.FilterType` enum class.
+        """
+
+        self.filter_1_type_b = Parameter(start=0, restorable=True)
+        """
+        Either `LOW_PASS` or `HIGH_PASS` of linien_common.common.FilterType` enum class.
+        """
+
+        self.filter_2_type_b = Parameter(start=0, restorable=True)
+        """
+        Either `LOW_PASS` or `HIGH_PASS` of linien_common.common.FilterType` enum class.
+        """
+
+        # ------------------- LOCK AND PID PARAMETERS ----------------------------------
 
         self.combined_offset = Parameter(min_=-8191, max_=8191, start=0)
         """
         After combining channels A and B and before passing the result to the PID,
         `combined_offset` is added. It uses the same units as the channel offsets, i.e.
-        a value of -8191 shifts the data down by 1V, a value of +8191 moves it up.       
+        a value of -8191 shifts the data down by 1V, a value of +8191 moves it up.
         """
 
-        self.p = Parameter(start=50, max_=8191)
+        self.p = Parameter(start=50, max_=8191, restorable=True, loggable=True)
         """
         Proportional part of PID parameters. Range is [0, 8191]. In order to change sign
         of PID parameters, use `target_slope_rising`
         """
 
-        self.i = Parameter(start=5, max_=8191)
+        self.i = Parameter(start=5, max_=8191, restorable=True, loggable=True)
         """
-        Integral part of PID parameters. Range is [0, 8191]. In order to change sign
-        of PID parameters, use `target_slope_rising`
+        Integral part of PID parameters. Range is [0, 8191]. In order to change sign of
+        PID parameters, use `target_slope_rising`
         """
 
-        self.d = Parameter(start=0, max_=8191)
+        self.d = Parameter(start=0, max_=8191, restorable=True, loggable=True)
         """
-        Derivate part of PID parameters. Range is [0, 8191]. In order to change sign
-        of PID parameters, use `target_slope_rising`
+        Derivate part of PID parameters. Range is [0, 8191]. In order to change sign of
+        PID parameters, use `target_slope_rising`
         """
 
         self.target_slope_rising = Parameter(start=True)
         """A boolean that inverts the sign of the PID parameters"""
 
-        self.pid_on_slow_enabled = Parameter(start=False)
+        self.pid_on_slow_enabled = Parameter(start=False, restorable=True)
         """Whether the PID on ANALOG_OUT 0 is enabled."""
 
-        self.pid_on_slow_strength = Parameter(start=0)
+        self.pid_on_slow_strength = Parameter(start=0, restorable=True)
         """
-        Strength of the (slow) PID on ANALOG_OUT 0. This strength corresponds to the
-        strength of the integrator. Maximum value is 8191.
+        Strength of the slow PID. This strength corresponds to the strength of the
+        integrator. Maximum value is 8191.
         """
 
-        self.check_lock = Parameter(start=True)
-        self.watch_lock = Parameter(start=True)
-        self.watch_lock_threshold = Parameter(start=0.01)
+        self.check_lock = Parameter(start=True, restorable=True)
+        self.watch_lock = Parameter(start=True, restorable=True)
+        self.watch_lock_threshold = Parameter(start=0.01, restorable=True)
 
-        #           --------- AUTOLOCK PARAMETERS ---------
+        # ------------------- AUTOLOCK PARAMETERS --------------------------------------
         # These parameters are used internally by the optimization algorithm and usually
         # should not be manipulated
         self.task = Parameter(start=None, sync=False)
         self.automatic_mode = Parameter(start=True)
         self.autolock_target_position = Parameter(start=0)
-        self.autolock_mode_preference = Parameter(start=AUTO_DETECT_AUTOLOCK_MODE)
-        self.autolock_mode = Parameter(start=FAST_AUTOLOCK)
+        self.autolock_mode_preference = Parameter(
+            start=AutolockMode.AUTO_DETECT, restorable=True
+        )
+        self.autolock_mode = Parameter(start=AutolockMode.FAST)
         self.autolock_time_scale = Parameter(start=0)
         self.autolock_instructions = Parameter(start=[], sync=False)
         self.autolock_final_wait_time = Parameter(start=0)
@@ -462,10 +546,10 @@ def __init__(self):
         self.autolock_failed = Parameter(start=False)
         self.autolock_locked = Parameter(start=False)
         self.autolock_retrying = Parameter(start=False)
-        self.autolock_determine_offset = Parameter(start=True)
+        self.autolock_determine_offset = Parameter(start=True, restorable=True)
         self.autolock_initial_sweep_amplitude = Parameter(start=1)
 
-        #           --------- OPTIMIZATION PARAMETERS ---------
+        # ------------------- OPTIMIZATION PARAMETERS ----------------------------------
         # These parameters are used internally by the optimization algorithm and usually
         # should not be manipulated
         self.optimization_selection = Parameter(start=False)
@@ -482,7 +566,7 @@ def __init__(self):
         self.optimization_channel = Parameter(start=0)
         self.optimization_failed = Parameter(start=False)
 
-        #           --------- PID OPTIMIZATION PARAMETERS ---------
+        # ------------------- PID OPTIMIZATION PARAMETERS ------------------------------
         # These parameters are used internally by the optimization algorithm and usually
         # should not be manipulated
         self.acquisition_raw_enabled = Parameter(start=False)
@@ -499,19 +583,118 @@ def __init__(self):
 
         self.psd_data_partial = Parameter(start=None)
         self.psd_data_complete = Parameter(start=None)
-        self.psd_algorithm = Parameter(start=PSD_ALGORITHM_LPSD)
+        self.psd_algorithm = Parameter(start=PSDAlgorithm.LPSD)
         self.psd_acquisition_running = Parameter(start=False)
         self.psd_optimization_running = Parameter(start=False)
-        self.psd_acquisition_max_decimation = Parameter(start=18, min_=1, max_=32)
-
-        #           --------- PARAMETERS OF GUI ---------
-        self.plot_line_width = Parameter(start=2, min_=0.1, max_=100)
-        self.plot_line_opacity = Parameter(start=230, min_=0, max_=255)
-        self.plot_fill_opacity = Parameter(start=70, min_=0, max_=255)
-
-        for color_idx in range(N_COLORS):
-            setattr(
-                self,
-                "plot_color_%d" % color_idx,
-                Parameter(start=DEFAULT_COLORS[color_idx]),
+        self.psd_acquisition_max_decimation = Parameter(
+            start=18, min_=1, max_=32, restorable=True
+        )
+
+    def __iter__(self) -> Iterator[Tuple[str, Parameter]]:
+        for name, param in self.__dict__.items():
+            if isinstance(param, Parameter):
+                yield name, param
+
+    def init_parameter_sync(
+        self, uuid: str
+    ) -> Iterator[Tuple[str, Any, bool, bool, bool, bool]]:
+        """
+        To be called by a remote client: Yields all parameters as well as their values
+        and if the parameters are suited to be cached registers a listener that pushes
+        changes of these parameters to the client.
+        """
+        for name, param in self:
+            yield (
+                name,
+                param.value,
+                param.can_be_cached,
+                param.restorable,
+                param.loggable,
+                param.log,
             )
+            if param.can_be_cached:
+                self.register_remote_listener(uuid, name)
+
+    def register_remote_listener(self, uuid: str, param_name: str) -> None:
+        self._changed_parameters_queue.setdefault(uuid, [])
+        self._remote_listener_callbacks.setdefault(uuid, [])
+
+        def append_changed_values_to_queue(value: Any) -> None:
+            """Appends changed values to the queue of a specific client."""
+            if uuid in self._changed_parameters_queue:
+                self._changed_parameters_queue[uuid].append((param_name, value))
+
+        param = getattr(self, param_name)
+        param.add_callback(append_changed_values_to_queue)
+
+        self._remote_listener_callbacks[uuid].append(
+            (param, append_changed_values_to_queue)
+        )
+
+    def unregister_remote_listeners(self, uuid: str):
+        for param, callback in self._remote_listener_callbacks[uuid]:
+            param.remove_callback(callback)
+
+        del self._changed_parameters_queue[uuid]
+        del self._remote_listener_callbacks[uuid]
+
+    def get_changed_parameters_queue(
+        self, uuid: str
+    ) -> Dict[str, List[Tuple[str, Any]]]:
+        """Get the queue of parameter changes for a specific client."""
+        queue = self._changed_parameters_queue.get(uuid, [])
+        self._changed_parameters_queue[uuid] = []
+
+        # filter out multiple values for collapsible parameters
+        already_has_value = []
+        for idx in reversed(range(len(queue))):
+            param_name, value = queue[idx]
+            if getattr(self, param_name)._collapsed_sync:
+                if param_name in already_has_value:
+                    del queue[idx]
+                else:
+                    already_has_value.append(param_name)
+        return queue
+
+
+def restore_parameters(parameters: Parameters) -> Parameters:
+    """When the server starts, this method restores previously saved parameters."""
+    filename = str(USER_DATA_PATH / PARAMETER_STORE_FILENAME)
+    try:
+        with open(filename, "r") as f:
+            data = json.load(f)
+    except FileNotFoundError:
+        return parameters
+
+    for name, attributes in data["parameters"].items():
+        try:
+            getattr(parameters, name).value = attributes["value"]
+            getattr(parameters, name).log = attributes["log"]
+        except AttributeError:  # ignore parameters that don't exist (anymore)
+            continue
+    print("Restored parameters from ", filename)
+    return parameters
+
+
+def save_parameters(parameters: Parameters) -> None:
+    """Gather all parameters and store them on disk."""
+
+    # make sure that USER_DATA_PATH exists
+    USER_DATA_PATH.mkdir(parents=True, exist_ok=True)
+    parameters_dict = {}
+    for name, param in parameters:
+        if param.restorable:
+            parameters_dict[name] = {"value": param.value, "log": param.log}
+
+    filename = str(USER_DATA_PATH / PARAMETER_STORE_FILENAME)
+    with open(filename, "w") as f:
+        json.dump(
+            {
+                "version": linien_server.__version__,
+                "time": time(),
+                "parameters": parameters_dict,
+            },
+            f,
+            indent=2,
+        )
+    print("Saved parameters to ", filename)
diff --git a/linien-server/linien_server/parameters_base.py b/linien-server/linien_server/parameters_base.py
deleted file mode 100644
index a7bef3e8..00000000
--- a/linien-server/linien_server/parameters_base.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-#
-# This file is part of Linien and based on redpid.
-#
-# Linien is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Linien is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
-
-from linien_common.common import pack
-
-
-class Parameter:
-    """Represents a single parameter and is used by `Parameters`."""
-
-    def __init__(
-        self,
-        min_=None,
-        max_=None,
-        start=None,
-        wrap=False,
-        sync=True,
-        collapsed_sync=True,
-    ):
-        self.min = min_
-        self.max = max_
-        self.wrap = wrap
-        self._value = start
-        self._start = start
-        self._listeners = set()
-        self._collapsed_sync = collapsed_sync
-        self.exposed_can_be_cached = sync
-
-    @property
-    def value(self):
-        return self._value
-
-    @value.setter
-    def value(self, value):
-        # check bounds
-        if self.min is not None and value < self.min:
-            value = self.min if not self.wrap else self.max
-        if self.max is not None and value > self.max:
-            value = self.max if not self.wrap else self.min
-
-        self._value = value
-
-        # we copy it because a listener could remove a listener --> this would
-        # cause an error in this loop
-        for listener in self._listeners.copy():
-            listener(value)
-
-    def on_change(self, function, call_listener_with_first_value=True):
-        self._listeners.add(function)
-
-        if call_listener_with_first_value:
-            if self._value is not None:
-                function(self._value)
-
-    def remove_listener(self, function):
-        if function in self._listeners:
-            self._listeners.remove(function)
-
-    def exposed_reset(self):
-        self.value = self._start
-
-    def register_remote_listener(self, remote_uuid):
-        pass
-
-
-class BaseParameters:
-    """Represents a set of parameters. In an actual program, it should be
-    sub-classed like this:
-
-        class MyParameters(BaseParameters):
-            def __init__(self):
-                self.param1 = Parameter(min_=12, max_=24)
-
-    Parameters can be changed like this:
-
-        p = MyParameters(...)
-        p.param1.value = 123
-
-    You can register callback functions like this:
-
-        def on_change(value):
-            # do something
-
-        p.param1.on_change(on_change)
-    """
-
-    def __init__(self):
-        self._remote_listener_queue = {}
-        self._remote_listener_callbacks = {}
-
-    def get_all_parameters(self):
-        for name, element in self.__dict__.items():
-            if isinstance(element, Parameter):
-                yield name, element
-
-    def init_parameter_sync(self, uuid):
-        """To be called by a remote client: Yields all parameters as well
-        as their values and if the parameters are suited to be cached registers
-        a listener that pushes changes of these parameters to the client."""
-        for name, element in self.get_all_parameters():
-            yield name, element, element.value, element.exposed_can_be_cached
-            if element.exposed_can_be_cached:
-                self.register_remote_listener(uuid, name)
-
-    def register_remote_listener(self, uuid, param_name):
-        self._remote_listener_queue.setdefault(uuid, [])
-        self._remote_listener_callbacks.setdefault(uuid, [])
-
-        def on_change(value, uuid=uuid, param_name=param_name):
-            if uuid in self._remote_listener_queue:
-                self._remote_listener_queue[uuid].append((param_name, value))
-
-        param = getattr(self, param_name)
-        param.on_change(on_change)
-
-        self._remote_listener_callbacks[uuid].append((param, on_change))
-
-    def unregister_remote_listeners(self, uuid):
-        for param, callback in self._remote_listener_callbacks[uuid]:
-            param.remove_listener(callback)
-
-        del self._remote_listener_queue[uuid]
-        del self._remote_listener_callbacks[uuid]
-
-    def get_listener_queue(self, uuid):
-        queue = self._remote_listener_queue.get(uuid, [])
-        self._remote_listener_queue[uuid] = []
-
-        # filter out multiple values for collapsible parameters
-        already_has_value = []
-        for idx in reversed(range(len(queue))):
-            param_name, value = queue[idx]
-            if self._get_param(param_name)._collapsed_sync:
-                if param_name in already_has_value:
-                    del queue[idx]
-                else:
-                    already_has_value.append(param_name)
-
-        return pack(queue)
-
-    def __iter__(self):
-        for name, param in self.get_all_parameters():
-            yield name, param.value
-
-    def _get_param(self, param_name):
-        param = getattr(self, param_name)
-        assert isinstance(param, Parameter)
-        return param
diff --git a/linien-server/linien_server/pid_optimization/pid_optimization.py b/linien-server/linien_server/pid_optimization/pid_optimization.py
index b30c4ba9..c8e8fdfa 100644
--- a/linien-server/linien_server/pid_optimization/pid_optimization.py
+++ b/linien-server/linien_server/pid_optimization/pid_optimization.py
@@ -20,9 +20,10 @@
 import random
 import string
 from time import sleep, time
+from typing import Tuple
 
 import numpy as np
-from linien_common.common import PSD_ALGORITHM_LPSD, PSD_ALGORITHM_WELCH
+from linien_common.common import PSDAlgorithm
 from linien_server.optimization.engine import MultiDimensionalOptimizationEngine
 from pylpsd import lpsd
 from scipy import signal
@@ -30,16 +31,17 @@
 ALL_DECIMATIONS = list(range(32))
 
 
-def calculate_psd(sig, fs, algorithm):
+def calculate_psd(
+    sig: np.ndarray, fs: float, algorithm: PSDAlgorithm
+) -> Tuple[np.ndarray, np.ndarray]:
     """
-    Calculates the power spectral density.
+    Calculate the power spectral density.
 
     :param sig: The signal to calculate the PSD for.
     :param fs: The sampling frequency.
-    :param algorithm: The PSD algorithm to use. Options are 'lpsd' and 'welch'.
+    :param algorithm: The PSD algorithm to use.
     :return: One-sided power spectral density.
     """
-    assert algorithm in [PSD_ALGORITHM_LPSD, PSD_ALGORITHM_WELCH]
 
     # at beginning or end of signal, we sometimes have more glitches --> ignore
     # them (200 points less @ 16384 points doesn't hurt much)
@@ -50,11 +52,11 @@ def calculate_psd(sig, fs, algorithm):
 
     sig = sig.astype(np.float64)
 
-    if algorithm == PSD_ALGORITHM_WELCH:
+    if algorithm == PSDAlgorithm.WELCH:
         f, Pxx = signal.welch(
             sig, fs, window=window, nperseg=num_pts, scaling="density"
         )
-    elif algorithm == PSD_ALGORITHM_LPSD:
+    elif algorithm == PSDAlgorithm.LPSD:
         fmin = fs / len(sig) * 10  # lowest frequency of interest
         fmax = fs / 20.0  # highest frequency of interest
 
@@ -112,30 +114,30 @@ def run(self):
         try:
             self.uuid = generate_curve_uuid()
             self.set_decimation(ALL_DECIMATIONS[0])
-            self.add_listeners()
+            self.add_callbacks()
             self.parameters.psd_acquisition_running.value = True
-        except:
+        except Exception as e:
             self.cleanup()
-            raise
+            raise e
 
-    def add_listeners(self):
-        self.parameters.acquisition_raw_data.on_change(
-            self.react_to_new_signal, call_listener_with_first_value=False
+    def add_callbacks(self):
+        self.parameters.acquisition_raw_data.add_callback(
+            self.react_to_new_signal, call_with_first_value=False
         )
 
     def cleanup(self):
         self.running = False
         self.parameters.psd_acquisition_running.value = False
 
-        self.parameters.acquisition_raw_data.remove_listener(self.react_to_new_signal)
+        self.parameters.acquisition_raw_data.remove_callback(self.react_to_new_signal)
 
         if not self.is_child:
-            self.control.pause_acquisition()
+            self.control.exposed_pause_acquisition()
             self.parameters.acquisition_raw_enabled.value = False
             self.parameters.acquisition_raw_filter_enabled.value = False
 
             self.control.exposed_write_registers()
-            self.control.continue_acquisition()
+            self.control.exposed_continue_acquisition()
 
     def react_to_new_signal(self, data_pickled):
         try:
@@ -172,9 +174,9 @@ def react_to_new_signal(self, data_pickled):
             else:
                 self.cleanup()
 
-        except:
+        except Exception as e:
             self.cleanup()
-            raise
+            raise e
 
     def publish_psd_data(self, complete):
         data_pickled = pickle.dumps(
@@ -200,7 +202,7 @@ def publish_psd_data(self, complete):
 
     def set_decimation(self, decimation):
         self.time_decimation_set = time()
-        self.control.pause_acquisition()
+        self.control.exposed_pause_acquisition()
         self.parameters.acquisition_raw_decimation.value = decimation
         self.parameters.acquisition_raw_enabled.value = True
 
@@ -213,7 +215,7 @@ def set_decimation(self, decimation):
         self.control.exposed_write_registers()
         # take care that new decimation was actually written to FPGA
         sleep(0.1)
-        self.control.continue_acquisition()
+        self.control.exposed_continue_acquisition()
 
     def exposed_stop(self):
         self.cleanup()
@@ -230,14 +232,14 @@ def __init__(self, control, parameters):
 
     def run(self):
         try:
-            self.parameters.psd_data_complete.on_change(
-                self.psd_data_received, call_listener_with_first_value=False
+            self.parameters.psd_data_complete.add_callback(
+                self.psd_data_received, call_with_first_value=False
             )
             self.parameters.psd_optimization_running.value = True
             self.start_single_psd_measurement()
-        except:
+        except Exception as e:
             self.cleanup()
-            raise
+            raise e
 
     def start_single_psd_measurement(self):
         new_params = self.engine.ask()
@@ -251,7 +253,7 @@ def start_single_psd_measurement(self):
 
     def cleanup(self):
         self.parameters.psd_optimization_running.value = False
-        self.parameters.psd_data_complete.remove_listener(self.psd_data_received)
+        self.parameters.psd_data_complete.remove_callback(self.psd_data_received)
 
     def psd_data_received(self, psd_data_pickled):
         try:
@@ -271,9 +273,9 @@ def psd_data_received(self, psd_data_pickled):
                 # FIXME: implement use of optimized pid parameters
                 self.cleanup()
 
-        except:
+        except Exception as e:
             self.cleanup()
-            raise
+            raise e
 
     def exposed_stop(self):
         self.cleanup()
diff --git a/linien-server/linien_server/registers.py b/linien-server/linien_server/registers.py
index 0e3ea7fb..7cbaafb8 100644
--- a/linien-server/linien_server/registers.py
+++ b/linien-server/linien_server/registers.py
@@ -17,19 +17,16 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
+from typing import Optional
+
 import numpy as np
-from linien_common.common import (
-    HIGH_PASS_FILTER,
-    LOW_PASS_FILTER,
-    MHz,
-    convert_channel_mixing_value,
-)
-from linien_common.config import DEFAULT_SWEEP_SPEED
-
-from .acquisition import AcquisitionMaster
-from .csr import PitayaCSR
+import rpyc
+from linien_common.common import FilterType, MHz, convert_channel_mixing_value
+from linien_common.config import ACQUISITION_PORT, DEFAULT_SWEEP_SPEED
+from linien_server.parameters import Parameters
+
+from . import csrmap
 from .iir_coeffs import make_filter
-from .utils import twos_complement
 
 
 class Registers:
@@ -40,164 +37,173 @@ class Registers:
     communicate by manipulating `Parameters` / `RemoteParameters`.
     """
 
-    def __init__(self, host=None, user=None, password=None):
-        self.host = host
-        self.user = user
-        self.password = password
-        self.acquisition = None
-
-        self._last_sweep_speed = None
-        self._last_raw_acquisition_settings = None
-        self._iir_cache = {}
-
-    def connect(self, control, parameters):
-        """Starts a process that can be used to control FPGA registers."""
+    def __init__(
+        self,
+        control,
+        parameters: Parameters,
+        host: Optional[str] = None,
+    ):
         self.control = control
         self.parameters = parameters
 
-        self.csr = PitayaCSR()
+        if host is None:
+            # AcquisitionService is imported only on the Red Pitaya since pyrp3 is not
+            # available on Windows
+            from linien_server.acquisition import AcquisitionService
 
-        def lock_status_changed(v):
-            if self.acquisition is not None:
-                self.acquisition.lock_status_changed(v)
-
-        self.parameters.lock.on_change(lock_status_changed)
+            self.acquisition = AcquisitionService()
+        else:
+            # AcquisitionService has to be started manually on the Red Pitaya
+            self.acquisition = rpyc.connect(host, ACQUISITION_PORT).root
 
-        def fetch_additional_signals_changed(v):
-            if self.acquisition is not None:
-                self.acquisition.fetch_additional_signals_changed(v)
+        self._last_sweep_speed = None
+        self._last_raw_acquisition_settings = None
+        self._iir_cache = {}  # type: ignore[var-annotated]
 
-        self.parameters.fetch_additional_signals.on_change(
-            fetch_additional_signals_changed
+        self.parameters.lock.add_callback(self.acquisition.exposed_set_lock_status)
+        self.parameters.fetch_additional_signals.add_callback(
+            self.acquisition.exposed_set_fetch_additional_signals  # noqa: E501
+        )
+        self.parameters.dual_channel.add_callback(
+            self.acquisition.exposed_set_dual_channel
         )
-
-        def dual_channel_changed(dual_channel):
-            if self.acquisition is not None:
-                self.acquisition.set_dual_channel(dual_channel)
-
-        self.parameters.dual_channel.on_change(dual_channel_changed)
-
-        use_ssh = self.host is not None and self.host not in ("localhost", "127.0.0.1")
-        self.acquisition = AcquisitionMaster(use_ssh, self.host)
 
     def write_registers(self):
         """Writes data from `parameters` to the FPGA."""
-        params = dict(self.parameters)
 
-        _max = lambda val: val if np.abs(val) <= 8191 else (8191 * val / np.abs(val))
+        max_ = lambda val: val if np.abs(val) <= 8191 else (8191 * val / np.abs(val))
         phase_to_delay = lambda phase: int(phase / 360 * (1 << 14))
 
-        if not params["dual_channel"]:
+        if not self.parameters.dual_channel.value:
             factor_a = 256
             factor_b = 0
         else:
-            value = params["channel_mixing"]
-            factor_a, factor_b = convert_channel_mixing_value(value)
+            factor_a, factor_b = convert_channel_mixing_value(
+                self.parameters.channel_mixing.value
+            )
 
-        lock = params["lock"]
-        lock_changed = lock != self.control.exposed_is_locked
-        self.control.exposed_is_locked = lock
+        lock_changed = self.parameters.lock.value != self.control.exposed_is_locked
+        self.control.exposed_is_locked = self.parameters.lock.value
 
         new = dict(
-            # sweep run is 1 by default. The gateware automatically takes care
-            # of stopping the sweep run after `request_lock` is set by setting
+            # sweep run is 1 by default. The gateware automatically takes care of
+            # stopping the sweep run after `request_lock` is set by setting
             # `sweep.clear`
             logic_sweep_run=1,
-            logic_sweep_pause=int(params["sweep_pause"]),
+            logic_sweep_pause=int(self.parameters.sweep_pause.value),
             logic_sweep_step=int(
                 DEFAULT_SWEEP_SPEED
-                * params["sweep_amplitude"]
-                / (2 ** params["sweep_speed"])
+                * self.parameters.sweep_amplitude.value
+                / (2**self.parameters.sweep_speed.value)
             ),
             # NOTE: Sweep center is set by `logic_out_offset`.
-            logic_sweep_min=-1 * _max(params["sweep_amplitude"] * 8191),
-            logic_sweep_max=_max(params["sweep_amplitude"] * 8191),
-            logic_mod_freq=params["modulation_frequency"],
-            logic_mod_amp=params["modulation_amplitude"]
-            if params["modulation_frequency"] > 0
+            logic_sweep_min=-1 * max_(self.parameters.sweep_amplitude.value * 8191),
+            logic_sweep_max=max_(self.parameters.sweep_amplitude.value * 8191),
+            logic_mod_freq=self.parameters.modulation_frequency.value
+            if not self.parameters.fast_mode.value
             else 0,
-            logic_dual_channel=int(params["dual_channel"]),
-            logic_fast_mode=int(params["fast_mode"]),
+            logic_mod_amp=self.parameters.modulation_amplitude.value
+            if (self.parameters.modulation_frequency.value > 0)
+            and (not self.parameters.fast_mode.value)
+            else 0,
+            logic_dual_channel=int(self.parameters.dual_channel.value),
+            logic_fast_mode=int(self.parameters.fast_mode.value),
             logic_chain_a_factor=factor_a,
             logic_chain_b_factor=factor_b,
-            logic_chain_a_offset=twos_complement(int(params["offset_a"]), 14),
-            logic_chain_b_offset=twos_complement(int(params["offset_b"]), 14),
-            logic_out_offset=int(params["sweep_center"] * 8191),
-            logic_combined_offset=twos_complement(params["combined_offset"], 14),
-            logic_control_channel=params["control_channel"],
-            logic_mod_channel=params["mod_channel"],
-            logic_sweep_channel=params["sweep_channel"],
-            logic_slow_control_channel=params["slow_control_channel"],
-            slow_chain_pid_reset=not params["pid_on_slow_enabled"],
-            logic_analog_out_1=params["analog_out_1"],
-            logic_analog_out_2=params["analog_out_2"],
-            logic_analog_out_3=params["analog_out_3"],
-            logic_autolock_fast_target_position=params["autolock_target_position"],
-            logic_autolock_autolock_mode=params["autolock_mode"],
-            logic_autolock_robust_N_instructions=len(params["autolock_instructions"]),
-            logic_autolock_robust_time_scale=params["autolock_time_scale"],
-            logic_autolock_robust_final_wait_time=params["autolock_final_wait_time"],
+            logic_chain_a_offset=twos_complement(
+                int(self.parameters.offset_a.value), 14
+            ),
+            logic_chain_b_offset=twos_complement(
+                int(self.parameters.offset_b.value), 14
+            ),
+            logic_out_offset=int(self.parameters.sweep_center.value * 8191),
+            logic_combined_offset=twos_complement(
+                self.parameters.combined_offset.value, 14
+            ),
+            logic_control_channel=self.parameters.control_channel.value,
+            logic_mod_channel=self.parameters.mod_channel.value,
+            logic_sweep_channel=self.parameters.sweep_channel.value,
+            logic_slow_control_channel=self.parameters.slow_control_channel.value,
+            slow_chain_pid_reset=not self.parameters.pid_on_slow_enabled.value,
+            logic_analog_out_1=self.parameters.analog_out_1.value,
+            logic_analog_out_2=self.parameters.analog_out_2.value,
+            logic_analog_out_3=self.parameters.analog_out_3.value,
+            logic_autolock_fast_target_position=self.parameters.autolock_target_position.value,  # noqa: E501
+            logic_autolock_autolock_mode=self.parameters.autolock_mode.value,
+            logic_autolock_robust_N_instructions=len(
+                self.parameters.autolock_instructions.value
+            ),
+            logic_autolock_robust_time_scale=self.parameters.autolock_time_scale.value,
+            logic_autolock_robust_final_wait_time=self.parameters.autolock_final_wait_time.value,  # noqa: E501
             # channel A
-            fast_a_demod_delay=phase_to_delay(params["demodulation_phase_a"])
-            if params["modulation_frequency"] > 0
+            fast_a_demod_delay=phase_to_delay(
+                self.parameters.demodulation_phase_a.value
+            )
+            if (self.parameters.modulation_frequency.value > 0)
+            and (not self.parameters.fast_mode.value)
             else 0,
-            fast_a_demod_multiplier=params["demodulation_multiplier_a"],
-            fast_a_dx_sel=self.csr.signal("zero"),
+            fast_a_demod_multiplier=self.parameters.demodulation_multiplier_a.value,
+            fast_a_dx_sel=csrmap.signals.index("zero"),
             fast_a_y_tap=2,
-            fast_a_dy_sel=self.csr.signal("zero"),
-            fast_a_invert=int(params["invert_a"]),
+            fast_a_dy_sel=csrmap.signals.index("zero"),
+            fast_a_invert=int(self.parameters.invert_a.value),
             # channel B
-            fast_b_demod_delay=phase_to_delay(params["demodulation_phase_b"])
-            if params["modulation_frequency"] > 0
+            fast_b_demod_delay=phase_to_delay(
+                self.parameters.demodulation_phase_b.value
+            )
+            if (self.parameters.modulation_frequency.value > 0)
+            and (not self.parameters.fast_mode.value)
             else 0,
-            fast_b_demod_multiplier=params["demodulation_multiplier_b"],
-            fast_b_dx_sel=self.csr.signal("zero"),
+            fast_b_demod_multiplier=self.parameters.demodulation_multiplier_b.value,
+            fast_b_dx_sel=csrmap.signals.index("zero"),
             fast_b_y_tap=1,
-            fast_b_dy_sel=self.csr.signal("zero"),
-            fast_b_invert=int(params["invert_b"]),
+            fast_b_dy_sel=csrmap.signals.index("zero"),
+            fast_b_invert=int(self.parameters.invert_b.value),
             # trigger on sweep
             scopegen_external_trigger=1,
             gpio_p_oes=0b11111111,
             gpio_n_oes=0b11111111,
-            gpio_p_outs=params["gpio_p_out"],
-            gpio_n_outs=params["gpio_n_out"],
-            gpio_n_do0_en=self.csr.signal("zero"),
-            gpio_n_do1_en=self.csr.signal("zero"),
+            gpio_p_outs=self.parameters.gpio_p_out.value,
+            gpio_n_outs=self.parameters.gpio_n_out.value,
+            gpio_n_do0_en=csrmap.signals.index("zero"),
+            gpio_n_do1_en=csrmap.signals.index("zero"),
             logic_slow_decimation=16,
         )
 
         for instruction_idx, [wait_for, peak_height] in enumerate(
-            params["autolock_instructions"]
+            self.parameters.autolock_instructions.value
         ):
             new["logic_autolock_robust_peak_height_%d" % instruction_idx] = peak_height
             new["logic_autolock_robust_wait_for_%d" % instruction_idx] = wait_for
 
-        if lock:
+        if self.parameters.lock.value:
             # display combined error signal and control signal
             new.update(
                 {
-                    "scopegen_adc_a_sel": self.csr.signal(
+                    "scopegen_adc_a_sel": csrmap.signals.index(
                         "logic_combined_error_signal"
-                        if not params["acquisition_raw_filter_enabled"]
+                        if not self.parameters.acquisition_raw_filter_enabled.value
                         else "logic_combined_error_signal_filtered"
                     ),
-                    "scopegen_adc_a_q_sel": self.csr.signal("fast_b_x"),
-                    "scopegen_adc_b_sel": self.csr.signal("logic_control_signal"),
-                    "scopegen_adc_b_q_sel": self.csr.signal("zero"),
+                    "scopegen_adc_a_q_sel": csrmap.signals.index("fast_b_x"),
+                    "scopegen_adc_b_sel": csrmap.signals.index("logic_control_signal"),
+                    "scopegen_adc_b_q_sel": csrmap.signals.index("zero"),
                 }
             )
         else:
-            # display both demodulated error signals (if dual channel mode)
-            # OR: display demodulated error signal 1 + monitor signal
+            # display both demodulated error signals (if dual channel mode) OR: display
+            # demodulated error signal 1 + monitor signal
             new.update(
                 {
-                    "scopegen_adc_a_sel": self.csr.signal("fast_a_out_i"),
-                    "scopegen_adc_a_q_sel": self.csr.signal("fast_a_out_q"),
-                    "scopegen_adc_b_sel": self.csr.signal(
-                        "fast_b_out_i" if params["dual_channel"] else "fast_b_x"
+                    "scopegen_adc_a_sel": csrmap.signals.index("fast_a_out_i"),
+                    "scopegen_adc_a_q_sel": csrmap.signals.index("fast_a_out_q"),
+                    "scopegen_adc_b_sel": csrmap.signals.index(
+                        "fast_b_out_i"
+                        if self.parameters.dual_channel.value
+                        else "fast_b_x"
                     ),
-                    "scopegen_adc_b_q_sel": self.csr.signal(
-                        "fast_b_out_q" if params["dual_channel"] else "zero"
+                    "scopegen_adc_b_q_sel": csrmap.signals.index(
+                        "fast_b_out_q" if self.parameters.dual_channel.value else "zero"
                     ),
                 }
             )
@@ -214,53 +220,56 @@ def write_registers(self):
         self.control._cached_data.update(new)
 
         # pass sweep speed changes to acquisition process
-        sweep_changed = params["sweep_speed"] != self._last_sweep_speed
+        sweep_changed = self.parameters.sweep_speed.value != self._last_sweep_speed
         if sweep_changed:
-            self._last_sweep_speed = params["sweep_speed"]
-            self.acquisition.set_sweep_speed(params["sweep_speed"])
+            self._last_sweep_speed = self.parameters.sweep_speed.value
+            self.acquisition.exposed_set_sweep_speed(self.parameters.sweep_speed.value)
 
         raw_acquisition_settings = (
-            params["acquisition_raw_enabled"],
-            params["acquisition_raw_decimation"],
+            self.parameters.acquisition_raw_enabled.value,
+            self.parameters.acquisition_raw_decimation.value,
         )
         if raw_acquisition_settings != self._last_raw_acquisition_settings:
             self._last_raw_acquisition_settings = raw_acquisition_settings
-            self.acquisition.set_raw_acquisition(*raw_acquisition_settings)
+            self.acquisition.exposed_set_raw_acquisition(*raw_acquisition_settings)
 
         fpga_base_freq = 125e6
 
         self.set_iir(
             "logic_raw_acquisition_iir",
             *make_filter(
-                "LP", f=params["acquisition_raw_filter_frequency"] / fpga_base_freq, k=1
+                "LP",
+                f=self.parameters.acquisition_raw_filter_frequency.value
+                / fpga_base_freq,
+                k=1,
             ),
         )
 
         for k, v in new.items():
             self.set(k, int(v))
 
-        if not lock and sweep_changed:
-            # reset sweep for a short time if the scan range was changed
-            # this is needed because otherwise it may take too long before
-            # the new scan range is reached --> no scope trigger is sent
+        if not self.parameters.lock.value and sweep_changed:
+            # reset sweep for a short time if the scan range was changed this is needed
+            # because otherwise it may take too long before the new scan range is
+            # reached --> no scope trigger is sent
             self.set("logic_sweep_run", 0)
             self.set("logic_sweep_run", 1)
 
-        kp = params["p"]
-        ki = params["i"]
-        kd = params["d"]
-        slope = params["target_slope_rising"]
+        kp = self.parameters.p.value
+        ki = self.parameters.i.value
+        kd = self.parameters.d.value
+        slope = self.parameters.target_slope_rising.value
         control_channel, sweep_channel, slow_control_channel = (
-            params["control_channel"],
-            params["sweep_channel"],
-            params["slow_control_channel"],
+            self.parameters.control_channel.value,
+            self.parameters.sweep_channel.value,
+            self.parameters.slow_control_channel.value,
         )
 
         def channel_polarity(channel):
             return (
-                params["polarity_fast_out1"],
-                params["polarity_fast_out2"],
-                params["polarity_analog_out0"],
+                self.parameters.polarity_fast_out1.value,
+                self.parameters.polarity_fast_out2.value,
+                self.parameters.polarity_analog_out0.value,
             )[channel]
 
         if control_channel != sweep_channel:
@@ -268,7 +277,9 @@ def channel_polarity(channel):
                 slope = not slope
 
         slow_strength = (
-            params["pid_on_slow_strength"] if params["pid_on_slow_enabled"] else 0
+            self.parameters.pid_on_slow_strength.value
+            if self.parameters.pid_on_slow_enabled.value
+            else 0
         )
         slow_slope = (
             1
@@ -278,7 +289,7 @@ def channel_polarity(channel):
         )
 
         for chain in ("a", "b"):
-            automatic = params["filter_automatic_%s" % chain]
+            automatic = getattr(self.parameters, "filter_automatic_%s" % chain).value
             # iir_idx means iir_c or iir_d
             for iir_idx in range(2):
                 # iir_sub_idx means in-phase signal or quadrature signal
@@ -291,38 +302,42 @@ def channel_polarity(channel):
 
                     if automatic:
                         filter_enabled = True
-                        filter_type = LOW_PASS_FILTER
+                        filter_type = FilterType.LOW_PASS
                         filter_frequency = (
-                            params["modulation_frequency"] / MHz * 1e6 / 2
+                            self.parameters.modulation_frequency.value / MHz * 1e6 / 2
                         )
 
                         # if the filter frequency is too low (< 10Hz), the IIR doesn't
-                        # work properly anymore. In that case, don't filter.
-                        # This is also helpful if the raw (not demodulated) signal
-                        # should be displayed which can be achieved by setting
-                        # modulation frequency to 0.
+                        # work properly anymore. In that case, don't filter. This is
+                        # also helpful if the raw (not demodulated) signal should be
+                        # displayed which can be achieved by setting modulation
+                        # frequency to 0.
                         if filter_frequency < 10:
                             filter_enabled = False
                     else:
-                        filter_enabled = params[
-                            "filter_%d_enabled_%s" % (iir_idx + 1, chain)
-                        ]
-                        filter_type = params["filter_%d_type_%s" % (iir_idx + 1, chain)]
-                        filter_frequency = params[
-                            "filter_%d_frequency_%s" % (iir_idx + 1, chain)
-                        ]
+                        filter_enabled = getattr(
+                            self.parameters,
+                            "filter_%d_enabled_%s" % (iir_idx + 1, chain),
+                        ).value
+                        filter_type = getattr(
+                            self.parameters, "filter_%d_type_%s" % (iir_idx + 1, chain)
+                        ).value
+                        filter_frequency = getattr(
+                            self.parameters,
+                            "filter_%d_frequency_%s" % (iir_idx + 1, chain),
+                        ).value
 
                     if not filter_enabled:
                         self.set_iir(iir_name, *make_filter("P", k=1))
                     else:
-                        if filter_type == LOW_PASS_FILTER:
+                        if filter_type == FilterType.LOW_PASS:
                             self.set_iir(
                                 iir_name,
                                 *make_filter(
                                     "LP", f=filter_frequency / fpga_base_freq, k=1
                                 ),
                             )
-                        elif filter_type == HIGH_PASS_FILTER:
+                        elif filter_type == FilterType.HIGH_PASS:
                             self.set_iir(
                                 iir_name,
                                 *make_filter(
@@ -335,7 +350,7 @@ def channel_polarity(channel):
                             )
 
         if lock_changed:
-            if lock:
+            if self.parameters.lock.value:
                 # set PI parameters
                 self.set_pid(kp, ki, kd, slope, reset=0, request_lock=1)
                 self.set_slow_pid(slow_strength, slow_slope, reset=0)
@@ -343,7 +358,7 @@ def channel_polarity(channel):
                 self.set_pid(0, 0, 0, slope, reset=1, request_lock=0)
                 self.set_slow_pid(0, slow_slope, reset=1)
         else:
-            if lock:
+            if self.parameters.lock.value:
                 # set new PI parameters
                 self.set_pid(kp, ki, kd, slope)
                 self.set_slow_pid(slow_strength, slow_slope)
@@ -368,11 +383,19 @@ def set_slow_pid(self, strength, slope, reset=None):
             self.set("slow_chain_pid_reset", reset)
 
     def set(self, key, value):
-        self.acquisition.set_csr(key, value)
+        self.acquisition.exposed_set_csr(key, value)
 
     def set_iir(self, iir_name, *args):
         if self._iir_cache.get(iir_name) != args:
-            # as setting iir parameters takes some time, take care that we don't
-            # do it too often
-            self.acquisition.set_iir_csr(iir_name, *args)
+            # as setting iir parameters takes some time, take care that we don't  do it
+            # too often
+            self.acquisition.exposed_set_iir_csr(iir_name, *args)
             self._iir_cache[iir_name] = args
+
+
+def twos_complement(num, N_bits):
+    max_ = 1 << (N_bits - 1)
+    full = 2 * max_
+    if num < 0:
+        num += full
+    return num
diff --git a/linien-server/linien_server/server.py b/linien-server/linien_server/server.py
index 0868e8eb..ec4665c8 100644
--- a/linien-server/linien_server/server.py
+++ b/linien-server/linien_server/server.py
@@ -17,34 +17,36 @@
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
 import _thread
-import os
+import atexit
 import pickle
-import sys
-import threading
-from random import random
+from copy import copy
+from random import randint, random
+from threading import Event, Thread
 from time import sleep
+from typing import List, Optional, Tuple
 
 import click
 import numpy as np
 import rpyc
-from linien_common.common import (
-    N_POINTS,
-    check_plot_data,
+from linien_common.common import N_POINTS, check_plot_data, update_signal_history
+from linien_common.communication import (
+    no_authenticator,
     pack,
     unpack,
-    update_signal_history,
+    username_and_password_authenticator,
 )
 from linien_common.config import DEFAULT_SERVER_PORT
+from linien_common.influxdb import InfluxDBCredentials, restore_credentials
 from linien_server import __version__
 from linien_server.autolock.autolock import Autolock
+from linien_server.influxdb import InfluxDBLogger
 from linien_server.optimization.optimization import OptimizeSpectroscopy
-from linien_server.parameter_store import ParameterStore
-from linien_server.parameters import Parameters
+from linien_server.parameters import Parameters, restore_parameters, save_parameters
 from linien_server.pid_optimization.pid_optimization import (
     PIDOptimization,
     PSDAcquisition,
 )
-from rpyc.utils.authenticators import AuthenticationError
+from linien_server.registers import Registers
 from rpyc.utils.server import ThreadedServer
 
 
@@ -54,78 +56,174 @@ class BaseService(rpyc.Service):
     on the client.
     """
 
-    def __init__(self):
+    def __init__(self) -> None:
         self.parameters = Parameters()
-        self.parameter_store = ParameterStore(self.parameters)
-        self._uuid_mapping = {}
+        self.parameters = restore_parameters(self.parameters)
+        atexit.register(save_parameters, self.parameters)
+        self._uuid_mapping = {}  # type: ignore[var-annotated]
+
+        influxdb_credentials = restore_credentials()
+        self.influxdb_logger = InfluxDBLogger(influxdb_credentials, self.parameters)
+
+        self.stop_event = Event()
+        self.stop_log_event = Event()
 
-    def on_connect(self, client):
+    def on_connect(self, client) -> None:
         self._uuid_mapping[client] = client.root.uuid
 
-    def on_disconnect(self, client):
+    def on_disconnect(self, client) -> None:
         uuid = self._uuid_mapping[client]
         self.parameters.unregister_remote_listeners(uuid)
 
-    def exposed_get_param(self, param_name):
-        return pack(self.parameters._get_param(param_name).value)
+    def exposed_get_server_version(self) -> str:
+        return __version__
 
-    def exposed_set_param(self, param_name, value):
-        self.parameters._get_param(param_name).value = unpack(value)
+    def exposed_get_param(self, param_name: str) -> bytes:
+        return pack(getattr(self.parameters, param_name).value)
 
-    def exposed_init_parameter_sync(self, uuid):
+    def exposed_set_param(self, param_name: str, value: bytes) -> None:
+        getattr(self.parameters, param_name).value = unpack(value)
+
+    def exposed_reset_param(self, param_name: str) -> None:
+        getattr(self.parameters, param_name).reset()
+
+    def exposed_init_parameter_sync(self, uuid: str) -> bytes:
         return pack(list(self.parameters.init_parameter_sync(uuid)))
 
-    def exposed_register_remote_listener(self, uuid, param_name):
-        return self.parameters.register_remote_listener(uuid, param_name)
+    def exposed_register_remote_listener(self, uuid: str, param_name: str) -> None:
+        self.parameters.register_remote_listener(uuid, param_name)
 
-    def exposed_register_remote_listeners(self, uuid, param_names):
+    def exposed_register_remote_listeners(
+        self, uuid: str, param_names: List[str]
+    ) -> None:
         for param_name in param_names:
             self.exposed_register_remote_listener(uuid, param_name)
 
-    def exposed_get_listener_queue(self, uuid):
-        return self.parameters.get_listener_queue(uuid)
+    def exposed_get_changed_parameters_queue(self, uuid: str) -> bytes:
+        return pack(self.parameters.get_changed_parameters_queue(uuid))
+
+    def exposed_set_parameter_log(self, param_name: str, value: bool) -> None:
+        if getattr(self.parameters, param_name).log != value:
+            print("Setting log for %s to %s" % (param_name, value))
+            getattr(self.parameters, param_name).log = value
+
+    def exposed_get_parameter_log(self, param_name: str) -> bool:
+        return getattr(self.parameters, param_name).log
+
+    def exposed_update_influxdb_credentials(
+        self, credentials: InfluxDBCredentials
+    ) -> Tuple[bool, int, str]:
+        credentials = copy(credentials)
+        (
+            connection_succesful,
+            status_code,
+            message,
+        ) = self.influxdb_logger.test_connection(credentials)
+        if connection_succesful:
+            self.influxdb_logger.credentials = credentials
+            print("InfluxDB credentials updated successfully")
+        else:
+            print(
+                "InfluxDB credentials update failed. Error message: %s (Status Code %s)"
+                % (message, status_code)
+            )
+        return connection_succesful, status_code, message
+
+    def exposed_get_influxdb_credentials(self) -> bytes:
+        return pack(self.influxdb_logger.credentials)
+
+    def exposed_start_logging(self, interval: float) -> None:
+        print("Starting logging")
+        self.influxdb_logger.start_logging(interval)
+
+    def exposed_stop_logging(self) -> None:
+        print("Stopping logging")
+        self.influxdb_logger.stop_logging()
+
+    def exposed_get_logging_status(self) -> bool:
+        return not self.influxdb_logger.stop_event.is_set()
 
 
 class RedPitayaControlService(BaseService):
     """Control server that runs on the RP that provides high-level methods."""
 
-    def __init__(self, **kwargs):
+    def __init__(self, host=None):
         self._cached_data = {}
         self.exposed_is_locked = None
 
-        super().__init__()
+        super(RedPitayaControlService, self).__init__()
+
+        self.registers = Registers(control=self, parameters=self.parameters, host=host)
+        # Connect the acquisition loop to the parameters: Every received value is pushed
+        # to `parameters.to_plot`.
+        self.exposed_pause_acquisition()
+        self.exposed_continue_acquisition()
+
+        # Start a timer that increases the `ping` parameter once per second. Its purpose
+        # is to allow for periodic tasks on the server: just register an `on_change`
+        # listener for this parameter.
 
-        from linien_server.registers import Registers
+        self.ping_thread = Thread(
+            target=self._send_ping_loop, args=(self.stop_event,), daemon=True
+        )
+        self.data_pusher_thread = Thread(
+            target=self._push_acquired_data_to_parameters,
+            args=(self.stop_event,),
+            daemon=True,
+        )
 
-        self.registers = Registers(**kwargs)
-        self.registers.connect(self, self.parameters)
+        self.ping_thread.start()
+        self.data_pusher_thread.start()
 
-    def run_acquiry_loop(self):
-        """Starts a background process that keeps polling control and error
-        signal. Every received value is pushed to `parameters.to_plot`."""
+        self.exposed_write_registers()
 
-        def on_new_data_received(is_raw, plot_data, data_uuid):
-            # When a parameter is changed, `pause_acquisition` is set.
-            # This means that the we should skip new data until we are sure that
-            # it was recorded with the new settings.
+    def _send_ping_loop(self, stop_event: Event):
+        while not stop_event.is_set():
+            self.parameters.ping.value += 1
+            if self.parameters.ping.value < 10:
+                print("ping", self.parameters.ping.value)
+                if self.parameters.ping.value == 9:
+                    print("further pings will be suppressed")
+            sleep(1)
+
+    def _push_acquired_data_to_parameters(self, stop_event: Event):
+        last_hash = None
+        while not stop_event.is_set():
+            (
+                new_data_returned,
+                new_hash,
+                data_was_raw,
+                new_data,
+                data_uuid,
+            ) = self.registers.acquisition.exposed_return_data(last_hash)
+            if new_data_returned:
+                last_hash = new_hash
+            # When a parameter is changed, `pause_acquisition` is set. This means that
+            # the we should skip new data until we are sure that it was recorded with
+            # the new settings.
             if not self.parameters.pause_acquisition.value:
                 if data_uuid != self.data_uuid:
-                    return
+                    continue
 
-                data_loaded = pickle.loads(plot_data)
+                data_loaded = pickle.loads(new_data)
 
-                if not is_raw:
+                if not data_was_raw:
                     is_locked = self.parameters.lock.value
 
                     if not check_plot_data(is_locked, data_loaded):
-                        print(
-                            "warning: incorrect data received for lock state, ignoring!"
-                        )
-                        return
-
-                    self.parameters.to_plot.value = plot_data
-                    self._generate_signal_stats(data_loaded)
-
+                        print("incorrect data received for lock state, ignoring!")
+                        continue
+
+                    self.parameters.to_plot.value = new_data
+
+                    # generate signal stats
+                    stats = {}
+                    for signal_name, signal in data_loaded.items():
+                        stats["%s_mean" % signal_name] = np.mean(signal)
+                        stats["%s_std" % signal_name] = np.std(signal)
+                        stats["%s_max" % signal_name] = np.max(signal)
+                        stats["%s_min" % signal_name] = np.min(signal)
+                    self.parameters.signal_stats.value = stats
                     # update signal history (if in locked state)
                     (
                         self.parameters.control_signal_history.value,
@@ -138,47 +236,10 @@ def on_new_data_received(is_raw, plot_data, data_uuid):
                         self.parameters.control_signal_history_length.value,
                     )
                 else:
-                    self.parameters.acquisition_raw_data.value = plot_data
-
-        # each time new data is acquired, this function is called
-        self.registers.acquisition.on_new_data_received = on_new_data_received
-        self.pause_acquisition()
-        self.continue_acquisition()
+                    self.parameters.acquisition_raw_data.value = new_data
+            sleep(0.05)
 
-    def run_periodic_timer(self):
-        """
-        Start a timer that increases the `ping` parameter once per second. Its purpose
-        is to allow for periodic tasks on the server: just register an `on_change`
-        listener for this parameter.
-        """
-
-        def send_ping():
-            while True:
-                self.parameters.ping.value = self.parameters.ping.value + 1
-                print("ping", self.parameters.ping.value)
-                sleep(1)
-
-        thread = threading.Thread(target=send_ping)
-        thread.daemon = True
-        thread.start()
-        self._periodic_timer = thread
-
-    def _generate_signal_stats(self, to_plot):
-        stats = {}
-
-        for signal_name, signal in to_plot.items():
-            stats["%s_mean" % signal_name] = np.mean(signal)
-            stats["%s_std" % signal_name] = np.std(signal)
-            stats["%s_max" % signal_name] = np.max(signal)
-            stats["%s_min" % signal_name] = np.min(signal)
-
-        self.parameters.signal_stats.value = stats
-
-    def exposed_write_registers(self):
-        """Sync the parameters with the FPGA registers."""
-        self.registers.write_registers()
-
-    def task_running(self):
+    def _task_running(self):
         return (
             self.parameters.autolock_running.value
             or self.parameters.optimization_running.value
@@ -186,13 +247,17 @@ def task_running(self):
             or self.parameters.psd_optimization_running.value
         )
 
+    def exposed_write_registers(self) -> None:
+        """Sync the parameters with the FPGA registers."""
+        self.registers.write_registers()
+
     def exposed_start_autolock(self, x0, x1, spectrum, additional_spectra=None):
         spectrum = pickle.loads(spectrum)
         # start_watching = self.parameters.watch_lock.value
         start_watching = False
         auto_offset = self.parameters.autolock_determine_offset.value
 
-        if not self.task_running():
+        if not self._task_running():
             autolock = Autolock(self, self.parameters)
             self.parameters.task.value = autolock
             autolock.run(
@@ -207,120 +272,102 @@ def exposed_start_autolock(self, x0, x1, spectrum, additional_spectra=None):
             )
 
     def exposed_start_optimization(self, x0, x1, spectrum):
-        if not self.task_running():
+        if not self._task_running():
             optim = OptimizeSpectroscopy(self, self.parameters)
             self.parameters.task.value = optim
             optim.run(x0, x1, spectrum)
 
     def exposed_start_psd_acquisition(self):
-        if not self.task_running():
+        if not self._task_running():
             self.parameters.task.value = PSDAcquisition(self, self.parameters)
             self.parameters.task.value.run()
 
+        print("Logging thread stopped.")
+
     def exposed_start_pid_optimization(self):
-        if not self.task_running():
+        if not self._task_running():
             self.parameters.task.value = PIDOptimization(self, self.parameters)
             self.parameters.task.value.run()
 
     def exposed_start_sweep(self):
-        self.pause_acquisition()
-
+        self.exposed_pause_acquisition()
         self.parameters.combined_offset.value = 0
         self.parameters.lock.value = False
         self.exposed_write_registers()
-
-        self.continue_acquisition()
+        self.exposed_continue_acquisition()
 
     def exposed_start_lock(self):
-        self.pause_acquisition()
-
+        self.exposed_pause_acquisition()
         self.parameters.lock.value = True
         self.exposed_write_registers()
-
-        self.continue_acquisition()
+        self.exposed_continue_acquisition()
 
     def exposed_shutdown(self):
-        """Kills the server."""
-        self.registers.acquisition.shutdown()
+        self.stop_event.set()
+        self.ping_thread.join()
+        self.data_pusher_thread.join()
+        self.registers.acquisition.exposed_stop_acquisition()
+        # FIXME: hacky way to trigger atexit handlers for saving parameters
         _thread.interrupt_main()
-        # we use SystemExit instead of os._exit because we want to call atexit
-        # handlers
-        raise SystemExit
-
-    def exposed_get_server_version(self):
-        import linien_server
-
-        return linien_server.__version__
-
-    def exposed_get_restorable_parameters(self):
-        return self.parameters._restorable_parameters
+        raise SystemExit()
 
     def exposed_pause_acquisition(self):
-        self.pause_acquisition()
-
-    def exposed_continue_acquisition(self):
-        self.continue_acquisition()
-
-    def exposed_set_csr_direct(self, k, v):
-        """Directly sets a CSR register. This method is intended for debugging.
-        Normally, the FPGA should be controlled via manipulation of parameters."""
-        self.registers.set(k, v)
-
-    def pause_acquisition(self):
-        """Pause continuous acquisition. Call this before changing a parameter
-        that alters the error / control signal. This way, no inconsistent signals
-        reach the application. After setting the new parameter values, call
-        `continue_acquisition`."""
+        """
+        Pause continuous acquisition. Call this before changing a parameter that alters
+        the error / control signal. This way, no inconsistent signals reach the
+        application. After setting the new parameter values, call
+        `continue_acquisition`.
+        """
         self.parameters.pause_acquisition.value = True
         self.data_uuid = random()
-        self.registers.acquisition.pause_acquisition()
+        self.registers.acquisition.exposed_pause_acquisition()
 
-    def continue_acquisition(self):
-        """Continue acquisition after a short delay, when we are sure that the
-        new parameters values have been written to the FPGA and that data that
-        is now recorded is recorded with the correct parameters."""
+    def exposed_continue_acquisition(self):
+        """
+        Continue acquisition after a short delay, when we are sure that the new
+        parameters values have been written to the FPGA and that data that is now
+        recorded is recorded with the correct parameters.
+        """
         self.parameters.pause_acquisition.value = False
-        self.registers.acquisition.continue_acquisition(self.data_uuid)
+        self.registers.acquisition.exposed_continue_acquisition(self.data_uuid)
 
+    def exposed_set_csr_direct(self, key: str, value: int) -> None:
+        """
+        Directly sets a CSR register. This method is intended for debugging. Normally,
+        the FPGA should be controlled via manipulation of parameters.
+        """
+        self.registers.set(key, value)
 
-class FakeRedPitayaControl(BaseService):
+
+class FakeRedPitayaControlService(BaseService):
     def __init__(self):
         super().__init__()
         self.exposed_is_locked = None
 
-    def exposed_write_registers(self):
-        pass
+        self.random_data_thread = Thread(
+            target=self._write_random_data_to_parameters_loop,
+            args=(self.stop_event,),
+            daemon=True,
+        )
+        self.random_data_thread.start()
+
+    def _write_random_data_to_parameters_loop(self, stop_event: Event):
+        while not stop_event.is_set():
+            max_ = randint(0, 8191)
+            gen = lambda: np.array([randint(-max_, max_) for _ in range(N_POINTS)])
+            self.parameters.to_plot.value = pickle.dumps(
+                {
+                    "error_signal_1": gen(),
+                    "error_signal_1_quadrature": gen(),
+                    "error_signal_2": gen(),
+                    "error_signal_2_quadrature": gen(),
+                }
+            )
+            sleep(0.1)
 
-    def run_acquiry_loop(self):
-        import threading
-        from random import randint
-        from time import sleep
-
-        def run():
-            while True:
-                max_ = randint(0, 8191)
-                gen = lambda: np.array([randint(-max_, max_) for _ in range(N_POINTS)])
-                self.parameters.to_plot.value = pickle.dumps(
-                    {
-                        "error_signal_1": gen(),
-                        "error_signal_1_quadrature": gen(),
-                        "error_signal_2": gen(),
-                        "error_signal_2_quadrature": gen(),
-                    }
-                )
-                sleep(0.1)
-
-        t = threading.Thread(target=run)
-        t.daemon = True
-        t.start()
-
-    def run_periodic_timer(self):
+    def exposed_write_registers(self):
         pass
 
-    def exposed_shutdown(self):
-        _thread.interrupt_main()
-        os._exit(0)
-
     def exposed_start_autolock(self, x0, x1, spectrum):
         print("start autolock", x0, x1)
 
@@ -328,18 +375,13 @@ def exposed_start_optimization(self, x0, x1, spectrum):
         print("start optimization")
         self.parameters.optimization_running.value = True
 
-    def exposed_get_restorable_parameters(self):
-        return self.parameters._restorable_parameters
-
-    def exposed_get_server_version(self):
-        import linien_server
-
-        return linien_server.__version__
+    def exposed_shutdown(self):
+        raise SystemExit()
 
-    def pause_acquisition(self):
+    def exposed_pause_acquisition(self):
         pass
 
-    def continue_acquisition(self):
+    def exposed_continue_acquisition(self):
         pass
 
 
@@ -350,77 +392,40 @@ def continue_acquisition(self):
     "--fake", is_flag=True, help="Runs a fake server that just returns random data"
 )
 @click.option(
-    "--remote-rp",
+    "--host",
     help=(
         "Allows to run the server locally for development and connects to a RedPitaya. "
-        "Specify the RP's credentials as follows: "
-        "--remote-rp=root:myPassword@rp-f0xxxx.local"
+        "Specify the RP's host as follows: --host=rp-f0xxxx.local"
     ),
 )
-def run_server(port, fake=False, remote_rp=False):
+@click.option("--no-auth", is_flag=True, help="Disable authentication")
+def run_server(
+    port: int,
+    fake: bool = False,
+    host: Optional[str] = None,
+    no_auth: bool = False,
+    use_credentials: Optional[str] = None,
+):
     print("Start server on port", port)
 
     if fake:
         print("starting fake server")
-        control = FakeRedPitayaControl()
+        control = FakeRedPitayaControlService()
     else:
-        if remote_rp is not None:
-            assert (
-                "@" in remote_rp and ":" in remote_rp
-            ), "invalid format, should be root:myPassword@rp-f0xxxx.local"
-
-            username, tmp = remote_rp.split(":", 1)
-            r_host, r_password = "".join(reversed(tmp)).split("@", 1)
-            host = "".join(reversed(r_host))
-            password = "".join(reversed(r_password))
-            control = RedPitayaControlService(
-                host=host, user=username, password=password
-            )
-        else:
-            control = RedPitayaControlService()
-
-    control.run_acquiry_loop()
-    control.run_periodic_timer()
-    control.exposed_write_registers()
+        control = RedPitayaControlService(host=host)
 
-    failed_auth_counter = {"c": 0}
-
-    def username_and_password_authenticator(sock):
-        # when a client starts the server, it supplies this hash via an environment
-        # variable
-        secret = os.environ.get("LINIEN_AUTH_HASH")
-
-        # client always sends auth hash, even if we run in non-auth mode
-        # --> always read 64 bytes, otherwise rpyc connection can't be established
-        received = sock.recv(64)
-
-        # as a protection against brute force, we don't accept requests after
-        # too many failed auth requests
-        if failed_auth_counter["c"] > 1000:
-            print("received too many failed auth requests!")
-            sys.exit(1)
-
-        if secret is None:
-            print("warning: no authentication set up")
-        else:
-            if received != secret.encode():
-                print("received invalid credentials: ", received)
-
-                failed_auth_counter["c"] += 1
-
-                raise AuthenticationError("invalid username / password")
-
-            print("authentication successful")
-
-        return sock, None
+    if no_auth or fake:
+        authenticator = no_authenticator
+    else:
+        authenticator = username_and_password_authenticator
 
-    t = ThreadedServer(
+    thread = ThreadedServer(
         control,
         port=port,
-        authenticator=username_and_password_authenticator,
+        authenticator=authenticator,
         protocol_config={"allow_pickle": True},
     )
-    t.start()
+    thread.start()
 
 
 if __name__ == "__main__":
diff --git a/linien-server/linien_server/utils.py b/linien-server/linien_server/utils.py
deleted file mode 100644
index 38c20c5c..00000000
--- a/linien-server/linien_server/utils.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2021-2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
-#
-# This file is part of Linien and based on redpid.
-#
-# Linien is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Linien is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Linien.  If not, see <http://www.gnu.org/licenses/>.
-
-import shutil
-import subprocess
-from pathlib import Path
-
-
-def stop_nginx():
-    subprocess.Popen(["systemctl", "stop", "redpitaya_nginx.service"]).wait()
-    subprocess.Popen(["systemctl", "stop", "redpitaya_scpi.service"]).wait()
-
-
-def start_nginx():
-    subprocess.Popen(["systemctl", "start", "redpitaya_nginx.service"])
-
-
-def flash_fpga():
-    filepath = Path(__file__).parent / "linien.bin"
-    shutil.copy(str(filepath.resolve()), "/dev/xdevcfg")
-
-
-def twos_complement(num, N_bits):
-    max_ = 1 << (N_bits - 1)
-    full = 2 * max_
-
-    if num < 0:
-        num += full
-
-    return num
-
-
-def sweep_speed_to_time(sweep_speed):
-    """Sweep speed is an arbitrary unit (cf. `parameters.py`).
-    This function converts it to the duration of the sweep in seconds.
-    """
-    f_real = 3.8e3 / (2**sweep_speed)
-    duration = 1 / f_real
-    return duration
diff --git a/linien-server/setup.py b/linien-server/setup.py
index 960314bf..dfb9e2df 100644
--- a/linien-server/setup.py
+++ b/linien-server/setup.py
@@ -1,5 +1,5 @@
 # Copyright 2018-2022 Benjamin Wiegand <benjamin.wiegand@physik.hu-berlin.de>
-# Copyright 2022 Bastian Leykauf <leykauf@physik.hu-berlin.de>
+# Copyright 2022-2023 Bastian Leykauf <leykauf@physik.hu-berlin.de>
 #
 # This file is part of Linien and based on redpid.
 #
@@ -18,7 +18,7 @@
 
 from setuptools import find_packages, setup
 
-version = "0.7.0"
+version = "0.8.0"
 
 setup(
     name="linien-server",
@@ -40,11 +40,14 @@
     entry_points={"console_scripts": ["linien-server=linien_server.server:run_server"]},
     python_requires=">=3.5",
     install_requires=[
+        "appdirs>=1.4.4",
+        "certifi==2021.10.8;python_version<'3.10'",  # pinned because of bug in pip 9.0.1, see #339 # noqa: E501
         "click>=7.1.2",
         "cma>=3.0.3",
         "pylpsd>=0.1.4",
-        "pyrp3>=1.1.0;platform_machine=='armv7l'",  # only install on RedPitaya
-        "rpyc>=4.0,<5.0",
+        "pyrp3>=1.1.0,<2.0;platform_machine=='armv7l'",  # only install on RedPitaya
+        "requests==2.25.1;python_version<'3.10'",  # pinned because of bug in pip 9.0.1, see #339 # noqa: E501
+        "requests>=2.25.1;python_version>='3.10'",
         "linien-common=={}".format(version),
     ],
     scripts=[
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 00000000..5970752f
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,34 @@
+[mypy]
+
+[mypy-linien_common.*]
+ignore_missing_imports = True
+
+[mypy-linien_client.*]
+ignore_missing_imports = True
+
+[mypy-rpyc.*]
+ignore_missing_imports = True
+
+[mypy-fabric]
+ignore_missing_imports = True
+
+[mypy-setuptools.*]
+ignore_missing_imports = True
+
+[mypy-scipy.*]
+ignore_missing_imports = True
+
+[mypy-cma.*]
+ignore_missing_imports = True
+
+[mypy-pyrp3.*]
+ignore_missing_imports = True
+
+[mypy-pylpsd.*]
+ignore_missing_imports = True
+
+[mypy-pyqtgraph.*]
+ignore_missing_imports = True
+
+[mypy-app_paths.*]
+ignore_missing_imports = True
diff --git a/version-info.json b/version-info.json
index b4fec861..22bf0c87 100644
--- a/version-info.json
+++ b/version-info.json
@@ -1,3 +1,3 @@
 {
-    "latest": "0.7.0"
+    "latest": "0.8.0"
 }
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/publish-test-pypi.yml b/.github/workflows/publish-test-pypi.yml
index 7e3ab8b7..41362f70 100644
--- a/.github/workflows/publish-test-pypi.yml
+++ b/.github/workflows/publish-test-pypi.yml
@@ -15,56 +15,33 @@ jobs:
         with:
           lfs: "true"
 
-      - name: Set up Python
-        uses: actions/setup-python@v4
-        with:
-          python-version: "3.x"
-
-      - name: Install dependencies
-        run: |
-          python -m pip install --upgrade pip
-          pip install build wheel
-
-      - name: Build linien-common
-        working-directory: linien-common
-        run: python -m build
-
-      - name: Build linien-server
-        working-directory: linien-server
-        run: python -m build
-
-      - name: Build linien-client
-        working-directory: linien-client
-        run: python -m build
-
-      - name: Build linien-gui
-        working-directory: linien-gui
-        run: python -m build
+      - name: Build Linien
+        uses: ./.github/actions/build-linien
 
       - name: Publish linien-common to TestPyPI
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-common/dist/
+          packages-dir: linien-common/dist/
           password: ${{ secrets.TEST_PYPI_API_TOKEN_COMMON }}
-          repository_url: https://test.pypi.org/legacy/
+          repository-url: https://test.pypi.org/legacy/
 
       - name: Publish linien-server to TestPyPI
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-server/dist/
+          packages-dir: linien-server/dist/
           password: ${{ secrets.TEST_PYPI_API_TOKEN_SERVER }}
-          repository_url: https://test.pypi.org/legacy/
+          repository-url: https://test.pypi.org/legacy/
 
       - name: Publish linien-client to TestPyPI
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-client/dist/
+          packages-dir: linien-client/dist/
           password: ${{ secrets.TEST_PYPI_API_TOKEN_CLIENT }}
-          repository_url: https://test.pypi.org/legacy/
+          repository-url: https://test.pypi.org/legacy/
 
       - name: Publish linien-gui to TestPyPI
         uses: pypa/gh-action-pypi-publish@release/v1
         with:
-          packages_dir: linien-gui/dist/
+          packages-dir: linien-gui/dist/
           password: ${{ secrets.TEST_PYPI_API_TOKEN_GUI }}
-          repository_url: https://test.pypi.org/legacy/
+          repository-url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index f27df02f..4fbb6daa 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -4,6 +4,8 @@ on:
   workflow_dispatch:
   push:
     paths:
+      - .github/workflows/pytest.yml
+      - .github/actions/install-linien/**
       - gateware/**
       - linien-common/**
       - linien-server/**
@@ -17,19 +19,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
-      - name: Set up Python 3.10
-        uses: actions/setup-python@v3
-        with:
-          python-version: "3.10"
+      - name: Check out repository
+        uses: actions/checkout@v3
 
-      - name: Install linien-common
-        working-directory: linien-common
-        run: pip install .
-
-      - name: Install linien-server
-        working-directory: linien-server
-        run: pip install .
+      - name: Install Linien
+        uses: ./.github/actions/install-linien
 
       - name: Install dependencies
         run: |
@@ -37,5 +31,4 @@ jobs:
           pip install pytest migen pytest-plt git+https://github.com/m-labs/misoc
 
       - name: Test with pytest
-        run: |
-          pytest .
+        run: pytest .
diff --git a/tests/test_algorithm_selection.py b/tests/test_algorithm_selection.py
index 1113a26f..abc7a275 100644
--- a/tests/test_algorithm_selection.py
+++ b/tests/test_algorithm_selection.py
@@ -19,11 +19,7 @@
 import pickle
 
 import numpy as np
-from linien_common.common import (
-    AUTO_DETECT_AUTOLOCK_MODE,
-    FAST_AUTOLOCK,
-    ROBUST_AUTOLOCK,
-)
+from linien_common.common import AutolockMode
 from linien_server.autolock.autolock import Autolock
 from linien_server.autolock.fast import FastAutolock
 from linien_server.autolock.robust import RobustAutolock
@@ -54,10 +50,10 @@ def __init__(self, parameters: Parameters):
         self.parameters = parameters
         self.locked = False
 
-    def pause_acquisition(self):
+    def exposed_pause_acquisition(self):
         pass
 
-    def continue_acquisition(self):
+    def exposed_continue_acquisition(self):
         pass
 
     def exposed_write_registers(self):
@@ -76,7 +72,7 @@ def test_forced_algorithm_selection():
     def _get_signal(shift):
         return get_signal(1, 0, shift)
 
-    for forced in (FAST_AUTOLOCK, ROBUST_AUTOLOCK):
+    for forced in (AutolockMode.FAST, AutolockMode.ROBUST):
         parameters = Parameters()
         parameters.autolock_mode_preference.value = forced
         control = FakeControl(parameters)
@@ -101,7 +97,7 @@ def _get_signal(shift):
 
         assert parameters.autolock_mode.value == forced
 
-        if forced == FAST_AUTOLOCK:
+        if forced == AutolockMode.FAST:
             assert isinstance(autolock.algorithm, FastAutolock)
         else:
             assert isinstance(autolock.algorithm, RobustAutolock)
@@ -116,7 +112,7 @@ def _get_signal(shift):
     for jitter in (LOW_JITTER, HIGH_JITTER):
         print(f"jitter {jitter}")
         parameters = Parameters()
-        parameters.autolock_mode_preference.value = AUTO_DETECT_AUTOLOCK_MODE
+        parameters.autolock_mode_preference.value = AutolockMode.AUTO_DETECT
         control = FakeControl(parameters)
 
         reference_signal = _get_signal(0)
@@ -146,10 +142,10 @@ def _get_signal(shift):
 
         assert autolock.autolock_mode_detector.done
         if jitter == LOW_JITTER:
-            assert parameters.autolock_mode.value == FAST_AUTOLOCK
+            assert parameters.autolock_mode.value == AutolockMode.FAST
             assert isinstance(autolock.algorithm, FastAutolock)
         else:
-            assert parameters.autolock_mode.value == ROBUST_AUTOLOCK
+            assert parameters.autolock_mode.value == AutolockMode.ROBUST
             assert isinstance(autolock.algorithm, RobustAutolock)
 
 
diff --git a/tests/test_approacher.py b/tests/test_approacher.py
index ede173c8..1aef3eca 100644
--- a/tests/test_approacher.py
+++ b/tests/test_approacher.py
@@ -18,7 +18,7 @@
 
 import numpy as np
 from linien_common.common import get_lock_point
-from linien_server.approach_line import Approacher
+from linien_server.optimization.approach_line import Approacher
 from linien_server.parameters import Parameters
 
 Y_SHIFT = 4000
@@ -45,10 +45,10 @@ class FakeControl:
     def __init__(self, parameters: Parameters):
         self.parameters = parameters
 
-    def pause_acquisition(self):
+    def exposed_pause_acquisition(self):
         pass
 
-    def continue_acquisition(self):
+    def exposed_continue_acquisition(self):
         pass
 
     def exposed_write_registers(self):
diff --git a/tests/test_fast_autolock_cpu.py b/tests/test_fast_autolock_cpu.py
index b3e98fd6..a7df26fa 100644
--- a/tests/test_fast_autolock_cpu.py
+++ b/tests/test_fast_autolock_cpu.py
@@ -19,7 +19,7 @@
 import pickle
 
 import numpy as np
-from linien_common.common import FAST_AUTOLOCK
+from linien_common.common import AutolockMode
 from linien_server.autolock.autolock import Autolock
 from linien_server.parameters import Parameters
 
@@ -48,10 +48,10 @@ def __init__(self, parameters: Parameters):
         self.parameters = parameters
         self.locked = False
 
-    def pause_acquisition(self):
+    def exposed_pause_acquisition(self):
         pass
 
-    def continue_acquisition(self):
+    def exposed_continue_acquisition(self):
         pass
 
     def exposed_write_registers(self):
@@ -75,7 +75,7 @@ def _get_signal(shift):
             print(f"----- ref_shift={ref_shift}, target_shift={target_shift} -----")
 
             parameters = Parameters()
-            parameters.autolock_mode_preference.value = FAST_AUTOLOCK
+            parameters.autolock_mode_preference.value = AutolockMode.FAST
             control = FakeControl(parameters)
 
             reference_signal = _get_signal(ref_shift)
diff --git a/tests/test_fast_autolock_fpga.py b/tests/test_fast_autolock_fpga.py
index 30e89be2..32caf639 100644
--- a/tests/test_fast_autolock_fpga.py
+++ b/tests/test_fast_autolock_fpga.py
@@ -18,7 +18,7 @@
 
 from pathlib import Path
 
-from linien_common.common import FAST_AUTOLOCK
+from linien_common.common import AutolockMode
 from migen import run_simulation
 
 from gateware.linien_module import LinienLogic
@@ -35,7 +35,7 @@ def tb(root: LinienLogic, lock_target_position):
         pid: PID = root.pid
         autolock = root.autolock
         fast = autolock.fast
-        yield autolock.autolock_mode.storage.eq(FAST_AUTOLOCK)
+        yield autolock.autolock_mode.storage.eq(AutolockMode.FAST)
 
         yield fast.target_position.storage.eq(lock_target_position)
 
diff --git a/tests/test_optimizer_engines.py b/tests/test_optimizer_engines.py
index f6d1fc29..fa7a890b 100644
--- a/tests/test_optimizer_engines.py
+++ b/tests/test_optimizer_engines.py
@@ -16,7 +16,12 @@
 # You should have received a copy of the GNU General Public License
 # along with Linien.  If not, see <http://www.gnu.org/licenses/>.
 
-import cma
+import warnings
+
+with warnings.catch_warnings():
+    warnings.simplefilter("ignore")
+    import cma
+
 import numpy as np
 from linien_common.common import MHz, Vpp
 from linien_server.optimization.engine import (
@@ -41,10 +46,10 @@ class FakeControl:
     def __init__(self, parameters: Parameters):
         self.parameters = parameters
 
-    def pause_acquisition(self):
+    def exposed_pause_acquisition(self):
         pass
 
-    def continue_acquisition(self):
+    def exposed_continue_acquisition(self):
         pass
 
     def exposed_write_registers(self):